Commit Graph

4366 Commits

Author SHA1 Message Date
Naiyuan Qing
5fa94e59ab fix(ui): let group rows report their height, and re-measure the frozen edge on resize
Self-review of the branch turned up two places where a change did not reach as
far as it was supposed to.

Group rows never took part in the row measurement. DataTable clones the
virtualizer's ref and data-index onto whatever renderRow returns, but
IssueTableGroupRow declared only its own three props and absorbed them — and a
group header, being shorter than a data row, is exactly the row the
measurement was added for.

The frozen-column shadow measured its boundary from the scroll handler alone.
Resizing a frozen column while the surface is scrolled sideways moves that
boundary with no scroll event to follow, leaving the shadow behind at the old
position.

Also drops a dependency left behind when the load-more rows stopped building
their own labels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 15:03:14 +08:00
Naiyuan Qing
e6be57ffb6 fix(issues): scope the title cell's actions to the title cell
The sub-issue and rename buttons keyed off the row's hover state, so pointing
anywhere along a row — a status chip, a date, empty space — put controls that
act on the title under a pointer that was somewhere else. They follow the
title cell's own hover now. The gradient behind them still tracks the row
colour, since that is what the cell is painted with while they are showing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 14:58:51 +08:00
Naiyuan Qing
b207a93117 fix(ui): measure table rows instead of assuming they are all one height
Virtualisation sized every row at the same 41px estimate, but the table does
not have one kind of row: group headers are 37px, an end-of-column footer
shorter still, and placeholders different again. Each one put the estimate a
few pixels out, and the error accumulates — with grouping on, the rendered
window drifts off the rows it should be showing and the scrollbar overshoots
the bottom.

Rows report their own height through the virtualizer's measureElement now, and
the estimate only covers rows that have never been mounted. Rows built by
renderRow are cloned to carry data-index and the measuring ref, so callers keep
returning a plain <tr> and still take part.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 14:36:15 +08:00
Naiyuan Qing
efb9bdadf4 feat(issues): show the table's own grid while its first page loads, and end its columns like every other surface
Two loading-state gaps, both in how the table's non-issue rows are modelled.

A cold load rendered a single "Loading…" line under a full header, which reads
as an empty table rather than a loading one. The surface-level skeleton meant
for it was unreachable: use-issue-surface-data hardcodes isLoading to false for
table, so the `mode === "table"` branch never ran — and it would not have
fitted, drawing rounded bars at p-2 where the table draws an edge-to-edge grid,
so switching it on would have traded one wrong state for a layout jump.

Placeholders are rows now, rendered through the ordinary cell renderer so they
inherit the real column widths, pinning and borders. Everything the table knows
before its data — header, toolbar, column layout — is up immediately, and the
rows swap in without moving anything. The unreachable surface branch is gone.

The end of a column was hand-rolled too, leaving the table the one surface
where a failed page read as muted body text rather than an error, where the
prompt sat left-aligned against three centred ones, and where reaching the end
of a paginated branch said nothing at all. The row carries its state rather
than a finished label and renders through ListLoadMoreFooter, the footer Board,
List and Swimlane already share — which exists, per its own comment, so those
states and their wording stay consistent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 14:25:40 +08:00
Naiyuan Qing
37c77e8a0b fix(issues): give the table's title column back the width its hover actions held
The sub-issue and rename buttons sat inline in the title cell at opacity 0.
Hidden is not absent: their boxes reserved around 40px of the column at all
times, in the one column with the least room to spare, for controls that only
appear on hover. They are positioned over the cell's trailing edge now, the
way SidebarMenuAction is, with a gradient fading the title running underneath
rather than icons sitting on top of it — the sidebar needs no gradient because
its labels are short, a title runs to the cell's edge. focus-within keeps them
reachable from the keyboard, where hover never fires.

Reordering a column also scrolled the table vertically. Modifiers constrain a
drag's movement but not its auto-scrolling, which reads raw pointer
coordinates, so a few pixels of vertical drift sent the rows moving under a
gesture that cannot act on them. Auto-scroll's y threshold is zero now; x
keeps it, since a table wider than its viewport needs it to reach a distant
slot, but at 0.05 rather than the default 0.2, which arms it a fifth of the
way in from either edge — most of a wide header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 14:25:29 +08:00
Naiyuan Qing
90e8d7871c fix(ui): stop the table header flickering black during scroll
The sticky header carried backdrop-blur over a translucent fill. A
backdrop-filter on a sticky element with content scrolling beneath it is a
known Chromium compositing fault — the blur layer recomputes its backdrop
every frame, and virtualisation is adding and removing the very rows it reads
from, so the strip flickers black under fast scrolling. Chromium's fix for the
same symptom was reverted, so upgrading does not carry it (electron#12906,
electron#45854, chromium#339841685).

The fill is now the opaque colour that bg-muted/30 was compositing to, which
is the mix the pinned header cells already use. A header the content scrolls
behind has no reason to show it through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 14:24:42 +08:00
Naiyuan Qing
37e4ed5330 feat(ui): size a table column to its content on double-click
Double-clicking a column's resize handle called column.resetSize, which
cleared the stored width and let TanStack fall back to its generic default of
150 — a number unrelated to any width this table was designed with. "Reset"
therefore widened priority from 130, collapsed labels from 220, and clamped
title to its 260 minimum. It restored nothing.

It now sizes the column to its widest rendered cell, the convention Excel,
Sheets, AG Grid and Notion all share for that gesture.

Fixed table-layout ignores content and the cells truncate their own text, so
nothing on screen reports the width the content wants. The measurement lifts
both constraints across the column's cells, reads them, and restores
everything within the same task, so the browser paints once — after the
restore — and the intermediate layout is never seen. Only the rows inside the
virtual window are measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:52:18 +08:00
Naiyuan Qing
bd7098a078 fix(ui): mark the frozen column boundary while the table is scrolled sideways
The edge where the frozen columns end had a shadow drawn inside the pinned
cell with `--border`. That token is tuned for hairlines between adjacent
surfaces — oklch .945 in light mode — and all but disappears once spread into
a shadow, so the boundary read as unmarked while content slid underneath it.
Darkening it in place only made the frozen column look outlined: an inset
shadow can shade that cell's own edge and nothing else, while the depth being
described belongs to the content passing beneath.

Split into the two things MUI X's data grid separates, a permanent border for
where the boundary is and a shadow for something crossing it right now:

- the rule between the frozen columns and the rest stays put, as before;
- a 12px gradient is cast past the frozen block, mounted outside the scroll
  container and shown only while scrolled sideways.

Its position is measured off the DOM — the trailing frozen header carries a
`data-pinned-edge` marker — rather than summed from column sizes. Fixed
table-layout stretches columns past their configured widths whenever the
container is wider than the table's min-width, so a sum lands the marker in
the middle of visible content.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:46:15 +08:00
Naiyuan Qing
078f9b824d fix(issues): stop a dragged table column stretching into its neighbour's width
Dragging a column header applied dnd-kit's full transform, which carries
scaleX/scaleY alongside the translation. Those come from its layout animation:
old rect over new rect, tweening an item into the shape of the slot it lands
in. Between two tabs of equal width the ratio is 1 and never shows. Between
two columns it is not — swapping a 174px column with a 96px one stretched the
header to 1.8x on the way across. Only the horizontal translation is applied
now; reordering changes no column's width, so there is no shape for a tween to
describe. The travel and the settle stay animated through `transition`.

The travelling wrapper was also fixed at h-8 while the header strip measures
39px once row borders are counted, leaving the block short at both edges and
misaligned by 3px — which read as the same flattening. Its height is derived
from the cell now.

The reorder grip returns as the drag handle, appearing on hover as before and
staying visible for the length of a drag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 11:26:40 +08:00
Naiyuan Qing
b3460114e4 feat(issues): drag a table column by its header, not by a hidden grip
Reordering columns was advertised by a grip that only appeared once the
pointer was already on the header, and dragging it moved a wrapper the height
of its own line of text while the <th> around it clipped anything that
travelled outside the cell — so the column being moved looked like it had
vanished rather than slid.

The header is now the handle. There is no grip: the cursor carries the
affordance (grab, then grabbing), the whole cell responds, and only the
sort/hide button opts out, since a press there is always the menu. The
wrapper spans the cell's box at all times — the negative margins undo the
<th>'s padding and put it back inside — so what travels is a header-sized
block and going translucent is the entire drag state, matching how a desktop
tab behaves. Overflow is lifted for the length of a reorder and the column in
hand is raised over its neighbours.

Columns are restricted to the horizontal axis, the same constraint the
desktop tab bar puts on tab reordering, which is why packages/views now
declares @dnd-kit/modifiers directly.

Transforming the <th> itself would carry the header's height along for free,
but `transform` on a table cell is a corner of the spec browsers take
liberties with — Chromium lifts the cell out of the table's box model and its
geometry stops matching the row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:39:27 +08:00
Naiyuan Qing
fa7cb897e8 fix(ui): make table column resizing land where the pointer is (MUL-5166)
Pressing the resize handle committed a width before any drag: it wrote the
rendered width, which fixed table-layout had stretched past the configured
one, so a stray click on a column edge silently rewrote and persisted that
column and stopped it adapting to the window. Dragging then ran ahead of the
pointer, because committing one width changes how much leftover space the
layout has to share out and rescales every other column mid-gesture. And the
gesture never ended if the pointer came up outside the window or the user
switched apps — the column kept tracking the pointer on return, with the page
stuck unselectable under a col-resize cursor.

- Require 4px of travel before anything is committed, matching the
  column-reorder sensor's activation distance on the same header.
- Pin every column to its rendered width on the frame the drag starts, so
  there is no leftover left to redistribute and the drag maps 1:1.
- Capture the pointer and end on blur / pointercancel / lostpointercapture,
  the same four-part contract the sidebar rail already uses.
- Own the cursor from a portaled full-viewport layer for the duration of the
  drag. document.body.style.cursor loses to any descendant that declares its
  own, which is why the cursor flickered over rows and text.

Resizing also re-rendered every cell each frame, and each cell carries a
popover, so a frame cost ~143ms. Widths now travel as custom properties
published once on the <table>, and the body is swapped for a memoized copy
while a drag is live — the browser applies each new width with no React work.

Visually the table had no column rules at all, which left the pinned columns'
trailing shadow as the only vertical line and made it read as a stray border
on one column. Every column now carries a rule, the pinned shadow appears
only once the surface is actually scrolled sideways, and the resize handle
brightens its rule to brand rather than matching the faint resting colour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:29:45 +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
Jiayuan Zhang
e30776dd9b feat(agent): add Claude Opus 5 to the Claude runtime catalog (MUL-5282) (#5910)
Opus 5 is the current flagship in Claude Code's bundled catalog (verified
against claude-code 2.1.219: id `claude-opus-5`, display name "Opus 5",
pricing tier_5_25, capabilities include xhigh_effort/max_effort). Without a
catalog entry the model picker never offered it, and — more damaging —
ModelKnownIncompatibleWithProvider treats any unlisted `claude-*` id as a
known mismatch, so an agent manually pinned to `claude-opus-5` had the value
erased on save.

- Add `claude-opus-5` to claudeStaticModels(). Sonnet 4.6 stays the sole
  badged default; Opus remains a deliberate opt-in.
- Allow the full low/medium/high/xhigh/max effort range in
  claudeModelEffortAllow, matching the rest of the Opus family.
- Price it on the standard 5/25 Opus tier in both the server table and the
  frontend estimator, so Opus 5 usage lands in cost totals instead of the
  unmapped-model diagnostic. The existing `[1m]` and `<provider>/` tolerances
  cover the other spellings runtimes report.

Verified: go test ./pkg/agent/... ./internal/metrics/..., vitest
runtimes/utils.test.ts, tsc --noEmit on packages/views.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-25 02:48:03 +08:00
Jiayuan Zhang
28a4203bc2 MUL-5261: revert domain-specific prompt additions (#5913)
Revert the built-in focused-testing skill (#5877) and the always-on
Repository Setup Preflight brief section (#5886). Both delivered
software-engineering domain content through platform-level prompt
surfaces that every agent receives regardless of workspace type.

- multica-focused-testing was the only built-in skill that did not
  describe a Multica platform contract, and the only one without
  `user-invocable: false` / `allowed-tools: Bash(multica *)`. Built-in
  skills are meta/system skills; a workspace with no repository bound
  still carried it in its skill index and slash-command menu.
- Repository Setup Preflight was emitted for every non-quick-create task
  without consulting `ctx.Repos`, so non-code workspaces received
  build/dependency instructions in the always-on brief. writeRepositories
  already elides itself when no repo is bound; this section did not.

Pure revert. No replacement behavior is introduced here.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-25 02:47:06 +08:00
Bohan Jiang
ecce589867 MUL-5265: GitHub API-snapshot PR cards — CI status + mergeability (#5889)
* feat(github): API-snapshot PR cards — CI status + mergeability (MUL-5265)

Fetch each linked PR's CI checks and mergeability from the GitHub GraphQL
API as the single source of truth (Plan C). Webhooks, page visits and a
bounded TTL sweep are refresh triggers only; nothing is inferred from
webhook payloads anymore.

Backend (server/internal/integrations/ghsnapshot):
- installation-token cache + GraphQL client (private key / tokens never logged)
- one paginated pullRequest query -> normalized per-check snapshot
- outbound queue: (installation,repo,PR) dedup + single in-flight per PR,
  bounded worker pool, Retry-After / rate-limit backoff, jitter
- head-SHA-guarded atomic batch replace (a slow response for an old head
  can never overwrite a newer head's snapshot)
- bounded chase window (30s->5m, stops on terminal/closed) + page-visit +
  TTL refresh; clean degradation when no App private key is configured

Removes the old suite-level webhook aggregation display path (query +
handlers + tests). check_suite / check_run / status are now pure triggers.

Frontend: PR card shows two independent tri-state elements (CI status +
mergeability). "Ready to merge" only when merge state is clean; no-checks
and unknown-mergeable never assert a positive verdict; progress strip
removed; four locales; stale marker.

Docs: github-integration + environment-variables (four languages) — now
required App private key, read-only Checks/Commit-statuses permissions,
new event subscriptions, capability boundaries and troubleshooting.

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

* fix(github): address PR snapshot review blockers

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

* fix(github): bound snapshot refresh scheduling

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

* fix(github): concurrent check-run index migration + singleflight token mint

Address Elon's third-round review on the MUL-5265 PR snapshot pipeline.

Must-fix — migration built a non-concurrent index. The
github_pull_request_check_run table declared PRIMARY KEY (pr_id, ordinal)
inside CREATE TABLE, which builds a unique index synchronously and violates
the repo rule that every migration-created index (including on a new table)
use CREATE UNIQUE INDEX CONCURRENTLY in its own single-statement file. Split:
222 now creates the table without a primary key; new 223 adds the
(pr_id, ordinal) unique index CONCURRENTLY. The atomic delete-all/insert
write path already guarantees ordinal uniqueness, so a plain unique index is
sufficient; the index also serves the pr_id-prefix list aggregation and the
workspace/PR cleanup deletes.

Nit — token mint now singleflights per installation. installationToken
released the lock before minting, so the N workers of one installation could
mint N tokens on a cold cache or a simultaneous renew. Concurrent callers for
the same installation are now collapsed via singleflight into one HTTP mint;
added a -race concurrent-mint test asserting a single mint under 16 callers.

Verified: fresh DB migrates through 223 (table has no PK, concurrent unique
index present); ghsnapshot suite + new test pass under -race; migration lint
and handler github/workspace-delete tests pass; sqlc produced no diff;
go build / vet / gofmt / git diff --check clean.

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-24 18:30:20 +08:00
Bohan Jiang
3c6bebaff0 MUL-5274: allow explicit persistent service handoffs (#5895)
* fix(runtime): allow explicit persistent service handoffs

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

* fix(runtime): resolve review ambiguities in persistent-service handoff wording

Address MUL-5274 review findings on #5895:

- Drop the "The rules above apply only to work owned by the current run"
  scoping sentence: with the persistent-service exception inserted above
  it, it would have swept in work that is precisely no longer run-owned
  after handoff. The external-systems bullet carries the boundary on its
  own, and both pin tests now reject any "The rules above" reintroduction.
- Replace "detach it" (skill-level mechanism) with the lifecycle
  contract: hand off only once the service no longer depends on this run.
- End the negative-boundary bullet with "the CI-specific rules below
  still apply" instead of "must be collected before exit", which
  misread as license to start CI polling and collect it.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-24 18:21:28 +08:00
Multica Eve
afb3def1b7 docs(changelog): add v0.4.10 release entry (2026-07-24) (MUL-5273) (#5894)
* docs(changelog): add v0.5.0 release entry (2026-07-24) (MUL-5273)

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

* docs(changelog): include #5893 board/list load-more fix in v0.5.0 (MUL-5273)

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

* docs(changelog): use version 0.4.10 (patch bump) per review (MUL-5273)

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.10
2026-07-24 17:45:56 +08:00
Naiyuan Qing
f599fe3b85 fix(views): board/list load-more no longer flashes the skeleton + friendlier footer (#5893)
* fix(views): board/list load-more stops flashing the full skeleton

Tail (load-more) page fetches were folded into the surface-level isLoading through the per-branch aggregate, so scrolling to load more in List and status-grouped Board replaced the already-rendered rows with the full-surface skeleton. Track head-page pending separately and gate the surface isLoading/isRefreshing on heads only; per-branch pagination state that drives the load-more sentinel is unchanged.

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

* feat(views): friendlier infinite-scroll footer for board/list/swimlane

Collapse four copies of the error/has-more/reached-end ternary into a shared ListLoadMoreFooter: the loading spinner now carries a "Loading…" label so a slow fetch no longer reads as stuck, and a column that actually paginated shows a muted "No more" marker at the end instead of stopping silently. InfiniteScrollSentinel gains an optional label; adds the table.no_more string in en/zh-Hans/ja/ko.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:14:11 +08:00