* fix(issues): stop issue-trigger preview flicker
The pre-trigger preview re-rendered/refetched on every workspace task
event: WS task lifecycle invalidated issueTriggerPreviewAll (staleTime 0),
forcing a background refetch whose isFetching was surfaced as isLoading,
collapsing and reopening CreateRunHint's reveal band.
The assign source (create / assignee change) cancels existing tasks before
enqueuing, so its verdict can't shift from a task event at all; the status
source's pending dedup could, but the preview is advisory and the write
path re-evaluates authoritatively, so a rare stale label is harmless. Drop
the WS invalidation so the preview refetches only on input (signature)
change. Keep the comment-trigger invalidation — its verdict genuinely
changes mid-compose and its chips drive an immediate, unconfirmed send.
Align the hook's data handling with the comment-trigger preview:
keepPreviousData so an input switch swaps in place instead of collapsing,
and treat only the first load (no prior data) as loading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(issues): skip run-confirm modal for backlog assign
Assigning a Backlog issue to an agent/squad never starts a run (the
parking lot — server/internal/service/issue_trigger.go), so the
pre-trigger confirm modal only rendered an empty "won't start" box with
a single Apply button. Apply directly instead: the single path checks
issue.status, the batch path skips only when every selected issue is
Backlog (mixed selections still confirm — the non-backlog ones trigger).
Mirrors the existing backlog short-circuit in handleBatchStatus.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(modals): run-confirm loading state + submit spinner
The dialog grew in height after open: it rendered the short "won't
start" variant while POST /api/issues/preview-trigger was in flight, then
the note box appeared when the predicate landed. Keep the note box
mounted (disabled) during loading so assign mode opens at its resolved
height, and show a Spinner + 'checking' headline while loading.
Submit had no feedback — buttons only disabled, which read as frozen for
note assigns (the request starts an agent server-side). Track which
footer action is in flight and show a Spinner on the clicked button.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(issues): show handoff note in execution-log trigger text
An assignment-triggered run that carried a handoff note showed the
generic "Initial run" label. Surface the note inline (truncated, like
comment triggers show their text) so the row reads as the handoff.
taskToResponse now populates handoff_note for all callers (dropping the
now-redundant explicit set in ClaimTaskByRuntime); the field is added to
the AgentTask type + zod schema (optional, additive — old clients ignore
it via the loose schema, new clients fall back to "Initial run").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`GET /api/issues/:id/usage` endpoint (handler `GetIssueUsage`). Returns the
aggregated token usage for an issue, summed across all of its task runs.
The usage is already captured server-side and shown in the issue detail view,
but was not reachable from the CLI, so it couldn't feed billing/cost scripts.
This closes that gap with no backend changes.
$ multica issue usage MUL-139
INPUT_TOKENS OUTPUT_TOKENS CACHE_READ CACHE_WRITE RUNS
5625 83880 9190806 154078 1
$ multica issue usage MUL-139 --output json
{ "total_input_tokens": 5625, ... "task_count": 1 }
Accepts an issue key (`MUL-139`) or UUID, mirroring `issue runs`.
- Table cells use the existing `formatMetadataValue` helper so large cache-token
counts render as plain integers (not scientific notation).
- Scope is the per-issue aggregate the endpoint already returns (`task_count` =
run count); a per-run token breakdown is out of scope.
- `go test ./cmd/multica/ -run TestRunIssueUsage` (added) ✅
- `go vet ./cmd/multica/` ✅
- Verified against a live self-hosted server; numbers match the issue UI.
- `server/cmd/multica/cmd_issue.go` — command + handler
- `server/cmd/multica/cmd_issue_test.go` — unit test
- `CLI_AND_DAEMON.md` — docs
* feat(issues): unify run-enqueue decision behind WillEnqueueRun + preview endpoint
Collapse the issue update/batch enqueue copies into one service predicate
service.IssueService.WillEnqueueRun, shared verbatim with a new dry-run
endpoint POST /api/issues/preview-trigger so the four entry points stop
drifting (squad/self-loop/batch omissions, MUL-3375). The private-agent gate
stays at the HTTP boundary: write paths inject allow-all, preview injects the
real gate so it never leaks a private agent's readiness.
Add suppress_run to issue update/batch: the change applies but no run starts.
Remove the now-dead handler mirrors shouldEnqueueSquadLeaderOnAssign /
isSquadLeaderReady. service.Create and the comment trigger chain are untouched.
Tests: preview behavior, preview<->write-path match, batch aggregation,
member no-trigger, suppress_run skip, malformed-body 400.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): inject handoff note into assigned runs via first-class task field
Add an optional handoff_note carried by issue assign/promote into the run's
opening prompt and issue_context.md, via a dedicated agent_task_queue column
(migration 122) and a daemon assignment-handoff render branch — never a
fabricated comment, never trigger_comment_id (MUL-3375 §6.1).
Thread the note through enqueueIssueTask/enqueueMentionTask + WithHandoff
public variants and dispatchIssueRun; suppress_run or a parked write drops it
(no run = nothing to inject). Soft version gate: MinHandoffCLIVersion +
HandoffSupported, surfaced per-trigger as handoff_supported in the preview so
the UI can gray the note box on old daemons; the assignment never hard-fails.
Tests: daemon prompt + issue_context render via the assignment branch (not
quick-create/comment), version helper matrix, note persists on the task,
suppressed assign enqueues nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): leave a display-only handoff record on the timeline
When an assign/promote with a handoff note starts a run, write one
type='handoff' timeline record via TaskService.RecordHandoff — a direct
Queries.CreateComment + timeline event that bypasses Handler.CreateComment, so
it never reaches triggerTasksForComment and cannot start a second run
(MUL-3375 §6.2, the must-not-retrigger invariant). Author is the actor who
handed off; body is the note. Migration 123 admits the 'handoff' comment type.
Recorded only on a real run start: suppress_run or a parked write writes
nothing. enqueueSquadLeaderTask now reports whether it enqueued so the trace
is gated on an actual dispatch.
Test: exactly one handoff record on assign-with-note, exactly one task (no
re-trigger), and no record when suppressed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): frontend plumbing for issue-trigger preview + handoff (core)
Add api.previewIssueTrigger + IssueTriggerPreviewSchema (zod parseWithFallback),
the use-issue-trigger-preview hook, issueKeys.issueTriggerPreview(+All) with WS
queue-state invalidation, suppress_run/handoff_note on UpdateIssueRequest, the
'handoff' CommentType, and stripping of the control fields from optimistic
update/batch cache patches (MUL-3375 §9).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): exclude handoff records from new-comment counting
type='handoff' is a display-only timeline record, not conversation. Exclude it
from CountNewCommentsSince so a handoff note never inflates the count of
"new comments to catch up on" fed to a claiming agent (MUL-3375 §12). Analytics
already excludes it (RecordHandoff is a direct write that emits no analytics
event), and the comment-trigger path is already bypassed.
Test: a handoff record does not bump the new-comment count; a real comment does.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): pre-trigger preview UI, handoff note, timeline card (web/desktop)
Wire the §9 frontend onto the preview endpoint + handoff fields:
- Delete the backlog blocking dialog (backlog-agent-hint*) and its modal type;
the over-eager nag is gone. Backlog awareness is now a passive label.
- RunConfirmModal: single assign + batch assign/status route here. Shows the
backend predicate's verdict ("将启动 @X" / "将启动 N 个" / parked), an optional
handoff note (assign only, soft-gated by handoff_supported), and 暂不启动 —
then applies via update/batch. No frontend guessing.
- create modal: passive CreateRunHint ("将启动 @X" / backlog parked).
- single status change stays a direct apply (unchanged).
- timeline: render type='handoff' as a distinct, non-interactive handoff card.
- i18n run_confirm + handoff_card across en/ja/ko/zh-Hans; drop backlog action
keys; locale parity green.
Tests: use-issue-actions (assign → run-confirm modal, member → direct),
create-issue + comment-card suites updated/green; views typecheck + lint clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(issues): use a valid anchor in the handoff count-exclusion test
CountNewCommentsSince filters id <> @anchor_id; SQL id <> NULL is NULL and
excludes every row, so an empty anchor made the control assertion read 0. The
production caller always passes a real anchor — mirror that with a non-matching
sentinel uuid.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(issues): RunConfirmModal apply logic (start/suppress/note-gate/batch)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(core): preview schema malformed/missing/null fallback coverage
Cover IssueTriggerPreviewSchema via parseWithFallback (MUL-3375): well-formed
parse, top-level + item default fills (empty/older backend), and fallback to
{ triggers: [], total_count: 0 } for malformed shapes, a dropped required
issue_id, a wrong-typed total_count, and null/non-object bodies — so the four
entry points degrade to "nothing will start" instead of throwing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(issues): remove display-only handoff timeline record (留痕)
The handoff "留痕" timeline record (type='handoff' comment written on run
start) was judged superfluous and dropped per product call. This removes
only the display-only trace; the handoff NOTE injection into the run's
opening prompt + issue_context.md is untouched.
- backend: drop RecordHandoff + its call in dispatchIssueRun
- db: drop the `type <> 'handoff'` exclusion in CountNewCommentsSince and
migration 123 (comment_type_check reverts to the 4-type set from 001);
no production data exists for this unreleased feature
- frontend: drop the "handoff" CommentType, HandoffCard, and handoff_card
i18n (all locales)
- tests: drop handoff_count_test.go and the record-write assertions in
issue_trigger_preview_test.go (note-injection tests retained)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): dismissable run-confirm modal + team-handoff copy
Two fixes to the pre-trigger confirm modal (MUL-3375).
1. Dismissable: switch RunConfirmModal from AlertDialog to the standard
shadcn Dialog so it has the close (X) button + Esc + click-outside.
Previously the only choices were "start" / "don't start now" with no
way to abort the action entirely; dismissing now cancels with no write.
2. Copy: rework the action-surface wording away from the backend term
"run" toward team-handoff voice — 指派 / 开始 / 交接 (run stays only on
record surfaces). Unifies the note's three names to "交接说明", and
parallels the rewrite across en/ja/ko.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* chore(agent): bump handoff note min CLI version to 0.3.28
The daemon release that renders handoff notes ships in 0.3.28 (0.3.27
was the prior tag), so move the soft-gate threshold up. Below this the
note is silently dropped and the frontend grays the note box — assignment
is never blocked.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(issues): skip run-confirm when batch-moving issues to backlog
A move into backlog never starts a run (service/issue_trigger.go), so the
pre-trigger confirm modal degenerated to an empty "won't start" box with a
single Apply button — pure friction. Apply directly instead, matching the
single-issue status path. Other target statuses still route through the
modal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(issues): refine pre-trigger preview hint and copy
- Move the create-issue run hint to a reveal band (grid 0fr→1fr) above the
property toolbar. It was sharing the footer button row and, lacking a
width constraint, reflowed the submit buttons whenever it appeared.
Restyle to a borderless, comment-style avatar+caption that is purely a
caption (non-interactive avatar).
- Distinguish squad from agent in the pre-trigger copy: a squad's leader
evaluates and delegates rather than "starting work" itself. Add
will_start_named_squad / will_start_squad / create_will_start_squad across
en/zh/ja/ko (reusing the squad_leader_* evaluate→arrange vocabulary) and
branch run-confirm + the create hint on squad assignees.
- Bold the assignee name in the run-confirm headline via a language-safe
sentinel split (no per-language prefix/suffix keys).
- Align zh "开始处理" → "开始工作" on the single-assign copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(issues): stub ActorAvatar in create-issue suite
CreateRunHint now renders an ActorAvatar for agent/squad assignees, which
pulls in getActorInitials/getActorAvatarUrl + the workspace/presence/navigation
hook tree. This form-focused suite only stubbed getActorName, so the
squad-forwarding test crashed with "getActorInitials is not a function". Stub
the avatar inert — its own behavior is covered elsewhere.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Agents creating sub-issues only saw the runtime brief's Sub-issue
Creation section, which taught the manual todo/backlog serial chain and
never mentioned stages — the `--stage` flow was documented only in the
multica-working-on-issues skill, which an agent reads only if it opens
it. So agents defaulted to hand-managed backlog chains and rarely reached
for stages.
- Add an "Ordering with stages" paragraph to the brief's Sub-issue
Creation section nudging agents to group ordered/waiting sub-issues
with --stage instead of hand-promoting a backlog chain.
- List --stage on the brief's issue create / update command lines and
add multica issue children to the Core command list for discoverability.
- Extend the brief test with the new stage assertions.
The Sub-issue Creation section stays gated to issue-bound runs (skipped
for chat/quick-create/autopilot), unconditional on parent_issue_id, and
free of parent-notification guidance — all existing canaries still pass.
MUL-3508
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): stage sub-issues so the parent wakes per stage, not per child
Sub-issues under a parent can be grouped into ordered stages (issue.stage).
The child-done -> parent notification + assignee wake now fire only when a
stage barrier closes: every sub-issue in the lowest unfinished stage has
reached a terminal status (done/cancelled). An unstaged sibling set is one
implicit stage, so the parent is woken once when the last sub-issue finishes
instead of on every child — the default fix for the fire-on-every-child
cascade reported in discussion #4320 / MUL-3508.
Stage advancement stays agent-driven: the server only detects the closed
barrier and wakes the parent assignee, who decides whether to promote the
next stage.
- DB: nullable issue.stage (CHECK >= 1) + sqlc regen
- API: stage on issue create/update/response and batch update
- CLI: `issue create`/`issue update` --stage; new `issue children` command
that lists sub-issues grouped by stage (table + json)
- stageBarrierClosed / stageProgressSummary in issue_child_done.go, with the
wake comment now stage-aware, plus unit tests
- skill docs (multica-working-on-issues SKILL.md + source map)
Web UI (create-form stage picker, sidebar edit, group-by-stage display) is a
follow-up; the API already returns stage for it to consume.
MUL-3508
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): address review on stage barrier (cancel, batch, unstaged)
Resolves the three blockers from the PR review:
1. Cancel can close a stage. The child-done barrier now fires on any
non-terminal -> terminal transition (done OR cancelled), not just done.
isTerminalChildStatus already treats cancelled as terminal (a cancelled
sibling never finishes, so it must not hold a stage open), so a cancelled
last-open child now closes its stage and wakes the parent. Keying on the
transition also makes a later cancelled -> done edit a no-op, avoiding a
lagging duplicate wake.
2. Batch update of stage no longer no-ops. `hasMutation` now includes
"stage", so `{"updates":{"stage":N}}` persists instead of returning
{"updated": 0}.
3. Unstaged children no longer participate in the staged frontier. In a
staged sibling set, NULL-stage children neither hold a stage open nor fire
on their own completion, and the wake comment no longer renders "Stage 0".
This matches migration 123 ("NULL does not participate in staged
grouping") and the CLI's separate unstaged group, removing the footgun
where an unstaged backlog child silently blocked Stage 1.
Tests: cancellation closes a stage (staged + unstaged), unstaged ignored in a
staged set, stage summary skips unstaged, and a stage-only batch update
persists.
MUL-3508
Co-authored-by: multica-agent <github@multica.ai>
* feat(web): stage UI — create picker, sidebar edit, group sub-issues by stage
Frontend for the sub-issue stage feature (web + desktop, shared via packages):
- core: `stage` on the Issue type + create/update request types; zod
IssueSchema parses it (defaults to null for older backends) with schema
tests for the numeric and omitted cases.
- StagePicker component (mirrors the other property pickers): "No stage" +
Stage 1..N, offering one beyond the current/sibling max.
- Create-issue modal: a Stage pill, shown only when a parent is selected,
threaded into the create payload.
- Issue detail sidebar: an editable Stage row + "add property" entry, gated to
sub-issues (issues with a parent).
- Sub-issue list grouped by stage with per-stage headers (flat when unstaged).
- i18n: stage keys across en / zh-Hans / ja / ko (parity test passes).
Verified: full typecheck (6/6), core (591) + views (1433) vitest suites,
lint clean (no new findings). Backend/CLI shipped earlier in this PR.
MUL-3508
Co-authored-by: multica-agent <github@multica.ai>
* test(issues): add stage to Issue fixtures merged from main
The merge brought in new Issue fixtures that predate the required `stage`
field: core issues/batch.test.ts, views batch-action-toolbar.test.tsx, and
the mobile EMPTY_ISSUE_FALLBACK sentinel. Add `stage: null` so they satisfy
the Issue type (mobile reuses core's IssueSchema for parsing, so only the
sentinel needs it).
MUL-3508
Co-authored-by: multica-agent <github@multica.ai>
* fix(web): feed StagePicker the sibling max stage so higher stages stay selectable
The StagePicker accepts maxStage to extend its option list beyond the
floored Stage 1-3, but neither call site passed it, so a parent with an
existing Stage 4/5 child could not pick that stage when creating a new
sub-issue or editing one in the sidebar.
- Compute the sibling max stage at both call sites: the create modal now
loads the parent's children (childIssuesOptions) and the detail sidebar
reuses the already-loaded parentChildIssues.
- Extract maxSiblingStage + stageOptions as pure helpers on stage-picker
and unit-test them (the regression: a Stage 5 sibling keeps Stage 5
selectable and offers Stage 6).
MUL-3508
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Fix GitHub pull_request and check_suite webhook routing so events are attributed to the workspace that registered the repository, with fallback to the installation workspace. Includes host-qualified repo matching, account-gated registry routing, deterministic matching, and regression coverage.
* feat(daemon): inject project description into the agent brief
Issues bound to a project only surfaced the project title in the runtime
brief; the project description (durable, project-wide context the owner
sets) was loaded but dropped. Carry it end-to-end:
- claim handler reads proj.Description onto the response (issue-bound and
quick-create paths)
- new ProjectDescription field on AgentTaskResponse, daemon Task, and
TaskContextForEnv
- rendered in the brief's `## Project Context` section and written to
.multica/project/resources.json as project_description
Empty descriptions render nothing (no extra heading). Updated the
projects-and-resources built-in skill docs in the same change.
MUL-3465
Co-authored-by: multica-agent <github@multica.ai>
* feat(projects): clarify project description is injected as agent context
The project description is now durable context injected into every task's
brief, but the UI still presented it as a plain "Description" field, so
existing descriptions could silently become agent input. Add a hint under
the description editor on the project detail page and in the create-project
modal, in all four locales, stating it is shared with agents as context for
every task in the project. No data-semantics change.
Addresses review feedback on PR #4395. MUL-3465
Co-authored-by: multica-agent <github@multica.ai>
* test(handler): assert project description flows through task claim
The execenv tests cover brief rendering, but nothing pinned the claim
handler boundary where proj.Description is read onto the response. Add
two tests — issue-bound and quick-create paths — so a regression in that
assignment fails loudly instead of silently dropping the description.
Addresses review feedback on PR #4395. MUL-3465
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(agent): Qoder ACP runtime, chat reconnect recovery, and task linkage
- Add Qoder CLI backend (ACP transport, model discovery, blocked-args policy)
- Wire daemon/runtime config, docs, and UI provider assets
- Retry terminal task reports; add backoff unit tests
- Chat: SQL attach user message to task; handler + optimistic cache reconcile
- Invalidate chat/task-messages caches on WS reconnect; extract helper + tests
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: drop non-Qoder changes (chat reconnect, task link, terminal report retries)
Keep only Qoder runtime, docs, daemon config/execenv, and UI provider assets.
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(agent): harden Qoder ACP drain and wire project skills path
- Stop streaming to msgCh after reader wait so grace timeout cannot race close
- Resolve injected skills to .qoder/skills per Qoder CLI discovery
- Update AGENTS.md skill copy and add execenv tests
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(qoder): add provider logo and wire MCP config into ACP sessions
- Add inline SVG QoderLogo component to provider-logo.tsx, replacing
the generic Monitor icon placeholder
- Add convertMcpConfigForACP helper to convert Claude-style MCP server
config (object map) into ACP array format for session/new and
session/resume
- Add unit tests for convertMcpConfigForACP covering stdio, SSE,
empty/nil, and multi-server cases
Co-authored-by: Orca <help@stably.ai>
* fix(test): capture both return values from InjectRuntimeConfig in Qoder test
Co-authored-by: Orca <help@stably.ai>
* fix(qoder): preserve remote MCP headers and promote provider errors
Addresses review feedback on #2461 (Bohan-J): two runtime-correctness
issues in the Qoder ACP backend.
1. Remote MCP headers were dropped. The bespoke convertMcpConfigForACP
only forwarded url/type, so an authenticated remote MCP server looked
configured in Multica but failed inside the Qoder session. Replace it
with the shared buildACPMcpServers helper (same path Hermes/Kimi/Kiro
use), which preserves headers as [{name, value}], sorts for
deterministic output, and handles remote transport aliases. Fail
closed on malformed mcp_config instead of silently dropping servers.
2. Provider failures could report as completed tasks. stderr was wired
via io.MultiWriter and the result was only promoted to failed when
output was empty, so a terminal upstream error (HTTP 429 / expired
token) racing a stopReason=end_turn with text still became
"completed". Switch to StderrPipe + an explicit copier, drain it
(bounded by the existing grace window, since qodercli can leave a
child holding the inherited fds) before the decision, and run the
shared promoteACPResultOnProviderError.
Tests: replace the convertMcpConfigForACP unit tests with two
end-to-end Qoder tests — one asserts the Authorization header reaches
the session/new payload as {name, value}, the other asserts a terminal
stderr error with non-empty output reports failed.
Co-authored-by: Orca <help@stably.ai>
* fix(qoder): align ACP session handling
Co-authored-by: Orca <help@stably.ai>
* fix(agent): guard qoder late output after drain
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
When a sub-issue transitions to done, notifyParentOfChildDone posts a
system comment on the parent and wakes the parent's assignee. A parent
deliberately parked in backlog should stay inert: waking it lets the
assignee promote sibling backlog sub-issues into todo, which is the
unwanted auto-activation reported in GitHub Discussion #4320 (MUL-3497).
Add a backlog guard alongside the existing done/cancelled guard so the
whole notification (comment + mention + trigger) is skipped until the
user explicitly moves the parent out of backlog.
MUL-3497
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The daemon auto-grants Codex item/permissions/requestApproval requests by
echoing back the network / fileSystem profile scoped to the current turn.
Previously a malformed params payload and any permission key outside
network / fileSystem were dropped silently, so a future app-server
protocol that adds a new permission shape would be narrowed away with no
trace in daemon logs.
Log both cases (parse failure and dropped keys) without changing the
granted response. Addresses review nits on #4346 / MUL-3451.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(lark): reply inside the originating thread (话题) instead of the group
When a user @-mentions the bot inside a Lark topic/thread, the bot now
replies back into that thread rather than posting a fresh message at the
chat level. Behavior is automatic and scoped: only triggers that were
themselves inside a thread get a threaded reply, so normal group/p2p
chats are unchanged.
The outbound path is event-driven and decoupled from the inbound
message, so the trigger message_id + thread_id are persisted on
lark_chat_session_binding (migration 122) at ingest time. The patcher
then routes the agent reply (text / markdown card / error card) and the
OutcomeReplier notices (/issue confirmation, offline/archived) through
Lark's reply endpoint with reply_in_thread=true when a thread is present,
falling back to a chat-level send if the threaded reply fails.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(lark): classify thread-reply failures before chat-level fallback
Only retry a threaded reply at the chat level when Lark returns an
explicit "this message/topic cannot receive a threaded reply" error
(recalled trigger, topic gone, topics disabled, aggregated message,
etc.). Transport errors, 5xx, timeouts, rate limits, and ambiguous
failures are now logged and returned as failures instead of being
retried, so we never duplicate a reply or leak a thread-only reply
into the main group chat.
The three reply-capable send methods now return a structured *APIError
carrying the Lark business code, and isThreadReplyUnsupported drives
the fallback via an allowlist. sendWithThreadFallback is promoted to a
package-level function so the immediate OutcomeReplier sends (/issue
confirmation, offline/archived notices) share the same classified
fallback path instead of silently swallowing thread-reply failures.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: kun <kuen@micous.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Skill import builds raw.githubusercontent.com URLs by hand and fetches them
via fetchRawFile, which sent no Authorization header. GitHub API calls were
authenticated by #2215 (doGitHubAPIGet/addGitHubAuthHeader) but the raw
content download path was missed, so importing a skill from a private/internal
GitHub repo listed the directory fine and then 404'd on the actual file
download, surfacing as a generic 502.
Attach the existing GITHUB_TOKEN bearer header in fetchRawFile, but only when
the URL host is raw.githubusercontent.com. fetchRawFile is shared with
clawhub.ai / skills.sh downloads, so the token must not leak to those hosts.
The host gate is extracted into newRawFileRequest so it is unit-testable
without a live round-trip.
MUL-3496
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
When an issue is assigned to a squad, only the leader is triggered. The
leader briefing's Squad Roster listed each member's name, type, role, and
mention link — but not the member's assigned skills, so the leader had to
infer capability from the free-text role label when deciding who to
delegate to.
renderMemberRow now loads each agent member's assigned skills via
ListAgentSkillSummaries and formatRosterRow renders them as
"skills: a, b" (or "no skills assigned" when the agent has none). Builtin
multica-* skills are excluded (they live outside agent_skill); human
members carry no skills segment; a skill-lookup error degrades to the
prior name+role row rather than asserting a misleading "no skills".
Operating-protocol step 1 now tells the leader to match the task to each
member's listed skills.
Updates the multica-squads builtin skill and its source map to document
the new roster content, and adds
TestBuildSquadLeaderBriefing_MemberSkillsInRoster.
Co-authored-by: hal9000botagent <hal9000botagent@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary
- expose `--callback-host` on `multica setup` and `multica setup cloud`, reusing the existing login callback override
- print an SSH tunnel hint when browser login runs in an SSH session with a loopback callback
- show local flags in parent-command help so `multica setup --help` documents the callback option
Fixes#4357
## Tests
- `go test ./cmd/multica -run 'TestCallbackHostFlagValueReadsParentSetupFlag|TestSetupHelpShowsCallbackHostFlag|TestSetupCallbackHostFlagWiring|TestBrowserLoginInstructionsSSHRemoteHint'`
- `go run ./cmd/multica setup --help`
- `go run ./cmd/multica setup cloud --help`
- `git diff --check`
- `go test ./...`
* fix(projects): require admin for project deletion
* test(projects): clean up orphaned member rows in delete-permission helper
The schema uses no foreign keys or cascades, so deleting the test user
left its member row behind in the shared test workspace, polluting later
tests in the package. Delete the member row before the user in both the
pre-seed cleanup and t.Cleanup.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* test: enable -race detector in Go test pipeline (WOR-61)
Add the -race flag to all three Go test invocation sites so the existing
concurrency regression harness (workdir_race_test.go for #3999,
runtime_gone_test.go, runtime_profile_drift_test.go) actually exercises
the race detector. The daemon package alone has 28+ goroutine launch
points with no automated race coverage before this change.
Sites updated:
- Makefile:299 (make test, local)
- .github/workflows/ci.yml:101 (CI backend job)
- .github/workflows/release.yml:55 (release verify job)
go test already runs a vet subset by default, so no separate -vet flag
is added. No production code touched.
Co-authored-by: multica-agent <github@multica.ai>
* test(execenv): serialize runtimeGOOS-mutating test (WOR-61)
TestInjectRuntimeConfigIssueMetadataCodexFormattingUnchanged called
t.Parallel() while mutating the package-level runtimeGOOS to drive the
windows/linux branches, racing with the other parallel tests that read
runtimeGOOS in buildMetaSkillContent. The -race flag enabled in the
prior commit surfaced it as 3 WARNING: DATA RACE reports and 11
"race detected" failures in CI (only the execenv package failed).
Drop t.Parallel() and add the "// Not parallel: mutates the package-level
runtimeGOOS." comment already used by the six sibling writer tests across
execenv_test.go and reply_instructions_test.go. This is test-isolation
only; no production code, no mutex/atomic, no signature change.
Verified locally:
go test -race -count=1 ./internal/daemon/execenv/ -> ok 2.276s
go test -race -count=1 ./internal/daemon/... -> all 3 pkgs ok
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: hzz <331380069@qq.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(daemon): discover local skills from ~/.agents/skills (MUL-3333)
Upgrade local skill discovery and import from a single provider root to an
ordered multi-root scan: the runtime's own skill directory (e.g.
~/.claude/skills) first, then the cross-tool universal root ~/.agents/skills.
- Rename localSkillRootForProvider -> localSkillRootsForProvider, returning
ordered roots [provider, universal] with a kind classifier.
- listRuntimeLocalSkills iterates the roots, gives each root its OWN visited
set (so a cross-root symlink alias is not collapsed), dedupes strictly by
Key with the provider root winning, and sorts once after the merge.
- loadRuntimeLocalSkillBundle walks the same priority order and only falls
through to the next root on os.IsNotExist; any other stat error is returned
so import never silently resolves a different same-key skill.
- Add a Root ("provider" | "universal") field to the local skill summary
(daemon + handler structs and the TS RuntimeLocalSkillSummary type) so the
UI can label a skill's origin without a future schema break.
Backward compatible: every skill visible today keeps its Key, SourcePath and
FileCount; the universal root only surfaces additional, non-conflicting skills.
Out of scope (follow-up issues): execution-time injection of ~/.agents/skills
into runtimes (e.g. Codex seedUserCodexSkills) and workspace-relative
.agents/skills discovery.
Tests cover universal-root discovery + import, provider-wins conflict
priority, both-roots merge, missing/both-missing roots, nested layouts,
IsNotExist fallback, the no-fallthrough-on-read-error guarantee, and the
per-root visited cross-root symlink alias. Docs updated in en/zh/ja/ko.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): fall through to next root when a same-key dir has no SKILL.md
loadRuntimeLocalSkillBundle previously only fell through to the next root on
os.IsNotExist for the skill DIRECTORY. A provider-root directory that shares a
skill's key but contains no SKILL.md (so listRuntimeLocalSkills descends past
it and surfaces the universal-root skill instead) made load stop on the
invalid provider dir and error — list and load disagreed, and the import the
user picked from the list could not be fetched.
Make the validity predicate match list: a root "has" the skill at a key only
when it is a directory containing a SKILL.md. A missing entry, a non-directory,
or a directory without a SKILL.md all mean "this root doesn't have it" and we
continue to the next root. Only a genuine non-IsNotExist stat error or an
unreadable existing SKILL.md (permission/IO) is returned, so we still never
silently substitute a different-content same-key skill from a lower-priority
root (Eve review #1, preserved by the existing read-error guard test).
Adds regression tests for the provider-dir-without-SKILL.md and provider-non-dir
fall-through cases.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Adds a dry-run-first operator command for historical Codex usage cache correction, packages it in the backend image, documents the operator-job flow, and covers execution with DB-backed tests.
Bundles the MUL-3404 disk-usage feature with the two Preflight BLOCK fixes.
- feat(daemon): `disk-usage --all-profiles` aggregates across every workspace
root (default + each ~/.multica/profiles/* root, incl. the Desktop app's),
with a per-root breakdown and combined grand total; the cross-root hint now
also fires when the current root is non-empty.
- fix(db): drop DB-level foreign keys/cascades from the new autopilot_subscriber
and comment.source_task_id migrations (resolved in the app layer — autopilot
delete now removes subscribers in a transaction); the autopilot_subscriber
down-migration relabels reason='autopilot' to 'manual' instead of deleting.
- fix(server): readiness verifies every required migration is applied, not just
the lexically-last one, so an out-of-order migration can't be masked.
MUL-3404.
* fix(daemon): reclaim autopilot_run workdir on terminal status (MUL-3403)
Autopilot run workdirs are never reused — there is no PriorWorkDir path
that hands a later run the same directory, so every run gets a fresh one.
Yet GC waited the full GCTTL (default 24h) before reclaiming a terminal
run's dir. Combined with one fresh dir per run, high-frequency autopilots
piled up hundreds of stale dirs (508 dirs / 22GB in the field report).
Drop the TTL gate so a terminal run (completed/failed/skipped/
issue_created) is reclaimed immediately, mirroring gcDecisionQuickCreate.
Existing safety constraints are untouched: active-env-root short-circuit,
404 -> orphanByMTime, non-404 error -> skip, and the local_directory
override all still apply.
Co-authored-by: multica-agent <github@multica.ai>
* docs(daemon): fix GetAutopilotRunGCCheck comment — completed_at is not a TTL anchor
The endpoint comment still claimed the daemon uses completed_at as the TTL
anchor for terminal runs. GC now decides purely on terminal status (the
workdir is never reused, so a terminal run is reclaimed on sight);
completed_at is returned for the API contract / diagnostics only. Addresses
the review nit on #4287.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Disambiguate client-side model pricing by provider: generic ids (e.g. `auto`) resolve ${provider}/${model} first, so they only price under their real provider instead of borrowing Cursor's rate. Provider is LOWER()-normalized on read and write so mixed-case historical rows merge.
Closes#4199. MUL-3346
* feat(chat): workspace-scoped attachment binding + fire-and-forget send
Uploads are now workspace-scoped: the chat session is created and
attachments are bound to the message at send time, so a paste/drop no
longer creates an empty session the user never sends.
- LinkAttachmentsToChatMessage returns the ids it actually bound; the
client diffs requested-vs-bound and warns on partial bind, replacing
an extra listChatMessagesPage fetch.
- Cancelling an empty chat task detaches attachments before deleting the
user message (attachment FK is ON DELETE CASCADE) and returns them via
cancelled_chat_message.attachments, so a restored draft can re-bind.
- SendChatMessageResponse.attachment_ids has no omitempty: "requested but
bound zero" serializes [] so the client can tell it apart from an older
server and still warn.
- Send is fire-and-forget: it no longer steals focus when the user has
navigated to another session (guarded on the live store + new-chat agent
id); the reply surfaces via the unread dot. commitInput gets clearEditor
so a navigated-away commit doesn't wipe the editor now showing another
session, while still clearing the sent draft's data.
- Draft restore is session-aware so a failed fire-and-forget send restores
into the session it was sent from, never the one the user moved to.
- Removed the now-unreferenced migrateInputDraft store action.
Verified: core/views typecheck, chat-input (15) / store (3) / api client
(24) unit tests, go build + vet, handler SendChatMessage + CancelTaskByUser
DB tests. Full make check / E2E left to CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(chat): guard attachment survival on empty-chat cancel
Cancelling an empty chat task deletes the user message, and
attachment.chat_message_id is ON DELETE CASCADE (migration 083), so the
detach-before-delete in finalizeCancelledChatMessage is the only thing
keeping the user's attachment from being silently destroyed. Nothing
covered it.
Add a DB regression test that binds an attachment to the cancelled user
message and asserts: the row survives the cascade (chat_message_id NULL,
chat_session_id retained), the cancel response returns it via
cancelled_chat_message.attachments, and a resend re-binds it to the new
message. Verified red when the detach step is removed.
Related issue: MUL-3364
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(comment): pessimistic submit for comment/reply composers
The comment and reply composers cleared the editor after `await onSubmit`
returned, with no in-flight lock. On a slow send the WS `comment:created`
event already dropped the real comment into the timeline while the box
still held the same text + spinner, so it read as two comments. And
because `submitComment`/`submitReply` swallow errors (toast, no rethrow),
a failed send still reached `clearContent` and silently discarded the
user's draft.
Recover the comment/reply portion of the closed#4236: make the submit
callback resolve a success boolean (true on success, false on the caught
failure), lock the editor while in flight (pointer-events-none + dimmed
wrapper + aria-busy, since ContentEditor can't toggle Tiptap `editable`
post-mount), keep the button spinning, and clear only on success — a
failed send keeps the draft. Chat composer is out of scope (already
reworked on this branch); attachment binding is untouched.
Adds two view tests (in-flight lock then clear-on-success; failed send
keeps the draft); both verified red against the un-fixed code.
Related issue: MUL-3364
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(autopilot): default subscriber template (MUL-2533) — server
Add per-autopilot member subscriber template that fans out to every
issue the autopilot spawns. New autopilot_subscriber table; extend
issue_subscriber.reason with 'autopilot' so the dispatch-time fanout
is distinguishable from manual subscriptions.
API: POST/PATCH /api/autopilots accept a `subscribers` array (member
user_type only for the first version); PATCH semantics are full-replace.
GET returns subscribers on the detail endpoint; the list endpoint omits
them to avoid an N+1.
Dispatch: dispatchCreateIssue lists the template inside the same tx as
the issue insert and writes the rows with reason='autopilot' before
EventIssueCreated fires, so notification listeners see the full
subscriber set on the first event.
Co-authored-by: multica-agent <github@multica.ai>
* feat(autopilot): default subscriber template (MUL-2533) — frontend
New SubscriberMultiSelect picker (members-only search + chips) wired
into the create / edit AutopilotDialog. The detail page renders the
saved template as read-only chips; edits flow through the dialog.
TS types expose the new `subscribers` field on Autopilot, plus an
AutopilotSubscriberInput shape for the create/update wire payloads.
Co-authored-by: multica-agent <github@multica.ai>
* fix(autopilot): notify template subscribers on issue creation (MUL-2533)
The autopilot create-issue path fans out template subscribers into
issue_subscriber inside the same tx as the issue insert, but the
issue:created notification listener only matches handler.IssueResponse
payloads and only direct-notifies the assignee + @mentions. The autopilot
publishes a map[string]any payload, so the listener falls through and the
template subscribers never receive an inbox item for the creation event —
breaking OQ3 ("reason='autopilot' subscribers receive all subscription
events, consistent with reason='manual'").
Fix it where the divergence lives: in dispatchCreateIssue, right after
EventIssueCreated fires, write an inbox_item (type='issue_subscribed',
severity='info') for each member subscriber and publish EventInboxNew so
the recipient's inbox WS feed updates in real time. The write is after
the tx commit so an inbox hiccup can't roll back the issue; failures are
logged, not propagated. The manual path is unchanged — manual subscribers
don't exist at creation time, so there is nothing to notify there.
Adds a new InboxItemType 'issue_subscribed' (en/zh labels) and two
covering tests in autopilot_subscriber_test.go: one asserts the inbox
row lands for a template subscriber on dispatch, the other asserts the
no-subscriber autopilot stays silent.
Co-authored-by: multica-agent <github@multica.ai>
* fix(autopilot): align subscriber PR with current main
Co-authored-by: multica-agent <github@multica.ai>
* fix autopilot subscriber template transaction
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): surface in-flight CI on PRs and recover out-of-order check_suite events
Two bugs caused PR cards to render "checks not reported yet" while CI
was actually running (MUL-2392):
1. handleCheckSuiteEvent dropped every action except `completed`, so
`requested`/`rerequested` events (status queued/in_progress) never
landed in the suite table. Aggregated `checks_pending` stayed at 0
until the first suite finished, and the frontend fell through to the
unknown bucket. Persist all actions; the ListPullRequestsByIssue
aggregation already counts status<>completed as pending.
2. A check_suite for an unmirrored PR was logged and dropped, with no
replay path. Add a `github_pending_check_suite` stash keyed by
(workspace, repo, pr_number, suite_id); the pull_request webhook
drains it after the PR upsert and replays each entry through the
normal check_suite upsert. One-shot drain via DELETE … RETURNING
keeps it idempotent and free of retry storms.
Follow-ups for fork PRs (empty `pull_requests[]`) and a more specific
frontend placeholder ship in separate issues.
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): guard pending check_suite stash against out-of-order events
UpsertPendingCheckSuite previously overwrote unconditionally on
conflict, so an older `requested/in_progress` event arriving after a
newer `completed/success` for the same suite would roll the stash
back to pending. The subsequent PR upsert then drained the stale
state and the PR card stuck on "pending" until the next suite. Mirror
the suite_updated_at guard from UpsertPullRequestCheckSuite and add a
regression test covering the PR-missing path.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The agent record already carries a top-level `thinking_level` field —
exposed by `agent get --output json`, settable from the web inspector,
and accepted/validated by `PUT /api/agents/{id}` — but the CLI had no
flag to write it. Scripted or version-controlled agent management could
set `--model` but not the thinking depth, forcing a drop to raw HTTP.
Add `--thinking-level` to `agent create` and `agent update`, mirroring
`--model`: a thin pass-through to the top-level `thinking_level` field.
On update an empty string clears it back to the runtime default (the
server reads it as a tri-state pointer: omitted = no change, "" = clear,
value = set). The CLI deliberately does not enumerate valid levels —
they are runtime/model-specific and the server already owns the catalog
(`agent.IsKnownThinkingValue`, `server/pkg/agent/thinking.go`), returning
a 400 for an unknown value or a runtime with no thinking concept, which
the CLI surfaces verbatim.
- server/cmd/multica/cmd_agent.go: register the flag on both commands,
Changed-gate it into the request body, add it to the no-fields error.
- server/cmd/multica/cmd_agent_test.go: cover create/update send,
unset-omission, empty-clears, the flag-exposed guard, and that a
server-side rejection surfaces to the user.
- multica-creating-agents builtin skill + source map: document the new
CLI write surface and re-derive shifted cmd_agent.go line numbers.
Closes#4170🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Matt Voska <voska@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
* MUL-3332: daemon picks up new custom runtime profiles without restart
The workspaceSyncLoop's already-tracked branch refreshed only settings and
repos via refreshWorkspaceRepos and never re-fetched runtime profiles, so
a custom runtime profile created via the web UI / CLI did not become a
registered runtime row until the daemon restarted (or a runtimeGone
recovery happened to fire).
Detect server-side profile drift each sync tick by hashing the workspace's
profile list with profileSetSignature(), caching the digest on
workspaceState.profileSetSig, and triggering reregisterWorkspaceAfterRuntimeGone
when the live signature differs from the cached one. Steady-state syncs cost
exactly one extra GetRuntimeProfiles round trip; only real drift fans out to
a Register call.
The fetch is best-effort: a 404 / network blip preserves the cached signature
so a transient failure cannot loop the daemon into spurious re-registrations.
Tests in runtime_profile_drift_test.go cover digest stability under reorder,
field-by-field drift detection (add / enable-flip / command_name /
protocol_family / fixed_args / visibility), the no-drift hot path (no
re-register), the new-profile drift path (single re-register + index update +
sig converges), and best-effort fetch error handling.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3332: split orphan recovery from profile drift; converge to zero
Addresses two blocking review concerns on #4225 (raised by GPT-Boy):
1. Profile drift must not kill running tasks on existing runtimes.
The first cut reused reregisterWorkspaceAfterRuntimeGone, which after
re-register calls /recover-orphans for every returned runtime ID. The
server's RecoverOrphanedTasksForRuntime hard-fails every
dispatched/running/waiting_local_directory row on that runtime — the
correct response when a runtime row was actually deleted server-side,
but a catastrophic false positive on profile drift: a built-in runtime
still actively executing the user's tasks would have its work killed
just because the user added an unrelated sibling custom profile.
Fix: extract applyRegisterResponseInPlace as the shared in-place state
converger between the two paths, and stop calling /recover-orphans from
the drift path. reregisterWorkspaceAfterRuntimeGone keeps the
/recover-orphans call because in that path the rows really were gone.
2. Disabling the only profile on a custom-only daemon must converge.
The first cut hit registerRuntimesForWorkspace's len(runtimes)==0 guard
and bailed out, so the disabled profile's runtime stayed alive in
local tracking and on the server (still polling, still heartbeating,
still online for the full 150 s stale-heartbeat window).
Fix: introduce ErrNoRuntimesToRegister as a sentinel, have
registerRuntimesForWorkspace return profileSig even on the empty case
(so the drift path can cache the converged-empty signature), and have
the drift refresh's error handler take a convergeWorkspaceRuntimesToZero
branch that clears local runtimeIDs / runtimeIndex entries and
Deregisters the orphaned IDs so the server marks them offline
immediately. The same Deregister step also runs on partial drift (a
built-in survives, the disabled profile's runtime drops) so the user
sees the dropped runtime go offline within the next sync tick instead
of after the 150 s sweep.
Tests:
- TestRefreshWorkspaceRuntimeProfiles_DriftWithRunningRuntimeSkipsOrphanRecovery
(mixed built-in + custom, add another profile, asserts zero
/recover-orphans calls).
- TestRefreshWorkspaceRuntimeProfiles_DisableConvergesCustomOnlyDaemon
(custom-only daemon, disable only profile, asserts local state
cleared, signature converges to empty digest, Deregister called with
the orphaned ID, no recover-orphans, follow-up tick is no-op).
- TestRefreshWorkspaceRuntimeProfiles_DisableOneOfManyDeregistersDroppedID
(partial drift: only the dropped ID is Deregistered, surviving
built-in is left alone and not orphan-recovered).
- TestRefreshWorkspaceRuntimeProfiles_NewProfileTriggersReregister
extended to also assert no /recover-orphans calls.
- TestRegisterRuntimes_SkipsProfileNotOnPath strengthened to assert the
ErrNoRuntimesToRegister sentinel and that profileSig is still returned
on the empty path.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3 (CLI): multica runtime profile subcommands + local path override
- cmd_runtime_profile.go: `multica runtime profile` group — list / create /
update / delete against /api/workspaces/{id}/runtime-profiles, plus set-path
/ unset-path for a per-machine command override. protocol-family validated
client-side via agent.IsSupportedType / agent.SupportedTypes; visibility
validated; update only sends changed flags (protocol_family immutable);
delete surfaces the server 409 body when agents are still bound.
- internal/cli/config.go: ProfileCommandOverrides map[string]string on
CLIConfig (omitempty), through the existing marshal/unmarshal so set/unset
round-trips without dropping other fields.
- internal/daemon: Config.ProfileCommandOverrides, loaded from CLIConfig;
appendProfileRuntimes now prefers an override path when set AND executable,
else falls back to exec.LookPath(command_name), else skips+logs as before.
- Tests: cmd_runtime_profile_test.go (registration, create/update/delete incl.
bad-family + missing-flag + 409 surfacing, set/unset path round-trip,
relative-path rejection, config preservation); cli/config round-trip;
daemon prefers-override / falls-back-when-not-executable.
Verified: go build ./..., go vet, go test ./cmd/multica/... ./internal/daemon/...
./internal/cli/... all pass.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3 (Web): custom runtime profiles in the Runtime page
Single-list integration — no new page, no tabs/grouping. Built-in protocol
families and custom profiles render mixed in one catalog, each row badged
built-in vs custom (progressive disclosure).
- packages/core: RUNTIME_PROFILE_PROTOCOL_FAMILIES (single-source 13-family
whitelist, matches server agent.SupportedTypes + migration 120 CHECK) and
RuntimeProtocolFamily / RuntimeProfile types; api client
list/get/create/update/deleteRuntimeProfile against
/api/workspaces/{id}/runtime-profiles; runtimes/profiles.ts query +
mutation hooks and a 409 "agents still bound" conflict parser.
- packages/views/runtimes: runtime-profile-catalog (mixed built-in+custom
rows), runtime-profiles-dialog (header "+ Add runtime" → step 1 pick
protocol family → step 2 display_name/command_name/description; edit form
for custom; admin-gated), delete-runtime-profile-dialog (confirm + graceful
409), runtimes-page / runtime-list integration.
- i18n: new strings added to all four locales (en, zh-Hans, ja, ko).
- a11y: dialogs are focus-trapped, Esc-closable, labelled; full
create/edit/delete flow is keyboard + screen-reader operable.
Iron rule honored: no generic per-agent args UI here (those stay on Agent
config). fixed_args is not surfaced as a general args field.
Verified: turbo typecheck + lint + test pass for @multica/core, @multica/views,
@multica/web; the @multica/web production build succeeds.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3: hide fixed_args from Web + CLI (not yet wired to launch)
Review fix. fixed_args was surfaced as a working feature, but the daemon does
not splice it into the agent launch command — exposing it promised admins a
no-op. Per the call, remove it from every user-facing surface while keeping the
underlying column/struct "carried but not exposed".
- Web (runtime-profiles-dialog.tsx + runtime-profile-catalog.ts): drop the
detail row, the create body field, the update patch field, and the form
textarea; remove the parseFixedArgs/fixedArgsToText helpers and the
fixedArgs form value. Left a NOTE pointing at the daemon TODO.
- i18n: removed the fixed_args strings from all four locales (en/zh-Hans/ja/ko).
- CLI (cmd_runtime_profile.go): removed the `--fixed-arg` flag from create and
update and stopped sending `fixed_args`; updated the "no fields" message.
Test now asserts the CLI never sends fixed_args.
Untouched (the carried-but-not-exposed layer): the runtime_profile.fixed_args
column, the server handler's accept/return, and the daemon's RuntimeProfile
field — all keep the existing TODO(MUL-3284) to wire it into the launch path
(with a test proving args reach the backend) before any UI/CLI re-exposes it.
Verified: turbo typecheck+lint+test pass for @multica/core and @multica/views;
go build/vet/test pass for ./cmd/multica/.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3: stop exposing profile visibility=private (server forces workspace)
Double-review (Eve) caught a fixed_args-shaped hole: visibility=private was a
user-facing toggle (Web form + detail + CLI), but the three server read paths
(ListRuntimeProfiles, daemon ListEnabledRuntimeProfilesForWorkspace,
DaemonRegister) never enforce it — so a "private" profile's name/command would
leak to other members and could be registered by other machines' daemons
(lateral data leak). Same "don't paint a pie" fix as fixed_args: hide the
control everywhere and force the stored value.
- Server (runtime_profile.go): drop `visibility` from the create + update
request structs; CreateRuntimeProfile always stores 'workspace'
(runtimeProfileDefaultVisibility); UpdateRuntimeProfile no longer accepts it;
removed validRuntimeProfileVisibility. The column + response field stay
(always 'workspace') as the carried-but-not-exposed layer.
- Web (runtime-profiles-dialog.tsx): removed the visibility form fieldset,
the VisibilityOption component, the detail row, the visibility state, and the
create/update submit fields.
- i18n: removed the profile visibility strings from all four locales
(profiles.detail.visibility, profiles.visibility.*, profiles.form.visibility_*).
Top-level runtime/agent visibility strings are untouched.
- CLI (cmd_runtime_profile.go): removed `--visibility` from create/update and
the VISIBILITY list column; removed validateVisibility; stopped sending the
field.
- Tests: new TestCreateRuntimeProfile_ForcesWorkspaceVisibility (POST
visibility:"private" -> response and DB row are 'workspace'); CLI create test
now asserts visibility is never sent.
Follow-up MUL-3308 tracks implementing real creator-visibility (and wiring
fixed_args to the launch path); TODOs left in server/Web/CLI point to it.
Verified: turbo typecheck+lint+test pass (@multica/core, @multica/views);
go build/vet pass; go test ./cmd/multica/... and the full ./internal/handler/
suite pass against a migrated Postgres 17.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284: add runtime_profile schema (custom runtime PR1)
Schema-only foundation for custom runtimes. Additive migration 120:
- New workspace-level `runtime_profile` table: the shared, team-visible
definition of a custom runtime (e.g. an in-house Codex wrapper).
protocol_family is CHECK-constrained to the exact backend list in
agent.New() (server/pkg/agent/agent.go). The only args column is
`fixed_args` (args every agent on the runtime must inherit); there is
deliberately no generic per-agent args field — those stay on
agent.custom_args.
- `agent_runtime.profile_id` (nullable, FK -> runtime_profile ON DELETE
CASCADE): NULL = built-in runtime, non-NULL = a registered instance of
a custom profile.
- Partial unique index agent_runtime_workspace_daemon_profile_key on
(workspace_id, daemon_id, profile_id) WHERE profile_id IS NOT NULL.
The legacy UNIQUE (workspace_id, daemon_id, provider) constraint is left
INTACT so the existing registration upsert
(ON CONFLICT (workspace_id, daemon_id, provider) in runtime.sql) keeps
resolving its arbiter and the server stays green. Converting that key to
a partial (WHERE profile_id IS NULL) index and making the upsert
profile-aware is PR2's registration work, not this migration.
Verified up + down against Postgres 17: full `migrate up` applies 120;
schema shows the table, column, partial index and intact legacy
constraint; functional checks pass (partial index blocks dup
(ws,daemon,profile), allows same profile on another daemon; CHECK and
display_name uniqueness reject bad input; legacy ON CONFLICT still
resolves; profile delete cascades to instances); down/up round-trip is
clean.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284: drop DB FKs/cascade from runtime_profile migration (review fix)
Per review (house rule: no new database foreign keys / cascades; relational
integrity lives in the application layer):
- runtime_profile.workspace_id: drop REFERENCES workspace ON DELETE CASCADE
-> plain UUID NOT NULL.
- runtime_profile.created_by: drop REFERENCES "user" ON DELETE SET NULL
-> plain UUID.
- agent_runtime.profile_id: drop REFERENCES runtime_profile ON DELETE CASCADE
-> plain UUID.
CHECK constraints, UNIQUE (workspace_id, display_name), the workspace index,
and the partial unique index agent_runtime_workspace_daemon_profile_key are
unchanged. The legacy UNIQUE (workspace_id, daemon_id, provider) constraint
remains untouched.
Behavioral consequence: the database no longer auto-removes a profile's
agent_runtime instance rows on profile delete. That cleanup moves into PR2's
profile-delete path. Up-migration comments document this; down-migration
comment no longer references FKs/cascade.
Re-verified on Postgres 17: migrate up applies 120; no FK constraints exist on
the new columns; partial index still blocks dup (ws,daemon,profile_id); CHECK
and display_name uniqueness still reject bad input; deleting a profile now
leaves the runtime row orphaned (proving cascade is gone); down/up round-trip
clean with the legacy constraint intact.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR2 (server): runtime_profile CRUD + profile-aware registration
Server/DB half of the custom-runtime feature.
- Migration 121: convert the legacy UNIQUE (workspace_id, daemon_id, provider)
constraint on agent_runtime into a partial unique index scoped to built-in
rows (WHERE profile_id IS NULL). With 120's partial index on profile_id this
lets one daemon host the built-in provider AND custom profiles of the same
protocol family without collision.
- Queries: runtime_profile CRUD; ListEnabledRuntimeProfilesForWorkspace
(daemon-facing); CountAgentsByProfile + DeleteAgentRuntimesByProfile for the
app-layer cascade; profile-aware UpsertAgentRuntimeWithProfile; the built-in
UpsertAgentRuntime ON CONFLICT now spells out WHERE profile_id IS NULL so it
targets the right partial index. sqlc regenerated.
- agent.SupportedTypes / IsSupportedType: single-source protocol_family
whitelist, in lockstep with agent.New and the migration 120 CHECK.
- Handlers + routes: runtime_profile CRUD (member-read, admin-write) with
protocol_family whitelist validation, display_name uniqueness (409), and
fixed_args validation (no generic per-agent args — iron rule); a
daemon-token endpoint GET /api/daemon/workspaces/{id}/runtime-profiles;
DeleteRuntimeProfile does the app-layer cascade (delete instance rows then
profile, in one tx) and refuses (409) while active agents are bound.
- DaemonRegister accepts an optional per-runtime profile_id: validates the
profile belongs to the workspace and is enabled, registers via the
profile-aware upsert, and skips legacy hostname merge for custom rows.
AgentRuntimeResponse now carries profile_id.
Verified on Postgres 17: migrate up through 121; built-in + custom codex
coexist on one daemon; both upsert arbiters are idempotent; delete-by-profile
cascade removes only the custom instance; migrate down reverses 121 then 120
and replays clean. go build ./... and go vet pass; handler test package
compiles.
Daemon-side wiring (fetch profiles, PATH-resolve command_name, register with
profile_id, exec uses command_name) lands in a follow-up commit on this branch.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR2 (daemon): pull profiles, PATH-resolve, register, exec command
Daemon-side half of custom runtime profiles, against the server contract on
this branch.
- client.go: GetRuntimeProfiles(workspaceID) -> GET
/api/daemon/workspaces/{id}/runtime-profiles (mirrors GetWorkspaceRepos);
RuntimeProfile / RuntimeProfilesResponse types.
- types.go: Runtime gains profile_id (parsed from the register response so
runtimeIndex carries it).
- daemon.go:
* appendProfileRuntimes — called inside registerRuntimesForWorkspace before
the empty-runtimes guard. Best-effort fetch (older server 404s are logged
and swallowed; never fails registration). Per enabled profile: resolve
command_name via PATH (exec.LookPath, behind a `lookPath` test hook),
skip+log when absent, best-effort version probe, record the resolved
absolute path keyed by profile_id, and append a registration entry
{name, type=protocol_family, version, status:online, profile_id}. A
custom-only host (no built-in agents) still registers.
* profileCommandPaths map (guarded by d.mu) + recordProfileCommandPath /
customCommandPathForRuntime helpers.
* runTask: looks up the claimed task's RuntimeID -> profile command path and
overrides the executable path, synthesizing an AgentEntry so a custom
runtime runs even when the host has no built-in agent of the same
provider. provider (=protocol_family) is unchanged so agent.New still
selects the right backend.
- Tests: GetRuntimeProfiles request shape; profile runtime appended + path
recorded (custom-only host); profile skipped when command not on PATH;
profiles-fetch-404 is best-effort; customCommandPathForRuntime bookkeeping.
- agent: lockstep test pinning SupportedTypes to agent.New and the migration
120 protocol_family CHECK.
Iron rule honored: profile carries no generic per-agent args. fixed_args are
parsed and carried but intentionally NOT wired into the launch command yet
(optional/best-effort; explicit TODO(MUL-3284) in appendProfileRuntimes).
Verified: go build ./... clean; go vet ./internal/daemon/... clean;
go test ./internal/daemon/... pass (existing + 5 new); full
go test ./internal/handler/ suite passes against a migrated Postgres 17;
agent lockstep test passes.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR2: profile delete runs full archived-agent cascade (fix 500)
Review fix. DeleteRuntimeProfile previously guarded only on ACTIVE agents, but
agent.runtime_id is ON DELETE RESTRICT — a profile whose runtimes had only
ARCHIVED agents passed the guard, then DeleteAgentRuntimesByProfile hit the FK
and the handler 500'd.
Now it mirrors the mature runtime-delete cascade (DeleteAgentRuntime): in one
transaction it enumerates the profile's runtime rows, refuses (409) any with
active agents or active squads led by archived agents, then for each runtime
pauses autopilots pinned to its archived agents, drops archived squads led by
them, and hard-deletes the archived agents before removing the runtime rows
and the profile. No code path can now fall through to a raw FK error.
- queries: ListAgentRuntimeIDsByProfile (sqlc regen). Reuses the existing
per-runtime teardown queries (CountActiveSquadsWithArchivedLeadersByRuntime,
ListArchivedAgentIDsByRuntime, PauseAutopilotsByAgentAssignees,
DeleteSquadsByArchivedAgentsOnRuntime, DeleteArchivedAgentsByRuntime).
- tests: TestDeleteRuntimeProfile_ArchivedAgentCascade (archived-only profile
deletes cleanly: 204, runtime + archived agent + profile gone) and
TestDeleteRuntimeProfile_ActiveAgentBlocks (active agent → 409, survives).
Verified against Postgres 17: both new tests pass; full handler suite, daemon
tests, and agent lockstep test pass; go vet clean.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>