mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 22:54:38 +02:00
agent/lambda/bd384503
85 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ae985ae2a3 |
fix(daemon): tighten 404 task-not-found semantics — server + final guard (#2127)
* fix(server): return 500 for transient DB errors in daemon task lookup requireDaemonTaskAccess used to turn any GetAgentTask error into 404 "task not found", including transient DB connection / pool errors. Combined with PR #2107 — which added 404+"task not found" as a daemon cancellation trigger — that means a single DB hiccup could kill an in-flight agent run. Distinguish pgx.ErrNoRows (real "task gone", 404) from other errors (transient, 500 + warn log) using the existing isNotFound helper. Tests cover both paths via the mockDB pattern already used by TestFindOrCreateUserGating. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): honor task-deleted signal in post-runTask completion guard The final pre-completion check in handleTask only looked for status == "cancelled" and ignored errors. After PR #2107 added a 404 task-deleted cancellation path to the in-flight watcher, this trailing guard fell out of sync — if the task was deleted between the watcher's last poll and runTask returning, handleTask would still try to call CompleteTask and only learn about the deletion via the 404 from that callback. Reuse shouldInterruptAgent so the same truth table (cancelled OR 404 task-not-found, but NOT transient errors) drives both polling and the final guard. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
09f04847d3 | feat(server): redis-backed runtime liveness with DB fallback (#2121) | ||
|
|
44a0ced558 |
fix(runtime): persist CLI update requests in Redis (#2113)
Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
d492b9d7a6 |
Revert "feat(quick-create): add preset issue fields (#2002)" (#2042)
This reverts commit
|
||
|
|
a039c4d803 |
feat(quick-create): add preset issue fields (#2002)
Fixed: #2001 |
||
|
|
ba5b7db78e |
fix(server): persist ModelListStore across replicas via Redis (#2022)
* fix(server): persist ModelListStore across replicas via Redis The model picker uses a pending-request pattern: the frontend POSTs to create a request, the daemon pops it on its next heartbeat, runs agent.ListModels locally, and reports back. Until now the store was a plain in-memory map per Handler instance. That works for self-hosted single-instance deploys but fails in any multi-replica environment (Multica Cloud). Each replica has its own map, so: POST /runtimes/:id/models → request stored in replica A GET /runtimes/:id/models/<requestId> → polls land on B/C → 404 daemon heartbeat → only A sees PendingModelList POST .../<requestId>/result → daemon's report has to land on A Success probability ~1/N². The visible symptom is "No models available" in the picker for every provider, even those (Claude/Codex) whose catalog is statically populated end-to-end. Same shape of bug, same Redis-backed fix as multica-ai/multica#1557 did for LocalSkillListStore / LocalSkillImportStore. Reuse the operational playbook (namespaced keys, ZSET-backed pending queue, atomic ZREM+SET-running via the shared Lua script) so we don't introduce a second concurrency model for the same primitive. Changes: - Convert ModelListStore from struct to interface with context-aware methods. Add HasPending for cheap heartbeat-side probing. - InMemoryModelListStore — single-node fallback, used when REDIS_URL is unset (self-hosted dev / tests). - RedisModelListStore — multi-node implementation using the same key layout and Lua atomic claim as RedisLocalSkillListStore. - Use RunStartedAt (not UpdatedAt) as the running-timeout reference point, matching the local-skill stores so subsequent UpdatedAt bumps don't reset the running clock. - Heartbeat now uses the probe-then-pop pattern for the model queue (matching local-skills) so a slow Redis can't stall every connected daemon. Extends heartbeatMetrics + slow-log with probe_model_ms / pop_model_ms / probe_model_timed_out for parity. - Wire the Redis backend in NewRouterWithOptions when rdb != nil. - Tests for both backends. Redis tests gate on REDIS_TEST_URL so laptop runs without Redis still pass; CI provides it. Co-authored-by: multica-agent <github@multica.ai> * fix(server): persist RunStartedAt + retry model report on transient failures Two follow-ups from PR #2022 review: 1. RedisModelListStore was dropping ModelListRequest.RunStartedAt on persistence — the field is tagged json:"-" so it doesn't leak into the HTTP response, which made plain json.Marshal(req) silently discard it. Across-node readers saw RunStartedAt=nil and applyModelListTimeout's running branch became a no-op, so the 60s running-timeout escape hatch never fired. CI's TestRedisModelListStore_RunningTimeout was failing on this exact case. Fix mirrors RedisLocalSkillImportStore's envelope pattern — wrap in an internal struct that re-promotes the field. HTTP shape stays clean. Adds a no-Redis unit test that pins the round trip. 2. Daemon's handleModelList called d.client.ReportModelListResult directly and swallowed any 5xx, leaving the pending request stranded in "running" until its 60s server-side timeout — exactly the failure mode the multi-node store fix was meant to eliminate. Generalize the existing local-skill retry helper into reportRuntimeResultWithRetry (kind: model_list / local_skill_list / local_skill_import) and wire handleModelList through a new reportModelListResult helper. Renames the test-overridable var localSkillReportBackoffs → runtimeReportBackoffs to match. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
e665b597b3 |
refactor(server): polish runtime-guard nits from PR #1905 review (#2021)
- Expand chat-resume comment in ClaimTaskByRuntime to spell out *why* the task-row fallback exists (single failed turn must not drop chat memory) and that it covers more than just legacy NULL rows. - Replace the sessionRuntimeID := t.RuntimeID; sessionRuntimeID.Valid = ... pattern in CompleteTask/FailTask with a clearer var-then-assign that makes the "no session_id, leave runtime_id alone" coupling obvious. - Add TestClaimTask_ChatLegacyNullRuntimeFallsBackToTaskRow covering the case the prior PR's tests didn't reach: chat_session.runtime_id IS NULL (legacy / unbackfilled) plus a matching-runtime task row, fallback should resume. This is the dominant post-migration shape and was previously only covered transitively. No behavior change beyond the new test; runtime-guard semantics stay identical to PR #1905. Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
cf47d9b702 | fix: guard session resume by runtime (#1905) | ||
|
|
da5dbc6224 |
refactor(repos): drop unused description + tighten create-project layout (#1930)
* refactor(repos): drop unused description + tighten create-project layout
Two related changes that touch the workspace-repos surface together.
1. Remove the per-repo `description` field everywhere it was threaded.
The only place it ever surfaced was a markdown table column the daemon
wrote into the agent runtime config, where most rows just read "—"
anyway. Agents already discover project structure by running
`multica project` / `multica issue` against the CLI, so the human-
readable description string carried no real value while taking up an
extra Settings input row and propagating through six layers (settings
UI → workspace.repos jsonb → handler RepoData → daemon RepoData →
repocache.RepoInfo → execenv.RepoContextForEnv).
- Settings → Repositories drops the description input; the URL field
now spans the whole row.
- WorkspaceRepo TS type loses `description`; backend RepoData /
RepoInfo / RepoContextForEnv all collapse to URL only.
- Daemon's runtime_config Repositories block changes from a
`| URL | Description |` markdown table to a simple bullet list.
- Tests updated; jsonb residue in existing workspaces is dropped at
normalize time, so no migration needed.
2. Tighten the Create Project modal footer: pull the Status / Priority /
Lead / Repos pills onto the same row as the Create Project button
(Linear-style single-row footer) instead of stacking them above it,
and swap the Repos pill icon from `FolderGit` to a real GitHub mark
(lucide-react v1 dropped brand icons, so the mark lives inline as a
small SVG component in this file).
I tried promoting Repos to its own "Resources" strip above the footer
to separate the resources abstraction from project metadata, but with
a single pill it looked too sparse — leaving a TODO comment in the
footer to revisit once we add Linear / Notion / Figma / Slack
resource types.
* fix(daemon test): drop residual Description field on RepoData literals
* fix(repos): drop Description residue surfaced after rebase on #1929
Project-resource github_repo lift path (#1929) and registerTaskRepos
both still constructed RepoData{...Description: ...} after the rebase.
Two test sites in daemon_test.go and execenv_test.go also reintroduced
the field. Strip them so the Description-removal change builds and
tests pass with the latest main.
|
||
|
|
2129aa3dee |
feat(projects): project github_repo resources override workspace repos (#1929)
* feat(projects): project github_repo resources override workspace repos When an issue's project has at least one github_repo resource, the daemon claim handler now sends only those as resp.Repos — workspace-level repos are hidden to avoid mixing two repo lists in the agent prompt. With no project github_repos (or no project), behavior is unchanged: workspace repos are surfaced as before. Lifts each project github_repo's url (and label, when present) into a RepoData entry so `multica repo checkout` and the meta-skill render the same URLs. The full structured list still ships at .multica/project/resources.json for skills that want everything. Adds TestProjectReposReplaceWorkspaceReposInMetaSkill covering the rendering side. Docs updated to spell out the new precedence. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): allow project repo URLs through the checkout allowlist When ClaimTaskByRuntime narrows resp.Repos to project github_repo URLs, the daemon receives URLs that may not exist in the workspace's GetWorkspaceRepos response. The existing checkout flow rejected those with ErrRepoNotConfigured because the allowlist (and cache) was built only from workspace-bound repos. Adds registerTaskRepos in daemon.runTask: before agent spawn, merge task.Repos into a new task-scoped allowlist (separate from the workspace-scoped one so a workspace refresh doesn't wipe project URLs) and kick off a background cache sync. ensureRepoReady now treats either allowlist as valid. Tests: - TestRegisterTaskReposAllowsProjectOnlyURL — project-only URL is checkout-able and does not trigger a workspace-repos refresh - TestRegisterTaskReposSurvivesWorkspaceRefresh — task URLs persist across refreshWorkspaceRepos - TestClaimTask_ProjectGithubReposOverrideWorkspaceRepos — claim handler returns only project repos when present, no workspace leakage - TestClaimTask_ProjectWithoutRepos_FallsBackToWorkspaceRepos — fall back to workspace repos when project has no github_repo resources Docs updated to spell out the daemon-side allowlist behavior. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
b1345685a3 |
fix(task): rerun starts a fresh session, skip poisoned resume (#1928)
* fix(task): rerun starts a fresh session, skip poisoned resume
When a task ended in a known agent fallback ("I reached the iteration
limit and couldn't generate a summary.", "Put your final update inside
the content string. Keep it concise.") the (agent_id, issue_id) resume
lookup would still pick that session, so a manual rerun inherited the
poisoned state and reproduced the same bad output.
Two complementary guards:
1. Daemon classifies poisoned terminal output and routes it through the
blocked path with failure_reason set ('iteration_limit' /
'agent_fallback_message'). GetLastTaskSession excludes failed tasks
with those reasons, so even comment-triggered tasks no longer resume
them. Tasks that failed mid-flight (timeout, runtime_recovery, etc.)
are still resumable, preserving MUL-1128's auto-retry contract.
2. Manual rerun marks the new task force_fresh_session=true. The daemon
claim handler skips the resume lookup entirely when the flag is set,
capturing the user-intent signal that "the prior output was bad" even
when poisoned classification misses a future fallback wording.
Auto-retry of orphaned mid-flight failures (MaybeRetryFailedTask →
CreateRetryTask) does not take this path, so it keeps resuming.
Tests: classifyPoisonedOutput unit test; integration tests assert the
SQL filter excludes poisoned classifiers, RerunIssue flips the flag,
and the normal enqueue path leaves it false.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): cap poisoned-output matcher to short trimmed text
GPT-Boy review on MUL-1630: the previous strings.Contains match would
classify any output that quoted the marker substring — including a
review/analysis that simply discussed the marker itself. Real fallback
messages are short single-sentence affairs, so cap the candidate at
~one paragraph and trim whitespace before matching. Adds regression
tests covering a long quoting review and a marker buried in a long
real conclusion; both must stay classified as completed.
Co-authored-by: multica-agent <github@multica.ai>
* fix(migrations): rename 065 force_fresh_session → 066 to clear collision
main introduced 065_project_resources after this branch was cut, so
both files shared the 065_ prefix. The readiness check
(server/cmd/server/health.go → migrations.LatestVersion) takes the
last entry by lexical order, which is 065_project_resources, leaving
this branch's 065_force_fresh_session unguarded — a deploy that
applied project_resources but not force_fresh_session would still
report ready, and the next enqueue / rerun / claim would crash on
"column force_fresh_session does not exist".
Renaming to 066_force_fresh_session puts it strictly after
project_resources so readiness blocks until it's applied.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
44608713bb |
feat(projects): typed project resources + agent runtime injection (#1926)
* feat(projects): typed project resources + agent runtime injection
Adds a `project_resource` table that lets a project carry typed pointers
(github_repo today, more later) and surfaces them at agent runtime.
Server
- migration 065: project_resource (resource_type TEXT + resource_ref JSONB)
- sqlc CRUD + handler at /api/projects/{id}/resources
- claim handler attaches project_id/title + resources to issue tasks
Daemon
- TaskContextForEnv carries project context
- writes .multica/project/resources.json into workdir
- adds "## Project Context" block to CLAUDE.md / AGENTS.md / GEMINI.md
via type-dispatched formatter so new resource types just add a case
CLI
- multica project create --repo <url> attaches repos in one step
- multica project resource add/list/remove
Frontend
- Project create modal: Repos pill (workspace repos + ad-hoc URL)
- Project detail sidebar: collapsible Resources section with attach/remove
Docs
- New "Project Resources" chapter explaining the abstraction and
exactly what code to touch when adding a new resource type
Co-authored-by: multica-agent <github@multica.ai>
* fix(projects): transactional resources[] on create + generic CLI ref + test fix
Addresses review feedback on PR #1926:
1. CI red: TestProjectResourceLifecycle delete step called withURLParam
twice, which replaced the chi route context and dropped the project id.
Switched to the existing withURLParams helper from daemon_test.go.
2. POST /api/projects now accepts resources[] and attaches them in the
same transaction as the project. Invalid refs roll back the whole
create — no more half-attached projects on failure. Web modal + CLI
`project create --repo` both use the new bundled payload.
3. CLI `project resource add` now accepts a generic --ref '<json>' flag
so a new resource_type works without a CLI change. Per-type
shortcuts (--url for github_repo) remain as a convenience but are no
longer the only way in. Docs updated to drop the CLI from the
"files you must touch" list.
Adds two new server handler tests:
- TestCreateProjectAttachesResources (resources[] happy path)
- TestCreateProjectRollsBackOnInvalidResource (transactional rollback)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
b6a3f8ed58 |
feat(daemon): add Co-authored-by trailer for Multica Agent to git commits (#1907)
* feat(daemon): add Co-authored-by trailer for Multica Agent to git commits Install a prepare-commit-msg hook in worktree bare repos that appends "Co-authored-by: multica-agent <github@multica.ai>" to every commit made by agents. Uses git interpret-trailers for proper formatting and skips duplicates. * feat(settings): add Co-authored-by toggle in workspace Labs settings Add a workspace-level toggle to enable/disable the Co-authored-by trailer for agent commits. Default is enabled (on). Backend: - Include workspace settings in daemon register response - Store settings in daemon workspaceState - Thread CoAuthoredByEnabled through WorktreeParams to conditionally install the prepare-commit-msg hook - Parse co_authored_by_enabled from workspace settings JSONB Frontend: - Replace empty Labs tab placeholder with a Git section containing a Switch toggle for the Co-authored-by trailer setting - Optimistically update the workspace query cache on toggle * chore(daemon): skip squash commits in Co-authored-by hook Test commit to verify the prepare-commit-msg hook appends the Co-authored-by trailer automatically. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
286ecf04b1 |
feat(daemon): add WebSocket heartbeat with HTTP fallback
Adds daemon WebSocket heartbeat acknowledgements while preserving HTTP heartbeat fallback and HTTP task claim/result paths. Keeps old daemon compatibility and task wakeup behavior intact. |
||
|
|
22136a55fc |
fix(server/heartbeat): split auth_ms into decode/runtime_lookup/workspace_check + auth_path (#1822)
Prod slow-log on the deployed v0.2.17 fix shows total_ms=4012,
auth_ms=4010, update_ms=1, all skill stages = 0 — meaning the bottleneck
on /api/daemon/heartbeat is now the auth section, not the Redis claim
path. To pinpoint which sub-stage dominates, decompose auth_ms into:
- decode_ms — JSON body decode
- runtime_lookup_ms — Queries.GetAgentRuntime (PG PK select)
- workspace_check_ms — requireDaemonWorkspaceAccess (string compare for
daemon-token, requireWorkspaceMember for PAT/JWT)
Also add auth_path ("daemon_token" | "pat" | "jwt") set by DaemonAuth
middleware so slow-logs disambiguate which token kind was used. PAT/JWT
takes an extra DB round-trip via requireWorkspaceMember and is a
candidate cause of long auth tails on daemons that haven't migrated to
mdt_ tokens.
The handler keeps the same external behavior; the change inlines and
instruments requireDaemonRuntimeAccess in DaemonHeartbeat only — other
callers of the helper are untouched. logHeartbeatEndpointSlow gains the
new fields.
Existing heartbeat tests pass; the slow-probe test output now shows the
new auth_path / decode_ms / runtime_lookup_ms / workspace_check_ms
fields populated.
|
||
|
|
2d9c153695 |
feat: quick-create issue (async agent + inbox completion) (#1786)
* feat(server): add quick-create issue async task path Adds POST /api/issues/quick-create which validates the picked agent's reachability up front (not archived, has runtime, runtime online) then queues an issue-less agent task whose context JSONB carries the user's natural-language prompt + requester + workspace. Daemon claim resolves the workspace from the context, and the prompt builder switches to a quick-create template instructing the agent to translate the prompt into a single multica issue create call. Task completion writes a success inbox item to the requester pointing at the newly-created issue (located by querying the agent's most recent issue in the workspace since task start, so we don't depend on agent stdout shape). Failures write an action_required inbox item carrying the original prompt + agent id so the frontend can offer "Edit as advanced form" without losing input. * feat(views): quick-create issue modal + inbox failure CTA Adds a streamlined create-issue UI bound to the c shortcut: pick an agent, type one line, submit. The modal closes immediately and the agent translates the prompt into a multica issue create call in the background. Shift+c keeps the legacy advanced form for users who want every field. The "Advanced" button inside the new modal seeds the shared issue-draft store with the prompt + picked agent so switching mid-flow doesn't lose input. Last-used agent persists per (user, workspace) via a workspace-aware zustand store so frequent users skip the picker on every open. Inbox renders quick_create_done items with a status pin to the new issue and quick_create_failed items with an "Edit as advanced form" CTA that re-seeds the legacy modal with the original prompt. ApiError now carries the parsed JSON body so the modal can branch on the structured agent_unavailable code without parsing the error message. * fix(quick-create): execenv injection, claim race, private-agent permission Addresses GPT-Boy review on #1786: 1. execenv was rendering the assignment-task issue_context.md / runtime workflow even for quick-create, telling the agent to call `multica issue get/status/comment add` against an empty IssueID. Adds QuickCreatePrompt to TaskContextForEnv, plus a quick-create branch in renderIssueContext + the runtime_config workflow that instructs the agent to run a single `multica issue create` and exit, with explicit "do NOT call issue get/status/comment add" guards. 2. ClaimAgentTask serialized only on issue_id / chat_session_id, so concurrent quick-creates on the same agent (both NULL on those columns) ran in parallel — making the success-inbox lookup race over "most recent issue by this agent". Adds a third OR clause that treats "all four FKs NULL" as a serialization key for the same agent, so quick-create tasks on a given agent run one at a time. 3. QuickCreateIssue handler bypassed the private-agent ownership rule that validateAssigneePair enforces elsewhere — a user could POST a private agent_id they didn't own and trigger it. Now routes the picked agent through validateAssigneePair before the runtime liveness check. 4. Clarifies the quick-create-store namespacing comment to match the actual workspace-aware StateStorage convention used by the other issue stores (per-user is browser-profile-local). * fix(quick-create): branch Output section + deterministic origin lookup Addresses GPT-Boy's second-pass review on #1786: 1. The runtime_config.go Output section forced "Final results MUST be delivered via multica issue comment add" for every non-autopilot task — quick-create still got this conflicting instruction even though there's no issue to comment on. Switched the Output block to a three-way switch so quick-create gets a tailored "stdout is captured automatically; do NOT call comment add" branch matching the autopilot variant. 2. Completion lookup was "most recent issue created by this agent since task.started_at", which races against concurrent issue creates by the same agent (assignment task running alongside quick-create when max_concurrent_tasks > 1). Replaced with a deterministic origin link: - Migration 060 extends issue.origin_type CHECK to allow 'quick_create'. - Daemon sets MULTICA_QUICK_CREATE_TASK_ID env var when running a quick-create task. - multica issue create CLI reads the env var and stamps the new issue with origin_type=quick_create + origin_id=<task_id>. - Server CreateIssue handler accepts (origin_type, origin_id) from trusted callers (only "quick_create" is allowed; the pair is rejected unless both fields are provided together). - notifyQuickCreateCompleted now calls GetIssueByOrigin keyed on (workspace_id, "quick_create", task.ID) — no more time-window racing against parallel agent activity. The old GetRecentIssueByCreatorSince query is removed. |
||
|
|
f628e48775 |
refactor(server): error-returning ParseUUID to prevent silent data loss
* refactor(server): make ParseUUID error-returning to prevent silent data loss (MUL-1410) util.ParseUUID previously swallowed errors and returned a zero pgtype.UUID on invalid input. When this zero UUID reached a write query (DELETE/UPDATE), the SQL matched zero rows and the handler returned 2xx success — producing silent data corruption. #1661 (DeleteIssue with identifier-style ID) was the visible symptom; PR #1680 patched that one site, this commit closes the class of bug. Changes: - util.ParseUUID now returns (pgtype.UUID, error). Add util.MustParseUUID for trusted round-trips that should panic on invalid input. - handler/handler.go: parseUUID wrapper now calls MustParseUUID — any unguarded user-input string reaching it surfaces as a recovered panic (chi middleware.Recoverer → 500) instead of silently corrupting data. Add parseUUIDOrBadRequest(w, s, fieldName) for handler entry points. - Convert every Queries.Delete*/Update* call site reachable from raw user input (autopilot, comment, project, skill, skill_file, label, pin, attachment, feedback, issue assignee, daemon runtime, workspace) to validate UUIDs explicitly with parseUUIDOrBadRequest, returning 400 on invalid input. Where a resolved entity.ID is already in scope, write queries now use it directly instead of re-parsing the URL string. - Update getWorkspaceMember + loadIssueForUser to handle invalid UUIDs gracefully (404/400 instead of panic). - Update util/middleware/cmd-level callers (subscriber_listeners, notification_listeners, activity_listeners, scope_authorizer, middleware/workspace) to use the error-returning API. - Add server/internal/util/pgx_test.go covering valid/invalid input and the MustParseUUID panic contract. - Add TestDeleteIssueByIdentifier + TestDeleteIssueRejectsInvalidUUID regression tests in handler_test.go (the original #1661 bug + the invalid-input case). - Document the handler UUID parsing convention in CLAUDE.md so the rule is enforceable in future PR review. * fix(server): address GPT-Boy review of #1748 P1 fixes from PR #1748 review: 1. Migrate remaining request-boundary UUIDs to parseUUIDOrBadRequest so malformed input returns 400 instead of panic/500. Was missing on: - issue.go: workspace_id in CreateIssue/ChildIssueProgress/ListIssues/ SearchIssues/BatchUpdateIssues/BatchDeleteIssues; project_id / parent_issue_id / lead_id / assignee_id / assignee_ids / creator_id filters; batch issue_ids and assignee/parent/project fields in BatchUpdateIssues (skip on bad input via util.ParseUUID, matching the existing per-row continue semantics). - project.go: project id + workspace_id in GetProject/UpdateProject/ DeleteProject; lead_id in CreateProject/UpdateProject; workspace_id in ListProjects + SearchProjects. - handler.go: resolveActor now uses util.ParseUUID for X-Agent-ID / X-Task-ID headers; invalid UUID falls back to "member" (matches pre-existing semantics) instead of panicking. - issue.go: validateAssigneePair returns 400 on invalid workspace_id instead of panicking. 2. Fix issue:deleted WS event payloads to emit uuidToString(issue.ID) instead of the raw URL string. After an identifier-path delete ("MUL-7"), the previous payload would have leaked the identifier to subscribers, leaving stale entries in frontend caches that key by UUID. Updated DeleteIssue (issue.go:1341) and BatchDeleteIssues (issue.go:1641). The slog "issue deleted" log line also now records the resolved UUID so logs match the WS payload. 3. Extend TestDeleteIssueByIdentifier to subscribe to the bus and assert issue:deleted.payload.issue_id is the resolved UUID, not the identifier. * fix(server): validate remaining reviewed UUID inputs * fix(server): validate remaining handler UUID inputs * fix(server): finish request boundary UUID audit * fix(server): validate remaining request body UUIDs * fix(server): validate runtime path UUIDs * fix(server): validate remaining audit UUID inputs --------- Co-authored-by: Eve <eve@multica.ai> |
||
|
|
683ff132ca |
fix(server/heartbeat): probe/claim split + slow-log + model-list running timeout (#1644)
Mitigates #1637 and the related model-discovery failure in MUL-1397 by bounding the /api/daemon/heartbeat hot path with an ack-safe probe/claim split, adding structured slow-log attribution, and closing the ModelListStore running-state gap. See PR description for details. |
||
|
|
13d9d7df1b |
fix: pass autopilot run-only context to agents
Fix run-only autopilot tasks so agents receive autopilot context instead of empty issue instructions. Add regression coverage for run-only terminal event sync. |
||
|
|
e0e91fc792 |
feat(daemon): harden agent mention-loop instructions (#1581)
* feat(daemon): harden agent mention-loop instructions Two agents that mention each other via `mention://agent/<id>` can fall into an infinite reply loop — each says "I'm done" in prose but keeps `@mentioning` the other, which re-enqueues their run. Adding hard caps on agent-to-agent turns conflicts with Multica's design principle of giving agents the same authorship freedom as humans, so this change hardens the instructions that the harness injects instead. - Replace the terse "mentions are actions" blurb with a full Mentions protocol: `side-effecting` warning, explicit "when NOT to mention" (replying to another agent, sign-offs, thanks) and "when a mention IS appropriate" (human escalation, first-time delegation, user asked). - Add a pre-workflow decision step for comment-triggered runs: decide whether a reply is warranted at all, decide whether to include any `@mention`, and clarify that the post-a-comment rule is mandatory *if* you reply — silence is a valid exit for agent-to-agent threads. - Thread the triggering comment's author kind + display name (`TriggerAuthorType` / `TriggerAuthorName`) from the claim endpoint through the daemon task type, per-turn prompt, and CLAUDE.md workflow. When the author is another agent, both surfaces now name that agent and warn against sign-off mentions. - Soften the old closing line that told agents to `always` use the mention format — the word generalized to member/agent mentions and encouraged the very behavior that causes loops. Refs GH#1576, MUL-1323. * fix(daemon): remove MUST-respond conflict and sanitize trigger author name Addresses two blocking points on PR #1581: 1. buildCommentPrompt told the agent "You MUST respond to THIS comment" and unconditionally appended the reply command — directly conflicting with the new agent-to-agent silence-as-valid-exit workflow. Models were likely to keep following the older must-reply rule and fall back into the loop this PR is trying to close. Rewrite the header as "Focus on THIS comment — do not confuse it with previous ones" (keeps the anti-stale-comment signal) and change BuildCommentReplyInstructions to open with "If you decide to reply, post it by running exactly this command" so the reply command is available but conditional across both prompt surfaces. 2. Raw agent/user display names were being embedded directly into the high-priority prompt and CLAUDE.md via TriggerAuthorName. Agent and member names are only validated as non-empty at write time, so a name containing newlines, backticks, or fake mention markup would turn the field into a cross-agent prompt-injection surface. Add execenv.SanitizePromptField — strip control runes, collapse whitespace, drop markdown structural characters (backtick, asterisk, brackets, pipe, angle brackets, hash, backslash), truncate to 64 runes — and apply it at both embed sites (per-turn prompt and CLAUDE.md). Defense-in-depth at the consumption layer so this works for already-stored names without a migration. Tests: TestSanitizePromptField covers the policy; TestBuildPromptSanitizesAgentName plants an attack payload in TriggerAuthorName and checks the rendered prompt does not leak the newline-anchored injection or the fake mention markup. TestBuildPromptCommentTriggered*{,ByMember} updated to lock in the conditional reply-command framing. * refactor(daemon): trim redundant CLAUDE.md preamble and drop name sanitizer Per PR #1581 feedback: 1. Remove the `if ctx.TriggerAuthorType == "agent"` preamble block in runtime_config.go. It duplicated what workflow steps 4 and 5 already say ("Decide whether a reply is warranted", "Never @mention the agent you are replying to as a thank-you or sign-off"), so the signal lands the same without the extra ~7 lines of CLAUDE.md. The per-turn prompt preamble in prompt.go stays — that surface has no numbered workflow below it and would otherwise lose the silence-as-exit signal. 2. Delete execenv.SanitizePromptField + its test. Workspace agents are created by trusted team members, so the cross-agent name-injection surface it defended isn't realistic in the current trust model. 3. Drop TriggerAuthorType/Name from execenv.TaskContextForEnv and stop populating them in daemon.go — they're no longer read by the execenv package. The same fields on daemon.Task stay because prompt.go still needs them to label the triggering author in the per-turn prompt. Tests simplified to match the leaner shape: CLAUDE.md regression guards now assert that the anti-loop phrases live in the numbered workflow, and the sanitizer-specific tests are removed. |
||
|
|
ad803b86ec |
fix(skills): shared-state runtime local-skill stores (MUL-1288) (#1557)
* fix(skills): shared-state runtime local-skill stores (MUL-1288)
Fixes the bug Bohan surfaced on MUL-1288: behind prod's multi-node API the
runtime-local-skill list/import flow would intermittently time out or 404.
Root cause: LocalSkillListStore and LocalSkillImportStore were per-process
sync.Mutex+map, so when the frontend POST, the daemon heartbeat and the
frontend GET landed on different API instances, each saw a different
pending set. Confirmed against production daemon logs — the failed
request_id never showed up in the daemon's "runtime local skills
requested" log, even though other requests around the same window worked.
Per Yushen's guidance (server must stay stateless; state lives in
storage), migrate both stores to Redis so every node agrees on the same
pending set.
What changed
- LocalSkillListStore / LocalSkillImportStore are now interfaces. Methods
take context.Context and return error.
- InMemoryLocalSkill{List,Import}Store — renamed from the existing types,
kept as the default for single-node dev and the in-process test suite.
- RedisLocalSkill{List,Import}Store — new. Keyed on
mul:local_skill:{list,import}:<id> (JSON record, TTL = retention), with
a per-runtime ZSET mul:local_skill:{list,import}:pending:<runtime_id>
(score = created_at UnixNano) providing cross-node ordering. PopPending
wins the claim via ZREM == 1, so concurrent pops from different nodes
never return the same request twice.
- NewRouter gets an optional *redis.Client; when non-nil it swaps in the
Redis-backed stores. main.go hoists the existing Redis client (already
used by the realtime relay) so both subsystems share one client.
- Handler fields flip to interface types; handler.New still constructs
in-memory stores by default.
- Daemon heartbeat's PopPending call sites thread r.Context() through so
Redis operations inherit request cancellation. Errors warn instead of
poisoning the heartbeat response.
Tests
- Existing in-memory tests updated for the new signatures (ctx + error).
- New runtime_local_skills_redis_store_test.go covers:
- Create/Get/Complete round trip preserves skills payload
- PopPending across two *store instances sharing one rdb (the exact
regression: node A creates, node B pops)
- N concurrent PopPending on one record => exactly one winner
- Pending-timeout threshold transitions the record and removes the zset
member so a later PopPending doesn't return a timed-out request
- Import store round-trips CreatorID (which is json:"-" on the public
struct — needs a Redis envelope so ReportLocalSkillImportResult can
still attribute the created Skill)
- Per-runtime isolation — a PopPending for runtime B does not disturb
A's pending zset
- Tests skip gracefully if REDIS_TEST_URL is unset; CI now spins up a
redis:7-alpine service and exports the URL so the suite actually runs
there.
Out of scope
PingStore / UpdateStore / ModelListStore have the same shape and the
same latent bug (they just fire rarely enough to have gone unnoticed).
Migrating them to Redis is a follow-up — MUL-1288 is specifically the
local-skills break Bohan is blocked on.
* fix(skills): atomic Redis claim + surface store write failures (PR #1557 review)
Two real gaps GPT-Boy flagged:
1. RedisLocalSkill{List,Import}Store.PopPending was doing ZREM then SET as
two separate round-trips. If the SET failed for any reason — transient
Redis error, context cancellation, pod getting SIGKILL'd mid-call — the
request was already gone from the pending zset but the stored record
still said "pending", and no subsequent PopPending would re-dispatch
it. Exactly the "request disappears" class of bug this PR is supposed
to kill.
Fix: push the claim into a Lua script so Redis runs ZREM + SET as one
atomic unit. If ZREM returns 0 (another node won the race), SET is
skipped and the caller retries.
2. ReportLocalSkill{List,Import}Result handlers were logging Complete/Fail
store failures at Warn and still returning 200 OK. That made the
daemon think the report landed when it hadn't, leaving the request
stuck in "running" until the server-side timeout and — worse for the
import flow — leaving the just-created Skill row orphaned in Postgres
so every retry collided with the unique-name constraint.
Fix: escalate to Error + return 500 so the daemon (and monitoring) can
see the write failed. For the import flow, Complete failure after the
Skill row is already committed also triggers a best-effort DeleteSkill
so a daemon retry lands on a clean slate instead of hitting
"a skill with this name already exists" forever.
Tests
- New TestRedisLocalSkillListStore_PopPendingAtomicClaim asserts the
happy-path invariant: after one PopPending the record is "running"
AND a second PopPending returns nothing. Deliberately does NOT poke
Redis internals directly so the test survives any future key-layout
refactor.
- Existing cross-instance / concurrent / timeout / per-runtime tests
continue to pass against the Lua-based claim path (verified locally
against a scratch redis-server; 8/8 Redis tests green).
|
||
|
|
6fd1255873 |
feat(runtimes): remove Test Connection / runtime ping feature (#1554)
* feat(runtimes): remove Test Connection / runtime ping feature The Test Connection action invoked a real single-turn agent run to verify runtime connectivity. In practice it was expensive (reuses none of the normal task exec env, so it also gave misleading results) and low value — daemon heartbeat + Online status already covers the "is the runtime alive" question. Dropping the whole end-to-end probe path: - deletes server handler and in-memory PingStore - drops pending_ping from the heartbeat response and daemon poll loop - removes daemon.handlePing, PendingPing, ReportPingResult - removes the CLI `multica runtime ping` command - removes the PingSection UI block and RuntimePing types / api methods * docs: fix runtime CLI subcommand list in product-overview |
||
|
|
91424752ac |
feat(realtime): phase 0 — extract Broadcaster interface + add metrics (MUL-1138) (#1429)
* feat(realtime): phase 0 — extract Broadcaster interface + add metrics Phase 0 of the WebSocket horizontal-scaling plan tracked in MUL-1138. This change is intentionally behavior-preserving: it sets up the seams needed for later phases (subscribe/unsubscribe protocol, scope-level fanout, Redis Streams relay) without altering any wire protocol or producer call sites. What changed - New realtime.Broadcaster interface covering the three fanout methods producers already use on *Hub (BroadcastToWorkspace, SendToUser, Broadcast). *Hub continues to satisfy it; a future Redis-backed implementation can be dropped in without touching listeners. - registerListeners now depends on realtime.Broadcaster instead of *realtime.Hub, isolating the bus → realtime fanout layer behind an interface. - New realtime.Metrics singleton with atomic counters: connects, disconnects, active connections, slow-client evictions, total messages sent/dropped, and per-event-type send counters. Wired into Hub register/unregister/broadcast paths and into every listener. - New GET /health/realtime endpoint returning a JSON snapshot of the metrics so we can observe baseline fanout pressure before phase 1. Why phase 0 first GPT-Boy's only-Redis plan and CC-Girl's review both call out the same prerequisite: get a Broadcaster seam and visibility in place before introducing scope-level subscriptions or a Redis relay. Doing this as a standalone step keeps each later PR focused and trivially revertable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(realtime): only-Redis fanout — scopes, subscribe protocol, Redis Streams relay (MUL-1138) Implements the final-version plan agreed in MUL-1138 on top of phase 0: * Hub: 4 scope types (workspace/user/task/chat), per-client subscription set, subscribe/unsubscribe WS frames, ScopeAuthorizer hook for task/chat scope auth, first/last-subscriber callbacks for the relay, workspace+user auto-subscribe on connect. * RedisRelay: Broadcaster impl that XADDs every event into ws:scope:{type}:{id}:stream and XREADGROUPs only the scopes for which this node has live subscribers. Per-node consumer group, heartbeat, stale-consumer sweeper, MAXLEN cap, lag/disconnect metrics. * Listeners: route task:* events to ScopeTask, chat:* events to ScopeChat; workspace remains the default for everything else. * events.Event: optional TaskID / ChatSessionID hints so the listener layer can pick the right scope without re-parsing payloads. * Handler: publishTask / publishChat helpers; chat + task message publishers updated to use them. * main.go: when REDIS_URL is set, wrap the hub with NewRedisRelay and pass the relay (instead of the hub) to registerListeners. A db-backed ScopeAuthorizer enforces that task/chat subscribes belong to the caller's workspace. * Metrics: per-scope subscribe/deny counters, redis connect state, node id, lag/dropped counters surfaced via /health/realtime. Behavior in single-node mode (REDIS_URL unset) is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(realtime): address PR #1429 review must-fix items (MUL-1138) - listeners: keep task/chat events on workspace fanout until the WS client supports scope-subscribe + reconnect-replay. Routing them through BroadcastToScope today (without any client subscriber) would silently drop every chat / task message and break the live timeline, chat unread badges, and pending-task UI. The server-side scope infra (Hub subscribe/unsubscribe, ScopeAuthorizer, Redis Streams relay) stays in place so flipping the switch in the client follow-up PR is a one-line change. - scope_authorizer: ScopeChat now enforces CreatorID == userID, mirroring the HTTP layer (handler/chat.go: GetChatSession / SendChatMessage / MarkChatSessionRead). Without this, any workspace member who learned a session_id could subscribe to chat:message / chat:done / chat:session_read for a peer's private chat. The same creator-only check is applied to ScopeTask when the task is a chat task (task.ChatSessionID set). Issue tasks remain workspace-scoped. - Refactor scope authorizer to depend on a narrow scopeAuthQuerier interface so its decisions can be unit-tested without a live DB. - Add tests: * listeners_scope_test.go pins the workspace-fanout fallback for task:message / task:progress / chat:message / chat:done / chat:session_read. * scope_authorizer_test.go covers chat creator-only access, chat-task creator-only access, and issue-task workspace-only access (creator allowed, peer denied, cross-workspace denied, missing session denied, empty userID denied). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: CC-Girl <cc-girl@multica.ai> |
||
|
|
f247a4f544 |
feat(skills): import runtime local skills into workspace (#1431)
* feat(skills): import runtime local skills into workspace * fix(skills): address runtime local skill review feedback * docs(skills): annotate local provider skill paths --------- Co-authored-by: zhangliang <zhangliang@gaoding.com> |
||
|
|
0b1333fb00 |
feat(server): orphan-task recovery + auto-retry + manual rerun (MUL-1128) (#1476)
* feat(server): orphan-task recovery + auto-retry + manual rerun (MUL-1128)
When the daemon process crashed mid-task the issue was stuck at
in_progress for up to 2.5h: the in-flight task timeout was the only
mechanism that ever moved the row, and the runtime heartbeat sweeper
only fires after the runtime stays offline for 45s — a quick restart
beats both windows.
This change implements the A+B plan from the issue thread:
A. lifecycle hygiene
- migration 055 adds attempt / max_attempts / parent_task_id /
failure_reason / last_heartbeat_at to agent_task_queue
- new daemon-auth endpoint POST /runtimes/{id}/recover-orphans:
daemon calls it on every register so the server fails any
dispatched/running tasks the previous process left behind
- new daemon-auth endpoint POST /tasks/{id}/session: persists the
agent's session_id + work_dir mid-flight so a crash doesn't
lose the resume pointer (claude+codex emit MessageStatus with
SessionID; daemon forwards on the first one it sees)
- FailAgentTask / FailStaleTasks / FailTasksForOfflineRuntimes
now set failure_reason ('agent_error' / 'timeout' /
'runtime_offline')
B. auto-retry with resume context
- TaskService.MaybeRetryFailedTask spawns a fresh queued attempt
carrying parent's session_id/work_dir when the failure reason
is infrastructure-shaped (timeout, runtime_offline,
runtime_recovery) and attempt < max_attempts; skips autopilot
- wired into the runtime sweeper paths and TaskService.FailTask
so the user transparently sees a new in_progress run instead of
a stuck row
- new user-auth POST /api/issues/{id}/rerun + multica issue rerun
CLI for the manual escape hatch
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(server): address PR review for orphan-task recovery (MUL-1128)
Three review-must-fix items on top of the A+B implementation:
1. recover-orphans now funnels through TaskService.HandleFailedTasks,
the same shared post-failure pipeline used by the runtime sweeper.
This guarantees task:failed events are emitted, agent status is
reconciled, and issues stuck in_progress with no remaining active
task are reset to todo even when no auto-retry is created
(max_attempts exhausted, autopilot, non-retryable reason).
2. RerunIssue now uses CancelAgentTasksByIssueAndAgent, scoped to the
issue's current assignee. The previous implementation called
CancelAgentTasksByIssue, which would collateral-cancel parallel
@-mention agents on the same issue.
3. GetLastTaskSession now considers both completed and failed tasks
(mirroring GetLastChatTaskSession), ordering by the most recent
timestamp. With UpdateAgentTaskSession pinning session_id/work_dir
mid-flight, an auto-retry or manual rerun of a daemon-crash failure
now actually resumes the prior conversation context instead of
starting fresh — matching the stated B-branch behaviour.
go build / go vet pass; the existing service and agent test suites pass.
runtime_sweeper / handler integration tests require a local DB with the
055 migration (and the pre-existing 050 first_executed_at column).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
||
|
|
637bdc8eb3 |
feat(analytics): full PostHog pipeline + 6 funnel events (MUL-1122) (#1367)
* feat(analytics): add PostHog client with async batch shipping Introduces server/internal/analytics, the shipping layer for the product funnel defined in docs/analytics.md. Capture is non-blocking — events are enqueued into a bounded channel and a background worker batches them to PostHog's /batch/ endpoint. A broken backend drops events rather than blocking request handlers. Local dev and self-hosted instances run a noop client until the operator sets POSTHOG_API_KEY. This is PR 1 of MUL-1122; signup and workspace_created emission land in the follow-up commit so this change is independently reviewable. * feat(server): emit signup and workspace_created analytics events Wires analytics.Client through handler.New and main, then emits the first two funnel events: - signup fires from findOrCreateUser (which now reports isNew), covering both the verification-code and Google OAuth entry points — a single emission site guarantees Google signups aren't missed. - workspace_created fires after the CreateWorkspace transaction commits, with is_first_workspace computed from a post-commit ListWorkspaces count so we can distinguish fresh-user activation from returning-user expansion. Tests use analytics.NoopClient so nothing ships from test runs. PR 1 of MUL-1122; runtime_registered and issue_executed follow in later PRs per the plan. * refactor(analytics): drop is_first_workspace from workspace_created Stamping "is this the user's first workspace?" at emit time races under concurrent CreateWorkspace requests: two transactions committing close together can both read a post-commit count greater than one and both emit false. Fixing it at the SQL layer requires a schema change we don't want in PR 1. PostHog answers the same question exactly from the event stream (funnel on "first time user does X" / cohort on $initial_event), so removing the property loses no information and makes the emit side race-free. * docs(analytics): document self-host safety defaults Spell out why self-hosted instances never ship events upstream by default (empty POSTHOG_API_KEY → noop client) and explain how operators can point at their own PostHog project without any code change. * feat(analytics): emit runtime_registered, issue_executed, team_invite_* Three server-side funnel events, all gated on first-time state transitions so retries and re-runs don't inflate the WAW buckets: - runtime_registered fires from DaemonRegister when UpsertAgentRuntime reports (xmax = 0) — i.e. the row was inserted, not updated. Heartbeats and re-registrations stay silent. - issue_executed fires from CompleteTask after an atomic UPDATE issue SET first_executed_at = now() WHERE id = $1 AND first_executed_at IS NULL flips the column for the first time. Retries, re-assignments, and comment-triggered follow-up tasks hit the WHERE clause and no-op. Carries nth_issue_for_workspace so the ≥1/≥2/≥5/≥10 buckets filter without extra queries. - team_invite_sent fires from CreateInvitation and team_invite_accepted from AcceptInvitation, closing the expansion funnel. Adds a 050 migration for issue.first_executed_at plus a partial index so the workspace-scoped executed-count query doesn't scan the never-executed tail. * feat(config): surface PostHog key via /api/config Extends AppConfig with posthog_key / posthog_host sourced from env on every request (so operators can rotate the key via secret refresh without a restart). Reading the key off the server — rather than baking it into the frontend bundle via NEXT_PUBLIC_* — means self-hosted instances inherit the blank key automatically and never ship events upstream. * feat(analytics): wire posthog-js identify + UTM capture on the client Adds @multica/core/analytics — a thin wrapper around posthog-js that owns attribution capture and identity merge. Posthog-js config comes from /api/config (not NEXT_PUBLIC_*), so self-hosted instances whose server returns an empty key automatically run the SDK inert. captureSignupSource stamps a multica_signup_source cookie with UTM params and the referrer's origin (never the full referrer — that can leak OAuth code/state in the callback URL). The backend signup event reads this cookie on new-user creation. Identity flows: - auth-initializer fires identify() right after getMe() resolves, on both cookie and token paths. A getConfig/getMe race is handled by buffering a pending identify inside the analytics module and flushing it once initAnalytics finishes. - auth store calls identify() on verifyCode / loginWithGoogle / loginWithToken and resetAnalytics() on logout so the next login merges cleanly without bleeding events. * docs(analytics): describe runtime_registered, issue_executed, invite events Fills in the schema for the remaining funnel events. Captures the design commentary that belongs next to the contract rather than in a PR description — in particular why issue_executed uses the atomic first_executed_at flip instead of counting task-terminal events, and why runtime_registered relies on xmax = 0 rather than a query-then-write. * fix(analytics): drop non-atomic nth_issue_for_workspace from issue_executed Computing the workspace's Nth-issue ordinal at emit time is not atomic under concurrent first-completions — two transactions can both run MarkIssueFirstExecuted, then both run CountExecutedIssuesInWorkspace, and both observe count=1 before either has committed, so both events go out stamped as n=1. Serialising it would mean a per-workspace advisory lock or a SERIALIZABLE-isolated tx; PostHog answers the same question exactly at query time via row_number() partitioned by workspace_id, so the emit-time property adds risk without adding information. Removes the property from analytics.IssueExecuted, deletes the unused CountExecutedIssuesInWorkspace query, and regenerates sqlc. The partial index stays — any future workspace-scoped executed-issue query will want it. * fix(analytics): wire $pageview and harden signup_source cookie payload Two frontend fixes from the PR review: - PageviewTracker, mounted under WebProviders, fires capturePageview on every Next.js App Router path / query-string change. Without this the capturePageview helper in @multica/core/analytics was never called and the acquisition funnel's / → signup step was empty. - captureSignupSource now caps each UTM / referrer value at 96 chars *before* JSON.stringify, and drops the whole cookie when the serialised payload still exceeds 512 chars. Previously the overall slice(0, 256) could leave a half-JSON string on the wire that neither the backend nor PostHog could parse. Both capturePageview and identify now buffer a single pending call when fired before initAnalytics resolves — otherwise the initial "/" pageview and same-turn login identify race the /api/config fetch and get dropped. resetAnalytics clears both buffers so a logout→login cycle stays clean. * fix(analytics): URL-decode signup_source cookie on read Go does not URL-decode Cookie.Value automatically, so the frontend's JSON-then-encodeURIComponent payload was landing in PostHog as percent-encoded garbage (%7B%22utm_source...). Unescape on read so the backend receives the original JSON string the frontend intended, and drop values that fail to decode or exceed the server-side cap — sending truncated garbage is worse than sending nothing. Oversized-cookie guard matches the frontend's SIGNUP_SOURCE_MAX_LEN. * docs(analytics): reflect nth-issue drop, $pageview wiring, cookie encoding Pulls the schema doc back in line with the code: issue_executed no longer advertises nth_issue_for_workspace (with a note about why PostHog derives it at query time instead), the frontend $pageview section names the actual PageviewTracker component that fires it, and the signup_source section documents the per-value cap / overall drop rule and the encode-on-write / decode-on-read contract. --------- Co-authored-by: Jiang Bohan <bhjiang@outlook.com> |
||
|
|
8eb81aa396 |
fix(daemon): enforce workspace isolation for agent execution (#1235) (#1260)
Phase 0 hotfix for the cross-workspace contamination reported in MUL-1027 / #1235: an agent running for workspace A ended up commenting on (and renaming) a two-day-old issue in workspace B. #1249/#1259 fixed resolution for autopilot tasks and consolidated the task-workspace resolver, and #1294 populated workspace_id in the claim response for run_only autopilot tasks. Those closed the known fallthroughs but the failure mode is still broader: whenever the daemon or server fails to supply a workspace, the CLI silently falls back to `~/.multica/config.json`, which is user-global, not workspace-scoped. On a host running daemons for multiple workspaces, a single gap in workspace propagation is enough to leak writes across workspaces. This PR adds three coordinated guards so no single layer's bug can cause a cross-workspace write: 1. `server/cmd/multica/cmd_agent.go` — `resolveWorkspaceID` detects the agent execution context (`MULTICA_AGENT_ID` / `MULTICA_TASK_ID` env, both daemon-only markers) and in that context refuses to fall back to the user-global CLI config. Human / script usage (no agent env) is unchanged: flag → env → config fallback chain still applies. 2. `server/internal/handler/daemon.go` — `ClaimTaskByRuntime` now captures the runtime's workspace from `requireDaemonRuntimeAccess` and enforces `resolved_task_workspace == runtime_workspace` after the existing issue/chat/autopilot branches. On mismatch or empty, the handler explicitly cancels the just-dispatched task (via `TaskService.CancelTask`, which also reconciles agent status) and returns 500. Without the explicit cancel, `ClaimTaskForRuntime` had already transitioned the task to 'dispatched' and the agent status to 'working', so a plain 500 would leave both stuck for the ~5 min stale-task sweep window. 3. `server/internal/daemon/daemon.go` — `runTask` refuses to spawn the agent when `task.WorkspaceID` is empty (defense-in-depth against server bugs and reused workdirs). Tests: - `cmd/multica/cmd_agent_test.go`: `TestResolveWorkspaceID_AgentContextSkipsConfig` — five subtests covering the full fallback matrix (outside agent context still reads config; agent context uses env; agent context with empty env returns empty; task-id-only marker also counts; requireWorkspaceID surfaces the agent-context error message). - `internal/handler/daemon_test.go`: `TestClaimTaskByRuntime_TaskWorkspaceMismatch_CancelsAndRejects` — constructs a data-inconsistent task (runtime_id in workspace A, issue_id in workspace B) and asserts the handler returns 500 AND leaves the task in 'cancelled' state (not 'dispatched'). Phase 1/2 follow-ups (prompt injection of workspace slug, session lookup workspace filter, cross-workspace audit of agent-facing endpoints, observability) are out of scope for this PR and tracked separately. |
||
|
|
b291db11c2 |
feat(agents): add per-agent model field with provider-aware dropdown (#1399)
Adds a first-class `model` field on agents so users can pick the LLM model from the create / settings UI instead of editing `custom_env` / `custom_args`. Each provider's dropdown is populated from the live CLI when possible (`opencode models`, `pi --list-models`, `openclaw agents list --json`, `cursor-agent --list-models`, hermes ACP `session/new` → `SessionModelState`), with a static catalog for providers that don't enumerate.
Daemon resolves the runtime model as `agent.model → MULTICA_<PROVIDER>_MODEL → ""` — empty passes through so each backend's CLI picks its own default, avoiding static-guess drift.
Per-provider honouring:
- Claude / Codex / OpenCode / Cursor / Gemini / Pi / Copilot — CLI `--model` / thread payload.
- OpenClaw — `opts.Model` is mapped to `--agent <name>` (the CLI rejects `--model`).
- Hermes — `session/set_model` ACP RPC; stderr is sniffed for provider-level errors so HTTP 4xx from the configured LLM surfaces instead of "empty output"; explicit-model failures mark the task `failed`.
Supporting changes: migration 050 adds `agent.model`; daemon ↔ server heartbeat piggyback carries a model-discovery request; new REST endpoints under `/api/runtimes/{id}/models`; `multica agent create --model` / `update --model`; shared `ModelDropdown` in `packages/views/agents` (searchable, creatable, provider-grouped, default-badge, runtime-supported gate).
|
||
|
|
cf74327aa6 |
chore(server): add slow-path timing logs for /tasks/claim (#1376)
* chore(server): add slow-path timing logs for /tasks/claim
We're seeing 3s+ tail latency on POST /api/daemon/runtimes/{rid}/tasks/claim
in production. Before changing the code, add structured timing logs along
the entire claim path so we can confirm where the time is actually going.
Three layers, all gated by a slow-only threshold to avoid log spam at the
default 3s daemon poll cadence:
- handler.ClaimTaskByRuntime (>=500ms): splits auth_ms / claim_ms /
build_ms so we can tell whether the slowness is in the actual claim
query or the post-claim response assembly (GetAgent, LoadAgentSkills,
GetIssue, GetWorkspace, GetComment, GetLastTaskSession, or the chat
branch's 4 queries).
- service.ClaimTaskForRuntime (>=300ms): logs list_pending_ms,
list_pending_count, agents_tried, claim_loop_ms — directly validates
the suspected N+1 amplification (one ListPendingTasksByRuntime + one
ClaimTask per unique agent).
- service.ClaimTask (>=300ms): splits get_agent_ms / count_running_ms /
claim_agent_ms so we can isolate the NOT EXISTS + FOR UPDATE SKIP
LOCKED cost from the surrounding metadata reads.
Pure observability change. No behavior change in the request path.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore(server): widen claim slow-log to cover post-claim DB work and error paths
Address review feedback on #1376: the previous version emitted
'claim_task slow' before updateAgentStatus and broadcastTaskDispatch,
both of which can hit the DB (broadcastTaskDispatch goes through
ResolveTaskWorkspaceID and may re-query issue/chat_session/autopilot_run).
That meant a claim that was actually slow in the post-claim tail would
either be under-counted or not logged at all, defeating the purpose of
the instrumentation.
Changes:
- ClaimTask: switch to defer-based exit logging. Adds update_status_ms
and dispatch_ms phase fields. Error paths now also emit a slow log
with outcome=error_get_agent / error_count_running / error_claim.
- ClaimTaskForRuntime: same defer pattern; error paths log with
outcome=error_list / error_claim, partial loop time still captured.
- ClaimTaskByRuntime handler: same defer pattern; auth-failure / claim-
error paths now also carry phase timings (outcome=unauth / error_claim).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
||
|
|
5fa1da448f |
fix(chat): preserve chat session resume pointer across failures (#1360)
* fix(chat): preserve chat session resume pointer across failures The chat 'forgets earlier messages' bug came from PriorSessionID being silently lost in several edge cases: - UpdateChatSessionSession unconditionally overwrote chat_session.session_id, so any task that completed without a session_id (early agent crash, missing result) wiped the resume pointer to NULL. - CompleteAgentTask + UpdateChatSessionSession ran in separate calls. A follow-up chat message claimed in between resumed against a stale (or NULL) session and started over. - FailAgentTask never wrote session_id back, so a task that established a real session before failing lost its resume pointer. - ClaimTaskByRuntime only trusted chat_session.session_id and never fell back to the existing GetLastChatTaskSession query, so a single bad turn could permanently drop the conversation memory. This change: - Use COALESCE in UpdateChatSessionSession so empty inputs preserve the existing pointer; surface DB errors instead of swallowing them. - Run CompleteAgentTask/FailAgentTask + UpdateChatSessionSession inside the same transaction (TaskService now takes a TxStarter). - Extend FailAgentTask + the daemon FailTask path (client, handler, service) to forward session_id/work_dir, so failed/blocked tasks that built a real session still record it. - Fall back to GetLastChatTaskSession in ClaimTaskByRuntime when the chat_session pointer is missing, and include failed tasks in that lookup so a single failure can't lose the conversation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(daemon): forward session_id/work_dir on blocked + timeout paths runTask previously dropped result.SessionID and env.WorkDir on the non-completed return paths: - timeout returned a naked error, so handleTask called FailTask with empty session info and the chat resume pointer was either left stale or eventually overwritten with NULL. - blocked / failed (default branch) returned a TaskResult without SessionID / WorkDir, so even though FailTask now COALESCEs into chat_session, there was no value to write through. - the empty-output completion path was the same: it raised an error even when a real session_id had been built. All three paths now return a TaskResult that carries the SessionID / WorkDir the backend produced. Combined with the COALESCE-based update in UpdateChatSessionSession and the FailTask plumbing introduced in PR #1360, the next chat turn can always resume from the latest agent session — even when the previous turn timed out, was rate-limited, or returned an empty completion — instead of starting over with no memory of the conversation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(copilot): capture session id from session.start as fallback The Copilot backend only read sessionId from the synthetic 'result' event, ignoring the one already present on session.start. When the CLI was killed before result arrived (timeout, cancel, crash, or a session.error mid-turn), the daemon reported SessionID="" and the chat-session resume pointer could not advance — causing the chat to silently drop conversation memory on the next turn. Capture session.start.sessionId into state up front, and only let 'result' overwrite it when it actually carries one. result still wins when present (it is the authoritative end-of-turn record). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(copilot): parse premiumRequests as float to preserve session id Copilot CLI v1.0.32 serializes premiumRequests as a float (e.g. 7.5), not an integer. Our copilotResultUsage struct typed it as int, which made the entire 'result' line fail json.Unmarshal — silently dropping sessionId on every turn. This was the real cause of chat memory loss: the daemon reported SessionID="" to the server, chat_session.session_id stayed NULL, and the next chat turn never received --resume <id>, so each turn started a fresh Copilot session with no prior context. Add a regression test using the real JSON line from CLI v1.0.32 that asserts sessionId is preserved when premiumRequests is fractional. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Devv <devv@Devvs-Mac-mini.local> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Eve <eve@multica.ai> Co-authored-by: yushen <ldnvnbl@gmail.com> |
||
|
|
aa9305f7e4 |
fix(daemon): populate workspace_id in ClaimTaskByRuntime for autopilot run_only tasks (#1294)
* fix(daemon): populate workspace_id in ClaimTaskByRuntime for autopilot run_only tasks (#1276) * test: add regression test for #1276 — ClaimTaskByRuntime autopilot workspace_id |
||
|
|
63800f05ff |
fix(agent): add per-agent mcp_config field to restore MCP access (#1168)
* fix(agent): add per-agent mcp_config field to restore MCP access Closes #1111 The --strict-mcp-config flag was added defensively in #592 to prevent Claude agents from inheriting MCP state from the outer Claude Code session. It was meant to be paired with --mcp-config <path> to inject a controlled set of MCPs, but that path was never implemented, which silently stripped all user-scope MCPs from spawned agents. This PR completes the original design by: - Adding a nullable mcp_config jsonb column to the agents table - Wiring mcp_config through AgentResponse, Create/Update requests - Piping it into ExecOptions.McpConfig in the daemon - Serializing to a temp file and passing --mcp-config <path> in buildClaudeArgs - Blocklisting --mcp-config in claudeBlockedArgs to prevent override via custom_args Does not touch Codex provider (tracked separately in #674). Does not implement Multica MCP auto-injection (out of scope). * fix: disambiguate JSON null vs absent for mcp_config |
||
|
|
a73336dcf8 |
feat(daemon): persistent UUID identity + legacy-id merge at register-time (#1220)
* feat(daemon): persistent UUID identity + legacy-id merge at register-time daemon_id is now a stable UUID persisted to `<profile-dir>/daemon.id` on first start, replacing the hostname-derived id that drifted whenever `.local` appeared/disappeared, a system was renamed, or a profile switched — each of which used to mint a fresh `agent_runtime` row and strand agents on the old one. To migrate existing installs without operator intervention, the daemon reports every legacy id it may have registered under previously (`host`, `host` with `.local` stripped, and `host[-profile]` variants for both). At register-time the server looks up each candidate row scoped to (workspace, provider), re-points its agents and tasks onto the new UUID-keyed row, records which legacy id was subsumed in the new `legacy_daemon_id` column for audit, and deletes the stale row. Result: users running `xxx.local`-keyed runtimes today transparently land on the new UUID row on next daemon restart. The hostname-prefix `MigrateAgentsToRuntime` / `daemon_id LIKE '...-%'` compatibility shim is no longer needed and has been removed along with the handler call that invoked it. * fix(daemon): handle bidirectional .local drift and case drift in legacy merge Review on #1220 flagged two gaps in the legacy-id migration candidate set: 1. Reverse .local: LegacyDaemonIDs only added the stripped variant when the current hostname ended in `.local`. The opposite direction — DB has `foo.local`, current host is `foo` — was missed, so runtimes registered under the `.local` variant stayed orphaned after upgrade. Now both variants (`foo` and `foo.local`) are always emitted, regardless of what `os.Hostname()` currently returns, plus their `-<profile>` suffix forms. 2. Case drift: os.Hostname() has been observed returning different casings on the same machine across mDNS/reboot state. A case-sensitive `=` comparison stranded rows like `Jiayuans-MacBook-Pro.local` when the daemon later reported `jiayuans-macbook-pro.local`. FindLegacyRuntimeByDaemonID now uses `LOWER(daemon_id) = LOWER(@daemon_id)` on both sides, so casing differences merge rather than orphan. The (workspace_id, provider) prefix still bounds the scan to a tiny set of rows so the non-indexed LOWER() comparison has negligible cost. Tests: TestLegacyDaemonIDs gets the mixed-case + reverse-direction cases; daemon_test.go adds TestDaemonRegister_MergesLegacyDaemonIDRuntime_ReverseDotLocal and TestDaemonRegister_MergesLegacyDaemonIDRuntime_CaseDrift. * fix(daemon): consolidate every case-duplicate legacy runtime, not just the first Follow-up review on #1220: after switching to `LOWER(daemon_id) = LOWER(@daemon_id)`, the single-row lookup still only merged one legacy row per candidate. If a machine already had two rows in the DB that differed only in casing (e.g. `Jiayuans-MacBook-Pro.local` AND `jiayuans-macbook-pro.local` coexisting because earlier hostname drift already minted a duplicate), only one of them got consolidated and the other stayed orphaned — violating the "no duplicate runtime per machine after backfill" acceptance. - FindLegacyRuntimeByDaemonID → FindLegacyRuntimesByDaemonID (:many) - mergeLegacyRuntimes iterates every returned row and dedupes across overlapping legacy candidates so `foo` and `foo.local` both resolving to the same stored row don't double-process Test: TestDaemonRegister_MergesAllCaseDuplicateLegacyRuntimes seeds two case-duplicate rows with one agent each and confirms both rows are deleted and both agents end up on the new UUID-keyed row. |
||
|
|
5a6a44a69e |
refactor(daemon): consolidate task workspace resolver + regression test (#1259)
Follow-up to #1249. Two small follow-ups requested in review: 1. `resolveTaskWorkspaceID` was duplicated between `handler/daemon.go` and `service/task.go`. #1249 fixed the handler copy but left both in place, meaning any future branch (e.g. a fourth task link type) still needs to be added in two files. Promote the service method to the exported `TaskService.ResolveTaskWorkspaceID` and delete the handler copy. Handler's `requireDaemonTaskAccess` and `ListTaskMessagesByUser` now call through `h.TaskService`. 2. Add a regression test `TestStartTask_AutopilotRunOnlyTask_ResolvesWorkspace` covering the exact scenario from #1224: a task linked only via `AutopilotRunID` must resolve to the autopilot's workspace. The test asserts 404 for a cross-workspace daemon token and 200 (with status transitioning to `running`) for the correct-workspace token. |
||
|
|
ea02a394dc |
fix(daemon): resolve workspace ID for autopilot run_only tasks (#1224) (#1249)
resolveTaskWorkspaceID only handled tasks linked via IssueID or ChatSessionID. Tasks created by run_only autopilots (introduced in #1028) have only AutopilotRunID set, so the resolver returned an empty workspace ID, causing requireDaemonTaskAccess to respond with 404. Add an AutopilotRunID branch that looks up the autopilot run, then its parent autopilot, to obtain the workspace ID. |
||
|
|
f8c6dd505f |
fix(security): bind URL issueId to workspace on four issue-scoped daemon.go handlers (#1145)
GetActiveTaskForIssue, CancelTask, ListTasksByIssue, and GetIssueUsage accepted the issueId URL parameter and queried by it without verifying that the issue belonged to the caller's X-Workspace-ID workspace. The RequireWorkspaceMember middleware only proves membership in the header workspace; it does not bind the path-parameter issue to it. A member of workspace A could therefore enumerate tasks, cancel tasks, and read usage metadata for any issue UUID in workspace B. Route every issueId through loadIssueForUser (matching GetIssue and the existing comment/subscriber handlers). For CancelTask additionally verify that the task's IssueID matches the loaded issue — the task must not only belong to the caller's workspace but also to the specific issue named in the URL, and the access check must run before any mutation. Follow-up to MUL-899 / #1112. |
||
|
|
93cf95f799 |
fix(security): enforce workspace access on GetIssueGCCheck (#1121)
The daemon GC check endpoint did not verify the caller's access to the issue's workspace, letting a daemon token or PAT scoped to workspace A read issue status/updated_at for any issue UUID across the instance. Mirror the pattern used by every other handler in daemon.go: look up the issue's workspace and gate on requireDaemonWorkspaceAccess. Closes #1112 Co-authored-by: shaun0927 <shaun0927@users.noreply.github.com> |
||
|
|
0427fd8cc7 |
fix(daemon): refresh workspace repos on checkout miss (#1085)
Co-authored-by: black-fe <black-fe@gate.me> |
||
|
|
ce447c7f06 |
feat(agent): add custom CLI arguments support (#986)
* feat(agent): add custom CLI arguments support Allow users to configure custom CLI arguments per agent that get appended to the agent subprocess command at launch time. This enables use cases like specifying different models (--model o3), max turns, or other provider-specific flags without needing separate runtimes. Changes: - Add custom_args JSONB column to agent table (migration 041) - Update API handler to accept/return custom_args in create/update - Pass custom_args through claim endpoint to daemon - Append custom_args to CLI commands for all agent backends - Add ExecOptions.CustomArgs field in agent package - Add Custom Args tab in agent detail UI - Add --custom-args flag to CLI agent create/update commands Closes MUL-802 * fix(agent): filter protocol-critical flags from custom_args Add per-backend filtering of custom_args to prevent users from accidentally overriding flags that the daemon hardcodes for its communication protocol (e.g. --output-format, --input-format, --permission-mode for Claude). This follows the same pattern as custom_env's isBlockedEnvKey: we only block the small, stable set of flags that would break the daemon↔agent protocol — not every possible dangerous flag. Workspace members are trusted for everything else. Each backend defines its own blocked set: - Claude: -p, --output-format, --input-format, --permission-mode - Gemini: -p, --yolo, -o - Codex: --listen - OpenCode: --format - OpenClaw: --local, --json, --session-id, --message - Hermes: none (ACP is positional) Includes unit tests for the filtering logic. * fix(agent): address code review nits for custom_args - Replace module-level `nextArgId` counter with `crypto.randomUUID()` in custom-args-tab.tsx to avoid SSR ID conflicts - Add unit tests for custom args passthrough and blocked-arg filtering in both Claude and Gemini arg builders |
||
|
|
40aa23a528 |
feat(desktop): daemon management panel with sidebar status bar (#952)
* feat(desktop): add daemon management panel with sidebar status bar Integrate multica daemon lifecycle management into the desktop app so users can start/stop/restart the daemon and view live logs without leaving the UI. Session tokens are automatically synced to the CLI config file, making daemon authentication transparent. - daemon-manager.ts: Electron main process module for daemon lifecycle (health polling, start/stop via CLI, token sync, log tail) - Preload bridge: new daemonAPI with IPC for all daemon operations - Sidebar bottomSlot: persistent daemon status indicator in sidebar footer (desktop-only, injected via AppSidebar slot) - Daemon panel Sheet: right-side drawer with status details, controls, and real-time log viewer with auto-scroll and level coloring - Token sync: on login and app startup, JWT is written to ~/.multica/config.json so daemon can authenticate seamlessly Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(desktop): add P1+P2 daemon features — runtimes card, auto-start, settings P1: Runtimes page Local Daemon card - Add topSlot prop to shared RuntimesPage for platform injection - DaemonRuntimeCard shows status, agents, uptime with Start/Stop/ Restart/Logs buttons (desktop-only, injected via slot) P2: Auto-start and auto-stop - Daemon auto-starts on app launch when user is authenticated (controlled by autoStart preference, default: true) - Daemon auto-stops on app quit (controlled by autoStop preference, default: false — daemon keeps running in background by default) - Preferences persisted to ~/.multica/desktop_prefs.json P2: Daemon settings tab - New "Daemon" tab in Settings > My Account section (desktop-only) - Toggle auto-start and auto-stop behavior - CLI installation status check with link to install guide - SettingsPage gains extraAccountTabs prop for platform injection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(desktop): address PR review feedback on daemon management Must-fix: - before-quit handler now calls event.preventDefault(), awaits stopDaemon(), then re-calls app.quit() so the daemon actually stops before the app exits - Add concurrency guard (operationInProgress lock) in daemon-manager to reject overlapping start/stop/restart IPC calls - Extract shared types (DaemonState, DaemonStatus, DaemonPrefs), constants (STATE_COLORS, STATE_LABELS), and formatUptime to apps/desktop/src/shared/daemon-types.ts — all renderer components now import from this single source Should-fix: - Log viewer uses monotonic counter (LogEntry.id) instead of array index as React key, preventing full re-renders on overflow - All start/stop/restart handlers now show toast.error() with the error message when the operation fails - startLogTail retries up to 5 times with 2s delay when the log file doesn't exist yet (handles first-run case) Minor: - Cache findCliBinary() result after first successful lookup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(logger): suppress ANSI color codes when stderr is not a TTY Detect whether stderr is connected to a terminal and set tint's NoColor option accordingly. Previously daemon.log files contained raw escape sequences like \033[2m and \033[92m which made them unreadable in the Desktop log viewer and any non-TTY sink (docker logs, systemd, etc). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(daemon): runtime watch/unwatch HTTP endpoints and denylist Add GET/POST/DELETE /watch handlers on the daemon's health port so clients (notably Desktop) can add or remove watched workspaces at runtime without restarting the daemon or editing config.json. Each handler updates in-memory state under d.mu and persists back to ~/.multica/profiles/<name>/config.json for survival across restarts. - CLIConfig gains UnwatchedWorkspaces as an explicit opt-out denylist. syncWorkspacesFromAPI skips entries in the denylist so a manual unwatch isn't silently revived 30s later by the periodic sync. - loadWatchedWorkspaces tolerates an empty config and returns nil instead of erroring out, because Desktop starts daemons with a fresh profile and relies on the sync loop / watch endpoint to populate the list. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(desktop): bundled CLI, per-backend profile, and watch UI Make the Desktop app self-sufficient: it bundles its own multica binary, manages its own daemon profile keyed by the backend URL, and authenticates that daemon with a long-lived PAT it mints on first login. The daemon panel gains a checkbox list of watched workspaces and surfaces the active profile + server URL. CLI bootstrap - scripts/bundle-cli.mjs copies server/bin/multica into apps/desktop/resources/bin/ before electron-vite dev and electron-builder package. asarUnpack: resources/** already covers this path, so the binary ships with the .app in prod. - main/cli-bootstrap.ts adds an ensureManagedCli() fallback that downloads the latest release from GitHub when no bundled binary exists (first launch on a machine without developer tooling). - daemon-manager.resolveCliBinary prefers bundled > managed > download > PATH, so local iteration uses the freshly built binary. Daemon profile - resolveActiveProfile now derives a desktop-<host> profile name from the target API URL and creates its config.json on demand. Never reads or writes the user's hand-configured CLI profiles, avoiding the "Desktop polluted my default profile" class of bug. - syncToken detects a JWT input and exchanges it for a PAT via POST /api/tokens; caches the resulting mul_* token in the profile config so subsequent launches skip the round-trip. - startDaemon / stopDaemon / log tail all operate on the resolved profile; renderer sets the target URL via a new daemon:set-target-api-url IPC. Workspace watching - daemon-manager exposes daemon:list-watched / daemon:watch-workspace / daemon:unwatch-workspace IPCs backed by the daemon's new /watch endpoints. - App.tsx reconciles the user's workspace list against the daemon's watched set whenever TanStack Query updates it — new workspaces are registered instantly instead of waiting for the daemon's 30s sync, and removed workspaces are unwatched. - daemon-panel gains a "Watched Workspaces" section with per-workspace checkboxes that call watch/unwatch directly. Opt-outs persist in the profile's unwatched_workspaces denylist. Lifecycle states + UI - DaemonStatus gains `profile`, `serverUrl`, and an `installing_cli` state. Panel shows Profile / Server info rows and a "Setting up…" blurb during first-run CLI download; failure surfaces a Retry button. - Status bar renders a spinner during installation and hides the Start button until setup finishes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(desktop): register /onboarding route The create-workspace modal navigates to /onboarding on success, but the Desktop router only had flat routes (issues, projects, runtimes, etc.) — resulting in an "Unexpected Application Error! 404 Not Found" page after creating a new workspace. Mirror the web app's wiring: render OnboardingWizard with onComplete pushing to /issues, via the shared navigation adapter. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(desktop): remove sidebar daemon status bar Drop the bottom-left daemon indicator in favor of the DaemonRuntimeCard at the top of the Runtimes page, which already shows the same info plus full Start/Stop/Restart controls and the Logs entry point. A single canonical place avoids fragmenting daemon status across the UI. Also remove the now-unused `bottomSlot` prop from AppSidebar — Desktop was the only consumer, Web never needed it, so keeping it would be dead scaffolding. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(desktop): daemon panel layout and close button - Logs section now fills the remaining vertical space down to the sheet bottom instead of being capped at h-64, which left a huge empty area below it. Top section (status, actions, watched list) keeps natural height as shrink-0; the watched list gets its own max-h-48 scroll so a long list can't push Logs off screen. - Replace the Sheet's built-in close button with an explicit <button> wired directly to onOpenChange(false). The Base UI Dialog.Close wrapped in Button via the render prop wasn't firing on click in this panel; going straight through the controlled state guarantees it responds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(desktop): make daemon panel clickable inside Electron drag region The sheet opens at the top of the window, which visually overlaps the TabBar's -webkit-app-region: drag zone. Even though the sheet portals to document.body, Chromium computes drag regions over the final composited pixels, so the sheet inherited "drag" and swallowed the mouseup of every click (mousedown fired but click never resolved) — including the X close button. Mark the entire SheetContent popup with -webkit-app-region: no-drag to subtract it from the drag region. This also fixes future buttons / checkboxes inside the sheet that would have hit the same issue. While here, move the close button into the SheetHeader as a flex sibling of SheetTitle instead of an absolutely positioned overlay — simpler layout and avoids any stacking-context weirdness. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(desktop): clickable daemon runtime card row The whole Local Daemon row now opens the sheet panel — icon, title, and status line are all part of one click target. This replaces the standalone "Logs" button, which was redundant now that clicking anywhere on the row does the same thing. The right-side action cluster (Start / Stop / Restart) wraps its onClick in stopPropagation so pressing those buttons doesn't bubble up and open the panel. Keyboard access: Enter / Space on the focused row opens the panel, with a focus-visible background for feedback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(runtimes): mark Desktop-launched daemons as managed When the Multica Desktop app spawns the CLI it ships with, the resulting daemon shares its binary with the Electron bundle — Desktop is responsible for updating that binary on every release. Letting the daemon self-update would just get clobbered on the next Desktop launch and could brick the embedded binary mid-update. Propagate a "launched_by" signal end-to-end so the UI can hide the CLI self-update affordance (and the daemon refuses updates as a second line of defense): - Desktop's startDaemon spawns execFile with env MULTICA_LAUNCHED_BY=desktop. - daemon.Config gains LaunchedBy; cmd_daemon reads the env var on boot. - registerRuntimesForWorkspace includes launched_by in the request body. - Server DaemonRegister folds launched_by into runtime.metadata (JSONB — no migration needed). - handleUpdate returns a "failed" status with an explanatory message when LaunchedBy == "desktop", so even a bypass API call can't trigger the self-update path. - RuntimeDetail extracts metadata.launched_by and passes it to UpdateSection, which swaps the Latest / → available / Update button cluster for a muted "Managed by Desktop" label. CLI-only users (brew install, direct tarball) keep the exact same behavior — the env var is empty, the UI shows the update button, the daemon still self-updates on request. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(desktop): harden daemon manager from PR review - syncToken now takes userId and mints a fresh PAT on user switch, restarting a running daemon so it picks up the new credentials. A .desktop-user-id sidecar in each profile records the owner so a previous user's cached PAT can't be reused on the next login. - App.tsx wires onLogout on CoreProvider to daemonAPI.clearToken() and daemonAPI.stop() so the cached PAT and live daemon don't outlive the session. - startLogTail replaced with a cross-platform watchFile implementation (initial 32 KB window + poll for new bytes, handles truncation). spawn("tail") was broken on Windows. - writeProfileConfig now serializes through a promise chain to prevent concurrent writes from corrupting config.json. - startDaemon keeps the "starting" state until pollOnce confirms /health, avoiding a running → stopped flash when the Go daemon isn't yet listening after the supervisor returns. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(desktop): verify downloaded CLI against checksums.txt Download goreleaser's checksums.txt alongside the release archive, parse the sha256 lookup, stream the archive through createHash, and refuse to install on mismatch or missing entry. Closes the supply- chain gap where auto-install would execute an unverified binary on first launch. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore(desktop): lint and style cleanups from PR review - eslint.config.mjs: add scripts/**/*.{mjs,js} override with globals.node so bundle-cli.mjs lints clean (was erroring on undefined process/console). - daemon-panel.tsx: log level classes now use semantic tokens (text-info, text-warning, text-destructive) instead of hardcoded Tailwind colors; escape the apostrophe in the retry copy. - daemon-settings-tab.tsx: import DaemonPrefs from shared/daemon- types instead of redefining it. - runtimes-page.tsx: fix indentation inside the new topSlot wrapper. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Devv <devv@Devvs-Mac-mini.local> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: yushen <ldnvnbl@gmail.com> |
||
|
|
0a998d1cef |
Merge pull request #846 from multica-ai/agent/j/feb218fd
feat(agent): support custom environment variables for router/proxy mode |
||
|
|
9dfe119f47 |
fix(daemon): use runtime's owner_id for agent migration on upgrade (#941)
* fix(daemon): prevent duplicate runtime registration on profile switch The daemon_id included a profile name suffix (e.g. "hostname-staging"), so switching profiles created a new daemon_id that bypassed the UPSERT dedup constraint, leaving orphaned runtime records in the database. Three changes: - Remove profile suffix from daemon_id — use stable hostname only. The unique constraint (workspace_id, daemon_id, provider) already prevents collisions within the same workspace. - Auto-migrate agents from old offline runtimes to the newly registered runtime during DaemonRegister (same workspace/provider/owner). - Add TTL-based GC in the runtime sweeper to delete offline runtimes with no active agents after 7 days. Closes MUL-695 * fix(daemon): address code review issues on PR #906 1. Move gcRuntimes() to the main sweep loop — previously it was inside sweepStaleRuntimes() after an early return, so it only ran when new runtimes were marked stale. Now it runs every sweep cycle independently. 2. Fix DeleteStaleOfflineRuntimes to exclude runtimes with ANY agent reference (not just active ones). The FK agent.runtime_id is ON DELETE RESTRICT, so archived agents also block deletion. 3. Scope MigrateAgentsToRuntime to the same machine by matching daemon_id LIKE '<current_daemon_id>-%'. This prevents cross-machine agent migration when the same user has multiple devices. * fix(daemon): use runtime's owner_id for agent migration, not caller's The migration was gated on ownerID.Valid which is only true for PAT/JWT registrations. Daemon token registrations (the common case for background daemon restarts) had ownerID as zero, skipping migration entirely. Fix: use registered.OwnerID (preserved via COALESCE on upsert) instead of the caller's ownerID. This ensures migration runs even when the daemon re-registers via daemon token after an upgrade. |
||
|
|
ff5f6ac2ee |
fix(daemon): prevent duplicate runtime registration on profile switch (#906)
* fix(daemon): prevent duplicate runtime registration on profile switch The daemon_id included a profile name suffix (e.g. "hostname-staging"), so switching profiles created a new daemon_id that bypassed the UPSERT dedup constraint, leaving orphaned runtime records in the database. Three changes: - Remove profile suffix from daemon_id — use stable hostname only. The unique constraint (workspace_id, daemon_id, provider) already prevents collisions within the same workspace. - Auto-migrate agents from old offline runtimes to the newly registered runtime during DaemonRegister (same workspace/provider/owner). - Add TTL-based GC in the runtime sweeper to delete offline runtimes with no active agents after 7 days. Closes MUL-695 * fix(daemon): address code review issues on PR #906 1. Move gcRuntimes() to the main sweep loop — previously it was inside sweepStaleRuntimes() after an early return, so it only ran when new runtimes were marked stale. Now it runs every sweep cycle independently. 2. Fix DeleteStaleOfflineRuntimes to exclude runtimes with ANY agent reference (not just active ones). The FK agent.runtime_id is ON DELETE RESTRICT, so archived agents also block deletion. 3. Scope MigrateAgentsToRuntime to the same machine by matching daemon_id LIKE '<current_daemon_id>-%'. This prevents cross-machine agent migration when the same user has multiple devices. |
||
|
|
04747b45a2 |
fix(auth): add user-facing task messages endpoint for cookie auth (#853)
The frontend's listTaskMessages() was calling /api/daemon/tasks/{id}/messages
which uses DaemonAuth middleware (requires Authorization header). After the
cookie auth migration (#819), cookie-mode sessions don't send an Authorization
header, causing 401 on this endpoint. The 401 then triggers handleUnauthorized()
which clears the workspace context, cascading into 400 errors on all subsequent
requests.
Fix: add GET /api/tasks/{taskId}/messages under regular user auth middleware,
and update the frontend to use it instead of the daemon endpoint.
Closes #833
|
||
|
|
a73a9d4036 |
fix(agent): address PR review — env var blocklist, unmarshal logging, stable React keys
1. Security: add isBlockedEnvKey() blocklist that rejects MULTICA_* prefix and critical system vars (HOME, PATH, USER, SHELL, TERM, CODEX_HOME) from custom_env injection 2. Observability: log warnings when json.Unmarshal fails on custom_env (agentToResponse + claim endpoint) 3. UX: use stable auto-increment IDs for env entry React keys instead of array index to prevent input focus/state issues on add/remove |
||
|
|
4165401d16 |
feat(agent): support custom environment variables for router/proxy mode
Add per-agent custom_env configuration that gets injected into the agent subprocess at launch time. This enables users to configure custom API endpoints (ANTHROPIC_BASE_URL), API keys (ANTHROPIC_API_KEY), and cloud provider modes (CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX) without requiring code changes. Changes: - Migration 040: add custom_env JSONB column to agent table - Backend: custom_env in agent CRUD API + claim endpoint - Daemon: merge custom_env into subprocess environment variables - Frontend: env var editor in agent settings (key-value pairs with visibility toggle for sensitive values) Closes #816 Related: #807, #809 |
||
|
|
2f1000d815 |
merge: resolve conflict with main (runTask refactor + mergeUsage)
Main introduced executeAndDrain/mergeUsage refactor. Resolve by keeping main's refactored structure and re-applying EnvRoot to the switch/case in runTask. Rename newTestDaemon → newGCTestDaemon to avoid collision with the helper added in daemon_test.go on main. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
ff206baa6f |
feat(daemon): add periodic GC for workspace isolation directories
Isolation directories accumulate indefinitely because they're preserved
for session reuse but never cleaned up after the issue is closed.
This adds a background GC loop that periodically scans local workspace
directories and removes those whose issue is done/canceled and hasn't
been updated for 5 days (configurable via MULTICA_GC_TTL). Orphan
directories with no metadata are cleaned after 30 days.
Changes:
- Write .gc_meta.json (issue_id, workspace_id) at task completion
- Add GET /api/daemon/issues/{issueId}/gc-check endpoint for status queries
- Add gcLoop goroutine to daemon with configurable interval/TTL
- Prune stale git worktree references from bare repo caches each cycle
- New env vars: MULTICA_GC_ENABLED, MULTICA_GC_INTERVAL, MULTICA_GC_TTL,
MULTICA_GC_ORPHAN_TTL
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
93fffad82a |
fix(daemon): embed triggering comment content in agent prompt
When a task is triggered by a comment, the agent prompt now includes the comment content directly. This prevents the agent from ignoring the comment when stale output files exist in a reused workdir. Closes #805 |
||
|
|
82bbce98fd |
fix(security): add workspace ownership checks to daemon API routes (#684)
* fix(security): add workspace ownership checks to all daemon API routes Switch daemon routes from middleware.Auth to middleware.DaemonAuth and add per-handler workspace ownership verification. This prevents cross-workspace access to runtimes, tasks, usage, and daemon lifecycle endpoints (HIGH-1/2/3 + CHAIN-1/2/3). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(security): support mdt_ daemon tokens in DaemonRegister + add regression tests DaemonRegister now handles both auth paths: - mdt_ daemon tokens: verify workspace match, skip member check, zero OwnerID (SQL COALESCE preserves existing owner on upsert) - PAT/JWT: existing member check + OwnerID from member Also adds WithDaemonContext helper and regression tests covering: - Successful register with daemon token - Workspace mismatch rejection - Cross-workspace heartbeat rejection - Cross-workspace task status rejection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |