mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 17:18:35 +02:00
agent/lambda/ca279bb0
4514 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d37338aac3 | Merge remote-tracking branch 'origin/main' into agent/emacs/f1fb8e1a | ||
|
|
55dc302d0e |
MUL-5610: add Codex first-item wait telemetry (#6312)
* fix(codex): add first-item wait telemetry Co-authored-by: multica-agent <github@multica.ai> * test(codex): stabilize first-item telemetry race coverage Co-authored-by: multica-agent <github@multica.ai> * test(codex): harden first-item wait lifecycle fixture Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
766c7a7fa2 |
refactor(daemon): retreat deep read semantics to --help, trim BTS prose (MUL-5442) (#6347)
* feat(cli): carry the comment-read contract in comment list --help (MUL-5442) The --recent flag help gains the MUL-5372 saturation semantics (N caps threads, not comments; every thread returns uncapped; small issues return the whole history) and a pointer at the bounded alternative. The --before help names the stderr cursor labels alongside the response header. This is the relocation target for the brief's deep read semantics: the contract follows the flag, and TestIssueCommentListHelpCarriesReadContract pins it here so the brief-side pointer cannot dangle. Co-authored-by: multica-agent <github@multica.ai> * refactor(daemon): retreat deep read semantics to --help and trim BTS prose (MUL-5442) Unblocked by #6309: the platform no longer recommends --recent anywhere, so the flag's deep semantics no longer need to ride in every brief. - Available Commands comment-list entry: keep the signature, the two bounded read shapes, and the one-clause saturation warning; per-thread cap detail, folding rules, and cursor labels retreat to --help (pinned there by TestIssueCommentListHelpCarriesReadContract). - Workflow step 3: drop the worked examples and the redundant trailing pointer; the mandatory two-step read and both motivation pins stay. - Background Task Safety: tighten connective prose only — every pinned phrase and every behavioural constraint is untouched. - multica-squads SKILL.md quick-start: replace the last remaining --recent recommendation on the platform with the bounded scan — same class of fix as #6309, found while relocating the warning. -676 bytes on the standard fixture (16,766 -> 16,090). Co-authored-by: multica-agent <github@multica.ai> * fix(cli,daemon): repair the help rendering, restore the BTS orphan scope, complete the squads read (MUL-5442) Three review catches by Elon on #6347: 1. The --before usage string wrapped the cursor labels in backticks, and pflag's UnquoteUsage hijacked the first pair as the flag's value placeholder — rendered help showed '--before Next thread cursor' instead of '--before string' (same regression class as TestLoginTokenHelpOutputRendersCleanly). Labels now use double quotes, and the --recent help no longer suggests composing mutually exclusive flags ('--roots-only + --thread --tail' -> an explicit two-step read). TestIssueCommentListHelpCarriesReadContract now asserts on the RENDERED FlagUsages output, pinning '--before string' and the two-step order. 2. The compressed BTS opening said 'anything still running is orphaned', which swept externally-owned work (GitHub Actions) into the orphan rule the same section later scopes out. Restored: 'any run-owned work still active is orphaned', pinned. 3. The squads quick-start's roots-only scan never returns reply bodies, where mention triggers and failure reasons usually live. Added the bounded drill-down step and the sequence rationale; both reads pinned in TestSquadsSkillCoversLeaderRoutingContract, which also guards against a regression back to --recent. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
757b09c801 |
fix(server): remove the last language seeds from the LLM prompts (MUL-5689) (#6348)
* fix(server): drop the Chinese seed from the chat title prompt (MUL-5689) chatTitleSystemPrompt carried literal Chinese in two places: a rule that spelled out "Chinese input → Chinese title", and a formatting example listing "标题:" as a prefix not to use. It is the same defect as the quick-actions label rule — a prompt that names a language, even only as a formatting example, reads as permission to answer in it. Chat titles are generated from the user's opening message alone, so they have neither the ALREADY SUGGESTED feedback loop nor an agent reply to be pulled by. Removing the seed is the whole fix; the language rule stays, now stated without naming a language. Nothing is lost by dropping the "标题:" example: chatTitleLabelPrefixes already strips 标题/题目/主题 (and the English forms) from the model's output, which is where that guarantee actually lives. Co-authored-by: multica-agent <github@multica.ai> * fix(server): correct two inaccuracies in the quick-actions language rule Both from review on #6345, non-blocking there and deferred to keep that merge unblocked. "these instructions" was self-referential: the LANGUAGE RULE is itself an instruction, so a model reading "ignore these instructions when choosing the language" could read the rule as disowning itself. It means the system prompt, so it now says so. The system prompt claimed the user message "ends with" the LANGUAGE RULE. It does not — the task line follows it. Reworded to "contains a LANGUAGE RULE line near the end", which is what the renderer actually produces. The rule's position is unchanged: still after the conversation and the replayed labels, which is the part that matters. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
1251cbca0b |
feat(views): confirm the assignment dialog with the send chord (MUL-5694) (#6346)
The "Confirm assignment?" dialog could only be completed with the mouse. It now submits on the configured `send` shortcut (⌘/Ctrl+Enter by default, following a user remap), matching the issue create modal, and the confirm button shows the keycap. Bound on the dialog rather than the note box, so the chord runs the primary action wherever focus is — including the old-runtime case where the disabled note hands initial focus to the footer. A focused button is yielded to only when `send` is remapped to plain Enter, the one key that natively activates it. |
||
|
|
738217c275 |
MUL-5516: fix(issues): re-trigger blocked issues when resumed (#6155)
* fix(issues): re-trigger blocked issues when resumed * fix(issues): enforce agent permission on resume --------- Co-authored-by: baoming.lv <baoming.lv@ly.com> |
||
|
|
2f431cd74a |
fix(server): follow the user's language in chat quick actions (MUL-5689) (#6345)
Quick actions never pinned their output language: the system prompt named Chinese in a rule about button width, and ALREADY SUGGESTED replayed the previous turn's labels, so one bad pass seeded the next. Neither prompt names a language now, and the user message closes with an explicit rule anchored on the most recent [user] turn, disowning the agent's reply, older turns, the instructions, and the replayed labels. MUL-5689 |
||
|
|
fe62be266d | fix(channel): defer bare fresh commands (#6338) | ||
|
|
671a1a0c21 |
fix(server): read the chat's channel from its binding instead of guessing (#6330)
A claim reported chat_channel_type by trying the binding lookup once per
channel it knew the name of. The list was Slack alone, then {Slack, Feishu}
after MUL-4899. Any channel added later — WeCom is the first — fell off the
end and claimed as a web chat, so the runtime brief told the agent to deliver
files with `multica attachment upload` into a conversation that cannot carry
an attachment. That is the same failure MUL-4899 fixed, reintroduced by the
shape of the fix.
Every channel writes the same channel_chat_session_binding row and differs
only in channel_type, and UNIQUE (chat_session_id) allows at most one, so the
row already holds the answer. Read it by session id and take channel_type off
what comes back. chat_in_thread stays Slack-only, now keyed on the row's own
channel_type: the two commands it picks between are hardwired to the Slack
history reader, and no other channel has one.
The channel_type-scoped query stays for the outbound senders, which are
per-platform by construction and must not deliver into a foreign channel.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
e422b169a8 |
MUL-5415: fix(issues): keep running-agent transcript open
Closes #6053 |
||
|
|
f5275597ce |
MUL-5677: skip squad assignment recipient writes (#6343)
* fix(notifications): skip squad assignment recipient writes Co-authored-by: multica-agent <github@multica.ai> * fix(notifications): address squad assignment review nits Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
e6a0d6f1a3 |
test(agent): make the ErrWaitDelay regression deterministic (MUL-5631) (#6320)
* test(agent): make the ErrWaitDelay regression deterministic (MUL-5631) Follow-up to the review on #6276. TestOpenclawExecuteToleratesLingeringStderrHolder asserted only the outcome — status stays `completed` — but that outcome is identical whether the ErrWaitDelay branch handled the run or was never reached at all. Reaching it depended entirely on the stub's descendant outliving the 500ms WaitDelay, so on a loaded runner the descendant could exit first and the test would pass without exercising the branch it exists for. A silent loss of coverage, which would let a later change delete the branch with CI still green. The branch logs a warning that nothing else in the tree emits, so the test now asserts on that: the log is the only observable proof of which path ran. newOpenclawTestBackendWithLog tees the logger into a mutex-guarded buffer while still writing to stderr, so a failure stays readable. The stub's hold also goes from 1s to 5s, taking the margin over WaitDelay from 2x to 10x — but that only lowers the odds of a vacuous pass; the assertion is what stops it being silent. Verified by mutation: redirecting the descendant's stderr away, so it no longer holds the pipe, leaves all three original assertions passing and fails only the new one, with `logged warnings were: ""`. Test-only. openclaw.go and openclaw_stdout.go are byte-identical to main. * docs(agent): address review nits on the ErrWaitDelay regression test Two comment-only follow-ups from review of #6320: - The test's doc comment still said the descendant holds stderr for ~1s; the stub was changed to 5s in this PR. - The warning the test asserts on is split across a string concatenation, so the asserted fragment does not turn up in a source grep. Note the coupling next to the warning so a future reword sees it before CI does. No behaviour change. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: weiweiwei <weiweiwei@xiaomi.com> Co-authored-by: Bohan-J <bohan.optimism@gmail.com> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
736fbc8a5f |
MUL-5685: fix agent builder composer clearing and conversation routing (#6339)
* fix(agents): clear the builder composer as soon as the send is accepted Sending a message in the Agent Builder left the user's text in the box while the message was already on screen. The composer is on the shared MUL-5181 contract, which clears the draft when the owner's send resolves. This owner resolved late: it awaited three cache invalidations (messages, pendingTask, drafts list) after the POST had already been accepted and the caches primed, so the clear was gated behind that many extra round-trips. Typing during that window also tripped ChatInput's stale-submit guard, which then declines to clear at all and strands the sent text in the box. `send` now takes ChatInput's `commitInput` and runs it the moment the server accepts and the caches render the message; the invalidations reconcile in the background. Same ordering as use-chat-controller's handleSend. The empty-state prompt buttons call `send` with no composer to clear, which is why the parameter is optional. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(agents): give a builder conversation its own route The AI creation studio served two unrelated screens from one route, switched on `?session=`: absent rendered the runtime picker, present rendered the conversation. That inverted what each one is. The runtime pick is a step — consumed the instant the session exists, since the runtime is frozen onto the hidden carrier agent and cannot be revisited. The conversation is a durable object: leaving the studio no longer deletes it (#6307), so it is left and resumed, and it is the thing that needs an address. It had the address of a modifier on the step. Split into one route per screen: /{ws}/agents/new/ai start one /{ws}/agents/new/ai/{sessionId} one conversation Behavior is deliberately unchanged: starting still REPLACES the picker entry so a back cannot land on a consumed step, back still leaves to the chooser, and a discarded conversation still replaces its own entry rather than leaving a dead address on the stack. One latent bug goes with it. The runtime a just-started conversation runs on lived in React state on the shared page, because a conversation joins the sessions list only once it holds a message or a saved configuration — so for the first turn there was nothing to read it back from. A refresh on that first turn dropped it and locked the runtime picker. It now rides in `?runtime=` on the conversation URL, which is a seed for exactly that gap and absent on every later visit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): register the builder conversation route for diagnostics diagnostic-context.ts keeps a route-mask table that every path builder must match, so a diagnostic report names the route instead of a wildcard. The new conversation path fell through to `/:slug/agents/*`, which its coverage test rejects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
08abec1873 | fix(mobile): refresh active chat when realtime stalls (#6325) | ||
|
|
e92d828f0c |
refactor(daemon): compress brief prose and demote the sub-issue playbook (MUL-5442) (#6310)
* refactor(daemon): compress brief prose and demote the sub-issue playbook (MUL-5442) Six static edits to the issue brief, -1,249 bytes on the standard fixture (17,977 -> 16,728). All edits are identical for every issue run; prompt-cache byte stability (#6008) is untouched. - BTS persistent-service bullet: keep the handoff contract (deliverable-only, lifecycle detached, readiness verified, best-effort survival), drop the operational walkthrough. Pins re-anchored to concepts (durable logs, recorded PID, verify readiness). - BTS CI-ban bullet: keep the command blacklist and the complete-hand-off rule, drop the auto-merge/snapshot elaboration. - Ownership mode: state the identity-forbids clause once on the header instead of once per status bullet. - Delivery invariant: merge the three sub-bullets into the lead paragraph; also fixes the stale '(below)' — the per-surface delivery line renders above the invariant, not below it. - Sub-issue Creation: demote the todo/backlog/stage playbook to the multica-working-on-issues skill; the brief keeps a one-line flag map plus the skill pointer. Skill-side anchors added to TestWorkingOnIssuesSkillCoversIssueLoopContracts so the pointer cannot dangle. - Attachments: collapse the two-sentence CLI-fetch intro into one line. Every pinned behavioral phrase is either carried verbatim or re-pinned to an equivalent semantic anchor in the same assertion; no assertion is deleted without a replacement. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): restore the full persistent-service handoff contract (MUL-5442) Review catch by Elon on #6310: the compressed bullet weakened the contract two ways — the reply requirement dropped 'logs' from the URL/logs/stop triple (durable logs alone are unobservable if the user is never told where they are), and the general ownership/cleanup handle narrowed to a bare PID (a supervisor/profile-managed service has no single stable PID). Both test pin sets had been updated to the weakened phrases, which would have made the regression look legitimate. Keeps the compressed sentence shape; restores both halves of the contract and pins them ("cleanup handle such as PID/profile", "URL, logs, and stop instructions") so they cannot be compressed away again. +38 bytes; the PR still nets -1,211 against main. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
547fcca069 |
MUL-5631: fix(openclaw): finish at the result boundary when the CLI does not exit (#6276)
* fix(openclaw): finish at the result boundary when the CLI does not exit
A chat reply was generated and then never delivered. Timeline from a host
running openclaw 2026.5.27, relative to the start of the run:
T+0s openclaw started
T+24s the complete result blob was written to stdout
T+8min process still alive, task slot still held, user saw nothing
processOutput read stdout with io.ReadAll, which returns only at EOF, and EOF
requires every write end of the pipe to be closed. `openclaw agent --local
--json` printed its complete result blob and then did not exit, so the pipe
stayed open, the read never returned, the goroutine never reached cmd.Wait,
and the finished answer sat in the daemon's buffer while the task held its
execution slot until the idle watchdog eventually reclaimed it.
## The boundary already has a precedent here
cursor-agent has the same misbehaviour, and cursor.go already handles it: its
`result` case notes that current versions "can emit the terminal result event
but keep a worker process alive", so it treats result as the protocol
boundary, calls cancel(), and guards its final status switch with
`if resultSeen` so the deliberate kill is not reported as an abort.
openclaw parses one whole-buffer blob rather than line events, so the
equivalent condition is "the buffer parses as a complete result" instead of
"a result line arrived". This change gives openclaw the same three pieces:
- readOpenclawStdout replaces io.ReadAll. It returns at EOF as before, or
early once the buffer parses as a complete result AND stdout has been
idle for 2s, reporting cutShort.
- On cutShort, Execute cancels the run context so CommandContext kills the
lingering process and cmd.Wait can return.
- The status switch gets a leading `case scanResult.cutShort:` so the
resulting cancellation is not turned into "aborted" — that would discard
a reply we already hold.
Both read conditions are required. Idle alone is not enough: an agent may
pause for minutes while thinking, and cutting off a partial buffer would
throw away work it has already done, which is worse than the hang. Parseable
alone is not enough either, since more output may still follow. Nothing is
cut short before any output arrives, so a silent agent stays governed purely
by the caller's context and its behaviour is unchanged.
## WaitDelay 10s -> 500ms
Matching cursor-agent, for the same reason. WaitDelay only applies once the
child is gone but its stdio is still held — which is exactly the cut-short
path, since that is where we kill a process still holding the pipe. Leaving
it at 10s would add 10s to every reply that takes this path. A CLI that exits
cleanly never reaches the delay at all. End to end this took the reproduction
from ~12.5s to ~2.9s.
## Verification
- pkg/agent green with -race under CI's flags (-p 2 -parallel 2); 4
consecutive runs, no flakes. All existing Openclaw* tests unchanged and
passing, including TestOpenclawProcessOutputReadError and the
empty-buffer cases, which exercise the new reader's EOF and error paths.
- GOOS=windows build, vet and test compilation all pass.
- 3 new tests, each mutation-verified against the mechanism it guards:
* io.ReadAll restored -> the test hangs and the dump is the production
stack: io.ReadAll <- openclawBackend.processOutput.
* cutShort status guard removed -> status = "aborted" instead of
"completed", i.e. the reply is thrown away.
* boundary cancel() removed -> the test hangs in os/exec.(*Cmd).Wait.
* fix(openclaw): keep a delivered result when a descendant outlives WaitDelay
Addresses the review on #6276. The finding is correct and the comment it quoted
was wrong: lowering WaitDelay to 500ms introduced a regression on the
*clean-exit* path, which is worse than the hang this PR set out to fix.
Verified against the documented contract rather than assumed:
The WaitDelay timer starts when either the associated Context is done or a
call to Wait observes that the child process has exited, whichever occurs
first. ... If pipes are closed due to WaitDelay, no Cancel call has occurred,
and the command has otherwise exited with a successful status, Wait and
similar methods will return ErrWaitDelay instead of nil.
So a clean exit does reach the delay whenever a descendant still holds one of
the pipes os/exec manages — and this backend has one, since cmd.Stderr is a
plain io.Writer for which os/exec creates an internal pipe plus a copy
goroutine. The path was:
1. openclaw writes its complete result, closes stdout, exits 0.
2. readOpenclawStdout returns via EOF, so cutShort is false.
3. A short-lived descendant keeps stderr open for >500ms.
4. cmd.Wait returns exec.ErrWaitDelay.
5. cutShort is false and runCtx.Err() is nil, so the switch fell through to
"openclaw exited with error" and a fully parsed, deliverable reply was
reported as failed.
The review is also right that the cursor-agent comparison did not carry over.
cursor ignores *every* exit error once a terminal result is parsed
(`if resultSeen`), whereas this diff had narrowed that protection to
`case scanResult.cutShort:` — which is self-consistent only if the clean-exit
route can never produce an exit error, and it can.
Fix, taking the reviewer's preferred option since it is the smallest and leaves
the WaitDelay timing and the watch goroutine alone: keep the success status when
the error is specifically ErrWaitDelay and a complete result was parsed. By
definition ErrWaitDelay means the process exited successfully, so this cannot
mask a real failure; the only thing lost is a tail of stderr log lines, and that
is logged as a warning. It is deliberately a separate case from cutShort, since
that path cancels on purpose and a Cancel call makes Wait report the kill rather
than ErrWaitDelay.
The comment that stated the wrong premise is rewritten to say what WaitDelay
actually bounds, and why lowering it is still right: the delay is only reached
when something is holding a pipe open, and on the cut-short path we deliberately
kill a process doing exactly that.
Also takes the non-blocking nit: readOpenclawStdout's ticker branch copied the
whole accumulated buffer before checking whether stdout had actually gone idle,
so a large result was reallocated on every 100ms tick. The cheap conditions are
now checked under the lock first and the buffer is copied only once the silence
threshold is met.
Regression test as requested: the parent writes a complete result, closes stdout
and exits 0 while a descendant holds stderr for ~1s (its own stdout goes to
/dev/null so the stdout pipe still reaches EOF), and the final status must be
completed. Mutation-verified — dropping the new case reproduces the reported
failure verbatim:
status = "failed" (error: "openclaw exited with error: exec: WaitDelay
expired before I/O complete"), want completed
pkg/agent passes with -race under CI's flags, all pre-existing Openclaw* tests
included, and GOOS=windows build and vet are clean.
---------
Co-authored-by: weiweiwei <weiweiwei@xiaomi.com>
|
||
|
|
3c6176cbcd |
refactor(daemon): de-duplicate cross-section rules in the runtime brief (MUL-5442) (#6302)
* refactor(daemon): fold four restatements of the end-of-turn rule into one (MUL-5442) Background Task Safety opened with four bullets that were four views of the same rule -- do not end the turn with run-owned work outstanding: the general ban, the "tool says it will notify you" case, the unobservable-result case, and the "standing by" sign-off. Separating them cost bytes without adding a distinct behaviour. Fold them into the leading bullet. Every phrase the behaviour tests pin is carried over verbatim, including "Do NOT end your turn while background tasks", "Never background-and-yield", "wait for a future notification/reminder", "running in the background so you can keep working", "run the work synchronously instead" and "standing by". MUL-5442 Co-authored-by: multica-agent <github@multica.ai> * refactor(daemon): demote workflow-step restatements of delivery, mention and metadata policy to pointers (MUL-5442) The six workflow steps and the Reply mode block restated policy that already has a dedicated section. Each restatement is a second place to edit when the policy changes, which is how they drifted apart in the first place. Give each rule one canonical home and leave a pointer at the call site: - delivery ("only a comment reaches the user") -> ## Output; steps 5 and the Reply block point at it. - mention discipline -> ## Mentions. The reply-time phrasing the loop-hardening test pins moves into that section rather than being duplicated in the Reply block, so the anti-loop signal is unchanged. - metadata read/write bar -> ## Issue Metadata; steps 2 and 6 point at it instead of paraphrasing the bar. Tests: the metadata scope test pinned the old pointer wording, and the mention test's comment claimed the sign-off rule lived in the workflow steps. Both are updated to the new placement; every behavioural phrase they guard is still asserted, file-wide. MUL-5442 Co-authored-by: multica-agent <github@multica.ai> * refactor(daemon): give the comment-read surface and the file-safety rules one home each (MUL-5442) Three cross-section duplications, each resolved toward the section that owns the rule: - comment reads: the workflow step and the Available Commands entry both explained what --roots-only / --summary / --thread --tail do. The step keeps the two reads it mandates and its anti-stale motive; flag semantics stay in Available Commands, which is the single discovery point. The saturation trap ("caps THREADS, not comments") and the pagination cursor labels stay put -- they are load-bearing after MUL-5372 and remain asserted. - --content-file: the comment add entry restated the guardrail Comment Formatting owns; it now names the rule and points there for the rationale. - workdir path rule (MUL-4252): issue create carried its own copy of the stale-file rationale; it keeps the rule and defers the why. - inbound attachments: trimmed to the pinned rule plus a pointer. MUL-5442 Co-authored-by: multica-agent <github@multica.ai> * refactor(daemon): merge the Agent Identity action list, gate squad maintenance, trim the attachment restatement (MUL-5442) Three follow-ups from review, each re-examined against what the text guards today rather than against the fact that a test pinned it. 1. Agent Identity enumeration. Instruction Precedence and workflow step 4 were added in the same commit (#3802) and each carried its own list of actions Agent Identity can forbid -- and the lists disagreed: one named status changes, the other named issue create/update and delegation, neither contained the other. Merge them into Instruction Precedence, which owns the rule. Step 4 keeps only what that section cannot express: a delegation-only role stops once its delegation is delivered. 2. Squad maintenance. `multica squad member set-role` shipped to every run, including every agent that leads no squad and therefore has no squad whose roles it could change. Gate it on IsSquadLeader -- agent configuration, not per-run state, so the brief stays byte-stable across runs of one session (MUL-5377), the same predicate the workflow already branches on. 3. Inbound attachments. The section restated Output's no-clickable-local-path rule verbatim. Keep the framing Output cannot express -- a downloaded attachment feels shared but landed in a private workdir -- and point at Output for the rule. The delivery test now also asserts the pointed-at rule is present, so the pointer cannot dangle. Brief size, plain issue task: 19,231 -> 18,009 bytes for an ordinary agent (-6.4%), 20,718 -> 19,759 for a squad leader (-4.6%). MUL-5442 Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
459dd90f82 |
fix(daemon): stop the coalesced-comment fallback from pulling the whole issue history (MUL-5442) (#6309)
* fix(daemon): stop the coalesced-comment fallback from pulling the whole issue history (MUL-5442) When a run covers comments that arrived before it started and the server sent only their ids (no bodies), the prompt told the agent to find them by running `multica issue comment list <issue> --recent 30`. `--recent N` caps THREADS, not comments, and every returned thread carries all of its descendants. On an issue with fewer than 30 root threads that is the entire comment history — measured on a live 3-thread issue: 88,301 bytes (~22k tokens) to locate two ids. It also contradicted the runtime brief's own catch-up step, which tells the agent to read in two bounded steps and never make one bulk pull (MUL-5372): the platform was recommending the exact shape it forbids elsewhere. Those comments all arrived between the agent's previous run and this one, so when the server supplied that anchor `--since` returns precisely them in one bounded read — 17,376 bytes on the same issue. When there is no anchor (no prior run on this issue, so the server sends none) the fallback now uses the same scan-then-expand pair the brief teaches instead of a bulk pull. Also drops the "--full if a thread is folded" note: per `comment list --help`, `--since`, `--tail` and `--roots-only` reads are never folded, so it was advice that could not apply to either replacement read. This was the last place the platform steered agents onto `--recent`. The flag and its saturation warning stay documented in Available Commands, since an agent can still choose it. MUL-5442 Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): make the coalesced-id lookup deterministic, not window-dependent (MUL-5442) Review found the previous version could still drop a user instruction. `--since` is not a reliable lower bound for these ids. A retry inherits the previous attempt's coalesced_comment_ids verbatim (queries/agent.sql RetryTask) while the anchor is recomputed from the last STARTED task's started_at (GetLastTaskStartedAtForIssueAndAgent), so an inherited id can predate the anchor. If any unrelated comment lands after the anchor, NewCommentsSince is populated, the prompt sent the agent at the window, and the inherited id was simply not in the result. It was not a precise fetch in the other direction either -- the window also carries the trigger comment and unrelated comments. Replace the window-or-heuristic pair with one deterministic contract: `--thread` accepts ANY comment id, reply or root, and the server resolves it to the containing thread. So every id is fetchable directly and bounded, with `--before`/`--before-id` paging when it is older than the tail window. That pass now runs unconditionally; `--since` is demoted to an optional prefetch, described as a candidate window rather than an exact fetch. This also removes the anchorless branch's "expand the threads whose last_activity_at is recent" heuristic. Completeness of user instructions is not a good place for the agent to guess. Tests assert the contract rather than the command spelling: the per-id lookup, the reply-id capability, cursor paging and the "account for every id" rule are required in both shapes; the anchored shape must not overpromise the window; the anchorless shape must carry no --since and no recency heuristic. MUL-5442 Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
a5c1d44701 |
MUL-5642: fix(agents): stop the creation studio polling, and stop it losing work (#6307)
* feat(agents): make AI agent creation resumable (#6246) Leaving the Agent Creation Studio destroyed the conversation. The unmount cleanup called deleteChatSession, so a sidebar click, a tab close or a route change deleted the builder session and every message in it — the bug external users reported. Archiving instead (PR #6247) would have stopped the deletion without giving anyone a way back in: builder sessions hang off a hidden `kind = 'system'` carrier agent, which the `kind = 'user'` filter keeps out of every chat list, so an archived one is unreachable rather than recoverable. A creation conversation is now a durable object with its own address. Server: - GET /api/agent-builder/sessions lists the caller's unfinished creations. Creator-scoped like every other chat read. It reports the CARRIER's runtime, not chat_session.runtime_id — the latter is the daemon's resume pointer and is deliberately left stale after a switch, so resuming from it would put the picker on a runtime that executes nothing (MUL-5163). - PUT /api/agent-builder/sessions/{id}/draft stores the configuration, including the edits the user typed but never sent. Migration 251 adds agent_builder_draft (no FK per repo rule; pruned explicitly by DeleteChatSession, the runtime teardown and the workspace teardown, and registered in the workspace-deletion manifest). - The payload is opaque to the server: its shape is the studio's AgentDraft, validated client-side. Teaching Postgres and the handler about it would create a second definition to keep in sync for no gain. Client: - The session id lives in `?session=`, so a refresh, a back/forward and a reopened tab land back in the same conversation. - Leaving no longer deletes anything. The only destructive path is an explicit "discard", confirmed in a dialog, next to the create button. - Creating the agent archives the conversation instead of deleting it: it is the record of how that agent was designed, and an idle carrier costs nothing since usage is booked per task. - The configuration autosaves (debounced) and restores on arrival, with the applied-assistant-message marker stored alongside it so a restore cannot re-apply the last reply over edits made after it. - The 1.5s polling of messages and pending-task is gone. The global realtime sync already invalidates both per session id, exactly as it does for the main chat window, which has never polled. - The `<agent_draft>` block collapses to one "configuration updated" line. The regex now also swallows an unterminated block, which is what streaming produces — the raw payload used to scroll past on every turn. The 2185-line agent-creation-studio.tsx is split into three routes (`/agents/new`, `/agents/new/manual`, `/agents/new/ai`), its pure logic moves to packages/core/agents/ with its tests, and the unreachable template flow — `setMode("templates")` had no caller — is removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): let the builder panes resize The conversation / configuration split was not draggable. Two structural reasons, both fixed by giving the group the same shape the chat page uses: - The panels reached the group through BuilderWorkspace's fragment, so they were not children the group could measure. - The group's children alternated between one panel (runtime setup) and two (conversation), under one persisted layout id. The group now lives inside BuilderWorkspace with its two panels as its only children, and the setup screen renders no group at all — it has nothing to split. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ui): give the resize handle a cursor on hover The separator had no cursor of its own, so the only signal that a split was draggable arrived after the drag started — the library writes a global `cursor: ... !important` while dragging, and nothing before it. Fixed on the shared handle rather than at one call site: every split surface (chat, inbox, issue detail, project detail, the agent builder) was missing the same affordance. The library's drag-time rule still outranks this one, so the cursor keeps narrowing to `e-resize` / `w-resize` once a panel hits its bound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(agents): render the builder's draft block as an inspectable row Every builder reply ends in an <agent_draft> block that rewrites the form on the right. Flattening it to a line of prose said that something changed but not what, and the payload — the only record of what the builder actually claimed — was unreachable. A settled reply now carries a full-width row saying the configuration was updated, which opens the exact payload. A streaming one keeps a text line instead: the block is still being written, so there is nothing complete to open, and without the line the half-finished JSON scrolls past. ChatMessageList gains an optional `renderAssistantAddon`. It is opt-in per surface and undefined everywhere but this one, because no other chat speaks this protocol — the alternative was to keep pushing an embedded protocol through `transformContent`, which can only ever produce prose. `extractBuilderDraftBlock` returns an unparseable payload verbatim rather than withholding it: a malformed block is exactly when someone wants to read it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): address review blockers on the resumable builder 1. Migration prefix collision. `251_agent_runtime_unbind` landed on main after this branch cut, so the backend's prefix-uniqueness guard failed. Renumbered to 252. 2. #6287 — the manual form still lost everything. The route split moved where you land, not what survives: the draft was `useState`, so a tab switch (the desktop shell mounts only the active tab) remounted it empty, and the beforeunload guard covered a hard reload and nothing else. It now persists through the repo's draft-store factory, scoped by what is being created — a blank agent and a copy of agent X are different work, and a copy of X is not a copy of Y — cleared once the agent is committed, and registered for logout / workspace-delete cleanup. 3. A saved draft with no messages was unreachable. The configuration form is editable from the moment a builder session exists and autosaves, so someone could open it, type a name and leave before the first turn; the list keyed "is this a draft" on messages alone, so that row existed and nothing could reach it. A session now qualifies on a message OR a stored draft, and sorts by whichever it has. 4. The debounce dropped the last edits. Its timer died with the component, so navigating away inside the 800ms window lost exactly the keystrokes the user had just made. The pending payload is now flushed on unmount. `useUnsavedDraftWarning` is gone with its last caller: both routes persist, so the browser prompt would have been warning about work that is already saved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ui): stop the resize cursor flipping mid-drag The library narrows its cursor the moment a panel hits a bound — col-resize while both directions are open, a one-way arrow once only one is. Truthful, but it reads as a glitch: the icon changes under your hand halfway through a drag you never stopped making. `disableCursor` turns that global rule off; the handle's own `cursor-col-resize` is now the only source. A drag captures the pointer and walks it across the panels, away from the 8px handle, so the group carries the same cursor for as long as a separator is active — otherwise it would fall back to a text caret the instant the pointer left the handle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): key manual drafts by owner, drop the draft-block rendering Two changes. One slot destroyed the other flow's work. The manual draft was stored under a single key: opening a blank form, or a copy of a different agent, refused to adopt the stored draft and then immediately wrote its own empty form over it — so a half-finished copy of agent A died the moment the user opened anything else, before typing a character. Drafts are now keyed by what is being created, the same shape the chat composer uses for its per-session drafts, and a slot is dropped when its content is gone rather than parked blank (which also stops the map growing a dead key per agent ever opened for duplication). Committing an agent clears that flow's slot only. The `<agent_draft>` block goes back to being hidden outright. Labelling it and opening its payload dressed up machinery as content: the block drives the configuration form, and the form is where its effect is already visible. `renderAssistantAddon` goes with it — ChatMessageList is back to what it was, since no surface needs the slot. The two-pattern strip stays: an unterminated block is what streaming produces, and without matching it the raw JSON scrolled past the reader on every turn. Also removed six barrel exports nothing imported through, and unexported five types only their own file used. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): keep a manual draft whose only edit is a picker The "is this worth storing" predicate listed six fields by name, and the draft serializes eleven. A form whose only change was the model, the thinking level, the service tier, the access scope or a team grant read as untouched, so the next save deleted its slot — picking a model before typing a name and switching tabs lost the model. Enumerating was the mistake, not the specific omissions: the predicate stops covering every field added after it is written, and the failure is invisible because each field saves correctly as long as some *other* field is also set. It now compares the whole draft against a fresh one. The runtime stays outside that comparison, on the entry rather than in the draft, because the form seeds it on every visit and counting it would store a draft for a form nobody touched. Covered field by field, one edit at a time, so a future field cannot quietly fall out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
04c48cac6f |
fix(skills): select files on click, add Edit to the row menu (MUL-5654) (#6313)
The row's action-menu trigger widens its hit area with `after:absolute after:-inset-1` but was not itself positioned, so that pseudo-element resolved against the row — the nearest positioned ancestor — and covered it end to end. Being absolutely positioned it painted above the in-flow name button, so every click on a supporting file's name opened the menu instead of selecting the file, and `opacity-0` never opted the overlay out of hit testing. Only SKILL.md, rendered without actions, stayed clickable, which read as "only SKILL.md can be edited". `relative` puts the ::after back inside the trigger. Alongside the fix, so the editor is reachable in one gesture: - the raw segment reads "Edit" for viewers who may edit, and stays "Plain text" for read-only viewers rather than offering an edit the page refuses; - the row menu gains an Edit entry that opens the file, switches the pane to the editor and puts the caret at its top — including for the file that is already open, which mounts nothing and so cannot rely on mount-time focus; - SKILL.md keeps that entry and still withholds rename/delete. Verified in Chromium against the compiled classes: clicking a file name or empty row space now hits the name button, while the glyph and 4px around it still hit the menu. Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
6b56bc05a4 |
docs(changelog): add v0.4.17 release entry (2026-08-03) (MUL-5655) (#6311)
* docs(changelog): add v0.4.17 release entry (2026-08-03) (MUL-5655) Co-authored-by: multica-agent <github@multica.ai> * docs(changelog): drop the MCP allowlist fix, reverted in #6314 (MUL-5655) Co-authored-by: multica-agent <github@multica.ai> * docs(changelog): add the Kimi resume fix from #6308 (MUL-5655) Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>v0.4.17 |
||
|
|
6890812ae4 |
MUL-5657 fix(agent): add missing streamingCurrentTurn gate to kimi ACP backend (#6308)
kimi.go was the only ACP backend missing the streamingCurrentTurn gate. Without it, history replay emitted by the Kimi CLI during session/resume contaminates Result.Output and the message stream with previous-turn content — the user sees the old answer duplicated alongside the new one. The root cause is chronological: the gate was introduced for Hermes in PR #2024 (2026-05-03) but kimi already existed at that point and was not updated. Later backends (grok, traecli) were written after the fix and included the gate from day one. Add the same atomic.Bool gate + acceptNotification callback pattern used by hermes, grok, traecli, kiro, and qoder. Pin with TestKimiBackendDropsHistoryReplayOnResume. |
||
|
|
4fe94a6d40 |
revert: "MUL-5637 fix(mcp): agent mcp_config is an authoritative allowlist (#6292)" (#6314)
This reverts commit
|
||
|
|
a54662f30e |
fix(daemon): provision config-referenced Codex instruction files into task home (#6291)
* fix(daemon): provision config-referenced Codex instruction files into task home A per-task CODEX_HOME copies the user's config.toml verbatim, so a model_instructions_file reference survived the move while the file it names did not. Codex resolves the relative value against CODEX_HOME — now the task home — and failed loading its configuration before the task prompt was ever delivered (#6271). Generalize the existing model_catalog_json provisioning into a keyed table of path-valued config keys and add model_instructions_file plus its deprecated experimental_instructions_file alias. Semantics are unchanged per key: absolute/~ values are left for Codex to read directly, relative values must stay inside the task home, the copy refreshes on reuse, and a missing source fails during environment preparation with a diagnostic naming the key instead of an opaque os error 2 at thread/start. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): root-scope task-home writes for config-referenced Codex files Review must-fix: filepath.IsLocal only proves the config string has no lexical '..'. A task home is reused, and the task that ran in it can replace an intermediate directory of the copy destination with a symlink, which made the daemon's next MkdirAll/Remove/copy follow that link and delete or overwrite a file outside the task home. Do the mkdir, stale-copy removal, and write through os.OpenRoot(codexHome) so links leaving the task home are rejected (links staying inside it are harmless), and refuse outright when codexHome itself is a symlink, since OpenRoot would resolve it before confining anything below. The pre-existing model_catalog_json path is covered by the same helper. Also stop reporting every source stat failure as a missing file — a permission or IO error now says so — and document the source-side symlink policy plus the stale-copy contract. Co-authored-by: multica-agent <github@multica.ai> * test(daemon): pin stale-copy contract when a referenced Codex file is repointed Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): bind codex-home root check to the opened handle Review must-fix: checking the path with os.Lstat and then opening it with os.OpenRoot leaves a window where the directory is swapped for a link to somewhere else. OpenRoot resolves the path it is given, so the resulting root is confined to the wrong tree and every root-scoped write lands outside the task home without ever escaping its root. Task homes are reused and Windows cannot confirm descendant cleanup, so a leftover process that knows its old CODEX_HOME can create that window. Open first, then prove identity against the handle: compare root.Stat(".") with a no-follow os.Lstat of codexHome via os.SameFile, and reject a symlink outright. A swap before the open now fails the check; a swap after it cannot matter because all writes go through the verified handle. verifyCodexHomeRoot is split out so the swap is tested deterministically instead of raced, plus an end-to-end test for a task home that is already a link to an outside directory. Co-authored-by: multica-agent <github@multica.ai> * docs(daemon): scope the codex-home symlink test name and root-handle claim Review nit: the end-to-end test asserted only that the referenced-file copy refuses a symlinked task home, but its name read as though the whole prepare were safe. Rename it accordingly and state in both the test and openVerifiedCodexHomeRoot that the earlier path-addressed steps of prepareCodexHomeWithOpts are out of scope, tracked in MUL-5647. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
aa349fed02 |
MUL-5637 fix(mcp): agent mcp_config is an authoritative allowlist (#6292)
* fix(mcp): treat agent mcp_config as an authoritative allowlist
An agent's saved mcp_config was silently widened with the runtime host's
own user-level MCP servers, so an explicitly empty `{"mcpServers":{}}`
resolved to the COMPLETE host set instead of no servers at all — the
opposite of what the operator configured (GitHub #6283).
`--strict-mcp-config` was being passed correctly; the merge happened
before it, in the daemon, so strict mode constrained an already-widened
set. Introduced by #5277 and present in v0.4.16 through main.
Restore the three-state contract in resolveEffectiveMcpConfig:
null / unset -> inherit the provider's native MCP configuration
{"mcpServers":{}} -> strict empty, no host servers
non-empty object -> strict allowlist, exactly those servers
Two explicit inherit paths keep the additive behaviour reachable without
weakening the default:
- runtime_config.mcp.inherit_runtime = true opts an agent back in.
- The claim response now carries mcp_config_overlay_only so the daemon
can tell an agent-authored config from a per-task Composio overlay.
Without it, enabling an integration on an agent that never configured
MCP would have stripped the host servers it was already inheriting.
Both decode paths fail closed: malformed runtime_config never enables
inheritance, and a failed runtime merge falls back to the agent's own
config.
The web MCP tab and the `agent create/update --mcp-config` help text
described the old additive behaviour, which is how a tightened config
could look correct while exposing every host server; both now state
which mode is in effect.
Note for rollout: the fix lives in the daemon, so self-hosted users must
upgrade the daemon — a server/UI upgrade alone does not apply it.
Co-authored-by: multica-agent <github@multica.ai>
* fix(mcp): close review gaps in the authoritative mcp_config change
Addresses the four must-fix findings from review of #6292.
1. Deleting the last managed server no longer widens access.
removeManagedMcpServer cleared the config to null, which now means
"inherit the host's MCP servers" — so a delete took the agent from one
allowed server to every server on the host. It now leaves an explicit
`{"mcpServers":{}}`. Restoring inheritance moved to a separate
clearManagedMcpConfig action behind its own confirmation that states the
widening. The delete dialog no longer claims "Runtime servers are not
affected", which was the opposite of the truth.
2. The UI no longer promises a boundary an old daemon does not enforce.
The strict semantics live in the daemon, so a config saved against an
older daemon is not yet in effect. Adds the authoritative-mcp-v1 daemon
capability:
- The daemon advertises it and reports authoritative_mcp on the
runtime-capabilities response.
- The claim path fails closed: a managed, non-inheriting mcp_config
claimed by a daemon without the capability cancels the task and
returns 412 with an actionable message, instead of letting that daemon
merge the host's servers in. runtime_config.mcp.inherit_runtime is the
documented escape hatch, and it is honest — it declares that the
operator accepts the host's servers.
- The MCP tab shows "needs upgrade" rather than "Not exposed" while the
bound runtime lacks the capability.
3. Saving OpenClaw settings no longer drops the inherit opt-in.
parseOpenclawRuntimeConfig discarded unknown keys and the tab persisted
the result as the whole runtime_config, so one unrelated routing save
silently deleted mcp.inherit_runtime. Unknown keys now round-trip
through OpenclawRuntimeConfig.passthrough, excluded from the dirty check
so they cannot make the form look edited.
4. Documents the new semantics in the built-in creating-agents skill and
its source map: the three states, the persisted
runtime_config.mcp.inherit_runtime field, and the claim-time capability
gate.
Also corrects the PR's rollout claim: there is no database migration, but
this does add a persisted JSON field and change the meaning of an existing
one.
Co-authored-by: multica-agent <github@multica.ai>
* fix(mcp): stop the authoritative-daemon gate from blocking valid claims
CI's backend job failed three handler claim tests with the new 412. Two
distinct problems, both real:
1. The gate fired for a non-object mcp_config. 66 handler fixtures seed
`[]`, which is not a valid MCP config and cannot carry `mcpServers`, so
it expresses no boundary to protect. An old daemon does not widen it
either: mergeRuntimeAndAgentMcpConfig fails to unmarshal a non-object
and falls back to the agent config alone (verified directly). Gating
these blocked tasks with no security benefit, so the gate now requires a
JSON object.
2. The shared daemon test-request helper advertised no capabilities, so
every claim test was accidentally simulating a pre-#6283 daemon. It now
defaults authoritative-mcp-v1 on, matching what every current daemon
sends. Only that capability — skill-bundles / coalesced-comments / rpc
are feature negotiations whose absence tests real legacy behaviour, so
they stay opt-in per test.
Adds claim-level coverage for the gate itself, which is what the unit tests
alone could not catch: an outdated daemon gets 412 with an actionable
message and the task is cancelled; a capability-advertising daemon gets
200; the inherit_runtime opt-in lets an outdated daemon through; and an
unmanaged or non-object config is never gated.
Verified against a real migrated schema this time (throwaway Postgres),
which is how the three failures were reproduced locally and confirmed
fixed: `go test ./internal/handler ./internal/daemon` both ok.
Co-authored-by: multica-agent <github@multica.ai>
* fix(mcp): surface the daemon-upgrade refusal and stop gating safe providers
Addresses the second review round on #6292.
1. The refusal is now visible wherever the operator looks. The default
claim path is the machine-level BATCH endpoint, which skips build
failures and still answers 200 {"tasks":[]}, so the previous bare
CancelTask showed a task that vanished with no stated reason — turning an
explicit upgrade requirement into an unexplained failure. The claim path
now fails the task with a new classified reason,
mcp_config_daemon_outdated, plus the actionable message. That reaches the
user on all three claim paths and on any daemon version, which a new
response field could not: the audience is by definition a daemon too old
to read one. The per-runtime path keeps its 412.
The reason is deliberately not auto-retryable — the same outdated daemon
would claim the retry and fail it again.
2. The gate no longer cancels safe tasks. It applied to every provider, but
only claude / codebuddy / codex / cursor / opencode / openclaw were ever
merged with host MCP by an old daemon (loadRuntimeMcpServerConfigs).
Qwen was never merged and already had strict semantics, so its tasks were
being failed for a risk that does not exist. Scoped via
providersOldDaemonsMergedRuntimeMcp; an unknown provider does not gate,
because the gate should only fire where the old behaviour is concrete.
3. The new authoritative_mcp flag now goes through the API schema layer.
Both local-skills responses were returning raw network JSON, so the flag
that decides whether the UI may assert an MCP boundary rested on an
unchecked type assertion. Adds RuntimeLocalSkillListRequestSchema with
authoritative_mcp and mcp_supported defaulting to FALSE — the fail-closed
direction — and a MALFORMED_ fallback that cannot express a guarantee.
Claim-level tests now cover all three paths, which is what the previous
helper-only tests missed: per-runtime 412, batch recording the refusal on
the task while still delivering the healthy tasks in the same batch, WS RPC
refusing and accepting, the qwen negative case, the inherit_runtime escape
hatch, and unmanaged / non-object configs.
Verified against a real migrated schema (throwaway Postgres):
go test ./internal/handler ./internal/daemon ./pkg/agent ./pkg/taskfailure
./internal/service all ok.
Co-authored-by: multica-agent <github@multica.ai>
* fix(mcp): register the new failure reason and wire its copy into the UI
Addresses the third review round on #6292.
1. mcp_config_daemon_outdated was declared but never registered in
taskfailure.allReasons, so metrics.NormalizeFailureReason missed the
known-value map and fell through to free-text Classify() — relabelling a
platform-side refusal as `agent_error.unknown` (verified directly) and
leaving the Prometheus series un-pre-warmed. Registered it, canonical
count 22 → 23 (platform 8 → 9), with the wire value and IsAgentError split
pinned. New test pins the WHOLE canonical set through
NormalizeFailureReason so forgetting the next reason fails a test instead
of quietly mislabelling a metric; NormalizeFailureReason had no coverage
at all before.
2. The upgrade copy was dead. The locale strings landed last round but
neither consumer mapped the reason: chatFailureCopy fell back to generic
failure text with the actionable detail buried in the collapsed raw
error, and task-failure.ts rendered the bare wire value
`mcp_config_daemon_outdated` in the agent activity list and issue
execution log. Both are mapped now, with regression tests, plus the
runtime class pinned in failure-class.test.ts. This directly contradicted
the claim in the claim-path comment that every path reaches the user, so
that is now actually true.
3. providersOldDaemonsMergedRuntimeMcp is documented as what it is: a FROZEN
record of what pre-capability daemons merged, not a mirror of the daemon's
current provider switch. The old "keep the two lists in lockstep" note was
actively harmful advice — runtime MCP discovery for a new provider can only
ship in a daemon that already advertises the capability (never gated), so
adding it here would fail tasks on old daemons that never merged for it,
re-creating the qwen false-positive. Pinned with a test.
Also corrects a stale count in task-failure.ts (7 → 9 platform reasons).
Verified against a real migrated schema (throwaway Postgres): full backend
suite green apart from the pre-existing environmental cmd/multica guard; all
9 TestMcpGate_* integration tests pass.
Co-authored-by: multica-agent <github@multica.ai>
* docs(taskfailure): correct taxonomy counts and finish the reason registration
Non-blocking nits from the fourth review round on #6292.
- Taxonomy counts now say 23 reasons / 9 platform-side. Registering
mcp_config_daemon_outdated last round updated the assertions but not the
prose. Swept the whole repo rather than only the flagged lines, which
turned up four more that were already stale at 21 and drifted further:
handler/dashboard.go, daemon/poisoned.go, core/types/agent.ts, and the
db/queries/task_usage.sql comment sqlc copies into the generated file.
The generated file's comment was updated by hand to match its source.
Running `sqlc generate` churned 58 lines across 47 unrelated files — the
local sqlc version differs from the one that produced the checked-in
output — so that churn was reverted and only the one intended line kept.
- failure_test.go's `required` list now includes
ReasonMcpConfigDaemonOutdated. Length and label assertions already covered
the reason, but the list is documented as the complete canonical set, so
the omission contradicted its own comment.
- Restored the line break in chat-message-list.test.tsx that a previous edit
of mine collapsed.
Comment, test-fixture and formatting only; no behaviour change.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
f1fc33f458 |
fix(autopilots): save the first schedule added from the edit dialog (MUL-5649) (#6303)
Editing a manual-only autopilot showed the schedule panel seeded with the editor's default — 09:00 every day, next runs and all — as though that were the autopilot's schedule. It was not: the autopilot had no trigger. Saving then compared that default against itself, found no change, wrote nothing, and toasted "Autopilot updated" while the detail page went on reading "No triggers configured". The dirty check is right when a schedule exists (it stops a re-picked, unchanged cron from being rewritten) and meaningless when none does, because the panel is showing a proposal rather than stored state. So the panel now says what is true — a dashed card matching the detail page's empty state, "No schedule — this autopilot only runs when triggered manually" — and asks for the schedule explicitly. Adding one writes it on save whether or not the user touched the default; leaving it alone keeps the autopilot manual, so a title-only edit can no longer put it on a daily cron by accident. The footer's auto-run promise drops out while that empty state is up. The schedule write also targets the first `schedule` trigger rather than `triggers[0]`, which on an api-triggered autopilot was a row a cron could have been patched into. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
76fbd48849 |
fix(skills): measure draft dirtiness against a seeded baseline (MUL-5645) (#6294)
* fix(skills): measure draft dirtiness against a seeded baseline (MUL-5645) The detail page inferred "the user edited something" from "the draft differs from the latest server skill". That difference has two independent causes — the user typed, or the server moved — and the page could not tell them apart, so it read both as a local edit. Two user-visible failures came out of it: - A description ending in whitespace (what `description: |` frontmatter yields, so every imported skill) compared unequal to itself, because the dirty check trimmed the draft side and not the server side. The page opened permanently dirty and Discard reseeded the same value, so the save bar could not be dismissed at all — only Save cleared it, by rewriting the stored description. - Any remote update to an open skill was taken for a local edit, so the page raised the conflict banner and refused to reseed. The editor stayed frozen on pre-update text with no way to see what had changed, and saving from there pushed the stale draft back over the newer version. Record the seeded snapshot in `baselineRef` and compare against that instead. With a baseline the two causes separate: `draft !== baseline` is a local edit, and a new `updated_at` with no local edits is just a remote update, which now reseeds silently. The conflict banner is left for the case it was written for — a remote update landing on real unsaved work. `toDraft` also trims name and description at the single seam where server data becomes a draft, matching what Save persists, so later comparisons are plain equality rather than a trim both sides have to remember. Content and file bodies are not normalized: whitespace in a SKILL.md body is content. File sets are compared through a path-sorted signature, since GET sorts files by path while PUT echoes request order and that difference is not a content change. Four of the five regression tests fail against the previous implementation; the fifth covers the true-conflict path, which was already correct and must stay that way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(skills): trim frontmatter name and description at the parse seam (MUL-5645) Both fields are single-line labels everywhere they are consumed, but YAML clip chomping gives `description: |` and `description: >` a trailing newline, so imports stored a description that differed from its own trimmed form. The detail page no longer depends on this — it normalizes when it seeds a draft — but leaving it means every new import keeps writing the padded value, and any future consumer that compares a stored description to a trimmed one inherits the same trap. Trimming here covers all four import paths (GitHub, skills.sh, archive, runtime-local) in one place rather than asking each to remember. Callers that need the raw SKILL.md still have it: `content` is stored untouched. Defensive only. Reverting this commit alone does not reintroduce the bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(skills): release the conflict when the user reverts their own edits (MUL-5645) Review catch on the previous commit. Once a conflict was raised, the seed effect only reconsidered when a new server version arrived, so a user who resolved the conflict by hand — retyping the field back to what it was — got stuck: the draft was clean again, and because the save bar renders only while dirty, its Discard button unmounted. That left the banner sitting above stale text with no control left to dismiss it, and no way forward short of a reload. This state is a regression from measuring dirtiness locally. Previously the draft was compared against the moved server value, so reverting still counted as dirty and Discard stayed on screen. Re-run the decision on draft changes too. When the local edits go away there is nothing left to protect, so the page adopts the server version and clears the banner — the same outcome as the never-edited case, reached a moment later. The true-conflict path is unchanged, and its regression test still passes, which is what keeps this from over-correcting into "always release". Reseeding also grew a third caller, so the four pieces that have to move together — draft, baseline, seeded key, conflict flag — are now assigned in exactly one place, `adoptServerVersion`, used by first load, silent refresh, Save and Discard alike. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
69d3208586 |
fix(channels): handle /new in shared router (#6251)
* fix(channels): handle /new in shared router * test(channels): cover /new provider reset end to end * fix(channels): preserve command source across rewrites --------- Co-authored-by: 无瑕 <yaowangjue.ywj@antgroup.com> |
||
|
|
0af0c6e27e |
docs(cli): tighten the issue search help text (#6300)
The help text added in #6293 is correct but runs 19 lines of prose, the longest Long in cmd_issue.go. Its job is only to correct two wrong expectations — that comments are not searched, and that an external identifier implies a cross-tracker link — and each paragraph carried a sentence that did not serve that job: - the first paragraph stated the comment-body scope twice; - the second explained, via LIKE-pattern set theory, that "412" also matches "1412" while "AGE-412" does not. That is reviewer-grade precision, not user-grade; simply not claiming the two forms are equivalent conveys it; - the third explained the number-only fallback before saying the part that is actionable. Down to 11 lines with no fact dropped: the fallback caveat is kept in short form, since without it "strongest field that matched" reads as wrong for a number-only hit. Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
20d2dd8cef |
MUL-5620: fix(daemon): close the repo-eviction liveness window and stop GC walking internal caches (#6299)
* fix(daemon): close the repo-eviction liveness window and stop GC walking internal caches Two review follow-ups from #6297. 1. The live-repo set was snapshotted once at the start of the .repos walk, then consulted per repo after running git and filesystem work on each one in turn. A workspace re-attaching a repo inside that window could see the cache evicted anyway. Replace the snapshot with a per-path query and ask twice: once as a cheap early-out so an attached repo never pays for the git work, and again immediately before RemoveAll. Re-reading in-memory state costs one mutex and no network, and shrinks the window from the whole walk to a few adjacent statements. This narrows the race rather than eliminating it — attachment updates workspaceState without taking the repo lock, so a sufficiently unlucky interleaving is still possible. It stays benign: a freshly attached repo has no last-used stamp and takes the backfill-and-skip path, and a wrong eviction costs one re-clone via ensureRepoReady. 2. runGC skipped only .repos, so it walked .skill-cache as if it were a workspace. Its "v1" directory then looked like a task dir with no .gc_meta.json and the orphan path deleted the entire bundle cache once its mtime went GCOrphanTTL without a new bundle — a few hundred KB reclaimed in exchange for a full re-download. Skip every dot-directory, matching what ScanDiskUsage already does; workspace directories are always UUIDs, so a dot-prefixed entry is one of our own caches. The skill cache has no lifecycle of its own, but it is measured in hundreds of KB, so leaving it unmanaged is clearly better than deleting it wholesale on an unrelated TTL. Giving it a real lifecycle is separate work if it ever grows. Steve's third observation — repoCacheSize measuring any second-level directory while eviction only handles isBareRepo ones — is left as is on purpose: measuring more widely than we delete is the right asymmetry for a visibility feature, so a corrupted leftover stays visible instead of silently uncounted. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): measure repo size before the final liveness check, not after Review follow-up: the second liveness check was not actually adjacent to the delete. dirSize walks every file in the bare repo and ran between the check and RemoveAll, so on a multi-GiB cache — the size that motivated this work in the first place — a workspace re-attaching during that walk would still lose its cache. Measure first, then check, then delete. That leaves only the window between two adjacent statements, which is what the comment claimed all along, and it keeps the slow filesystem walk outside the section the check is meant to protect. No behaviour change beyond the ordering: bytes_reclaimed still reports the size measured just before deletion, which TestEvictRepoCache_RemovesIdleDetachedRepo already asserts is non-zero. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
091d6e7c23 |
MUL-5648: docs(cli): issue search searches comments too, and its number match ignores the prefix (#6293)
* docs(cli): say that `issue search` searches comments too
`multica issue search --help` claimed "Search issues by title or
description", but the server has always matched comment bodies as well
(`server/internal/handler/issue.go` WHERE clause + rank tiers 7/8) and
returns `match_source: "comment"` for those hits.
On a real workspace that is not an edge case: over a 20-query sample,
52% of hits came from comments vs 35% description and 13% title. Readers
who trusted the help text concluded that a decision recorded in a comment
thread was unfindable via search, and fell back to reading whole comment
histories instead.
Fix the one-liner and add a Long that also documents the `match_source`
value set (`title` / `description` / `comment`), including that `comment`
doubles as the fallback for a number-only match — so it reads as a
display hint rather than a filter.
Docs only; no behavior change.
* docs(cli): spell out that identifier-shaped queries ignore the prefix
`parseQueryNumber` accepts a bare number OR anything matching
`(?i)^[a-z]+-(\d+)$`, and never checks the prefix against this
workspace. So "MUL-412" and "ZZZ-412" both match local issue 412, and
because a number match is rank tier 0 it lands at the top of the results
labelled `comment` with an empty snippet.
That matters in practice: external tracker ids appear in our own docs
and in code comments, so pasting one into search returns a confident,
wrong top hit. The previous wording ("a numeric query also matches an
issue by its number") read as bare-digits-only and hid it.
Documents the existing behavior; the loose prefix match is plausibly
deliberate (paste an id from anywhere and still find something) and
tightening it is a product decision, not a bug fix. Still docs only.
* docs(cli): don't claim "AGE-412" and "412" are equivalent
Only the number match is the same for both forms. The text search still
uses the query as written, so a bare number matches a strictly wider set
of text than the identifier form ("412" hits "1412", "AGE-412" does not).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Liaoyuan Ning <truetalents.lynn@gmail.com>
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
a735e4808d |
feat(daemon): account for and evict the bare repo cache (#6297)
The .repos bare-clone cache was excluded from disk-usage and never reclaimed, so it grew monotonically and was invisible while doing it. On the machine in #6265 it was 3.78 GB — 29% of the workspaces root, and exactly the difference between what disk-usage reported (9.1 GiB) and what the user's file manager showed (12.88 GiB). Accounting: .repos is now measured and reported on its own line rather than skipped. It stays out of the task totals on purpose — every task in a workspace checks out from this one shared cache, so folding it into per-task numbers would attribute it to directories that do not contain it. Other daemon-internal dot-directories (.skill-cache) are no longer counted as workspaces, which is what produced bogus rows like '.skillca'. Eviction: a bare repo is removed only when all four hold — GCRepoTTL > 0, no watched workspace still claims it, no worktrees remain, and no task has created a worktree from it within the TTL (default 30d). Two decisions worth calling out: - The workspace check is a RETAIN predicate, not a delete predicate. Sync re-clones every listed repo that is missing whenever a workspace registers, which happens on every daemon start, so evicting a repo the workspace still claims just buys a full re-clone on the next restart — that moves disk cost, it does not reclaim it. Because the set only prevents deletion, a stale or empty one cannot widen what we delete. - Idleness is an explicit stamp written by CreateWorktree, not directory mtime. Restarts re-fetch every cached repo, refreshing the mtime of repos no task has checked out in months; atime is unavailable in practice (noatime on Linux, off by default on Windows). A cache with no stamp reports unknown and gets its clock started, never treated as ancient — otherwise the first cycle after an upgrade would wipe every cache on the machine. Evicting wrongly costs a re-clone, not a failure: the next task that needs the repo takes the cache-miss path in ensureRepoReady. Verified on a live runtime: both table views now show the .repos line (242.8 MiB across 3 repos) and no longer list .skill-cache as a workspace. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
10746ad3af |
docs(license): state that free public hosting still needs a commercial license (MUL-5558) (#6296)
* docs(license): state that free public hosting still needs a commercial license (MUL-5558)
Condition 1a's operative sentence restricts providing a hosted service to
third parties without qualifying it by commerciality, but clause 1's
preamble frames the whole section around commercial use, so a reader can
argue that a free public instance escapes (a). A downstream fork asked
exactly this question, which is evidence enough that the ambiguity is real.
Say it directly: hosting for third parties is restricted whether or not a
fee is charged. Free hosting is the substitution risk the condition exists
to price, so leaving it to inference is the wrong default.
Also state the other half explicitly: publishing a fork's source is not a
hosted service and needs no commercial license, but it conveys no hosting
right downstream — each operator needs its own.
This ports the net content of commit
|
||
|
|
eb14e73a20 |
MUL-5617: fix(storage): disable request checksum in buffered S3 Upload (#6261)
* fix(storage): disable request checksum in buffered S3 Upload
* test(storage): make the checksum-trailer regression test actually fail without the fix
The test built its client with s3.New(s3.Options{...}), which leaves
RequestChecksumCalculation unset. An unset value emits no checksum at all,
so the assertions passed with or without the fix in Upload(). Production
builds the client via config.LoadDefaultConfig, which resolves the field to
WhenSupported.
Pin that production default in the test client and serve over TLS, since the
SDK only switches to a trailing checksum when the request is HTTPS. Removing
the option from Upload() now fails buffered_upload on the exact
STREAMING-UNSIGNED-PAYLOAD-TRAILER value Aliyun OSS rejects, while
streaming_upload still passes.
Also gofmt s3.go: the added option callback used spaces instead of tabs.
Co-authored-by: multica-agent <github@multica.ai>
* fix(storage): scope the checksum workaround to non-AWS endpoints
Downgrading RequestChecksumCalculation to WhenRequired is what makes Aliyun
OSS and Tencent COS accept the buffered upload, but it drops the client-side
checksum entirely rather than moving it out of the trailer: on TLS the request
goes out as UNSIGNED-PAYLOAD with no x-amz-checksum-* at all. Applying that to
every backend would change requests that work today, and AWS buckets with a
default Object Lock retention require a checksum to be present.
Gate the option on the endpoint instead. Real AWS S3 — no AWS_ENDPOINT_URL, or
an explicit amazonaws.com host — keeps whatever the SDK resolved, including any
operator-set AWS_REQUEST_CHECKSUM_CALCULATION. Only S3-compatible endpoints get
WhenRequired.
Adds TestS3StorageAWSUploadKeepsChecksumTrailer so the AWS path is pinned from
the other side: making the workaround unconditional now fails that test, while
removing it entirely still fails buffered_upload.
Also drops the incorrect claim that the seekable body stays covered by a real
SigV4 payload hash.
Co-authored-by: multica-agent <github@multica.ai>
* fix(storage): match the AWS endpoint on the host label boundary
usesAWSEndpoint substring-matched "amazonaws.com" anywhere in the configured
endpoint, which got the decision wrong in both directions: an uppercase
"https://S3.US-EAST-1.AMAZONAWS.COM" was treated as third-party and lost its
checksum, while "https://notamazonaws.com", "https://s3.amazonaws.com.evil.net"
and any URL merely carrying the string in its path or query were treated as AWS
and never got the fix they needed.
Parse the endpoint and compare the hostname on the label boundary instead,
covering the China partition. Scheme-less values are retried as https, since
url.Parse otherwise reads the whole value as a path.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: renjianjun <renjianjun@angelalign.com>
Co-authored-by: Bohan-J <bohan.optimism@gmail.com>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
cb04226fd4 |
MUL-5632: fix(engine): broadcast the full issue payload for chat-created issues (#6278)
* fix(engine): broadcast the full issue payload for chat-created issues
An issue opened with /issue from a chat channel broadcast
{"issue_id": <uuid>} — the minimal fallback IssueService.Create emits
whenever a caller leaves IssueCreateOpts.BroadcastPayload nil, which the
engine did. Every issue:created consumer reads payload["issue"], so the
missing key meant extractIssueFields (cmd/server/subscriber_listeners.go)
failed its map assertion and returned false before it ever reached the
id / creator_id check. The listener returned early and no auto-subscribe
rule ran, so the person who typed /issue was never subscribed to the
issue they had just filed and got no notifications for it. Both channels
that register with the engine — Feishu/Lark and Slack — were affected.
The HTTP handler already supplies a BroadcastPayload and autopilot
publishes its own event through issueToMap; only the engine path was
short. Export issueToMap as IssueToMap and have the engine use it, so a
single builder is the one source of truth for that shape instead of
three descriptions drifting apart. The workspace issue prefix comes from
the GetWorkspace call the /issue path already made for the chat reply's
identifier, hoisted so it is read once and used for both.
No regression — the payload only gains keys, and consumers read named
fields. The activity and notification listeners type-assert
payload["issue"] to handler.IssueResponse and still skip a map, exactly
as they already do for autopilot-created issues. The subscriber listener
accepts either shape. The workspace WS fanout marshals the payload
as-is, so open clients now render a chat-created issue live instead of
waiting for a refetch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(service): complete the issue:created payload contract
IssueToMap omitted project_id, stage, metadata and properties, all of
which handler.IssueResponse — the other rendering of the same event —
always emits. Clients type both as a complete Issue and insert the
object straight into the list cache without runtime validation, so an
issue created by autopilot, quick-create or the chat /issue command
appeared in open clients missing its project and custom properties
until the next refetch. Broadcasting the full issue from the chat path
would otherwise have spread that existing defect to a third entry
point.
Fill in the missing keys and pin the contract with a test that fails if
the two renderings ever drift apart again, so adding a field to
IssueResponse without adding it here is caught in CI rather than in the
UI. metadata and properties render as {} when unset, matching the
"always present" part of the contract.
Also route both identifier renderings through service.IssueIdentifier,
so a workspace lookup that degrades the prefix cannot show "#42" in the
chat reply while the realtime list shows "-42".
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* docs(service): name the actual IssueToMap call sites
The doc comment and the shape test listed quick-create among the
non-HTTP publishers of the issue payload. It is not one: the three call
sites are autopilot and the channel engine on issue:created, and the
background stuck-issue status reset on issue:updated.
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
c037c134ad |
MUL-5620: fix(daemon): make disk-usage report what is actually on disk (#6270)
* fix(daemon): make disk-usage report what is actually on disk Two defects made `multica daemon disk-usage` misreport, both of which matter when a user is trying to work out why their workspaces root is large (#6265). 1. The STATUS column was dead code. TaskDiskUsage.ParentStatus was declared and rendered but never assigned anywhere, so the column always printed "-" and parent_status was always "" in JSON. The GC is unaffected — it resolves status independently via gcDecisionIssueResult — but the one column that answers "which of these issues are still open?" was blank. ScanDiskUsage stays network-free; ResolveParentStatuses is an opt-in second pass wired into the CLI, backed by the same batch gc-check endpoint the GC loop uses, so the column reports exactly the status the GC would act on. The daemon already authenticates with the profile's CLI token, so this needs no new API surface. Best-effort: offline or logged out, the column stays blank and the command still works. Only issue-kind dirs are resolved — they dominate, and they are the only kind with a batch endpoint. Chat / autopilot-run / quick-create keep an empty status rather than costing one request each. 2. taskSize skipped .git entirely, so size_bytes under-reported every task dir holding a real git checkout. That disagreed with both the user's file manager and the GC itself: a full gcActionClean removes .git with the rest of the dir, and dirSize (which reports bytes_reclaimed there) counts it. Now counted wholesale into totalBytes, still never descended into, so artifact accounting stays aligned with cleanTaskArtifacts. Verified against a live runtime: STATUS resolves (in_review on the largest dirs, i.e. not done — which is exactly the question that prompted this), and top dirs went from 104.9 MiB to 132.6 MiB reported, closing most of the gap against du. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): skip disk-usage status resolution when nothing renders it Review follow-up on #6270. The --by-workspace table has no STATUS column, but the CLI resolved statuses unconditionally. With credentials on disk and an unreachable server that cost a full timeout before printing a purely local report — and --all-profiles paid it once per root, serially — then warned about a column the output does not contain. Gate resolution on whether task rows are actually rendered; JSON keeps resolving because it always carries the task array. Also correct two contract statements that the .git change had made false: the command's Long help still claimed 'The walk skips .git', and ScanDiskUsage's doc comment still claimed it never enters .git. Both now state the real rule (counted toward the total, never toward the artifact subset, symlinks still never followed), and the help now says STATUS needs the network and is left blank when it cannot be reached. Adds the CLI-level regression tests the wiring was missing: the by-workspace table makes no request, the per-task table resolves and renders the status, a failing server still exits 0 with valid JSON on stdout, and --all-profiles resolves each root with its own profile's token. Verified the no-request test fails when the gate is removed. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
d38da27ed4 |
MUL-5619: fix(cli): surface the server's 409 message instead of the generic conflict template (#6267)
* fix(cli): show the server's conflict message instead of the generic 409 template
Every 409 this API returns is a deterministic refusal that names its own fix
("a skill with this name already exists", "set parent_id (--parent) to <id>").
The CLI replaced all of them with a template that says the opposite — that the
state changed underneath you and you should re-fetch and retry. Agents took the
retry hint literally: GH #6264 reports 15+ identical retries over 10 minutes
followed by hours spent chasing an optimistic-concurrency theory that never
existed, and GH #5948 is a second user misdiagnosing the same way. MUL-4417 had
already written the useful message server-side; it just never reached anyone.
Route 409 through the same server-message extraction 400/422 already uses, so
roughly forty hand-written conflict messages across skills, agents, runtimes,
labels, projects and comments become visible by default. A body we cannot
recognize still falls back to the template, so this never dumps a raw response.
extractServerMessage now prefers prose over a bare identifier, because a few
endpoints put a stable code in "error" and the sentence in "message".
MUL-5619
Co-authored-by: multica-agent <github@multica.ai>
* fix(comments): stop telling a wrong --parent that it posted a top-level comment
The reply guard returns one message for two different mistakes. A resumed
session that carries a previous turn's --parent forward (GH #6264) did not ask
for a top-level comment, but is told it did — which sends it looking for a
new-thread opt-in (GH #5383) instead of correcting the parent it already passed.
Split the copy: name the rejected parent when one was supplied, and keep the
existing top-level wording for the parentless case. Both still point at the
trigger comment to use.
MUL-5619
Co-authored-by: multica-agent <github@multica.ai>
* fix(runtime): return 500, not a 409 echo, when the update store fails
InitiateUpdate answered every UpdateStore.Create failure with a 409 carrying
err.Error(). The in-memory store only ever returns errUpdateInProgress, so this
looked safe — but the Redis store also wraps infrastructure failures as
"reserve active update: <dial error>" and "persist update request: <error>".
Surfacing 409 bodies in the CLI turns that into a user-visible leak of internal
addresses, and labels an outage as a conflict the caller could fix by retrying.
Classify instead: errUpdateInProgress keeps its 409 and its actionable message,
everything else is logged and answered with a 500 and fixed copy.
Also pins the prose-over-machine-code preference for validation bodies, which
the shared extractor applies to 400/422 as well as 409. Only the issue-table
endpoints are shaped that way and none is reachable from the CLI today, but the
change is intentional and should fail loudly if reverted.
MUL-5619
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
|
||
|
|
b06af2ae17 |
feat(runtime): unbind agents on runtime delete instead of destroying them (#6220)
* feat(runtime): unbind agents on runtime delete instead of destroying them Deleting a runtime archived its agents and then hard-deleted the rows, so the agents and every conversation with them disappeared — while the confirmation dialog said "archive", which a user reasonably reads as recoverable. Retiring a laptop is an ordinary action; losing the agents configured on it is not an ordinary consequence. An agent is now a persistent business object and a runtime is replaceable execution capacity: deleting a runtime unbinds its agents. `runtime_id IS NULL` means unbound — orthogonal to archived — and the agent keeps its instructions, skills, chats, labels, channel installations, autopilots and task history. service.AgentReadiness already refused an agent with no runtime, so the scheduling safety gate needed no change. Two columns become nullable, not one. Without `agent_task_queue.runtime_id`, deleting the runtime still cascades the task history away (and task_message / task_usage / task_token with it), so the agents would survive with no record of anything they did — the same class of loss. A NOT VALID CHECK keeps NULL confined to history: an active task must always have a runtime, so claim / dispatch / delivery-CAS paths can never observe one without. It is written against completed_at rather than a status list so a future non-terminal status fails closed instead of slipping through. Two prerequisites this depends on: - 'deferred' (migration 128) was missing from CancelAgentTasksByRuntimeOrAgent. It went unnoticed because the delete used to cascade those rows away; with the new CHECK it would abort the delete and make the runtime undeletable. - The channel-installation / label / chat-pin / invocation-target / draft-restore cleanups were scoped to "archived agents on this runtime". Archived user agents now survive, so that scope is narrowed to kind='system' — otherwise the fix would produce a subtler loss: agent alive, configuration wiped. Also removes the squad guard that refused (409) when an active squad's leader was an archived agent on the runtime, plus the archived-squad delete that existed only to get past squad.leader_id's RESTRICT FK. The leader is no longer deleted, so nothing needs to be given up to retire a machine. Autopilots are no longer paused either: their assignee survives, and a rebind restores them without the owner having to remember to re-enable. Reason codes: an unbound agent reports agent_runtime_required, not runtime_offline. The copy for runtime_offline tells users to reconnect a machine; an unbound agent has no machine to reconnect, and the fix is to bind a runtime. Chat's bare 409 string gains the same code so the composer can offer that action. API: agents gain runtime_bound. runtime_id stays a string (empty when unbound) so installed clients keep parsing and no gated two-release rollout is needed. The confirmed-delete endpoint is /unbind-agents-and-delete; /archive-agents-and-delete still routes to it, and the compared expected_active_agent_ids set is unchanged — widening it would 409 every older client forever. Co-authored-by: multica-agent <github@multica.ai> * fix: make runtime unbinding recoverable Co-authored-by: multica-agent <github@multica.ai> * fix: address runtime unbind review nits Co-authored-by: multica-agent <github@multica.ai> * fix: resolve runtime unbind review blockers Co-authored-by: multica-agent <github@multica.ai> * fix(migrations): renumber runtime unbind after main merge Co-authored-by: multica-agent <github@multica.ai> * test(daemon): avoid late-request lease flake Co-authored-by: multica-agent <github@multica.ai> * test(autopilots): bind validation fixture runtime Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
829a4e5af9 |
fix(daemon): honour HTTP(S)_PROXY when dialing the wakeup WebSocket (#6279)
runTaskWakeupConnection built its dialer by hand:
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
A zero-value Proxy field means "dial direct and ignore the
environment". websocket.DefaultDialer sets Proxy to
http.ProxyFromEnvironment; a dialer built this way gets nothing, and
gorilla skips the CONNECT wrapper entirely.
This is the daemon's control connection to the Multica server, so in
the SaaS deployment it dials out to the public internet. On a machine
whose only egress is a corporate proxy the handshake can never succeed.
Nothing points at the cause either: the loop logs "task wakeup
websocket unavailable; polling fallback remains active" at debug level
and never mentions a proxy. The daemon then runs permanently degraded —
task pickup waits for the HTTP poll (PollInterval, 30s by default)
instead of a server push, heartbeats stay on HTTP, and the WS-first
batch claim path (MUL-4257) falls back to HTTP on every task.
Set Proxy: http.ProxyFromEnvironment. gorilla rewrites wss:// to
https:// on the parsed URL before it calls Proxy, so HTTPS_PROXY — and
NO_PROXY — apply to this dial the same way they apply to every other
HTTPS client in the process. The lark connector fixed the same defect
the same way in #4165; this was the last bare dialer left in non-test
code.
No regression where no proxy is configured: ProxyFromEnvironment
returns a nil URL, gorilla leaves netDial untouched, and the dial is
byte-for-byte the direct dial it was before. Everything downstream of
the handshake — headers, heartbeat writer, RPC attach, teardown — is
unchanged.
The regression test drives the dial from a child process. net/http
resolves the proxy environment once per process and caches the result
(envProxyOnce), so by the time a test in this package runs, an earlier
test has already primed that cache with "no proxy" and t.Setenv can no
longer reach it. The child starts with a clean environment pointing at
a stub CONNECT proxy, and the parent asserts the CONNECT for the wss
target arrived. Without the fix the stub proxy sees nothing.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
3d0e1324c6 | fix(mobile): recover from zombie WebSocket connections | ||
|
|
0398d29675 |
docs: four-language end-to-end tutorial with 41 screenshots (#6268)
* docs(tutorial): add the 41 walkthrough screenshots Screenshots for the end-to-end tutorial, captured against the English UI and shared by all four locales. Compressed with pngquant (18 MB -> 4.8 MB) to stay in line with the existing docs images. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(tutorial): add a four-language end-to-end tutorial A hands-on walkthrough that builds one personal website project from an empty workspace: connect a computer, create agents through chat, deliver the first issue, review and merge it, form a squad so the leader handles coordination, attach imported skills, and set up a scheduled autopilot. Sits in Get started between the quickstart and the concept pages, so a reader who finished the 5-minute path can walk the full flow before moving on to reference material. UI labels, statuses and feature names follow each locale's product strings, matching how the rest of the docs are written. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(tutorial): move the tutorial below How Multica works Places it after the concept pages rather than straight after the quickstart, so a reader meets the core objects before the full walkthrough. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9b013e34e8 |
feat(inbox): move the selection with the arrow keys (MUL-5622) (#6269)
Up/Down inside the inbox list scrolled the container instead of walking the selection, so a notification could only be opened with the mouse. The list's scroll container now owns the arrow keys: it moves the selection by one row, scrolls the new row into view through Virtuoso (never the DOM's scrollIntoView, which also scrolls ancestors), and claims the keypress so the native scroll cannot pull the viewport off the selected row. Keyboard focus is parked on the container rather than a row, because virtualization unmounts rows as they scroll out. Scoping the handler to the container keeps Down from swapping the row out while the user is reading the issue detail. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
37f3bb7dd9 |
MUL-5587: fix(autopilots): tell the user which required field blocks Create (#6231) (#6237)
* fix(autopilots): tell the user which required field blocks Create (MUL-5587) The create dialog gated its submit button with native `disabled` on an empty title, an unpicked assignee, or a server-rejected schedule. A natively disabled button is neither hoverable nor focusable, so a user who had not picked an assignee got a dead grey control and no hint about what was missing (GitHub #6231) — the assignee picker looks exactly like the optional Project and Subscribers pickers beside it, and nothing said only that one was required. An unmet requirement now uses `aria-disabled` instead: the button still takes the hover that shows a tooltip naming the missing field, and still takes the click, which reveals an inline error under the field at fault and focuses it. `handleSubmit` is the real gate either way, so nothing can be submitted that could not be before. The assignee section also carries a required marker up front, so the dead end is avoided rather than only explained. The three states come from one `submitBlock` value shared by the button, the tooltip, the inline errors and `handleSubmit`, so a rendered affordance cannot disagree with what submitting actually does. Co-authored-by: multica-agent <github@multica.ai> * refactor(autopilots): stop dimming Create at all — the click is the feedback (MUL-5587) Follow-up on review. The previous commit kept the button greyed via `aria-disabled` and explained the grey with a tooltip. Since a blocked click now intercepts and points at the offending field, the greying earns nothing: drop `aria-disabled`, the dimming classes and the tooltip, and let the button be an ordinary live button whenever a save isn't already in flight. The schedule case no longer scrolls to the editor's inline error and returns. It falls through to `scheduleGate.ensureAccepted`, which re-asks the server and toasts its actual reason — visible feedback where the scroll could be a no-op, and it self-heals when a stored expression the server once rejected is accepted again. That leaves nothing in this dialog reading the gate's `scheduleValid`, so its `onValidityChange` / `clearRejection` wiring goes too rather than sitting inert. The shared hook keeps them for the detail page's add-trigger dialog, which still gates on it. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
8df7549d84 |
refactor(issues): move sidebar Details section below the execution log (MUL-5599) (#6244)
Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
3cab03cc36 |
feat(labels): remove agent labels from the product (MUL-5600) (#6245)
Label Settings no longer exposes an Agents catalog, so agent labels can no longer be created, renamed, recolored, or deleted from the UI. Removing only that catalog would have left the agent detail settings form with an attach-only label picker pointing at a catalog the user can no longer populate, so the Labels row there goes too. That picker was the only other agent-label surface in the product — the CLI never managed them, and no list, filter, or chip renders them anywhere else. The backend still models the `agent` resource type and keeps its endpoints; this change removes the product surface, not the API. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>v0.4.16 |
||
|
|
bdd90dd9ca |
docs(changelog): add v0.4.16 release entry (2026-07-31) (MUL-5596) (#6242)
* docs(changelog): add v0.4.16 release entry (MUL-5596) Co-authored-by: multica-agent <github@multica.ai> * docs(changelog): rewrite v0.4.16 entries for clarity (MUL-5596) Each line now states the user-visible result instead of the mechanism: name the agents whose replies were truncated, say what the Linux Codex change actually gives you, drop WebSocket/migration internals, and use the same word for link chips across all four languages. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Bohan-J <bohan@devv.ai> |
||
|
|
33a2743f93 |
fix(llm): make quick actions GPT-5.6 compatible (MUL-5573) (#6243)
* fix(llm): use max completion token limits Co-authored-by: multica-agent <github@multica.ai> * fix(llm): harden GPT-5.6 JSON generation Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
28b6105edc |
fix(subscribers): notify the human an agent files sub-issues for (MUL-5483) (#6209)
When an agent created a sub-issue while working on a human's behalf, that human received no notifications for it at all. issue_subscriber modelled ACTOR identity, so an agent-created, agent-assigned issue had a full subscriber list and zero members to deliver to. The platform already knew who the work was for (agent_task_queue.originator_user_id, MUL-4302); notification never asked. - attribution.DelegatedSubscriber: one shared rule over the same origin waterfall ClassifyDirect uses. agent_create subscribes the originator as 'delegated'; quick_create keeps the direct 'creator' tier; autopilot and degraded attribution subscribe nobody. - Delegated is a reduced delivery tier: in_review/done/cancelled/blocked plus failures and mentions. Routine churn is suppressed, and the parent bubble cannot re-deliver what the tier dropped. - Unsubscribe becomes stateful: an unsubscribed_at tombstone survives later rule passes, and opt_out_scope distinguishes "this issue" from "this subtree" so a narrow opt-out no longer silently suppresses future children. - Subtree unsubscribe is its own endpoint. A body flag cannot fail loudly against an older backend (Go drops unknown fields); an unknown route 404s, which the UI now surfaces with a distinct message. - Eligibility and the write share one statement under a (workspace, user) advisory lock that subtree unsubscribe and member revoke also take, closing the check-then-insert races. Revoke additionally clears the departing member's subscriptions in the same tx. - UI explains a delegated subscription and offers both unsubscribe scopes. Migrations 249/250 add the delegated reason, the opt-out tombstone, and the opt-out scope, using NOT VALID + VALIDATE CONSTRAINT so the widened CHECK does not scan issue_subscriber under an exclusive lock. Reviewed across eight rounds; an earlier write-time subtree roll-up was built and then removed in full once it proved unfixable without serializing every topology mutation. The parent's own status transition already carries that signal. Closes MUL-5483. |
||
|
|
9daa291d00 |
fix(migrations): resolve duplicate migration number (#6239)
Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai> |
||
|
|
d4ae220cc1 |
feat(rich-content): render bare in-app project/issue URLs as chips (MUL-5499) (#6141)
* feat(rich-content): render bare in-app project/issue URLs as chips (MUL-5499) A project has no `MUL-123`-style identifier — only a UUID and a free-text title — so there is nothing for the bare-identifier autolink preprocessor to detect, and the link copied out of the app is how people actually reference one. It rendered as a raw URL. RichLink now unfurls a bare in-app entity URL into the same chip the `mention://project/<uuid>` form already produces (issue URLs go through the same path for symmetry). Render-only: stored markdown is untouched, and the editable Tiptap path is deliberately unaffected. Three guards, each load-bearing: the link must be bare (an authored label is never discarded), same-workspace (a chip resolves its title in the current workspace only), and address exactly one entity page by UUID with no query or fragment. Also: - mobile: tapping a `mention://project/` link navigated nowhere despite the `project/[id]` route existing — it now pushes the project detail. - agents had no documented way to emit a clickable project reference: add the link form to the runtime brief's Mentions section and to the projects skill, and record in the mentioning skill why `project` sits outside `MentionRe` (render-only, enqueues nothing). Co-authored-by: multica-agent <github@multica.ai> * fix(rich-content): unfurl issue URLs in identifier form The unfurl required a UUID id, on the stated grounds that "every link the app itself produces carries a UUID". That holds for a project but not for an issue: `copyLink` and `openInNewTab` both build `paths.issueDetail(issueIdentifier || issueId)`, and the issue route rewrites a UUID URL back to the identifier — so `MUL-123` is the shape a user actually copies, out of the app or out of the address bar. The issue half of the feature could not fire on the links people paste, while bare `MUL-123` prose did become a chip: the fuller reference lost to the shorter one. `parseWorkspaceEntityLink` now accepts an issue identifier as well as a UUID. A project still requires a UUID — it has no shorthand, so an identifier-shaped id under /projects/ addresses nothing. An identifier needs a lookup, which means it can miss, and the miss has to differ by entry point. `AutolinkedIssueMentionLink` degraded to plain text, which is right for autolinked prose and wrong for a URL: the author wrote a link, and an issue this workspace cannot see must not cost them the only pointer to it. The fallback is now a prop — plain text for the autolink path, the original anchor for a URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(editor): stop drawing link chrome over mention chips A mention chip already carries its own affordance — border, icon, hover background — so the generic `.rich-text-editor a` color and underline draw a second, competing one straight through the card. `.issue-mention` reset it; `.project-mention` never did, so project chips shipped with a brand-coloured underline through them. The rule belongs to the chip shape rather than to one entity, so both selectors now share it and a future chip is one line. The hover card had the same gap: it skipped `.issue-mention` only, so hovering a project chip opened a URL card offering to copy `/{slug}/projects/{uuid}` — an in-app path, not the shareable link that wording implies. Both are pre-existing, but a bare project URL now renders as a chip, so what used to surface on hand-written mentions alone shows up on ordinary pasted links. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(rich-content): decide in-app by resolving the URL, not by its prefix `href.startsWith("/")` was standing in for "this deployment". It is not: a browser reads `//other.example/x` and `/\other.example/x` as another host and goes there, and both start with a slash. The parser skipped the origin check for exactly the hrefs that most needed it. Nothing shipped from this: an unfurled chip links to `paths.projectDetail(uuid)`, so the href it was parsed from is discarded and a misparse could not send anyone anywhere. The prefix test was still the wrong instrument. Adding `&& !startsWith("//")` would have looked like a fix while leaving the backslash spelling through — the gap is the technique, not the case, so this resolves the href against the app origin with `URL` and compares `origin`, which is one comparison for every spelling and for the schemes (`javascript:`, `data:`) whose opaque origin can never match. Relative and absolute now take the same path, so the slugless legacy form parses identically whether or not it carries the origin — previously the absolute spelling was rejected by a reserved-slug test meant for workspace slugs, and the two disagreed. `openLink` still tests the prefix, and its result IS navigated. That is a live issue, older than this feature and wider than it; it needs its own change rather than a quiet ride here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(skills): state what mobile actually does with a project mention The projects skill told agents a `mention://project/<uuid>` link "renders as a navigable project chip on web, desktop, and mobile", and that a pasted project URL is unfurled into that same chip by "the reader's client". Neither holds on mobile: `apps/mobile/lib/markdown/markdown.tsx` renders the default enriched link and only routes the tap, and a bare URL still goes to `Linking.openURL`, which leaves the app. These files enter agent context and read as product contract, so an agent choosing between a mention link and a pasted URL was choosing on false information — and the URL is the option that strands a mobile reader in a browser. Both skills and both source maps now say chip on web/desktop, ordinary link that opens the project on tap on mobile, and unfurling as web/desktop only. The projects skill also now states the preference outright rather than presenting the two forms as equivalent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(views): give a project mention the component an issue mention has `IssueMentionCard` owns "chip inside a link" for issues; the project equivalent lived inline in the readonly renderer, so nothing named the pairing and nothing held the rules that come with being a link. That cost was not hypothetical. Both gaps fixed a commit ago landed on project mentions alone: `.project-mention` never got the CSS rule cancelling generic link chrome, and the hover card never learned to skip it. Each was written for `.issue-mention` at the component that owns it, and project had no such place for the second half to be written. `ProjectMentionCard` is that place. No behaviour change: same anchor, same href, same hover affordance, same accessibility contract that project-mention-a11y.test.tsx pins. The "open in new tab" preference stays out — it is scoped to issue links, and inheriting it by symmetry would be inventing product. Also drops `not-prose` from both cards. It has no definition anywhere in the repo — Tailwind's typography plugin is not installed, and the class does not appear in built CSS — so it read as protection that was not there. The editor's `MentionView` keeps its hand-rolled anchors: it needs a modifier-click intent hook `AppLink` does not expose, and it does the same for issues, so the two stay symmetric there too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |