Commit Graph

4422 Commits

Author SHA1 Message Date
Naiyuan Qing
ba17fcf46a fix(editor): open mention and slash pickers only for typed triggers (MUL-5429) (#6072)
* fix(editor): stop pasted @ text from hijacking Enter and Escape (MUL-5429)

Pasting a line containing `@` into the create-issue composer left an empty
mention picker open that swallowed Enter, and Escape then closed the whole
dialog instead of just the picker.

Two independent causes:

1. The Tiptap suggestion plugin re-runs findSuggestionMatch on EVERY
   transaction, including the one that commits a paste, so pasted text
   containing `@` opened the picker. With `allowSpaces` the match runs from
   the `@` to the end of the line, so the entire pasted tail became one query
   that matched nothing — and the empty popup deliberately captures Enter.
   Gate the mention picker with `shouldShow`: skip paste/drop transactions,
   and skip queries longer than any real mention target (60 chars). The
   empty-list Enter capture is intentional and is left alone; the fix stops
   the picker opening instead.

2. ProseMirror calls preventDefault() for a handled key but never stops
   propagation, and Base UI's dismiss layer listens for Escape on `document`
   in the bubble phase without consulting defaultPrevented. So the Escape
   that closed the picker also closed the host dialog. Stop propagation in
   the shared suggestion popup handler — the only layer that knows a picker
   is open — which fixes every host at once.

The slash pickers get the same paste guard so a pasted path no longer opens
the command menu; they were never affected by the Enter capture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(editor): open mention and slash pickers only for typed triggers (MUL-5429)

Pasting a line containing `@` opened an empty mention picker that swallowed
Enter. The first attempt at this fix aimed at the wrong target and did not
work in the product; this replaces it.

The picker also opened with no paste involved at all: placing the caret at the
end of a line that already contained `@` was enough. Tiptap's Suggestion plugin
is state-derived, not event-driven — it re-runs findSuggestionMatch on EVERY
transaction and asks whether the text before the cursor looks like a trigger,
never whether the user just typed one. Pasted, dropped, undone and
server-loaded text produce an identical document, so it cannot tell them apart.
With allowSpaces the match then runs from the `@` to the end of the line, so
the whole pasted tail became one query that matched nothing, and the empty
popup deliberately captures Enter.

That is upstream's known, unfixed design: ueberdosis/tiptap#4183 (open since
2023, labelled `complexity: hard`, reopened in January after `shouldShow`
proved insufficient) and #7371. Upstream's position is that applications supply
the missing provenance themselves. `shouldShow` alone cannot: it receives the
transaction without `prev.active`, while `allow` receives `prev.active` without
the transaction, so no transaction-inspecting predicate can express "only on
the transaction that opened the picker".

Add a trigger-arming plugin instead. `handleTextInput` is the one ProseMirror
hook that fires for real keyboard and IME input only — paste goes through
handlePaste/doPaste and drop through handleDrop, neither of which reaches it.
Typing a trigger character arms its document position; the pickers' shouldShow
opens only for a match anchored there; a deliberate caret move, or losing the
trigger character, disarms it. This is the same signal Tiptap's own InputRules
run on, which is why typing `- ` makes a list but pasting it does not.
Suggestion is the one Tiptap feature that does not use it.

Also stamp the paste metas markdownPaste was dropping. ProseMirror's doPaste
returns early once a handlePaste prop claims the event, and markdownPaste is a
catch-all that claims nearly every paste, so the transaction that commits a
paste carried no `paste` / `uiEvent` mark. That is why the previous fix never
fired in the product, and it independently broke issueIdentifierAutolink, which
reads exactly that mark: pasting `See MUL-2 now` autolinked nothing, because
the unmarked transaction sent it down the typing path that only inspects the
token before the caret.

Both features' paste tests passed throughout because they hand-built
transactions carrying a meta the real paste path never produces. Every paste
case now fires a real paste event, and typing is routed through
handleTextInput the way readDOMChange does, so a test cannot be green while the
product is broken.

The Escape containment from the previous attempt is unrelated to all of this
and is kept as is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 10:43:21 +08:00
Bohan Jiang
0abee49005 docs(self-hosting): require COOKIE_DOMAIN for split-domain deployments (MUL-5413) (#6055)
The split-domain reverse-proxy recipe listed FRONTEND_ORIGIN,
CORS_ALLOWED_ORIGINS and NEXT_PUBLIC_API_URL but never COOKIE_DOMAIN.
Without it the session cookies are host-only on the API host, so the
frontend cannot read multica_csrf, never sends X-CSRF-Token, and every
non-GET request is rejected with 403 "CSRF validation failed" while
reads keep working.

Add COOKIE_DOMAIN to the recipe and explain the failure mode, including
the stale-cookie cleanup needed after changing it.

COOKIE_DOMAIN also scopes multica_auth, so the browser sends the session
JWT to every host under that domain — HttpOnly stops page scripts from
reading it, not sibling subdomains from receiving it. Require the
narrowest parent domain covering both hosts, state that every host in
scope must be operated by the same trusted party, and mark the
same-origin layout as recommended since it keeps the cookie host-only.

Also document the requirement and the scope caveat in the environment
variable reference (en/zh/ko/ja).

Refs #6046 (MUL-5413)
2026-07-28 22:11:54 +08:00
Bohan Jiang
0066ab259e refactor(agent): drop unreachable inline system-prompt branches (MUL-5392) (#6050)
* refactor(agent): drop unreachable inline system-prompt branches (MUL-5392)

The daemon only populates ExecOptions.SystemPrompt for openclaw, kimi and
traecli (providerNeedsInlineSystemPrompt); every other backend receives the
runtime brief as a per-task context file in the workdir. The inline branches in
claude, codex, opencode and pi were therefore dead, and read as if they were the
live delivery path.

Probed each backend over its real launch path with a canary in the context file
and no inline delivery — claude 2.1.220 (CLAUDE.md), codex 0.144.6 via the
app-server, opencode 1.17.7, pi 0.67.2, hermes 0.18.2 via ACP — and all of them
picked the brief up from disk, so the branches were removable with no behaviour
change. An empty workdir returned no canary, confirming the probe could fail.

opencode's branch was worse than dead: `opencode run` has no --prompt flag, so
enabling inline delivery there would have made every opencode task exit 1 with a
usage dump. The DevEco backend, forked from opencode, already documents this
constraint; opencode itself never got the fix.

Regression tests pin all three arg builders against re-adding the flag, and
providerNeedsInlineSystemPrompt now documents what was verified and what is
still unprobed (grok, qoder, codebuddy).

Hermes and kiro are untouched: their exclusion is deliberate and already tested.

Co-authored-by: multica-agent <github@multica.ai>

* test(agent): pin codex developerInstructions contract, drop stale pi flag doc

Review follow-up on MUL-5392.

buildPiArgs' doc comment still advertised --append-system-prompt after the
branch that emitted it was removed — exactly the stale-signal this PR set out
to delete.

The two codex sites fixed to a literal nil had no regression test, so restoring
nilIfEmpty(opts.SystemPrompt) would still have gone green. Both thread/start and
thread/resume now run with a canary SystemPrompt and assert developerInstructions
comes through as an explicit null. Mutation-checked: reverting either site fails
its test.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 22:02:55 +08:00
Bohan Jiang
962d376fda refactor(agent): share acpDeliverableTracker across ACP backends (MUL-5405) (#6044)
#6022 stopped qoder from delivering interim narration as Result.Output, but
hermes / kimi / kiro / traecli / grok still accumulate every MessageText into
one builder and hand the whole thing to Result.Output — the same leak (#6006),
since Result.Output becomes the channel reply and the auto-generated issue
comment.

Extract the boundary rule into acpDeliverableTracker (observe / result) and
share it across all six backends instead of copying qoder's block five times:
Result.Output keeps only the text after the latest tool call, a turn that ends
on a tool call falls back to the latest non-empty text block so the reply is
never empty, and provider-error detection keeps reading the full text stream.

Covered by tracker unit tests and a cross-backend regression test that pins
both scenarios on all six backends, over both tool-use emission paths
(emitted at the tool call and deferred to tool completion).

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 21:18:12 +08:00
Bohan Jiang
0254090853 docs(changelog): file the Claude Code cache fix under fixes and lead with it (MUL-5400) (#6047)
* docs(changelog): lead v0.4.13 with the prompt-cache saving (MUL-5400)

Co-authored-by: multica-agent <github@multica.ai>

* docs(changelog): scope the cache fix to Claude Code and move it to fixes (MUL-5400)

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
v0.4.13
2026-07-28 18:48:09 +08:00
Multica Eve
c0f397b8d2 fix(migrations): remove historical skill-bundle backfill (MUL-5400) (#6045)
* fix(migrations): skip historical skill bundle backfill (MUL-5400)

Co-authored-by: multica-agent <github@multica.ai>

* fix(migrations): drop unused skill bundle migration

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 18:36:38 +08:00
Multica Eve
79cc3abc8d docs(changelog): add v0.4.13 release entry (#6038)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 18:33:42 +08:00
Naiyuan Qing
831ef926e1 fix(editor): limit paste-as-file to chat (#6043)
#6019 opted four composers into converting an over-threshold plain-text
paste into a `pasted-text.txt` attachment. Only chat should: a wall of
text there is context handed to an agent for one turn, and the body is
not what anyone reads. In an issue comment the paste IS prose a human
reader is expected to see in the thread, so hiding it behind an
attachment chip makes the thread worse, not better.

So the three comment surfaces — new comment, reply, comment edit — stop
passing `pasteAsFileThreshold`. Nothing else changes: the extension, the
threshold constant and the failed-paste recovery all stay, because the
prop was always opt-in per editor and chat still uses every part of it.

The recovery test moves from comment-composers to
use-coordinated-uploads. Comments can no longer produce a paste-as-file
upload at all, so hosting the test there would pin a path that cannot
happen; the hook is where the contract actually lives, since the upload
outlives its mount and no editor can own the failure.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:03:55 +08:00
YYClaw
54469766fd fix(qoder): deliver final answer only (MUL-5394) (#6022)
* fix(qoder): deliver final answer only

* fix(qoder): preserve tool-terminated replies
2026-07-28 17:28:22 +08:00
Naiyuan Qing
18adb20c14 MUL-5391: unify upload placeholder UI (#6025)
* fix(editor): an in-flight upload placeholder is never content, and is drawn once

Two defects with one cause: a placeholder for an upload in progress was both
serialised into the draft body and drawn a second time as a chip.

The document IS the persisted draft (getMarkdown -> setDraft), so serialising
an in-flight node turns it into text that outlives the upload:

  - fileCard emitted `!file[x.pdf]()`. Its own tokenizer cannot parse an empty
    href back, so the line survived reopen as dead literal text, sat next to
    the real link the write-back appended, and shipped with the comment.
  - image emitted its process-local `blob:` URL, which ContentEditor then
    scrubbed back out with a regex on every serialise.

Both renderMarkdown implementations now emit nothing while `attrs.uploading`
is set (or no URL exists). A node becomes content the moment it holds a real
URL and never before, which is strictly stronger than scrubbing after the
fact — so BLOB_IMAGE_RE / stripBlobUrls are deleted rather than extended.

Separately, ComposerUploadChips rendered every non-`uploaded` entry, including
ones whose placeholder node is right there in the editor. Every upload started
from a live mount inserts a node first (uploadAndInsertFile is the uploader's
only caller), so those chips were the same upload drawn twice, in two visual
languages, shifting layout as they appeared and vanished. useCoordinatedUploads
now exposes `orphanUploads` — the entries inherited from the persisted draft,
whose originating mount is gone and whose node died with it. That is the case
the chip strip was introduced for, and now the only one it covers.

`getMarkdown()` deliberately stays untrimmed (see its safety-net test); only
its stripBlobUrls wrapper is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(editor): keep a failed upload visible after the chip/node split

Self-review catch on the previous commit: suppressing the chip for every
upload this mount started also suppressed it for FAILED ones. The document
cannot stand in for those — uploadAndInsertFile removes the placeholder node
on failure — so the outcome was left to a toast that has already gone.

The rule is not "started here" but "the document is showing it", and the
document only ever shows a live placeholder: still `uploading` AND started by
this mount. `failed` / `interrupted` always get a chip, `uploaded` never does
(the editor and AttachmentList render those), which also makes an
`orphanUploads.length` gate mean what the call sites assume.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(editor): keep the chip when the user deletes a running upload's placeholder

Code-review catch: "started by this mount" is necessary but not sufficient for
"the document is showing it". Cmd+Z right after a paste removes the placeholder
node while the upload keeps running — and gate.isBlocked keeps blocking send on
the store entry regardless of the node — so the previous filter left a dead send
button with nothing on screen explaining it.

The filter now also consults editorGate.uploading, which is the document's own
answer to "am I showing a placeholder right now" (sourced from the uploading-node
scan via onUploadingChange). Started-here AND still shown is what suppresses a
chip; either half failing brings it back.

Also drops a stale stripBlobUrls reference from the use-upload-gate docstring —
that helper no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(editor): a failed upload leaves nothing behind

The failure chip carried no information the toast had not already given at the
moment it happened, and it could not act on it: the bytes were never persisted,
so there is nothing to retry, and the file is still on disk to re-attach. Its
only affordance was a dismiss ✕.

It cost more than that. The entry lives in the persisted draft, so it survived
reload and reopen until dismissed by hand — and `isMeaningful` counts uploads,
so a single flaky request kept an otherwise-empty draft alive for the full
30-day TTL. Uploading again did not clear it either: a new upload is a new
clientUploadId.

Failures now remove their placeholder outright instead of marking it. Both
failure paths (size check, coordinator settle) collapse into that one rule,
which also folds the paste-as-file recovery into the shared branch rather than
duplicating it.

`interrupted` keeps its chip: it is discovered a session later, when the user
no longer remembers attaching anything. `orphanUploads` still handles `failed`
because an older client may have persisted one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(editor): one upload, one node, from start to finish

The chip strip existed because the document could not answer for an upload it
was not showing. Give it that ability and the strip has no reason to exist.

Three changes make one model:

- ONE IDENTITY. The node's `uploadId` and the draft's `clientUploadId` were
  two independently minted random values, because the node is inserted before
  the handler that created the draft record runs. `uploadAndInsertFile` now
  mints the id up front and hands it to the uploader, which adopts it. Asking
  "is this upload in the document" becomes a lookup instead of an inference.

- REBUILD ON MOUNT. A placeholder is never serialised (it is not content), so
  it dies with the document that drew it and a reopened composer showed no
  trace of an upload still running. The draft record is enough to draw it
  again. Once per id per mount: a placeholder the user deleted mid-upload
  stays deleted (MUL-5181), and the guard is what stops the next store write
  from undoing that. Skipped entirely while chat pins its document to another
  draft — `uploads` follows the selected key, the document does not.

- SETTLE IN PLACE. The write-back replaces the placeholder where the user last
  saw it instead of appending the link at the end. A card promotes to an image
  when that is what arrived; the rebuild path only ever has a filename, so it
  cannot know in advance.

With that, the chips are deleted outright, along with `orphanUploads` and the
three-condition rule that approximated all of the above. `interrupted` goes
too: nothing could act on it, no surface rendered it after this change, and
`isMeaningful` counted it — one dead record kept an empty draft alive for the
full TTL. The attachment's absence from the body is the signal to re-attach.

SubmitButton's `busy` now spins rather than only greying out, so an upload
with no other on-screen trace (a composer still rebuilding, a placeholder the
user deleted) does not read as a dead control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(editor): whoever draws a placeholder registers it, not whoever finds it

Review catch on the rebuild effect. An upload started by the current mount had
its node drawn synchronously by uploadAndInsertFile, but its id only entered
`rebuiltUploadIdsRef` once the effect ran and happened to find that node. In
between, a delete (Cmd+Z right after a paste) left the effect looking at an
unmarked `uploading` record with no node — so it drew a second one, undoing a
removal MUL-5181 says must stick, and letting the settle land an attachment
the user had taken out.

The id is now registered where it is minted: an id handed into handleUpload
means the editor already drew the node. The window is sub-frame and needs a
keystroke inside one render pass, but "whoever draws it registers it" is a
rule, where "the effect will notice in time" was a race.

The composer mocks called `onUploadFile(file)` with no id, so they were not
exercising the one-id contract at all — every mount-started upload looked
inherited to the hook. They now mint and pass one like the real handle does,
which is what lets the new regression test see the difference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:08:09 +08:00
Multica Eve
81797b5a03 feat(agents): thinking level + Codex speed in the create flow (MUL-5390) (#6027)
* feat(agents): expose thinking level and Codex speed in the create flow (MUL-5390)

Creating an agent could only set runtime + model, so a Codex agent that
needed Fast had to be created and then fixed in settings — and duplicating
one silently dropped both overrides. The create API and the TS contract
already carried `thinking_level` / `service_tier`; only the creation
surface did not.

- AgentDraft carries thinkingLevel / serviceTier, rendered in the
  Execution section through the same ThinkingSettingField /
  ServiceTierSettingField the settings page uses. They stay fail-closed:
  a field appears only when the exact selected model's live catalog on an
  online runtime advertises the capability, so no value can be sent that
  the daemon would refuse.
- Payload and duplicate-draft assembly move into pure functions
  (buildCreateAgentRequest / buildDuplicateDraft); empty overrides are
  omitted instead of sent as "".
- Dependency cleanup: a runtime change clears model + thinking + speed, a
  model change clears the two per-model overrides. Applies to the plain
  form, the AI-builder setup screen, a committed builder runtime rebind,
  and a builder draft that moves the model.
- Duplicate now follows the rule `multica agent copy` already enforces:
  same runtime copies model / thinking / speed, a forced fallback to
  another runtime clears all three (it previously kept a model the target
  runtime may not serve) and says so. custom_args and concurrency are
  runtime-independent and still copied.
- Settings page: changing the model no longer leaves an orphan override.
  Only what the authoritative catalog says the new model does not support
  is cleared, and it travels with the model in one request. An unknown
  catalog (offline runtime, discovery in flight or failed), an empty
  "runtime default" model or a model missing from the catalog leaves the
  stored values untouched instead of deleting a value the daemon would
  have honoured.
- Restores the 255-rune description limit plus counter that the studio
  lost relative to the old dialog, and gates create on it since a
  duplicate or builder draft can seed a longer value programmatically.
- zh-Hans / en / ja / ko strings, including a fuller duplicate notice
  (MCP servers and machine-local runtime config are not copied either).

Out of scope, tracked separately: max_concurrent_tasks bounds on the API,
from-template parity (its UI is unreachable on main), and copying skill
enabled state / runtime-skill overrides.

Co-authored-by: multica-agent <github@multica.ai>

* fix(agents): do not seed a duplicate draft before runtimes resolve (MUL-5390)

On a cold start — a direct ?duplicate=<id> link or a refresh — the agent list
can resolve while the runtime list is still pending. The one-shot seeding
effect ran anyway, so buildDuplicateDraft judged the source runtime
unavailable against an empty list and permanently cleared the model /
thinking_level / service_tier a same-runtime copy must keep, plus showed the
fallback notice. Re-running on every runtime change would instead overwrite
edits the user already made, so the gate is on the query being decidable.

Seeding now lives in useDuplicateDraftSeed and waits for the runtime query to
resolve or fail; a failure still seeds, since an unconfirmable runtime makes
the fallback correct. Verified the new test fails without the gate.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 17:03:41 +08:00
Bohan Jiang
1018c052e9 test(daemon): extend the byte-identity guarantee to chat and the other kinds (#6033)
The MUL-5377 regression guard only covered issue runs, but chat sessions resume
too: handler/daemon.go:2172 hands the daemon a PriorSessionID from the
chat_session row, with the same PriorWorkDir and PriorSessionResumeUnavailable
plumbing as an issue task. A chat brief that varied per turn would lose the
prompt cache the same way, and a long chat is exactly where that costs most.

The three blocks that moved out of the brief (Task Initiator, Session Continuity
Notice, Connected Apps) were removed for every kind, so chat is already stable —
this locks that in rather than leaving it as an accident. The initiator variant
matters most: in a Slack-backed session a different person can trigger each
turn, which is precisely when the old brief's Task Initiator block changed.

Autopilot and quick-create are single-shot today; the invariant is free to hold
for them too and stops a future resume path from silently reintroducing the bug.

Test-only change.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 17:00:06 +08:00
Multica Eve
aa110aac9c MUL-5088: import repositories from GitHub App (#5896)
* feat(github): import repositories from app installations

Co-authored-by: multica-agent <github@multica.ai>

* fix(github): address repository import 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>
2026-07-28 16:19:35 +08:00
Naiyuan Qing
420ffe7dbc feat(diagnostics): capture the JS stack of a hung renderer (MUL-5345) (#6026)
* feat(diagnostics): capture the JS stack of a hung renderer (MUL-5345)

Route attribution shipped in 0.4.12 and did its job: hard hangs are no longer
scattered, they cluster on one page (10 of 12 hangs, 8 distinct users, spread
over 16 hours). It also hit its ceiling there. That page hosts three modes on a
single route and the mode lives in component state, so the route field cannot
say which of them froze — and a page name was never going to name the function
either. Two rounds of code-reading produced two hypotheses and both were wrong,
and one manual repro attempt covered a path the telemetry never pointed at.
Naming the code requires reading it off the stuck thread.

When the renderer hangs, the main process attaches the DevTools protocol and
asks for the stack. The channel is warmed while the renderer is healthy because
a command sent after the thread is stuck is never dispatched — measured on the
pinned Electron 39.8.7, where a post-hang attach returned nothing in 5s while a
pause on a warm channel returned the stack in 2ms with the blocking function on
top. Holding the channel open all session showed no cost beyond run-to-run
noise (A/B/A; the ordering drift between cold phases exceeded the effect).

That channel is the reason this ships behind a fail-closed server flag rather
than on by default. `desktop_hang_stack_capture` rides the existing
`/api/config` feature flags; main starts off, only an explicit `true` enables
it, and revoking it detaches the channels rather than merely skipping the next
capture. Main cannot read config itself, so the renderer forwards the one bit —
which means a config that never arrives also lands on off.

Privacy is unchanged in kind from `$exception`: four scalar fields per frame,
`scopeChain` and `this` dropped so no handle can be dereferenced into user
data, script URLs reduced to their bundle-relative tail, and a four-verb CDP
allowlist that a source-level test pins to a single callsite. Resume is
unconditional — a capture must never turn a recoverable hang into a permanent
one.

Delivery is fixed alongside, because a stack that cannot be sent is not worth
capturing: `freeze:get-last` no longer deletes on read, the report goes out
with `send_instantly`, and the breadcrumb is retired only after a grace window,
so a second hang inside that window leaves the file for the next boot instead
of taking the report with it (the MUL-4115 failure mode).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(diagnostics): close the kill switch, egress and multi-window gaps (MUL-5345)

Three review findings on #6026, all in failure paths the tests didn't reach.

The kill switch didn't reliably revoke. `coolDebuggerChannel` only detached
after `Debugger.disable` resolved, so a failed disable left the channel
attached — the exact state the switch exists to exit. Disable is a courtesy to
the renderer; detach is the contract, so it now runs in `finally`. Warming had
the mirror bug: an attach we made and could not enable returned false while
leaving the channel open, stranding a debugger on a renderer nothing tracks.
That attach is rolled back now, and only that one — a channel someone else owns
(DevTools) is left alone.

Stack frames were sanitized at capture and then forwarded verbatim at egress.
Between those two points they cross an on-disk breadcrumb that `readFreezeBreadcrumb`
barely validates, by design: it only has to survive version skew. So "sanitized
once" was not a property the flush side could rely on — an older build, a
corrupt file or a future writer could put a `scopeChain` handle or an absolute
install path in there and it would ship. Both ends now rebuild frames through
one shared whitelist, which also makes them impossible to drift apart. The url
reduction is idempotent so re-running it costs nothing.

The control flag was global, and that does not survive multiple windows. Every
renderer publishes `false` before its own config lands, so a window opened
while capture was on either never warmed (the global value never changed, so
nothing warmed the new webContents) or cooled every other window on its way up.
State is per renderer now; they converge on the same value because they read
the same config, but each on its own schedule.

Regression tests for each: detach after a throwing disable and rollback after a
throwing enable, a frame carrying `scopeChain` / `this` / an absolute path
reaching the flush side, and a second window warming while the first is already
on without revoking it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 16:05:32 +08:00
Bohan Jiang
ce45b8d353 feat(usage): rank the Errors offender list by failures or rate (MUL-5389) (#6023)
* feat(usage): rank the Errors offender list by failures or rate (MUL-5389)

The Top offenders list ranked agents by absolute failure count and sized
its bars the same way, while the loudest number on each row was the
failure rate. The three never agreed: the worst-rate agent could sit near
the bottom of the list with one of the shortest bars, and the busiest
agent was pinned to the top with a full bar regardless of how healthy it
was.

Adopt the leaderboard's contract — sort metric, bar length and the
emphasised column move together:

- A Failures / Rate segmented control above the list. Bars are scaled to
  whichever metric is active, so a bar can no longer imply a number it is
  not measuring.
- Under Rate, agents with fewer than MIN_RATE_SAMPLE terminal runs sort
  after every agent with enough runs. One run that failed is a 100% rate
  and would otherwise win outright. Those rows still render, and say why
  their rate is muted on hover — dropping them would stop the list
  reconciling with the workspace failure count above it.
- `4 / 10 · 40%` becomes labelled Failed / Runs / Rate columns.
- The bar is stacked by failure class and the dominant-class badge is
  gone. Colour now means the whole composition rather than repeating the
  badge: an agent failing one way is a solid block, one failing five ways
  is visibly striped. The bar carries that split as its accessible name.
- `By class` becomes `Failure mix · N`, so the section states its own
  denominator instead of sitting under a rate over a different one.

The failure rollups are unchanged; per-agent per-class counts were
already on the wire and were being discarded after picking a top class.

Co-authored-by: multica-agent <github@multica.ai>

* fix(usage): announce the segmented control's active option, refresh a stale comment

Review follow-ups on #6023.

`Segmented` expressed the active option only as a colour swap, so a screen
reader could not tell which metric the leaderboard or the offender list was
ranked by. Each option now carries `aria-pressed`, and the group carries a
required `label` — a naked group of toggle buttons announces "Rate, pressed"
without ever saying what it ranks. Toggle buttons rather than a radiogroup:
a radiogroup owes the user arrow-key roving focus, and these are ordinary
tab stops everywhere they appear.

`anonymizeUnresolvedAgentRows`' comment still argued that merging aggregated
rows would lose the class breakdown, which stopped being true once
AgentFailureRow started carrying the full per-class split. The reason to
rewrite raw rows is now that the bucket reaches `aggregateAgentFailures` as
an ordinary agent_id, so its totals, rate, class split and rank all come from
one code path instead of a hand-rolled second copy at the merge site.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 16:03:35 +08:00
Bohan Jiang
4eb80c8b77 fix(daemon): keep the runtime brief byte-stable across triggers (MUL-5377) (#6021)
* fix(daemon): keep the runtime brief byte-stable across triggers (MUL-5377)

Claude Code loads the runtime brief (CLAUDE.md / AGENTS.md) into messages[0],
ahead of the entire conversation. A cache breakpoint is all-or-nothing, so a
single differing byte there invalidates the prompt cache for the whole history
on every `--resume`. InjectRuntimeConfig rewrites that file on every run and
interpolated nine per-run values into it, so in practice the cache was thrown
away on the first comment that landed on any issue.

Measured on one issue over three runs: run 1 (cold) spent 89.9k cache-write
tokens building 105k of context; runs 2 and 3 each spent ~425k re-creating a
prefix they should have read. 842k of 946.5k cache-write tokens (89%) went into
re-creation, with only tools[]+system[] surviving each resume (a constant
18,085 tokens both times).

Fix: the brief now carries only what is stable for the lifetime of a resumed
session, and per-run state travels in the per-turn user message, which is
appended after the cached prefix.

- Merge kindCommentTriggered + kindAssignmentTriggered into kindIssue, and stop
  reading TriggerCommentID in classifyTask. The brief can no longer diverge by
  trigger type structurally, rather than by convention.
- Replace writeWorkflowComment/writeWorkflowAssignment with one writeWorkflowIssue
  that routes on the per-turn message. The mode-specific status rules live inside
  their own mode block, so "own the status arc" and "do not touch the status"
  can never be read as unarbitrated peers.
- Move Task Initiator, Session Continuity Notice and Connected Apps out of the
  brief into BuildPrompt via BuildTaskInitiatorBlock / SessionContinuityNotice /
  BuildConnectedAppsBlock.
- Drop TriggerCommentID, TriggerThreadID, NewCommentsSince, NewCommentCount,
  PriorSessionResumed and CommentReplyTargets from the brief; BuildPrompt already
  emitted all six from the same helpers, so this is de-duplication.
- Set PriorSessionResumeUnavailable on `task` as well as `taskCtx` in both local
  resume gates, or the notice would silently vanish on exactly the failure path
  it exists to disclose.

Tests: TestInjectRuntimeConfigByteIdenticalAcrossTriggers renders the brief
across nine per-run variants (trigger type, differing comment/thread ids, resume
delta, resume-unavailable, cross-thread fan-out, member/agent initiator,
connected apps) for two providers and requires bytes.Equal, with a non-vacuity
guard so it cannot pass on a function that ignores its input. Daemon-side tests
assert the moved sections still reach the agent through the per-turn prompt.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): route the issue workflow on an explicit turn-mode marker

Review follow-up on the mode router. The brief said Reply mode applies when
the per-turn message "opens with a [NEW COMMENT] block", but buildCommentPrompt
writes two paragraphs before that block and only emits it when
TriggerCommentContent is non-empty. Two ways to get it wrong:

- The message never literally opens with the block, so the router's own
  wording did not match the prompt it describes.
- A comment-triggered run with an empty comment body — or an older server that
  does not send one — emitted no block at all. An agent following the brief
  would fall through to Ownership mode and change the issue status on a turn
  whose rule is "do NOT change the issue status".

BuildPrompt now emits an unconditional `**Turn mode: Reply.**` /
`**Turn mode: Ownership.**` line from the same branches it uses to pick a code
path, and the brief routes on that marker. Brief and prompt can no longer
disagree about the mode, because the value that selects the path also states
it. The router also names a safe fallback (treat an unlabelled turn as Reply
mode and leave the status alone).

Tests: TestTurnModeMarkerAlwaysPresent covers comment-triggered with and
without comment content, plus both assignment shapes;
TestTurnModeMarkerAbsentOnIssuelessKinds keeps the marker off chat /
quick-create / autopilot; TestBriefModeRouterMatchesPromptMarkers fails if the
brief ever describes a marker the prompt does not emit.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 16:00:16 +08:00
Bohan Jiang
a874dc8066 feat(usage): cap the leaderboard at the top 10 agents behind a Show all toggle (MUL-5388) (#6020)
The leaderboard flattened every agent in the workspace, so a workspace
with dozens of them pushed the Errors card a full screen or more below
the fold. Rank the top 10 by the selected metric and collapse the tail
behind the same toggle the Errors card's top-offenders list already
uses; bar widths still scale to the global max so nothing re-scales on
expand. Rows become a real list so the truncated ranking exposes its
item boundaries to screen readers.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 14:39:07 +08:00
Naiyuan Qing
995ec8fcf2 feat(editor): 粘贴超长文本自动转为文本附件 (#6019)
* feat(editor): convert over-threshold pastes into a text attachment

Pasting more than PASTE_AS_FILE_THRESHOLD (4000) characters into a
turn-based composer now uploads a `pasted-text.txt` attachment instead of
writing thousands of characters into the body. Opt-in per editor: chat,
new comment, reply and comment edit pass the threshold; issue and project
descriptions deliberately do not, because there a long paste IS the
content.

The synthesised File goes through the existing upload pipeline unchanged
(nothing in it inspects a File's origin), so draft persistence, status
chips and .txt preview all come for free.

Recovery is the part that needed real design. A dropped file survives a
failed upload on disk; this text exists nowhere else — it was never
written into the document and its source tab may be closed. Since uploads
outlive their mount (MUL-5181), the editor cannot own that recovery, so
useCoordinatedUploads does: deliverPastedTextBack mirrors
deliverFinishedUpload (live editor, else the persisted body) and is the
single responder, so the live and dead cases can neither both fire nor
both be skipped. The text is restored as markdown because markdown-paste
is what would have handled it had it never been converted.

A paste inside a code block stays inline — opening a fence is the request
to show the thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(editor): pin the paste handler order against the real extension array

Both markdownPaste and fileUpload register catch-all handlePaste handlers,
so which one ProseMirror consults first decides whether paste-as-file
works at all. That ordering is not visible in either file: Tiptap reverses
the extension array before collecting plugins (@tiptap/core `get
plugins()`), and neither extension sets a priority, so the array position
in createEditorExtensions is the whole contract.

The existing tests mounted the fileUpload extension alone and could not
see it. This one builds the production array via createEditorExtensions;
swapping the two entries makes it fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 14:31:44 +08:00
Jiayuan Zhang
2274f521dc feat(issues): agents-working chip on the sub-issues header (#5825) (#5834)
* feat(issues): aggregate agents-working chip on the sub-issues header (#5825)

Add a live "N agents working" chip next to the sub-issues progress ring
in issue detail. The per-row IssueAgentActivityIndicator shows which
sub-issue is being worked; this chip shows how many agents are on the
parent's children at a glance — and keeps that signal visible while the
list is collapsed.

Derives from the shared workspace agent-task snapshot narrowed by a new
selectIssuesTasks select (structural sharing keeps unrelated snapshot
churn from re-rendering the header). Counts unique agents to match the
workspace chip, whose chip_agents_working / hover_header_queued strings
it reuses — already translated in every locale. Hover opens the shared
AgentActivityHoverContent task list.

Fixes #5825

Co-authored-by: multica-agent <github@multica.ai>

* refactor(issues): read the sub-issues chip from the working-agents projection (#5825)

The chip landed deriving its own count from the workspace agent-task
snapshot, which put a second definition of "an agent is working" in the
client. It showed up immediately: the number came from the running tasks
only while the hover body listed running plus queued, so a parent with 2
running and 3 queued agents read "2 agents working" over a five-row card.

A header count is a claim about a scope, so let the server own both the
scope and the arithmetic, exactly as the Issues list header already does.
ListWorkspaceWorkingAgents grows an optional parent_issue_id narrowing and
the chip reads /api/working-agents?type=issue&parent=<id>. The number, the
avatars and the hover body are now one list rather than three derivations,
so they cannot disagree.

Row indicators keep reading the snapshot. One shared query sliced per row
is the right shape for a per-row cue and a stale row decoration costs
nothing; a header number is the opposite, it has to be authoritative.

The new parameter is additive: omitted, the query and the response are
byte-for-byte what they were, so an installed client that never sends it
keeps the workspace-wide behaviour. A regression test pins that.

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>
2026-07-28 14:30:19 +08:00
Bohan Jiang
ba108978ac fix(channel): deliver the final answer only to Slack and Lark (MUL-5378) (#6016)
* fix(channel): deliver the final answer only to Slack and Lark (MUL-5378)

Channel replies could carry the agent's interim narration alongside its
answer (GH #6006). Two independent causes, both fixed at the layer that
owns the deliverable.

Prompt regression. #4776 told every channel-backed chat to reply with the
answer only and not narrate. The MUL-4899 split (#5557) moved that rule
into the Slack branch along with the `chat history` / `chat thread`
commands its wording happened to mention, so Feishu/Lark silently lost it
on 2026-07-17. The rule is a third axis — it keys off "is there a channel
at all", like the attachment-upload axis — so it now sits outside the
Slack gate, generalized from "these history reads" to any progress note.
The old two-layer matrix could not catch the regression because it only
asserted the rule on the Slack case; the new test pins all three states.

Runtime contract. Result.Output is documented as "final user-facing output
selected by the backend" (agent.go), but Codex concatenated every
agent_message and Copilot joined every assistant turn with "\n\n", so a
tool-using run handed the daemon narration + answer as one string. Codex
now takes the message its app-server labels phase="final_answer", falling
back to the most recent agent message on the legacy protocol; Copilot
keeps the latest complete turn, with the streaming deltas retained as the
process-died-mid-turn fallback. This narrows delivery only — every message
still streams as MessageText, so the Multica transcript is unchanged.

Claude Code, CodeBuddy and qwen already selected a terminal result and are
untouched; opencode/deveco/openclaw share the accumulating shape and want
the same audit (pi was already fixed this way in #4894).

Verified: go build ./..., go vet, full ./pkg/agent and ./internal/daemon/...
suites pass locally.

Co-authored-by: multica-agent <github@multica.ai>

* fix(channel): scope the no-narration rule to process, not results

Review follow-ups on the channel delivery rule and the Copilot turn
boundary.

The prompt said a reply "must not say what you are about to do or just
did", which literally forbids the deliverable itself: asked to create an
issue, the correct reply IS "created issue X". Rewritten to ban planned
and in-progress narration while explicitly protecting the completion
confirmation. The test now pins both halves — a future edit that drops
the carve-out, or restores the blanket past-tense ban, fails.

The example also referenced "check the code", which is not a thing an
agent does inside a Slack or Lark conversation. Replaced with a generic
"let me look into that first".

Copilot cleared pendingDelta only when the authoritative assistant.message
carried content. A tool-only turn reports content:"" with the requests as
the whole turn, so its streamed deltas stayed buffered and were stitched
onto the next turn's partial text if the process then died mid-stream —
verified: the new test yields "Checking the logs now.The retry loop"
before the fix. The reset now happens on every assistant.message, since
that event is the turn boundary regardless of whether it carries text.

Verified: go build ./..., go vet, full ./pkg/agent and ./internal/daemon/...
suites pass locally.

Co-authored-by: multica-agent <github@multica.ai>

* fix(channel): tighten the no-narration rule to one sentence

Same contract, fewer tokens: the four-line rationale comment collapses to
one, and the delivery rule drops the restatement, the second example and
the completion examples. What survives is exactly the semantic boundary
the tests pin — no planned/in-progress narration, completed actions still
count as the outcome — plus the one narration example actually observed in
the report.

Verified: go build ./..., go vet, ./internal/daemon/... suite pass locally.
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 14:28:38 +08:00
Bohan Jiang
50c89c7094 feat(autopilots): hide webhook URL token by default (MUL-5374) (#6015)
* feat(autopilots): hide webhook URL token by default (MUL-5374)

The webhook URL is a bearer credential — anyone who reads it off a screen
share or screenshot can fire the autopilot. GH #6004 reports exactly that
leak during a live demo.

The trigger row and the post-create panel now render the URL through a
shared WebhookUrlField that masks the token segment by default. Clicking
the value (or the eye toggle) reveals it; Copy keeps working while hidden,
so the common case never needs a reveal. The plaintext token is not in the
DOM until the user asks for it — this is a real display boundary, not a
CSS blur.

Co-authored-by: multica-agent <github@multica.ai>

* fix(autopilots): scope webhook URL reveal to the URL it was granted for (MUL-5374)

The reveal was a bare boolean, so a token rotation under a mounted trigger
row swapped in the new URL while `revealed` was still true — exposing the
new credential in the clear at the exact moment the user rotated to contain
a leak.

Track which URL the reveal was granted for and derive `revealed` during
render. Deriving it (rather than resetting in an effect) means the new
token never reaches the DOM, not even for the pre-effect frame.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 14:10:42 +08:00
Bohan Jiang
c271f80999 MUL-5370 fix: label stalled skill-bundle downloads, align failure-reason copy with the backend taxonomy (#6001)
* fix(daemon): label stalled skill-bundle downloads and make them retryable

A skill bundle that could not be downloaded during task preparation surfaced
as the bare string "resolve skill bundles: context deadline exceeded".
taskfailure.Classify has no rule for a Go context deadline, so it landed in
agent_error.unknown — a bucket that is NOT on the server's retry allowlist.
A transient stall therefore became a terminal chat failure carrying a label
nobody could act on, and the failure was invisible on the Usage page's Errors
breakdown. (MUL-5370)

- Add the platform-side reason skill_bundle_unavailable and put it on
  retryableReasons. Retrying is cheap and safe: the agent process never
  started, and bundles that did arrive are already cached on disk, so
  successive attempts converge.
- Carry a sentinel error from the resolve loop so the reason is derived
  structurally rather than by matching the wrapped transport error's text,
  and name the skill, its declared size and the elapsed wait in the wrap —
  enough to tell "this bundle is too big for the link" from "the link is
  dead" without reading daemon logs.
- Normalise the wire shape an OLD daemon produces (a non-empty catchall plus
  the previous "resolve skill bundles:" wrapper) on the server side. Installed
  daemons upgrade on their own cadence, and FailTask only classifies when the
  caller supplied nothing, so without this the fix would reach only hosts that
  happened to update — while the un-upgraded hosts most likely to be hitting
  the bug kept failing terminally.
- Teach Classify about "deadline exceeded" and net/http's "Client.Timeout
  exceeded while awaiting" so any other Go-side deadline that reaches it as
  text stops falling into the unknown bucket too.
- Backfill historical rows in both agent_task_queue and chat_message. Scoped
  to agent_error.unknown alone — the old wrapper string postdates the
  in-flight classifier by three weeks, so no row carrying it can hold the
  legacy coarse value — which keeps the down migration an exact inverse.

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): give chat its own failure copy for the refined reasons

#5991 rebuilt the operator-facing failure labels around an open wire string
with a raw-value fallback, but the chat bubble kept its own exact-key lookup
against the six coarse values from migration 055. So all 14 agent_error.*
values still missed and rendered the generic "Something went wrong and the
agent couldn't finish replying" — the classification the backend had already
computed was discarded at the last step, and that is the message the MUL-5370
reporter saw.

- Add resolveFailureReasonKey in packages/core: exact match, else degrade an
  `agent_error.*` value to its family, else undefined. A reason newer than the
  shipped client now lands on the family line instead of the fallback.
- Rekey the chat copy map by wire value and route it through the helper.
  Chat deliberately degrades to friendly copy rather than adopting the
  operator surfaces' raw-value fallback: it is read by the person who just
  sent a message, and the raw error is one click away under the collapsible.
- Add refined chat copy (en / zh-Hans / ja / ko) only where it can say
  something the family line can't — a different next step: network, auth,
  quota, rate limit, context overflow, missing/outdated CLI, skill download.
- Give skill_bundle_unavailable a label on the web and mobile surfaces and a
  class on the Usage page's Errors breakdown (runtime — the operator response
  is "check the daemon's link to Multica", the provider is not involved).
- Mobile's two label maps were still coarse-only for the same reason; rekey
  them by wire value and fill in the refined taxonomy.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 13:31:29 +08:00
Naiyuan Qing
b5f19adbab feat(composer): post-send caret policy per surface (afterAccepted) (#6014)
* feat(composer): post-send caret policy per surface (afterAccepted)

Where the caret goes after a send was hand-rolled per composer: chat blurred,
quick-create hand-wrote its own requestAnimationFrame focus, and comment/reply
did nothing at all. The mechanics are identical everywhere and easy to get
wrong (must run after the clear, must survive a dialog focus trap, must not
steal focus the user moved elsewhere mid-flight), so they now live once in the
shared send contract.

`useComposerSubmit` gains `afterAccepted` ("refocus" | "blur" | "none",
default "none") plus an optional `containerRef` that bounds focus reclaim to
the composer that sent. The mode may be a function so a surface can decide at
accept time — chat only reclaims focus when the commit actually scrubbed the
shared editor, never when a fire-and-forget send left another session's draft
on screen.

Per-surface policy:
- Chat refocuses (one file, three mount points: chat page, floating window,
  agent creation studio). Replaces the deliberate blur.
- Thread replies refocus — the user is mid-conversation.
- A top-level comment blurs, and IssueDetail reveals what was posted instead:
  submitComment now returns the created id, and the page scrolls to that row
  and flashes it with the same jumpToThread the inbox deep-link uses.
- Quick create's keep-open mode drops its hand-written rAF for the option.
- Inline comment edit and Create Issue keep "none": both close on save.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(composer): drop the scroll-to-posted-comment reveal

The top-level composer is sticky at the bottom of the timeline, so a comment
posted from it lands directly above the box and is almost always already on
screen — the scroll was a no-op and the flash re-announced something the user
had just deliberately done. The flash earns its keep for inbox deep-links,
where the user did not choose the landing spot.

`submitComment` goes back to returning a boolean; it only carried the created
id to feed the reveal. The composer still blurs after posting: the turn is
over, so the caret is dropped rather than kept.

The shared contract is untouched — `afterAccepted` never knew about comments,
which is why removing this costs nothing outside IssueDetail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(composer): drop stale references to the removed comment reveal

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(composer): bind the post-send caret policy to an actually-cleared editor

Review blocker: CommentInput passed a literal `afterAccepted: "blur"`, but its
stale-submit guard declines to clear when the user typed during the request.
Posting comment A on a slow connection while typing comment B therefore
dropped the caret out of B mid-sentence — the guard kept the text, and the
blur fired anyway.

Both issue composers now resolve the mode from a ref set only on the branch
that really wipes the editor, matching what ChatInput and quick-create already
do. ReplyInput gets the same treatment: refocusing a box the user is typing in
happens to be harmless, but leaving one surface on the unsafe shape invites the
next one to copy it.

The rule is now stated on the option itself: a surface whose `onAccepted` can
decline to clear must pass a function and resolve to "none" on those paths.

Both regressions are mutation-verified — reverting either binding fails the new
assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:15:43 +08:00
beast
077ac8acc9 refactor(channel): claim media ledger rows one at a time (MUL-5367) (#5993)
The reconciler claimed a batch of ledger rows under one lease and settled them
serially, so a tail row could expire and be reclaimed before its own DELETE was
ever tried — inflating attempt/backoff for work that never happened, and
delaying the row's first real attempt.

Rows are now claimed one at a time, immediately before the work each claim
authorizes, so attempt counts attempts. The per-row lease heartbeat is gone:
the lease only has to cover one row's settle (30s delete timeout << 2m lease)
and every settle write is already lease-token guarded.

Migration 232 adds an index on next_attempt_at: migration 230's index leads
with state and cannot serve the claim's cross-state ordering, so under backlog
every claim in a sweep paid a full seq scan plus an external merge sort.

Shutdown that lands mid-settle now stays quiet — the row keeps its lease and is
reclaimed after expiry, like any interrupted worker.
2026-07-28 13:10:34 +08:00
Naiyuan Qing
77b309a5ac feat(drafts): unified draft lifecycle + upload ownership inversion (MUL-5181) (#5900)
* feat(drafts): unified draft lifecycle + upload ownership inversion (MUL-5181)

Unify how every composer preserves unsent work, sends, and handles uploads.

L1 foundation (packages/core/drafts):
- createDraftStore factory + self-registering cleanup-registry replacing the
  hand-maintained WORKSPACE_SCOPED_KEYS list; register-all-drafts guarantees
  registration completeness. Fixes the confirmed cross-user draft leak
  (persistence + in-memory) on logout / workspace delete.

L3 send paradigm:
- useComposerSubmit: one await-then-render contract (lock/spin, keep-on-fail,
  clear-on-success, single-flight, submit-time upload-gate), adopted by
  comment/reply/edit, create-issue, quick-create, and chat.

Per-surface:
- Comment/Reply/Edit: attachments moved into the persisted draft.
- Create Issue: draft split into shared/manual/agent/activeMode with
  non-destructive mode switching + migration for old flat drafts.
- Chat: optimistic send converted to await-then-render (kept server-driven
  cancel restore_to_input); chat draft keys registered for cleanup.

L2 upload coordinator (ownership inversion, Linear-validated shape):
- upload-coordinator + DraftUpload placeholder: uploads owned by a module
  coordinator that outlives the component, state persisted in the draft;
  AbortController + abort-on-logout; interrupted-on-reload. Comment surface
  fully wired. Create-issue/chat upload wiring is a documented residual.

Verified: core + views typecheck clean; core 1064 + views 2928 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(drafts): close three review gaps in the unified draft lifecycle (MUL-5181)

1. Logout resurrection: reset in-memory draft stores BEFORE removing their
   persisted keys — each reset is a setState and persist writes it straight
   back under the still-active slug, so the old order re-created the deleted
   keys. The issue draft store's reset is now a full reset including
   lastAssignee, which clearDraft deliberately re-seeds and would otherwise
   hand the previous user's last-picked assignee to the next login.

2. Submit gate blind spot: the composer gate now also reads the draft's
   coordinator-owned upload placeholders (hasUploadingDraft). A composer
   reopened over a still-in-flight upload could previously send past the
   editor-only gate, clearing the draft out from under the settling upload.

3. Attachment binding returns to reference-filtering: a submit binds only
   uploads the body references, so deleting an inline image really unbinds
   it. An upload that settles after its mount died gets its markdown link
   written back into the body instead — via the reopened composer's live
   editor (new ContentEditorRef.insertMarkdownAtEnd) or appended to the
   persisted draft (new appendToDraftContent) — so close-surviving files
   stay visible, deletable, and honestly bound.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): harden upload write-back delivery after independent review

Review of the previous commit (fresh-context reviewer + probe against real
@tiptap/react) found the write-back could still lose a file:

- insertMarkdownAtEnd now returns a boolean: the imperative handle exists
  from first commit but the Tiptap instance arrives in a passive effect, so
  an insert in that window (or after destroy) no-ops. Callers previously
  assumed it landed.
- Write-back is now confirmed delivery (deliverFinishedUpload): insert into
  the live editor and, on success, persist the same body as insurance
  against the debounced emit being dropped by a quick unmount; append to
  the store only when NO composer is mounted (a mounted editor's first emit
  would erase a store-only append); retry while a mounted composer's
  instance is still warming up. Every attempt re-checks the generation
  guard and the body reference.
- mountedRef flips in a layout effect: React nulls the child editor ref in
  the unmount commit, and a settle in the gap before passive cleanup saw
  "mounted" with no editor left to swap.
- uploadAndInsertFile guards editor.isDestroyed after the await: now that
  uploads outlive mounts, the swap/remove paths could dispatch against a
  destroyed EditorView and escape as an unhandled rejection.
- Tests: the reopened-composer test now asserts the editor actually
  received the insert (it previously passed with liveEditors disabled),
  plus a warming-up retry case; the mock editor mirrors isDestroyed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(drafts): roll coordinated uploads out to issue-create and chat (MUL-5181 L2)

Completes the upload-ownership layer for every composer surface. The generic
engine is extracted from the comment implementation into
editor/use-coordinated-uploads (UploadDraftBinding adapter: store-backed
accessors + registry key + body append), and use-comment-uploads becomes a
thin binding over it — behavior unchanged, all comment tests green.

Issue-create (manual + agent panels):
- shared.attachments migrates Attachment[] -> DraftUpload[]; load normalizes
  legacy bare rows to `uploaded` and coerces stale `uploading` to
  `interrupted`.
- Uploads are coordinator-owned: placeholder at pick time, survives dialog
  close, aborts on logout, chips for uploading/failed/interrupted, combined
  gate on Create and both mode-switch actions.
- Write-back targets the body of the MODE that started the upload (manual
  description vs agent prompt); mount-time prune keeps placeholders and drops
  only unreferenced `uploaded` entries.

Chat (tab + floating window):
- inputDraftAttachments migrates to DraftUpload[] with load-time
  normalization; new store ops (add/settle/fail/remove upload, append-to-
  draft) mirror the comment store.
- ChatInput adopts the engine; the upload target is snapshotted at pick time
  via resolveUploadTarget so a file dropped while the editor is pinned to a
  previous session's document files under THAT draft.
- uploadMapRef is gone — the draft's uploads are the single binding source,
  reference-filtered at send. Hosts no longer own transport: onUploadFile
  prop becomes uploadEnabled, and the controller/window drop uploadWithToast.
- commitDraft prunes only `uploaded` entries the body no longer references;
  placeholders survive keystrokes (chips are their only UI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): harden L2 rollout after independent review

- attachmentToDraftUpload now strips the response-scoped signed download_url
  before the row is persisted (draft uploads survive restarts; a stale
  signature 403s the preview on reopen). Covers comments, issue-create, and
  chat in one place; issue-create's settle reuses the helper, and the
  Signature assertion the rollout had dropped is restored.
- chat's live-editor registry follows the LOADED draft key (reactive mirror
  of editorDraftKeyRef): a settle for draft B must not insert into an editor
  still pinned to draft A's document.
- removeUpload aborts an in-flight request before dropping its placeholder.
- issue-create hasDraft counts only uploaded/uploading entries so a failed
  remnant can't pin the sidebar draft dot forever.
- Tests: mutation-proof coverage for the two placeholder-preservation rules
  (create-issue mount prune, chat commitDraft prune) — both previously
  survived rule inversion; direct core tests for the five new chat store
  upload ops incl. persistence and signed-URL stripping; quick-create test
  gets the editor i18n namespace; dead uploadWithToast scaffolding removed
  from both modal tests; chat-input mock aligned with the real append
  semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): close third-round review gaps in the upload engine

- The live-editor registry registers in a layout effect: chat's adopt swaps
  the editor's document and loaded key synchronously during commit, and a
  passive re-registration one task later left a settle window where the old
  key mapped to an editor already holding another draft's document. The
  registry key is also built only when a binding exists.
- removeUpload aborts only a request THIS surface tracks as `uploading`
  (guarded before the abort), with the comment now honest about the path
  being defensive — no current chip exposes ✕ mid-upload.
- Mutation-proof test for the loaded-key registry rule: a dead mount's
  settle for a pinned draft must insert into the editor HOLDING it, not the
  selected one (verified to fail with the registry keyed by selection).
- hasDraft upload semantics pinned by tests (uploaded/uploading count;
  failed/interrupted remnants don't pin the sidebar dot).
- Dead scaffolding dropped: identity use-file-upload mocks and a redundant
  assertion in the modal tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): stale-submit draft guard + registry layout timing (review BLOCKED items)

Blocker 1 — a submit that outlives its composer may only consume the draft
it submitted (MUL-5181 P0). Every accepted-submit clear is now guarded:
- create-issue / quick-create snapshot the singleton draft's object identity
  at submit; a dead panel clears (and records last-assignee/mode) only if
  the draft is untouched, and never runs close/reset effects. A replaced
  draft B typed after close survives a late success of draft A.
- comment / reply / edit snapshot the per-key draft entry; a dead composer
  clears only the exact entry it submitted.
- chat snapshots the sent slot's value; a dead mount's commitInput clears
  only an unreplaced draft.
Mutation-verified tests for the create panels and comments (guard inverted
=> tests fail), plus untouched-draft control cases.

Blocker 2 — the live-editor registry is now genuinely registered in a
layout effect. The prior commit claimed this fix but a test-time
`git checkout --` discarded the unstaged engine edits before committing;
re-applied: layout registration, binding-gated registry key, and the
tracked-only abort in removeUpload. New registry timing test captures the
registry from a parent layout effect across a key switch — verified to
fail with passive registration.

Also: `multica:chat:selectedProjectId` joins the workspace-scoped cleanup
list (was leaking across logout; flagged as a pre-existing risk).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): mounted submits also clear only the draft they submitted

The stale-submit snapshot guard previously protected only dead composers;
a mounted one cleared unconditionally on success. But the editor stays
interactive during a request (Tiptap cannot toggle editable post-mount), so
text typed while draft A was in flight was wiped by A's success. The guard
is now unconditional across every surface: success consumes exactly the
submitted snapshot, and any later edit survives.

- create-issue / quick-create: the editor's pending debounce is flushed into
  the store BEFORE snapshotting (a late flush of pre-submit typing must not
  read as a mid-flight edit); a touched draft skips clear AND close/reset —
  the dialog stays open on the newer work. Untouched behavior unchanged.
- comment / reply / edit: same flush + snapshot; a touched entry keeps both
  the store draft and the editor content (edit mode stays open on it).
- chat: commitInput's value compare now applies while mounted too, and the
  editor is scrubbed only for an untouched draft.
- use-composer-submit docs no longer claim "editor locked": they state the
  real contract — send affordance locks, edits after submit survive.

Regression tests: mounted mid-flight-edit cases for manual create (incl.
"dialog must not close over draft B"), quick create, comment, and chat,
plus mounted-untouched controls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drafts): idempotent draft writes so a tab switch cannot resurrect a posted comment

Final-review blocker: the comment/reply visibilitychange/pagehide flush
re-writes IDENTICAL content on every tab switch, and writeDraft minted a
new entry object each call — the stale-submit guard's identity compare then
read a mid-flight tab switch as "edited during the request", kept the
posted comment's draft alive, and left Send enabled for a duplicate.

- writeDraft is now a no-op when content and uploads are unchanged (also
  kills a spurious persist write per tab switch). Regression tests: entry
  identity preserved on identical setDraft (core), and the reproduced
  tab-switch-mid-send scenario clears the posted draft (views) — verified
  to fail with the idempotence removed.
- onAccepted now flushes the editor's pending debounce before judging
  `untouched` on every surface, so typing still inside the debounce window
  counts as a mid-flight edit instead of being scrubbed.
- create-issue records last-assignee/mode from the SUBMITTED values,
  outside the untouched gate — a created issue updates the preference even
  when the dialog stays open on newer edits.
- Stale guard comments corrected in both create panels; the
  use-composer-submit docstring no longer claims project/feedback were
  migrated (they still hand-roll await-then-clear; registered debt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 10:10:00 +08:00
Bohan Jiang
f1cb726d7c fix(usage): move the Errors card to the page bottom and rebalance its layout (#6003)
Two problems with the card as shipped, both visible on real data (283
failures across 28 agents):

1. It sat between the trend chart and the leaderboard. Spend is the headline
   of this page; failures are the follow-up question you ask after seeing who
   is spending. Moved below the leaderboard.

2. The two-column split put a 7-row list next to an unbounded one. In
   practice that was 7 rows of classes beside 28 rows of agents — roughly
   400px of content next to 1500px, so the left half was mostly whitespace
   and the card was taller than the rest of the page combined.

Rebalanced by stacking two full-width sections instead:

- Class breakdown is now a single 100%-stacked bar plus a legend, replacing
  seven individual progress bars. The question is "what is the mix", and one
  bar answers it directly instead of making the reader compare seven lengths
  and total them mentally. ~340px becomes ~70px.
- Offender rows fold onto one line, borrowing the leaderboard's grid shape
  (identity, bar, numbers) instead of stacking a full-width bar under each
  name. Halves the per-row height and lines the numbers into a scannable
  column.
- The list caps at 8 with a "Show all N" toggle. It is ranked by absolute
  failure count, so the tail is agents that failed once or twice — real, but
  not what anyone opens this card to see. The toggle label carries the full
  count, so the cap is never silent.
- The raw error-code list gets two columns on wide viewports now that the
  section has the full page width.

Card height on the screenshot's data drops from ~1500px to ~420px.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 19:18:47 +08:00
Bohan Jiang
4d0475ce89 feat(usage): error/failure charts on the Usage page (MUL-5352) (#5991)
* feat(usage): add error/failure visibility to the Usage dashboard

The Usage page could only answer "how much did we spend"; nothing on it
showed how often agents fail, what kind of failure it was, or which agent
is responsible. Operators had to open failed tasks one at a time to spot a
pattern.

`agent_task_queue.failure_reason` already carries the refined 21-value
taxonomy from server/pkg/taskfailure, so this is a read path over data that
already exists.

Backend — two rollups, both scoped by workspace/project/window like the
existing dashboard endpoints:

  GET /api/dashboard/failures/daily     per-(date, failure_reason)
  GET /api/dashboard/failures/by-agent  per-(agent, failure_reason)

They return every terminal task, not just failures: the `failure_reason: ""`
row carries the succeeded count. That is what makes the error rate's
denominator share filters with its numerator. The run-time rollups can't
serve as that denominator — they require `started_at IS NOT NULL`, so a task
that expired in the queue (the signature of a runtime outage) contributes
nothing to their failed_count. A failed row with an empty reason column
lands in an `unclassified` bucket rather than being mistaken for a success.

Frontend:
- "Errors" joins the trend toggle, daily and weekly, stacked by failure
  class with the bucket's error rate in the tooltip.
- An Errors card breaks the window down by class and by agent, with the raw
  failure_reason strings behind a disclosure (unlocalised — an operator
  pastes them into a log search). Each agent row links to its Work tab,
  which lists the actual failed runs.
- The 21 backend reasons fold into 7 display classes in
  @multica/core/dashboard. Unknown reasons — including ones from a backend
  newer than the client — land in "other" instead of being dropped, so the
  class totals always reconcile with the failure count.

The Tasks KPI tile is deliberately left alone: its value counts started
tasks only, so quoting the failure rollup's larger count there would put two
denominators in one tile. The Errors card states its rate with the
denominator spelled out instead.

Migration 225 adds a partial index on agent_task_queue(completed_at) for
terminal statuses. The table had no completed_at index at all, so the two
pre-existing run-time rollups were already scanning it; these two new
queries would have doubled that.

Closes #4429 (MUL-5352)

Co-authored-by: multica-agent <github@multica.ai>

* fix(usage): correct the Errors drill-down, window and agent exposure

Review findings on PR #5991.

1. The drill-down pointed at the wrong page. `?view=work` renders
   ActorIssuesPanel — the issues assigned to the agent — while its runs live
   in the Overview pane's ActivityTab. Link to Overview.

   That page also could not show why a run failed: `failureReasonLabel` was a
   `Record<TaskFailureReason, string>` indexed with a cast to the old 6-value
   coarse enum, so every refined reason the backend has written since
   MUL-1949 resolved to `undefined`. It is now a function over the full
   21-value taxonomy plus the legacy coarse values, falling back to the raw
   wire string for anything newer than the client. Fixes the issue execution
   log too, which had the same cast.

2. The Errors card covered one more calendar day than the chart above it.
   `parseSinceParamInTZ` returns N+1 days of headroom on purpose and the
   dashboard trims the surplus client-side — but only a series carrying a
   date can be trimmed that way. Totals / classes / reasons now derive from
   the date-bucketed rollup after that trim, and the per-agent rollup (which
   has no date to trim on) closes its window server-side via a new
   `parseExactSinceParamInTZ`. At days=1 the card previously reported
   yesterday's failures beside a chart showing none.

3. The top-offenders list leaked agents the viewer cannot see. The failure
   rollups are workspace-scoped and deliberately skip per-agent visibility,
   but the agent list they are joined against does not — members only see a
   private agent when they own it or are owner/admin. `name ?? row.agentId`
   therefore rendered a bare UUID along with that agent's failure count,
   rate and dominant error class. Unresolvable agents now fold into one
   anonymous row, and the renderer never falls back to an id. Stricter than
   `bucketUnknownAgentRows` while the agent list loads: a transient flash of
   UUIDs is the leak, not a cosmetic glitch.

Also from the review: the Errors tooltip echoed the raw Recharts dataKey
("rate_limit") instead of the translated label the legend already carries.

Not changed — the schema's `failure_reason` default stays `""`. Defaulting a
missing field to a failure bucket guards against a deflated rate, but the
realistic drift is `omitempty` on the Go struct tag, which would strip the
field from exactly the SUCCESS rows and read as a 100% error rate. Added
TestDashboardFailureWireContractKeepsEmptyReason to pin that the server
always emits the field, which is the assumption the default rests on.

Co-authored-by: multica-agent <github@multica.ai>

* fix(usage): renumber migration and fix the anonymous bucket's failure class

Review findings on PR #5991, round 2.

1. Migration prefix 225 collided with `225_chat_message_channel_media_pending`,
   which landed on main while this branch was open — backend CI failed on
   TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Merged main and
   renumbered to 231; main now carries 225 through 230, so 226 is taken too.

2. The anonymous "Other agents" bucket could announce the wrong failure class.
   It merged rows that had ALREADY collapsed to one dominant class per agent,
   then credited each agent's entire failure count to that class. An agent
   failing auth 6 / timeout 5 contributed 11 to auth and 0 to timeout, so a
   bucket whose real composition was timeout 15 / auth 6 rendered as Auth.

   Fixed by anonymizing the raw per-(agent, reason) rows instead: the sentinel
   becomes just another agent_id and `aggregateAgentFailures` computes its
   classes from real counts. That also deletes the parallel bucketing pass —
   one identity rewrite replaces it. `knownAgentIds` moves up to where both
   consumers can see it.

Also from the review:
- The wire-contract test decoded both payloads into one map. json.Unmarshal
  merges into a non-nil map rather than resetting it, so a residual
  failure_reason from the first case could have masked an omitempty
  regression in the second — exactly what the test is meant to catch. Now
  table-driven with a fresh map per case.
- A test comment still described the drill-down as pointing at the Work tab.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 18:40:48 +08:00
Bohan Jiang
7d2d745d9d fix(daemon): probe built-in agent CLIs once per registration batch (MUL-5225) (#5842)
Built-in agent CLIs are a machine-level fact, but runtime registration is
per-workspace, and every registration re-ran `<cli> --version` for every
configured agent. A daemon serving N workspaces spawned N×M probe processes at
startup instead of M (24 workspaces × 5 agents = 120 instead of 5).

- Detect the built-in payload once per workspace-sync batch and reuse it for
  every workspace that sync registers. Nothing is cached on the Daemon, so
  standalone re-registrations still re-probe and an in-place CLI upgrade is
  still reported with its current version. The probe is lazy: a steady-state
  sync with nothing to register never shells out.
- Retry a failed provider's probe once inside the round before dropping it,
  so one transient failure can't cost every workspace in the batch that
  runtime. Only failed providers retry, so the round stays O(M).
- Time each attempt across resolve + detect, so a slow probe inside the
  MUL-4486 self-heal is not misread as a fast failure and retried.

Fixes #5837

Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 18:39:57 +08:00
Bohan Jiang
ceba14a227 fix(issues): MUL-5362 return to the source list after deleting an issue (#5997)
* fix(issues): return to the source list after deleting an issue

Deleting an issue from its detail page always pushed the workspace
Issues list, so opening an issue from My Issues (or a project list,
search, a pin, an agent panel) and deleting it dropped the user's
navigation context — GH #5995.

Go back instead. `useBackOrReplace` steps back when the platform
reports in-app history and replaces with a fallback path when there
is none, so a shared link opened cold or a new tab never steps off
the app. Web answers via the Navigation API, falling back to counting
its own pushes; desktop reads the active tab's virtual history.

`replace`, not `push`: the deleted issue's URL must not stay in
history for the back button to land on a 404.

The not-found "Back to Issues" button loses the same context, so it
moves to the same helper and its label becomes a plain "Back".

Co-authored-by: multica-agent <github@multica.ai>

* fix(web): track history position, not push count, for canGoBack

The Navigation API fallback only ever counted pushes, so `pushes > 0`
did not mean the current entry still had an in-app page behind it.
Cold-open an issue, click Issues, press the browser's Back button, then
delete: the tracker still claimed history and `back()` would step off
Multica — the exact case the fallback exists to prevent.

Count depth instead: a push adds one, any traversal takes one away
(clamped at zero). Reaching the document's first entry requires
traversing back at least as many times as we pushed, so a positive
depth can never be claimed while sitting on it. A browser Forward is
now conservative — it reports no history where a step back would have
been fine — which costs a fallback navigation rather than an exit.

Also corrects the useBackOrReplace contract comment: stepping back
leaves the dead URL in forward history, so the guarantee is that it
never lands on the back stack, not that it leaves history entirely.

Co-authored-by: multica-agent <github@multica.ai>

* fix(web): answer canGoBack from the browser alone, never from push counts

Review found the counter still lied, one level deeper: it counted
`router.push` calls, and a call is not a committed history entry. Next
drops a push to `replaceState` when the canonical URL is unchanged
(app-router.js, the `pendingPush && href !== canonicalUrl` branch), so
clicking a self-link — the breadcrumb on an issue detail page, a pin to
the issue you are on — incremented depth with no entry behind it, and a
delete from there could still step off the app.

Fixing the count needs per-entry markers, which means depending on both
React effect ordering (our provider's effect runs before app-router's
history commit) and Next's own preserveCustomHistoryState behaviour.
Two rounds of review have now found holes in hand-rolled history
tracking; a third layer of it is not the way to buy this guarantee.

So stop deriving. `canGoBack` is the Navigation API's answer or `false`.
Browsers without it take the fallback path, which is exactly what they
did before any of this existed — nobody regresses, and the "wrong true
walks the user out of the app" failure is now unreachable by
construction.

Drops the tracker, the popstate wiring and the push wrapper: the
`multica:navigate` bridge returns to its original shape.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 18:39:25 +08:00
Multica Eve
e52b50658a docs(changelog): add v0.4.12 release entry (2026-07-27) (MUL-5364) (#5996)
* docs(changelog): add v0.4.12 release entry (2026-07-27) (MUL-5364)

Co-authored-by: multica-agent <github@multica.ai>

* docs(changelog): move PR-card live status from v0.4.11 to v0.4.12 (never enabled in prod) (MUL-5364)

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.12
2026-07-27 17:36:27 +08:00
Naiyuan Qing
38b08acf00 feat(diagnostics): report which page a desktop hang happened on (MUL-5345) (#5989)
* feat(diagnostics): attribute desktop hangs to route, function and stack (MUL-5345)

A desktop hang currently reports "froze for 8s" and nothing else, so MUL-5345
could not be diagnosed at all. Three gaps, all fixed here.

Route attribution was silently dead. The main window's route reporting lived in
the PostHog pageview tracker and was deleted with it (MUL-4127), leaving
`getDiagnosticContext` in main reading a WeakMap nothing ever wrote — every
field report carried only the asar index.html URL. `DiagnosticRouteReporter`
restores the push, and now feeds the in-renderer watchdog too: the renderer runs
a memory router, so `location.pathname` could never identify the page either.
Paths are bucketed to templates (`/:slug/issues/:id`) before publishing.

Function attribution did not exist. The watchdog now prefers
`long-animation-frame` over `longtask` where supported, which carries per-script
`sourceFunctionName` / `sourceURL` / `sourceCharPosition`. That covers hangs the
thread survives. For hangs it does not, main captures the JS call stack over CDP
— which requires the Debugger channel to be warmed at window creation, because a
command sent after the thread is stuck never gets dispatched. Commands go
through a four-verb allowlist, only scalar code locations are copied out of the
paused frames (never `scopeChain`, whose handles dereference into user data),
and resume is unconditional so a capture can never turn a recoverable hang into
a permanent one.

Reports could also be lost before delivery. `freeze:get-last` no longer deletes;
the renderer sends with `send_instantly` and acks by exact timestamp, so a
report killed by a second hang is retried next boot instead of vanishing with
the file (the MUL-4115 failure mode: three deterministic hangs, zero events). A
7-day TTL keeps an undeliverable breadcrumb from becoming permanent boot noise.

Operations name themselves: `parseMarkdownChunked` marks the diagnostic context
before it runs, and the mark travels to main over the async IPC channel that
still lands after the main thread stops responding. The event carries how stale
the mark is, so it reads as context rather than as a cause.

Verified on Electron 39.8.7 / Chromium 142 (throwaway spike, not committed):
Debugger.pause during a 12s synchronous block returned the stack in 2ms with the
blocking function on top; holding the channel open all session showed no cost
beyond run-to-run noise (A/B/A, ordering drift larger than the effect); DevTools
and the channel coexist in both open orders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(diagnostics): close the freeze report ack race and stop shipping raw ids (MUL-5345)

Two review findings on #5989.

Acking on hand-off was not acking on delivery. `onCaptured` fires when
posthog.capture() returns; the request is still in flight, and posthog-js
exposes no delivery callback to wait on (`CaptureOptions` has `send_instantly`
and `transport`, nothing else). Deleting the breadcrumb there loses the report
whenever the app freezes again or is killed in that gap — the same MUL-4115
failure the ack protocol was added to prevent. The flush now waits out a grace
window before acking: if the process dies inside it the timer never fires, the
file survives, and the next boot retries. Duplicates are the accepted trade and
are trivially deduped on `breadcrumb_ts`; a lost report is not.

Raw identifiers were reaching telemetry. The breadcrumb context was spread
wholesale into the event props, so `workspaceSlug`, `tabId` and the absolute
`windowUrl` shipped with every report despite the stated "bucketed path only"
constraint. Fixed at both ends: the slug and tab id are no longer put into the
route context at all (nothing else read them), the sanitizer constructs its
result explicitly so a stale renderer's payload can't reintroduce them,
`windowUrl` is dropped since it is an install path that can carry the OS
username and the bucketed route already says which page it was, and the event
props are now assembled field by field so a future context key cannot ship
itself.

The flush moved into `freeze-flush.ts` to make both behaviours testable:
`onCaptured` does not ack, the grace window does, a cancelled window keeps the
breadcrumb, and props built from a context still carrying slug/tabId/windowUrl
contain none of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* refactor(diagnostics): reduce MUL-5345 to route attribution only

Scope call from product review: the next hang should answer "which page", and
nothing more. Removes the CDP stack capture, the long-animation-frame observer,
the operation breadcrumb, and the read/ack delivery protocol added earlier on
this branch, along with the spike-derived build note. What stays is the smallest
change that gives the two existing hang events a real route.

The route reporting had been dead since MUL-4127 (#4996) deleted it along with
the PostHog pageview tracker: `getDiagnosticContext` in main kept reading a
WeakMap nothing wrote, so a hang report carried only the asar index.html URL.
`DiagnosticRouteReporter` restores the push to main — the only party alive
during a true hang, which cannot ask a blocked renderer anything — and also
publishes to the in-renderer watchdog, whose `location.pathname` is that same
useless packaged path because the shell runs a memory router.

Paths are bucketed to templates (`/:slug/issues/:id`) before publishing, and
the workspace slug and tab id are not sent at all; nothing outside diagnostics
read them. The sanitizer constructs its result explicitly so a renderer older
than this build cannot reintroduce them, `windowUrl` is dropped because it is an
install path that can carry the OS username, and the breadcrumb event props are
assembled by whitelist rather than by spreading the context.

Both hang events now report `path` under the same name, so they group in one
query.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(diagnostics): bucket hang routes by known structure, not id shape (MUL-5345)

The bucketer guessed which segments were ids by looking at them — UUID, issue
key, or all digits. Every id that does not look like one therefore travelled to
telemetry intact, and most of ours do not: project, autopilot, agent, member,
squad, runtime, skill and attachment ids are arbitrary strings from
`paths.ts`. `/acme/projects/p1` bucketed to `/:slug/projects/p1`, and
`/acme/runtimes/machine%2Fruntime/runtime/runtime%20one` came through
completely unchanged.

It now matches structurally against the known route shapes, so a `:param` slot
is whatever occupies that position regardless of how it is spelled or encoded.
Where several patterns fit, the most literal one wins, which keeps `agents/new`
the create page rather than an agent whose id happens to be "new".

An unmatched path is masked (`/:slug/issues/*`, `/:slug/*`) rather than passed
through: a route we do not know is exactly the case where an id cannot be told
from a page name, so nothing from it travels. That makes a route added to
paths.ts without being added here a loss of detail instead of a leak.

To stop the list falling behind quietly, a parity test walks the real path
builders — not a copy of them — and asserts that no builder leaks its slug or
ids and that none falls back to the mask. Removing a single route from the
table fails it with the builder named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:20:23 +08:00
Naiyuan Qing
c7fe10e549 fix(issues): move the thread quick-jump rail to the right edge (MUL-4522) (#5990)
The rail looks and behaves like a scroll affordance, but sat on the far
left: on a wide screen it was hundreds of pixels from both the body text
and the scrollbar the pointer was already on, so every jump cost a full
sweep across the page.

Move it to the right edge, just inside the scrollbar. right-3 plus a
20px strip is the inset that holds in both scrollbar modes: it clears a
classic scrollbar's ~11px gutter, and lands exactly on the content
column's 32px padding when the gutter is 0 (overlay scrollbars), so it
covers neither the scrollbar nor body text. Ticks flush right and the
hover wave grows them inward; the preview card opens leftward. The find
bar steps inside the rail on desktop so it can't cover the top ticks.

No left/right preference setting: the evidence is one-sided, and a
toggle would mean maintaining and testing two layouts for a preference
nobody has argued for yet.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 15:50:36 +08:00
beast
60048172a7 fix(lark): ingest inbound images and videos as chat attachments (MUL-4934) (#5580)
* fix: ingest feishu media as chat attachments

* fix: ingest feishu post embedded media

* fix(lark): make inbound media retries safe

* fix lark media resource limit

* fix(lark): move inbound media off ack path

* fix(channel): make inbound media runs durable

* fix(channel): close enqueue-vs-append race on media deferral

EnqueueChatTask read the session-wide media deadline in one statement and
sealed the input batch in a later one. Under READ COMMITTED a media message
committing between the two got sealed into a task the deadline read had
already decided was 'queued', so the daemon could claim it before its
attachment bound — the agent received the bare placeholder, and the later
media-ready promotion was a no-op against a non-deferred task.

After the seal, re-derive the deferral from the sealed batch itself in the
same transaction (DeferChatTaskForSealedPendingMedia): if any sealed message
still carries an unexpired media marker, flip the task to deferred with
fire_at aligned to the latest marker. The existing post-commit promote fence
already covers the opposite direction (marker cleared mid-transaction).

Adds a deterministic regression test that injects the media append between
the deadline read and the seal via a wrapped pgx.Tx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel): keep committed chat task out of enqueue error path

The post-commit media-ready fence returned its error from EnqueueChatTask
even though the deferred task was already durably committed. The router
flush treats any enqueue error as "no task exists": it clears the typing
indicator and logs an enqueue failure while the run still happens at its
fire_at deadline. Log the fence failure instead — the claim-path deferred
promoter re-queues the task regardless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel): cap global media resolution concurrency

Media jobs were serialized per session but unbounded across sessions: a
burst could open arbitrarily many concurrent 45s Lark downloads, and each
unknown-length upload may buffer up to the 100 MiB resource cap in memory.
Gate resolveAndBindMedia behind a global slot semaphore (default 8,
RouterConfig.MediaConcurrency). Per-session ordering is unchanged; on
shutdown a job cancelled while waiting for a slot proceeds straight to the
bounded DB finalize so marker clearing stays prompt. Also document that the
per-message media budget spans queue/slot waits (it must match the
persisted fire_at) and why timed-out uploads cannot leak unbounded orphans.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(chat): keep channel-sealed user messages on task cancel

Sealing the channel input batch stamps task_id onto channel user
messages, which exposed them to the cancel draft-restore path: an
empty-transcript cancel would DeleteUserChatMessageByTask the sealed
Feishu/Slack messages and detach their attachments. Those messages are
the durable record of what the platform sender wrote — the sender has
no Multica composer to restore a draft into.

Gate the restore-delete on ChatSessionHasChannelBinding in both the
synchronous finalize and the deferred finalize (the latter covers
markers left by an older replica during a rolling deploy); a bound
session now settles as "Stopped." instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel): skip the media pipeline for messages without media

Every inbound message on a Media-enabled platform persisted a 45s
media deadline and queued a resolution job, so a plain text message
could wait behind the global media semaphore (its task deferred while
other sessions download 100 MiB videos) and a crash between append and
clear delayed a pure-text run to the full 45s fallback.

Add MediaResolver.HasMedia — a pure in-memory probe the Router calls
on the ACK path — and only persist the deadline / enqueue the job when
the message actually references platform media. The Feishu resolver
decodes the already-received payload and reports standalone image or
video keys and post-embedded img/media spans.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(chat): gate cancel restore on immutable channel provenance

The previous guard keyed the cancel restore-delete off
ChatSessionHasChannelBinding, but a binding only proves routing exists
right now: archiving a session and rebinding an installation both
delete the binding while preserving chat history, so a still-cancellable
sealed task could again restore-delete the original inbound messages.

Persist provenance on the message instead: migration 203 adds
chat_message.channel_ingested, stamped inside the channel append
transaction and never mutated, and both cancel finalize paths now gate
on TaskHasChannelIngestedMessages over the task's sealed batch. The
binding-existence query is removed. Regression tests cover ingest ->
archive/unbind -> cancel for a queued and a started task.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel): reclaim media uploads that never gain an attachment row

Deadline expiry dropped already-resolved refs and a BindMedia failure
was log-only, leaving uploaded objects with no attachment row and no
reclaim path — the dedup mark commits with the message before media
runs, so a redelivery is dropped as a duplicate and never re-resolves
(and thus never overwrites) those keys, and workspace/session deletion
only enumerates the attachment table.

Add MediaResolver.DiscardMedia — a best-effort delete by StorageKey —
and call it from both failure paths in resolveAndBindMedia. The Feishu
resolver forwards to the storage backend's Delete. Tests cover a
partial upload discarded at the deadline, discard on bind failure, and
key-level deletion in the resolver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(server): refresh comments stale after detached media ingestion

Channel tasks now seal a self-owned input batch, media ingestion is no
longer out of scope for the flattener, and MediaRefs are filled by the
detached resolver after append rather than by feishuChannel pre-engine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(chat): stop keying channel empty-completion silence off chat_input_task_id

Sealing gave channel tasks a self input-owner, which broke
writeChatCompletionOutcome's discriminator: it treated any owned task
as direct, so an empty channel completion wrote the no_response
fallback row and the outbound patcher — which forwards any non-empty
chat:done content verbatim — pushed the English fallback body to
Feishu/Slack, violating the MUL-4351 contract.

Silence is now decided by the immutable channel_ingested provenance of
the task's input batch, looked up by the batch OWNER id
(chat_input_task_id): auto-retry clones inherit the owner while their
sealed messages stay tagged with the parent's id, so keying off the
task's own id would misread a channel retry as direct. The cancel-path
provenance gates switch to the same owner key via chatInputOwnerID.
chat_input_task_id is back to meaning only "input batch owner".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): renumber to 203/204 after upstream took 202

Upstream main merged 202_runtime_profile_add_qwen while this branch
held 202/203, tripping TestMigrationNumericPrefixesStayUniqueAfterLegacySet
on the CI merge tree. channel_media_pending becomes 203 and
channel_ingested becomes 204; no content changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channels): gate outbound delivery on channel provenance, not owner

Merging main brought #5645 (keep direct chat replies in Multica),
whose outbound gate assumed channel tasks leave chat_input_task_id
NULL. Sealed channel tasks own an input batch too, so on the merge
tree every channel reply and failure notice was classified as direct
and silently dropped — agents stopped replying in Feishu/Slack.

Both outbound gates now call engine.TaskInputIsChannelIngested: a NULL
owner keeps #5645's deliver-by-default for pre-sealing tasks, an owned
batch delivers only when it carries the immutable channel_ingested
stamp (keyed by the owner id, so auto-retry clones inherit the
verdict). Direct replies stay in Multica; sealed channel replies reach
the platform. Tests cover both directions on both platforms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel): discard media orphans on a fresh context, after finalize

DiscardMedia shared finalizeCtx with BindMedia, so a bind that failed
because the finalize deadline expired handed the storage deletes an
already-dead context — the compensation silently no-opped and the
orphans leaked anyway. The deadline path also ran S3 deletes before
the marker clear, eating the same 5s budget the user-facing
bind/promotion needed.

Collect the refs from both failure paths, run bind + promotion on the
finalize budget first, then delete on a fresh discard context. The
bind-failure test now pins that discard receives a live context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel): compensate result-uncertain media uploads and commits

The compensation protocol treated "the call returned an error" as "the
side effect did not happen", which is wrong in both directions across
the result-uncertain windows:

- An upload error can follow a server-side write (lost response,
  deadline mid-write). The attempted key never reached the router, so
  nothing could reclaim it and dedup guarantees no re-resolve. The
  resolver now idempotently deletes the deterministic key on a fresh
  budget right at the failure site.

- A commit error is not a rollback guarantee: a lost ack can report
  failure after Postgres durably committed the attachment rows, and
  the router's discard would then delete objects those rows reference.
  BindMediaRefs now converges the ambiguity on a fresh budget — any of
  the batch's URLs present proves the atomic commit landed (bind
  reports success); none proves the rollback (discard stays safe); a
  failed verification returns ErrMediaBindResultUnknown and the router
  keeps the uploads, preferring a rare orphan over a broken attachment.

Fault-injection coverage: an upload error deletes the attempted key; a
lost-ack commit keeps the bound attachment and reports success; a
verified rollback stays a discardable error; the router keeps uploads
on the unknown-outcome sentinel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): renumber to 207/208 after upstream took 203-206

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(channel): note DiscardMedia self-invocation and the unknown-outcome skip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): renumber to 212/213 after upstream took 207-211

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(channel): replace inline media compensation with an intent ledger and reconciler

Inline best-effort compensation cannot answer "did my side effect
happen?" at the moment it needs the answer — the DELETE/PUT reordering
and the empty-read-vs-in-flight-COMMIT gaps were both instances of the
same two-system atomicity problem. Persist the intent instead and let
an asynchronous reconciler settle it:

- channel_media_pending_object (migration 214; claim index 215 as its
  own single-statement CONCURRENTLY migration): a state machine row
  ('pending' -> 'deleting') with lease, attempt, and backoff columns.
- The resolver upserts the row BEFORE each PUT, state-guarded so a key
  the reconciler owns is never resurrected (the resource is skipped).
  ObjectURL is a pure function of configuration, so the row carries the
  attachment URL pre-upload.
- BindMediaRefs deletes the batch's rows INSIDE the attachment-insert
  transaction: commit landed <=> intents gone, atomically, so an
  ambiguous COMMIT never needs adjudication. A key already claimed to
  'deleting' is skipped (placeholder stays).
- Nothing is ever deleted inline. The reconciler — an independent
  worker so storage latency cannot starve other sweepers — claims due
  rows ('pending' past the settle delay, or expired leases) under a
  fresh lease, checks for a durable attachment reference only AFTER the
  claim (race-free: bind can no longer succeed on the key), deletes
  unreferenced objects outside any transaction, and backs off failed
  deletes with attempt-based retry. Crash windows converge for free.
- The settle delay is a fixed constant carrying NO correctness weight;
  invariant tests pin it at >=10x every pipeline budget. Metrics cover
  deletes, referenced clears, delete failures, and ledger backlog.

Removed: MediaResolver.DiscardMedia, ErrMediaBindResultUnknown, the
post-commit verification, and both router discard branches.

Tests: intent-before-upload ordering; upload error leaves the row and
deletes nothing; bind-wins vs reconciler-wins on the same key; lost-ack
and rolled-back commit injections (intent cleared iff the attachment
landed); reconciler three-state settle; expired-lease reclaim; delete
failure backoff and retry; settle invariants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel): never build or sweep the media reconciler without storage

store is nil when S3 is unconfigured AND the local upload dir fails to
initialize, but the reconciler was constructed unconditionally and
main only gates the goroutine on the reconciler pointer — the first
unreferenced ledger row (rows can pre-exist from a boot where storage
worked) would nil-pointer panic a bare goroutine and take down the
process.

Construct the reconciler only when a storage backend exists, and guard
RunOnce defensively: with no deleter it skips the sweep without
claiming, so rows are not stranded in 'deleting' until lease expiry.
Test covers the pre-existing-row + missing-storage boot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): renumber to 213-216 after upstream took 212

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(channel): remove the dead pre-resolved MediaRefs ingress path

lark.InboundMessage.MediaRefs and the resolver's early-returns for
pre-populated refs were vestiges of the pre-detached synchronous design
— no producer fills them before the router anymore. Worse, the intent
ledger made the path actively misleading: refs arriving without ledger
rows would be silently skipped at bind (with a log blaming the
reconciler), contradicting the field's "already persisted" contract.

Delete the field, its channelMessageFromLark mapping, and both
early-returns; channel.InboundMessage.MediaRefs is now documented as
what it actually is — ResolveMedia's output channel, always empty on
ingress, attachable only through a claimed ledger intent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel): enforce workspace tenancy on every ledger query

The intent-ledger upsert's conflict branch guarded only on state, so a
cross-workspace storage_key collision could rewrite the row's
workspace/message/url ownership; release and delete keyed on
(storage_key, lease_token) alone. The derived key embeds the workspace
UUID so none of this is reachable today — but tenancy must be enforced
by the workspace column in every query, never derived from the key
string (MUL-3515 rule, restated in this PR's review).

The upsert now updates only within the same workspace (a cross-tenant
conflict updates nothing, returns no row, and the resolver skips the
upload — the fail-safe direction), and release/delete take
(workspace_id, storage_key, lease_token). Tests pin that a foreign
workspace can neither steal, release, nor delete a row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): build the ledger primary key via a concurrent index

storage_key TEXT PRIMARY KEY created its unique index implicitly at
CREATE TABLE, against the repo convention that every migration index —
including a new table's unique index — is built CONCURRENTLY in its
own single-statement migration (the exact three-step pattern
client_usage_daily shipped in 207-209). The table now declares
storage_key NOT NULL, 216 builds the unique index concurrently, and
217 attaches the primary key USING INDEX; the claim index moves to
218. ON CONFLICT (storage_key) still resolves against the constraint,
and the full down/up round-trip is verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel): bound each reconciler object delete with its own timeout

DeleteObject ran on the worker-lifetime context and the SDK's default
HTTP client has no overall request timeout, so one black-holed
connection would wedge the sequential sweep loop — and with it every
later batch and the backlog gauge — forever; a single-replica
deployment has no other worker to reclaim the lease. Each delete now
gets a 30s timeout (well under the 2min lease), and a timed-out delete
takes the existing release/backoff path. Covered by a blocking-deleter
test with an injectable timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel): anchor the media deadline to the DB clock and bound queue waits by it

Two deadline gaps from review:

- The persisted marker was an application-clock timestamp compared
  against SQL now() everywhere it is read, so a skewed app node could
  shrink the fallback window and hand the agent a placeholder before
  the resolver's local budget ended. The append transaction now anchors
  a relative budget (MediaPendingSeconds) with now() + make_interval,
  writer and readers sharing one clock; the local resolve budget stays
  monotonic app-side. A DB test pins that the remaining budget measured
  by the DB clock equals the requested one.

- enqueueMedia's waits (per-session order, global slot) only watched
  shutdown, so in a burst an already-expired job kept its goroutine and
  payload until it reached the front. Both waits now also watch the
  message's deadline; on expiry the job skips the resolver entirely and
  runs only the empty finalize (marker clear + promotion), which also
  unblocks the session's later messages. Covered by a queued-expiry
  test that finalizes while the only slot is deterministically held.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): renumber to 216-221 after upstream took 213-215

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel): start the local media budget before the append transaction

The DB anchors the durable fallback at insert-time now(), but the
local monotonic budget started only after AppendMessage returned — so
the resolver outlived the fallback by the append/commit latency, a
window where the deferred task is already claimable while the resolver
still runs and the agent reads a placeholder that binds moments later.
Capture the local deadline before calling AppendMessage, restoring the
ordering local-gives-up <= durable-fallback-fires. A slow-append test
pins that the resolver's context deadline is measured from the
pre-append instant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(migrations): renumber to 224-229 after upstream took 216-223

Verified against the merged tree: the numeric-prefix uniqueness test
passes and the full migration set applies cleanly from scratch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel): heartbeat the reconciler lease per row

One claim covers up to 50 rows under a single 2-minute lease, but the
batch is processed sequentially and each delete may run its full 30s
timeout — a few stalled deletes could outlive the lease mid-batch,
letting another replica reclaim the tail: duplicate concurrent
deletes, inflated attempt/backoff on rows whose owner was alive, and
skewed metrics.

The lease is now renewed before EACH row's settle work, so it only
ever needs to cover one row's worst case (invariant-tested: lease >=
2x the per-delete timeout). A renewal that matches no row means
another worker reclaimed it after a genuine expiry — the row is
skipped, leaving the new owner's state untouched. Test simulates a
mid-batch reclaim and pins the skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel): dedup post media resources and make local writes atomic

A rich post may reference the same image_key/file_key in several spans.
The object key derives from (message, type, key), so duplicates uploaded
to the SAME key twice: LocalStorage.UploadStream truncated the
destination up front and removed it outright on a copy error, so a
second failing attempt destroyed the object the first success had
produced — leaving an attachment row pointing at nothing. A second
succeeding attempt instead produced two attachment rows for one object.

Collapse duplicate spans by (fetch type, platform key) before the
upload loop, and write local uploads through a temp file renamed into
place so a failed write can only discard its own temp file. Tests cover
a duplicated span uploading once and a failed re-upload leaving the
previous object intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel): fence late-materializing PUTs with a tombstone schedule

A DELETE cannot be ordered against a PUT the client already abandoned:
the store may materialize the object after the delete completes. The
reconciler cleared the ledger row right after deleting, so such an
object had no row and nothing to reclaim it — which made the settle
delay the de-facto correctness barrier for the PUT/DELETE race, exactly
what the design says it must not be.

The row is now kept as a tombstone ('tombstoned' state, migration 226's
CHECK) and re-deleted on a widening schedule (15m, 1h, 6h, 24h, the
pass index carried in last_error), so a late materialization is
reclaimed by a later pass; only after the schedule is exhausted is the
row dropped. Claim, heartbeat, lease, and tenancy predicates are
unchanged — a tombstone is claimed exactly like any other due row. A
separate gauge reports tombstones so they cannot be mistaken for a
backlog of objects awaiting reclaim, and the header comment now states
precisely what state fences (bind/commit) versus what the schedule
fences (late PUTs).

Tests: the reviewer's interleaving — DELETE completes, the abandoned PUT
materializes right after, and the object is gone by the end of the
schedule — plus a full schedule walk asserting the object is counted
once and the row clears at the end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel): tombstones must re-delete, not re-ask the reference question

A tombstone revisit ran the same reference check as a first settle, so
an attachment carrying the same URL — a re-ingested copy of the object —
sent the row down the "referenced, keep it" branch: the object was kept
and the row cleared, abandoning the re-delete schedule that fences the
ORIGINAL object against an abandoned PUT. A tombstone has already been
judged unreferenced and deleted; it exists only to re-delete whatever
materializes later, so it now goes straight to the delete + schedule
tail (extracted as settleDeletedObject, shared with the first settle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(channel): keep the tombstone schedule position in its own column

The re-delete pass index was encoded into last_error, which the failure
path also writes: one failed re-delete erased the position and restarted
the walk. A store failing intermittently could therefore keep a tombstone
alive indefinitely — every recovery would resume at pass 1 and the row
would never reach the end of the schedule to be dropped.

tombstone_pass is now its own column (the table is introduced in this PR,
so migration 226 carries it), advanced only by a successful delete, and
the tombstone write clears the now-stale last_error. Test walks the
schedule across a failed re-delete and asserts it resumes rather than
restarts, and that the row still terminates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(lark): derive media object keys per chat message

The object key was derived from the platform message alone, so a second
ingest of the same Feishu message reused the first ingest's ledger row.
That row can be a tombstone (up to ~31h while the re-delete schedule
runs), and the intent upsert refuses anything that has left 'pending', so
the second ingest skipped the upload and silently produced a placeholder
with no attachment. A re-ingest is reachable: the inbound dedup claim is
reclaimable once 60s stale and the dedup row is only vacuumed after 24h.

Keying on the chat message the object will attach to keeps the two
ingests independent, and nothing leaks: each one's objects are covered by
its own ledger row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(storage): route both local upload paths through one atomic write

UploadStream wrote through a temp file and renamed into place, but the
buffered Upload path still truncated the destination up front — the
destructive shape the stream path exists to avoid, one caller away from
coming back. Both now share writeAtomic, which also restores the 0644 the
direct write used (CreateTemp makes files 0600).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(channel): gofmt the media-pending append fields

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(storage): keep the local upload chmod best-effort

The rename-into-place rewrite made a failed chmod fail the whole upload.
CreateTemp's 0600 has to be widened to the 0644 the direct write used, but
an upload dir on a mount that ignores chmod (SMB/NFS/FUSE) accepted the
old direct write fine — turning those deployments' uploads into hard
errors would be a regression for a cosmetic property. Log and continue.

Tests pin 0644 on both upload paths, and that a failed buffered upload
leaves no temp litter and no damage to a previous object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(channel): never re-delete an object an attachment references

The tombstone pass skipped the reference check and deleted unconditionally,
so a durable attachment carrying that URL lost the only object it can read
— the dangling attachment the intent ledger exists to prevent, and the
opposite of the posture every other path here takes ("a reclaimable orphan
beats a broken attachment").

The check now runs on every pass. A positive result on a tombstone is
unreachable by design — keys are per (chat message, resource) and a bind
cannot attach a key that has left 'pending' — so reaching it means an
invariant broke: keep the object, clear the row, log it, and count it on
a dedicated reconciler_tombstone_referenced_total counter. The test's
contract is flipped to assert the referenced object survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(storage): make the local staging file reclaimable after a crash

os.CreateTemp's random suffix meant a crash between the staging write and
the rename left a file nothing could name: the ledger records only the
final storage key, and DeleteObject removed only the object and its
sidecar. Each leftover can approach the 100 MiB resource cap and they
accumulate without bound.

The staging path is now derived from the object key, so DeleteObject
removes it alongside the object — which makes the media reconciler reclaim
it too, since the intent row is written before the upload. Opening it 0644
directly also drops the chmod the previous commit had to make best-effort.
Both read paths refuse the staging name (keys come from the request URL,
and a half-written body should not be readable); a user-supplied ".tmp"
extension is unaffected, since object keys are generated and never
dot-prefixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(channel): renumber the media migrations after merging main

main took 224 (agent_task_session_rollout_missing), so the ledger group
moves to 225-230 and the cross-references inside the table migration follow.
main's CompleteTask also grew a sessionRolloutMissing parameter; the three
call sites this PR added to chat_input_ownership_test.go pass false.

Verified the way the numbering is meant to be verified: full migration set
applied from scratch on the merged tree, and the whole server suite run
against that database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(channel): let Postgres compute every reconciler deadline

The reconciler built settle cutoffs, lease expiry, backoff and re-delete
times from the process clock and compared them against the database's
now(). A replica whose clock had drifted would therefore settle rows whose
upload was still in flight (the object is deleted and the bind then refuses
to attach — media silently lost), hand out leases that are born expired
(rows churn between workers, attempt/backoff inflate), or compress the
tombstone schedule that fences a late-materializing PUT.

The four settle queries now take durations and derive their timestamps from
now(), so every replica reads one clock. The parameter types are the guard:
an app-side timestamp can no longer be passed. Test asserts the persisted
lease, backoff and re-delete deadlines all track the database's now().

The generated code also picks up main's new agent_task_queue column in the
two RETURNING task.* queries this PR adds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(lark): drop unrelated gofmt-only churn from this PR

Six files carried whitespace/comment-reformatting with no functional
change, unrelated to the inbound media pipeline. Reverting them to the
base revision keeps the diff focused on the feature (75 -> 69 files):

  server/internal/service/empty_claim_cache.go
  server/internal/integrations/lark/markdown_detect.go
  server/internal/integrations/lark/ws_chunk_assembler.go
  server/internal/integrations/lark/ws_chunk_assembler_test.go
  server/internal/integrations/lark/ws_frame_test.go
  server/internal/integrations/lark/registration_test.go

Verified: `git diff -w` against these files was already empty, so no
behavior is affected. go vet clean; tests covering these files pass.

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>
2026-07-27 15:44:39 +08:00
Bohan Jiang
4581e9ee76 fix(server): surface real reason for failed quick-create (MUL-5268) (#5898)
* fix(server): surface real reason for failed quick-create (MUL-5268, #5885)

When an agent's quick-create run finishes without producing an issue, the
completion path wrote a fixed "agent finished without creating an issue"
inbox, discarding the real reason — most often the active-duplicate guard
rejecting the create. Users saw no actionable detail.

notifyQuickCreateCompleted now:
- distinguishes pgx.ErrNoRows (a confirmed no-issue → real failure) from a
  genuine lookup fault (DB/timeout), so a transient error no longer mislabels
  a run that may actually have created the issue;
- on the real-failure branch, surfaces the agent's final output as the
  failure reason. The quick-create prompt already requires the agent to exit
  with the CLI error as its only output, so this carries the concrete cause
  (e.g. the existing issue's identifier + status), unescaped, bounded, and
  redacted. Empty output falls back to the existing generic message.

No API/CLI contract or migration change.

Co-authored-by: multica-agent <github@multica.ai>

* fix(server): never end quick-create with no notification on lookup fault

Review follow-up. The previous commit returned silently when the completion
lookup failed with a non-ErrNoRows error, to avoid misreporting a failure that
was never observed. But the task is already completed and nothing retries this
reconciliation, so a single transient DB fault permanently stranded the
requester with no inbox result at all.

The indeterminate branch now writes a neutral, terminal notification: it does
not assert failure (the agent may have created the issue), does not reuse the
agent output as if it were the confirmed reason, and points at the one safe
next step — check recent issues before retrying, so a retry cannot silently
produce the duplicate the guard exists to prevent.

notifyQuickCreateFailed / notifyQuickCreateUnconfirmed are now thin wrappers
over a shared writer so both outcomes keep the identical row shape and the
frontend's 'Edit as advanced form' recovery affordance.

Tests:
- TestQuickCreateLookupFault_WritesUnconfirmedInbox: fails against the previous
  commit with 'no rows in result set' (the exact silent-drop), passes now. Uses
  a DBTX wrapper that faults only GetIssueByOrigin so the inbox write still
  reaches the real DB.
- TestQuickCreateFailure_RedactsAgentOutput: locks in that the newly-surfaced
  agent output is scrubbed before storage.

Co-authored-by: multica-agent <github@multica.ai>

* fix(inbox): render unverified quick-create outcome as neutral, not failed

Review round 2. Three fixes.

1. Rebased onto main and updated the three CompleteTask call sites for the
   new sessionRolloutMissing parameter; the branch no longer compiles-fails
   on the merge ref.

2. The unverified outcome reused the quick_create_failed inbox type, so every
   client framed it as a failure regardless of the neutral title/body: web
   list rendered 'Failed: {detail}', web detail showed 'Create with agent
   failed', mobile rendered 'Failed: ...', and getInboxDisplayTitle replaced
   the neutral title with the original prompt. Users saw 'Failed: Couldn't
   confirm...' — asserting a failure never observed. Added a distinct
   quick_create_unconfirmed type end to end: core type union, web list label
   (no failure framing), web detail pane, the original-prompt box and 'Edit as
   advanced form' recovery affordance, mobile label + display title, and en /
   zh-Hans / ja / ko strings. Older clients hit their existing default branch
   and render the already-neutral title.

3. The terminal notification reused the caller's context, so a lookup that
   failed with context.Canceled / DeadlineExceeded failed the write for the
   same reason and still dropped the notification. The write is now detached
   via context.WithoutCancel with a bounded timeout.

Tests (each verified to fail without its fix):
- TestQuickCreateLookupCancelled_StillWritesUnconfirmedInbox: cancels the ctx
  at the lookup; without the detach, 'no rows in result set'.
- inbox-detail-label.test.tsx: resolves accessors against the real en locale;
  pointing the unconfirmed case back at failed_with_detail reproduces
  'Failed: Couldn't confirm...'.
- inbox-display.test.ts: both outcomes stay recoverable rows.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 15:35:09 +08:00
Bohan Jiang
982a8c5784 perf(ci): split frontend jobs and cache turbo results (MUL-5347) (#5982)
* perf(ci): split frontend build and test onto separate runners (MUL-5347)

The frontend job is the critical path of every CI run that touches web
code: p50 576s, versus 267s for backend. 94% of it is a single
`turbo build typecheck lint test` invocation.

Profiling showed the cost is contention, not inefficient tasks. A
standard runner has 4 vCPUs, and the job scheduled two CPU-saturating
tasks onto them concurrently: `@multica/views:test` (259 jsdom files)
and `@multica/web:build` (a webpack production build). Measured against
the same suites running with the machine to themselves:

  @multica/views:test    104s owning 4 cores  ->  500s sharing them
  @multica/web:build      26s owning 10 cores ->  342s sharing 4

Split the work across two runners so neither starves the other. The
split is weighted rather than even, because the halves are not close:
the test group costs ~575 CPU-seconds and everything else ~250, so
tests get a runner alone.

Also drop `^typecheck` from the `test` and `lint` task definitions. No
package in this repo emits build artifacts -- core/ui/views export raw
.ts that the consumer transpiles -- so that edge ordered tasks without
producing anything they consumed, and left the heaviest task in the
graph idle for ~38s while core/ui ran `tsc --noEmit`. Removing it also
drops three redundant typecheck tasks from the test job, taking it from
7 tasks / 798 CPU-seconds to 4 / 575. Type errors still fail CI through
the `typecheck` task, which frontend-build owns.

`frontend` is retained as an aggregate gate so the existing status-check
name keeps reporting, including the established behaviour that it goes
green rather than pending on PRs the path filter excludes.

Co-authored-by: multica-agent <github@multica.ai>

* perf(ci): cache turbo task results across runs (MUL-5347)

Every CI run rebuilt all 16 frontend tasks cold -- turbo reported
`Remote caching disabled` / `Cached: 0 cached, 16 total` on every run,
because only the pnpm store was cached. Persist turbo's filesystem cache
with actions/cache instead. No Vercel remote-cache credentials exist in
this repo, so this is the local cache keyed per job.

Measured, simulating a fresh checkout against a warm cache:

  frontend-build   54.7s -> 184ms   (12/12 cached)
  frontend-test   115.7s ->  50ms   (7/7 cached)

Cache footprint is 20MB for the build job and ~60KB for the test job --
`test` declares no outputs, so turbo stores exit codes and logs, yet a
hit still skips the most expensive task in the graph.

Turning the cache on promotes two latent hashing bugs into real ones,
so both are fixed here:

`build` declared narrow `inputs` globs that matched neither
`content/**/*.mdx` (compiled by fumadocs-mdx during the build),
`public/**` assets, nor `package.json` / `tsconfig.json`. Editing any of
them left the task hash byte-identical, which is inert when nothing is
ever restored and a stale-build bug the moment something is. Dropped in
favour of turbo's default input set.

`test` regains its `^typecheck` edge, reverting part of the previous
commit. That change was justified on the basis that the edge only
imposed ordering, which was true without a cache: a turbo task hash
covers a workspace dependency's sources only if a task edge reaches
them, so with no edge, editing packages/views or packages/ui left
`@multica/web#test` and `@multica/desktop#test` unchanged in hash and a
cached pass would be replayed over changed code. `typecheck` is the only
task every package defines, so it is the edge that closes the gap;
`^test` would miss packages/ui, which has no test script. `lint` keeps
no edge -- eslint here is not type-aware, so it cannot observe a
dependency's sources.

Also adds .github/workflows/ci.yml to globalDependencies: it pins the
Node version, and without it a toolchain bump would replay results
produced by the old runtime behind a green check.

Co-authored-by: multica-agent <github@multica.ai>

* fix(ci): scope turbo cache to the resolved runtime, drop typecheck from tests (MUL-5347)

Addresses review feedback on the caching commit.

Must-fix: the cache was not isolated by interpreter. `node-version: 22`
floats across patch releases and turbo's global hash does not include
the interpreter at all (`engines` is null in its dry-run cache inputs),
so a runner moving to another 22.x would leave the workflow text and
every task hash unchanged, restore the old cache through `restore-keys`
and report green without executing anything. Both jobs now resolve
`node --version` into a step output and carry it, plus `runner.arch`,
in the cache key and the restore prefix.

Must-fix: `actions/cache@v4` runs on the deprecated Node 20 runtime and
was being force-migrated to Node 24 with a warning on every run. Moved
to v6 -- the review suggested v5, but v6.1.0 is current and satisfies
the same runner floor (>= 2.327.1; hosted runners are on 2.335.1).

`test` no longer depends on `^typecheck`. It needs dependency sources in
its hash, not a type check: a new hash-only `cache-inputs` transit task
carries them. No package implements that script, so every node resolves
to <NONEXISTENT> and nothing executes, while the edges still pull each
dependency's files into the hash. Declared recursively so the chain
survives a package gaining a purely transitive dependency. This returns
the ~223 CPU-seconds the previous commit gave up: the cold test job goes
from 7 tasks / 115.7s back to 4 tasks / 67.2s, and editing packages/ui
still correctly re-runs the views, web and desktop suites while
core:test stays cached.

Corrects a comment that claimed `^test` would miss packages/ui because
it has no test script. It would not -- turbo materialises a
<NONEXISTENT> node that participates in hashing, verified against the
dry graph. `^test` is still the wrong edge here, but because it
serialises the suites behind each other, not for the stated reason.

Also drops the stale note that tests no longer depend on `^typecheck`,
and swaps the aggregate gate's `always()` for `!cancelled()` so a run
superseded by a newer push does not spend a runner on a verdict nobody
reads.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 15:26:13 +08:00
Bohan Jiang
49fd6cd08a fix(handler,service): return 409/coalesced instead of 500 on duplicate-key violations (MUL-5285) (#5958)
Two unique-constraint violations surfaced as an HTTP 500 with the raw Postgres
constraint name leaked to the caller (#5914).

- UpdateAgent now mirrors CreateAgent: a 23505 / agent_workspace_name_unique
  violation returns a clean 409 instead of a 500 whose body leaked the constraint
  name. The (workspace_id, name) constraint does not exclude archived agents, so
  renaming into a name still held by an archived agent hit exactly this path.
- The mention enqueue detects the idx_one_pending_task_per_issue_agent violation,
  returns a bare typed sentinel (no driver text), and logs the benign race at
  debug instead of error.

Review then hardened the same duplicate-enqueue path so it never reports an
outcome it cannot back: coalesced only after an atomic head-scoped merge,
deferred only when an active task's reconcile will replay the comment, queued
only after a fresh enqueue, and an honest internal_error otherwise. Head scoping
(TEN-356) is preserved throughout, planned-id registration excludes queued tasks
so re-attribution stays atomic (MUL-4302), and a blocked completion replay is
handed on rather than discarded.

One shape remains best-effort — a different-head queued blocker can neither cover
the comment nor accept it without violating TEN-356 — and is logged at error;
that drop predates this change. Tracked in #5985.

No migration, foreign key, or cascade.

MUL-5285

Fixes #5914
2026-07-27 14:26:23 +08:00
Multica Eve
a1bd3ca9b2 fix(editor): allow spaces in mention search (#5980)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 13:54:00 +08:00
Multica Eve
2294f450e8 fix(kiro): recover from oversized-history-image session resume failures
Merges MUL-5338 / fixes GH #5975.
2026-07-27 13:53:58 +08:00
Bohan Jiang
ef69c3d4a3 feat(projects): add search and max-height to the project picker (MUL-5344) (#5979)
* feat(projects): add search and max-height to the project picker (MUL-5344)

Migrate ProjectPicker off the bare DropdownMenu onto the shared
PropertyPicker (the same primitive assignee/label/status pickers use), so
the project dropdown now caps its height with a scrollable list and gains a
client-side search box. Search matches on title substring and pinyin, so
Chinese project names are reachable by latin input.

Preserves the full existing contract: controlled/uncontrolled open with the
Base UI open-latch normalization, the disabled read-only lock, the inline
hover/keyboard clear button, and every caller's custom trigger.

Co-authored-by: multica-agent <github@multica.ai>

* fix(pickers): reset picker search state on programmatic close (MUL-5344)

PropertyPicker cleared its search query inside the popover's open-change
handler. Every picker closes itself after a selection by calling its own
setOpen(false), which flips the `open` prop directly and never routes
through that handler — so the stale query survived into the next open and
kept the rest of the list filtered out.

Move the reset onto the open -> closed transition so it covers programmatic
closes too. This also fixes the same latent staleness in the assignee and
label pickers, which close on selection the same way.

Adds a regression test: search -> select -> reopen must show an empty input
and the full list.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 13:52:22 +08:00
Bohan Jiang
6da0407b03 fix(issues): keep issue row pages in sync on observer reattach (MUL-5341) (#5978)
* fix(issues): refetch invalidated row pages on observer reattach (MUL-5341)

issueTableRowPageOptions used `refetchOnMount: false`, which blocks the
mount refetch of a *successful-but-invalidated* row page as well as an
errored one. When a row page is WS-invalidated while its dynamic
useQueries observer is detached and the observer later reattaches, the
page stays `invalidated: true / fetchStatus: idle` under the global
`staleTime: Infinity` default — the status count (facet query, always
active) updates but the list keeps a stale snapshot missing the moved
issue, until a full page refresh.

Switch to `retryOnMount: false`, which expresses the intended
"don't auto-retry an errored page" behavior without blocking stale
(invalidated) successful pages from refetching. Fresh cached pages still
don't refetch (staleTime: Infinity), so re-expanding a collapsed section
reuses settled cursor pages.

Add core regression tests for both the invalidated-refetch and the
errored-stays-errored paths, and align the status-branches test fixture
with the production client's `staleTime: Infinity`.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): keep errored row pages stable on reattach (MUL-5341 review)

Address Elon's review: `retryOnMount: false` alone only guards a no-data
first-load error. When a page has loaded, then an invalidation-triggered
background refetch fails, TanStack's "error" reducer flags the page
`isInvalidated: true` while retaining its data. Under the default
`refetchOnMount: true` that page is both stale and errored, so it re-fires
the failing request on every dynamic-observer reattach — bypassing the
page's explicit Retry.

Add `refetchOnMount: (query) => query.state.status !== "error"` alongside
`retryOnMount: false`: a successful-but-invalidated page still refetches on
reattach (the original bug), a fresh page stays put under
`staleTime: Infinity`, and both first-load and background-refetch errors now
wait for an explicit Retry.

Add a regression test covering the has-data background-refetch-error path
(load → invalidate → refetch fails → detach/reattach asserts no extra
request until an explicit refetch).

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 13:30:48 +08:00
Naiyuan Qing
a964c5229d feat(editor): pan/zoom the image attachment preview (MUL-5316) (#5971)
* feat(editor): pan/zoom the image attachment preview (MUL-5316)

Image preview could only fit-to-window, so a screenshot of text opened
unreadably small with no way in. It now runs on the same pan/zoom canvas
the Mermaid viewer uses: fit on open, then wheel (cursor-anchored), drag,
pinch, double-click fit<->100%, +/-/0 and arrow keys, plus a toolbar with
zoom out / % / zoom in / fit / actual size / reset.

The canvas is generalised out of Mermaid rather than duplicated:
diagram-transform -> zoom-transform, use-diagram-canvas -> use-zoom-canvas,
the canvas CSS out of mermaid.css into zoom-canvas.css, and a shared
ZoomCanvas + ZoomControls that MermaidViewer now composes too (dropping its
hand-rolled toolbar and canvas markup). The five zoom labels move from
editor.mermaid.* to a shared editor.canvas.* in all four locales.

Two fixes the generalisation forced, both of which also improve Mermaid:

- MIN_SCALE floored computeFitScale at 25%, so content more than 4x the
  canvas could not be fitted at all. A 1600x8000 full-page screenshot needs
  ~10% and would have opened cropped — worse than the fit-only behaviour it
  replaces. The lower bound is now min(0.25, fitScale), threaded through
  clampTransform / zoomToAt / canZoomOut.
- The canvas measured its viewport with getBoundingClientRect while its
  container was mid scale-in animation, fitting against a viewport a few
  percent too small; ResizeObserver reports the untransformed layout box so
  it never fired to correct it. Measures offsetWidth/offsetHeight now.

Image-specific handling: natural size is read from the ref as well as
onLoad (a cached image is already complete before onLoad attaches), and an
image with no intrinsic size — an SVG with only a viewBox — keeps the old
letterboxed render with the zoom controls hidden instead of a blank canvas.
The canvas is focused on open so the keyboard controls work without a click
first, native image drag is disabled so it can't hijack the pan, and the
backdrop only closes on a click that actually lands on it.

Verified: pnpm typecheck, pnpm lint, pnpm test (3003 views tests, 50 in
attachment-preview-modal). The flex chain and the long-screenshot fit were
also checked in headless Chromium, which jsdom cannot measure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(ui): keep kbd keycaps readable inside tooltips

Upstream shadcn inverts its tooltip surface, so Kbd forced near-white text
inside tooltip-content. Our TooltipContent keeps the popover surface, which
made keycaps render white-on-white. Drop the inverted-surface overrides so
keycaps keep their regular muted colors, which read correctly on popover in
both themes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(editor): drop the redundant reset zoom control, add shortcut tooltips

The reset toolbar button was a literal alias of fit (reset: fit) — it could
never do anything fit doesn't, so remove it along with the reset API, the
isFitted state that only served its disabled look, and the reset_view copy
in all four locales.

Replace the native title attribute on the remaining zoom controls with the
shared Base UI Tooltip + ShortcutKeycaps pattern (same as the editor bubble
menu), showing the matching keyboard hints: zoom out (-), zoom in (+), fit
to view (0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 13:27:58 +08:00
Bohan Jiang
85a14cde37 fix(daemon): gate codex session pointer writes on rollout presence (MUL-5305) (#5960)
* fix(daemon): gate codex session pointer writes on rollout presence (MUL-5305)

Codex issue follow-ups on local_directory projects intermittently lost
their session: the server sent a prior session whose rollout was not in
the task CODEX_HOME, so the daemon dropped the resume and started a fresh
thread (gateCodexResumeToRolloutPresence), losing the conversation.

Root of the bad pointer: the daemon persists a Codex session id as the
resumable pointer at two points -- the mid-flight pin and the terminal
report -- before the rollout is guaranteed on disk. A task that exits
early (crash / runtime offline / timeout) leaves a pinned/reported
session id with no rollout; GetLastTaskSession (which accepts failed
rows) then hands it to the next follow-up, which drops it.

Enforce the invariant at write time: only record a Codex session as the
resumable pointer once its rollout is present in the per-issue store,
with a short bounded wait for flush. If it never lands, don't overwrite
the last good pointer -- a blanked session_id becomes NULL server-side,
so GetLastTaskSession falls back to the most recent session whose
rollout is real. Non-Codex providers are unaffected; crash recovery is
preserved because a present rollout still pins.

- codexSessionResumable: shared write-time presence check (bounded wait)
- runTask: gate the terminal session_id before reporting
- executeAndDrain: gate the mid-flight pin (thread codexHome through)
- tests: helper cases + behavioral pin test

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): address review — don't silently downgrade completed sessions (MUL-5305)

Follow-up to review feedback on #5960:

- Must-fix 1 (silent downgrade): limit the write-time session withholding
  to NON-completed terminal states. A missing rollout means no resumable
  conversation was persisted, so a withheld non-completed attempt loses
  nothing; a completed session is authoritative and, if its rollout is
  anomalously absent, is still recorded so the next run's resume gate
  discloses the loss (PriorSessionResumeUnavailable, MUL-4424) instead of
  silently falling back to an older session. Extracted
  resumableTerminalSessionID.
- Non-blocking risk: pin the mid-flight resume pointer with a per-status
  presence check instead of one fixed 2s window, and set sessionPinned
  only once the rollout is confirmed, so a rollout that lands shortly
  after the first status is still pinned this run.
- Must-fix 2 (regression coverage): pin skipped when rollout absent (no
  /session call); terminal helper (completed keeps / failed withholds);
  and a DB-backed GetLastTaskSession test proving the next claim falls
  back to the older recorded session when the latest was blanked.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): disclose Codex session continuity gaps end-to-end (MUL-5305)

Addresses review feedback on #5960.

Must-fix 1 — a completed turn whose rollout is missing is exactly the
#5934 case (the reporter waits for each turn to finish), so it can no
longer be excluded from withholding. Withhold the session for ANY
terminal state, and pair the withhold with a persisted continuity-gap
signal so the next claim still discloses the loss even while resuming an
older good session:
  - new agent_task_queue.session_rollout_missing column (migration 224)
  - daemon sends session_rollout_missing on the terminal report; the
    handler clears the resume pointer (MarkTaskSessionRolloutMissing,
    overriding FailAgentTask's COALESCE) and flags the row
  - claim reads GetLatestTaskRolloutMissing and sets a new
    prior_session_resume_unavailable response field, which the daemon ORs
    into the brief's PriorSessionResumeUnavailable disclosure

Must-fix 2 — Codex reveals the session id on a single task_started
status, so a one-shot presence check missed a rollout that flushed later
and lost in-flight crash recovery. Pin via a background waiter bounded by
the run's context that pins the moment the rollout lands.

Tests: - completed + rollout missing -> next claim withholds the bad session
    AND flags the continuity gap (cross-layer DB test)
  - session pinned once its rollout appears after the status (mid-run)
  - pin skipped while the rollout is absent
Co-authored-by: multica-agent <github@multica.ai>

* fix(server): make continuity-gap write atomic + disclose on all claim paths (MUL-5305)

Addresses review round 3 of #5960.

Must-fix 1 — the previous handler-level marker ran AFTER the terminal
transaction committed, and FailTask creates + wakes the auto-retry inside
that same transaction, so a retry could claim the rollout-missing session
before the marker cleared it (and a marker failure was swallowed). Move
session_rollout_missing INTO the terminal write: CompleteAgentTask and
FailAgentTask now force session_id NULL (overriding Fail's COALESCE that
would keep a stale mid-flight pin) and set the flag in the SAME UPDATE, so
the withhold + gap flag commit atomically with the retry creation. The
flag is threaded through TaskService.CompleteTask/FailTask; the swallowed
best-effort MarkTaskSessionRolloutMissing query is removed.

Must-fix 2 — the daemon withholds for all Codex tasks, but only the issue
non-rerun claim consumed the disclosure. Now every fallback path sets
prior_session_resume_unavailable: the manual-rerun branch reads the source
task's session_rollout_missing, and the chat branch reads a new
GetLatestChatTaskRolloutMissing.

Tests (cross-layer DB):
- completed + rollout missing via the real CompleteAgentTask terminal
  write -> session withheld AND gap flagged
- failed + rollout missing forces session_id NULL over the COALESCE-
  preserved mid-flight pin in ONE statement

Deploy order: migration + server first, daemon second (new fields are
omitempty and ignored by an old peer).

Co-authored-by: multica-agent <github@multica.ai>

* fix(handler): return 5xx on FailTask error + cover claim-response gap paths (MUL-5305)

Addresses review round 4 of #5960.

Must-fix 1 — the FailTask handler returned 400 on a service/DB error, but
the daemon's terminal callback treats 400 as permanent (postJSONWithRetry
/ isTransientError bails without retrying). Since the fail transaction is
now the sole persistence point for the withheld session + continuity-gap
flag + auto-retry, a rolled-back fail must be retried, so return 5xx (an
invalid request body still returns 400), mirroring CompleteTask.
Regression: client.FailTask retries on a transient 5xx and eventually
succeeds.

Must-fix 2 — add claim-response-level regressions that drive the two new
disclosure branches through buildClaimedTaskResponse:
  - chat: the latest terminal task on the session withheld -> the next
    chat claim sets prior_session_resume_unavailable
  - manual rerun: the source task withheld -> the rerun claim discloses
These handler DB tests run under CI's fully-migrated database (the local
workspace DB cannot set up the handler fixture).

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 13:12:06 +08:00
Rusty Raven
67d3952ee2 fix(transcript): auto-follow live task output in transcript dialog (#5932)
* fix(transcript): auto-follow live task output in transcript dialog (#5921)

Since the transcript event list moved to Virtuoso (#5733), a live task's
new events required manual scrolling to see — the dialog opened at the
top and never followed appended output.

Wire live-follow through Virtuoso's own primitives, per sort direction:

- Chronological (default): `followOutput` returns "smooth" while the
  task is live and the reader is at the bottom (Virtuoso's own atBottom
  tracking, with a forgiving 120px threshold). Scrolling up suspends the
  follow until the reader returns.
- Newest-first: growth is PREPENDS, which the firstItemIndex anchoring
  deliberately holds in place — so a reader parked at the top would
  silently stop seeing new rows. Track the top edge via
  `atTopStateChange` into a ref and snap back to index 0 when new events
  arrive while at the top. Readers who scrolled away are left in place.
- Opening a live chronological transcript now lands on the newest event
  (`initialTopMostItemIndex: LAST/end`, same pattern as the chat list);
  the per-listEpoch remount re-applies it after task/sort/filter
  changes. Completed tasks keep opening at the top.

Fixes #5921.

* fix(transcript): rework newest-first live follow as a user-intent latch (#5921)

The previous approach gated the newest-first follow on "is the viewport at
the top right now" — but firstItemIndex prepend anchoring moves the viewport
away from the top on every flush, so the signal broke itself: after the
first prepend the follow silently disengaged and never recovered.

Replace it with a pure latch controller (transcript-follow.ts):

- Disengage only on accumulated USER displacement (wheel/touch/key deltas,
  scrollbar drag) beyond the 120px edge zone; system displacement never
  counts, no matter how far it pushes the viewport.
- While following, non-user displacement is pinned back to the live end on
  the scroll event itself (Virtuoso's prepend compensation lands after React
  effects, so an effect-timed snap alone stays one flush behind).
- Enforcement is suppressed while the mouse is held (text-selection
  autoscroll) or mid-gesture; returning within the edge zone re-engages.
- Timeline segment clicks explicitly unlatch so navigation isn't pinned back.

Chronological followOutput switches "smooth" -> "auto": a still-animating
smooth scroll reads as "not at bottom" on the next flush and drops the
follow under rapid appends.

The latch decision table is unit-tested (transcript-follow.test.ts), and the
mechanism was validated end-to-end against react-virtuoso 4.18.7 in a
browser harness: follow-at-top, in-zone nudge + flush (no false disengage),
scroll-away with stable anchor, return-to-top re-engage, rapid flushes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 13:10:44 +08:00
Bohan Jiang
1869b1b5e0 test(issues): give table-view editor test 60s to survive CI CPU starvation (MUL-5326) (#5964)
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 03:12:50 +08:00
kami
156c592c9d fix(attachments): presign desktop inline media (#5946)
* fix(attachments): presign desktop inline media

* refactor(attachments): unify inline-media presign to DownloadPresigner

The GetAttachmentByID inline-media presign branch asserted storage.Presigner
while resolveAttachmentDownloadMode keys its presign decision on
storage.DownloadPresigner. Both are implemented by S3Storage today, but a
storage implementing only one would make the mode resolution and the presign
call silently disagree. Route the branch through DownloadPresigner with an
empty content disposition (identical to PresignGet: the object's stored
Content-Disposition is inherited) so the two can never diverge.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 02:49:30 +08:00
Bohan Jiang
3d4c5c7da2 feat(cli): add 'multica agent copy' to fork an agent across runtimes (MUL-5279) (#5961)
Add a CLI/headless equivalent of the web Duplicate action: copy an existing
agent's portable config into a new agent, optionally on a different runtime,
leaving the source untouched.

The command composes existing endpoints (GET source, then POST create) — no new
server API — passing the source's skill ids in skill_ids so bindings attach in
the same create transaction the server already runs, keeping the mutation atomic.

- Copied by default (each overridable): name (+" (copy)"), description,
  instructions, avatar, custom_args, max_concurrent_tasks, invocation permission,
  and assigned workspace skills.
- Runtime-specific fields (model/thinking_level/service_tier) copy only on the
  same runtime; a different --runtime-id drops them and requires --model.
- Secrets/machine-local (custom_env/mcp_config/runtime_config) are never copied;
  they are set only via explicit secret-safe flags.

Docs: updated the multica-creating-agents built-in skill + source map.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 02:38:26 +08:00
Bohan Jiang
286ed61bb0 fix(agent): reap claude process group on cancellation (#5918) [MUL-5288] (#5957)
* fix(agent): reap claude process group on cancellation (#5918)

The Claude backend spawned its child with a bare exec.CommandContext, so
cancellation SIGKILLed only the leader. On a resumed stream-json session
(no wall-clock timeout) the MCP servers and tool subprocesses it spawned
were orphaned and kept running — 64+ min in #5918 — while, under
--max-concurrent-tasks 1, holding the only slot and starving the queue.

Put claude in its own process group and drive a group-wide
SIGTERM->grace->SIGKILL on cancel/timeout before closing stdout, mirroring
the fix already made for codex (#4520) and opencode (#4533). Add
claude_cancel_unix_test.go covering the graceful and SIGKILL-escalation
paths.

MUL-5288

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent): gate claude SIGKILL escalation on whole process group

Review found the grace-window escalation keyed off procDone (leader exit),
not the process group. A SIGTERM-ignoring descendant that does not hold
claude's stdout lets the leader exit, closes procDone, and skips the group
SIGKILL — leaking exactly the orphan #5918 targets.

Escalate to a group SIGKILL unless waitProcessGroupGone confirms the whole
group has exited within the grace window (matching codex). It returns as
soon as the group empties, so the graceful path adds no latency. Add a
mixed-signal regression: TERM-respecting leader + TERM-ignoring,
stdio-detached descendant, which fails against the leader-keyed escalation.

MUL-5288

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 02:01:41 +08:00
Bohan Jiang
4931ee6f7a docs(chat): correct the chat sandbox description (MUL-5287) (#5956)
The Chat docs claimed agents in a chat are fully sandboxed and cannot
see or change issues (`multica issue list` returns empty, issue API
calls blocked by permission checks). That is not what the server
enforces: a chat run is dispatched with the same owner-scoped,
workspace-bound task token as an issue run, and /api/issues is guarded
only by workspace membership — no chat-origin check. So a chat agent can
run `multica issue create`/`list` and operate on the workspace when
explicitly asked (creator recorded as the agent), matching the report in
GH #5917.

Reframe the section around the real boundary: chat isolation is about
context and privacy, not permissions. The genuinely-true guarantees are
preserved (private transcript, no issue context in the prompt, no
auto-promotion of chat content into issues); the false 'cannot see/change
issues / fully sandboxed' claims are corrected. Updated en/zh/ja/ko.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 01:24:00 +08:00
YYClaw
af1ff00e14 test(cli): use example domains in setup fixtures (#5944) 2026-07-27 01:10:46 +08:00
Multica Eve
b0bae3f95e docs(changelog): add v0.4.11 release entry (2026-07-25) (MUL-5284) (#5916)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
v0.4.11
2026-07-25 03:38:12 +08:00