* fix(agent/cursor): send prompt on stdin so CLI-like flags cannot be re-tokenised (MUL-4992)
On Windows a Cursor task whose prompt contains CLI-like flags failed in ~2s
with `error: unknown option '-X'` and no agent output (#5649). A pasted build
log such as
go build -ldflags "-X main.version=foo" -o bin/server ./cmd/server
was enough to kill the run.
buildCursorArgs put the whole prompt in argv as the positional after -p.
The official Windows launcher chain ends in `& node.exe index.js $args`
inside cursor-agent.ps1, where PowerShell re-serialises $args onto node's
command line. Under Windows PowerShell 5.1 and pwsh <= 7.2 (Legacy native
argument passing) an argument holding embedded double quotes is not
re-escaped, so the quoted region closes early, node's argv parser re-splits
at the interior spaces, and `-X` reaches commander.js as a standalone flag.
#1709 removed the cmd.exe `%*` re-tokenisation but stopped at the
Go -> PowerShell boundary, one hop before this. Its Windows tests only
compare the argv slice Go builds and never execute a shim, which is why the
gap stayed invisible.
Fix: keep the prompt off every command line. cursor-agent's -p is a boolean
print-mode switch and the prompt is positional; with no positional prompt and
a non-TTY stdin the CLI reads stdin to EOF and uses that as the prompt. So
drop the prompt from argv on all platforms and write it to stdin, leaving
only fixed, content-free flags in argv. No shell or launcher on any platform
can re-tokenise what is not on a command line.
The write runs in its own goroutine: a prompt larger than the pipe buffer
(~64 KiB) blocks mid-write until the child drains it, and the child cannot
drain while nothing reads its stdout. Closing stdin signals end-of-prompt, so
it is closed on both success and error paths, and on cancellation to release
a blocked write. Write failures surface in the result diagnostic, ranked
below explicit agent errors so an early child exit (bad auth, bad flag) is
not masked by the resulting EPIPE.
Tests: a prompt carrying the exact `-ldflags "-X ..."` shape must arrive
byte-for-byte on stdin and appear nowhere in argv; a 512 KiB prompt must not
deadlock; the prompt is written verbatim (the CLI trims it, we do not). Both
new unix tests fail against the pre-fix code. A Windows-tagged test drives a
real PowerShell host through the same .cmd -> -File rewrite.
Closes#5649
Co-authored-by: multica-agent <github@multica.ai>
* test(agent/cursor): prove the Windows launcher fix on both PowerShell hosts in CI
The stdin fix has to hold on the host that actually exhibits the bug.
powershell.exe (5.1) and pwsh <= 7.2 default to Legacy native argument
passing; pwsh >= 7.3 defaults to Standard. A fix verified only on the newer
host would not be a fix for the reporter.
Run the shim probe against every PowerShell host on PATH rather than only the
one defaultPowerShellLookup would select, and hook the windows-tagged launcher
tests into the existing windows-execenv CI job. These tests are windows-tagged
and the backend job runs on ubuntu, so until now they ran nowhere.
Co-authored-by: multica-agent <github@multica.ai>
* test(ci): run Windows launcher tests verbosely so skips are visible
A skipped or unmatched test still reports "ok", which would make the
Windows job look like coverage it is not providing.
Co-authored-by: multica-agent <github@multica.ai>
* test(agent/cursor): close both coverage gaps in the #5649 regression tests
Two tests claimed guarantees they did not actually establish.
Windows: the fake cursor-agent.ps1 called [Console]::In.ReadToEnd() and wrote
the result itself, so it never launched a native child. The official shim ends
in `& node.exe index.js $args`, and that last hop is precisely where the bug
lives -- PowerShell re-serialises $args onto the child command line, and
whether the child inherits stdin was left unproven. The fake ps1 now
re-executes the test binary as a real native child (helper-process idiom),
which records the argv it actually received and drains stdin. Both PowerShell
hosts still run.
Interlock: the large-prompt fake drained stdin before writing any stdout, so
the child always unblocked the parent immediately and the test passed even
against a synchronous write -- it could not fail for the reason it existed.
The fake now floods stdout past pipe capacity *before* reading stdin, creating
the real mutual block. Verified: with the writer made synchronous the test
deadlocks to its 30s timeout, and passes only with the concurrent writer.
Also adds the missing cancellation case: a child that never reads stdin leaves
the writer blocked forever, so cancelling the context must close stdin,
release the writer and settle the run as aborted.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): confirm assignment without a pre-flight run preview (MUL-5010)
RunConfirmModal called POST /api/issues/preview-trigger on open and blocked
the entire dialog behind a "检查中…" spinner until it landed. That query is
keyed per issue id with staleTime 0, so every new issue was a guaranteed
cache miss — the wait was structural, not incidental. The dialog also
promised "会立即开始工作" while still checking whether anything would start.
Redefine it as "dialog = confirm the assignment", not "confirm N runs":
- Open fires no request. Note box and both buttons are usable on frame one.
- Title/primary action become 确认指派; copy states the assignment as certain
and the run as conditional, with no predicted count.
- The write reports what actually happened: UpdateIssue returns runs_started
(0/1) and batch-update returns the batch total, surfaced as one short
toast — "已指派给 X,已启动 N 个 run", or just "已指派给 X".
runs_started counts successful enqueues rather than predicate hits, since an
insert can still no-op on the pending unique index. It is pointer+omitempty
like Labels, so only the PUT reply carries it, and it is stripped in
useUpdateIssue's onSuccess so a write-scoped fact never lands in the cache.
The handoff-note version gate stays local and synchronous, now covering
squads too by resolving the leader's runtime from the warm squad list —
removing the last reason the dialog needed the server round-trip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(issues): assert runs_started on the write responses (MUL-5010)
The assign-confirm dialog now reports the enqueue outcome from the write
reply instead of predicting it, so the number itself is the contract. Pin
it directly on the responses:
- single assign that enqueues → runs_started 1
- single assign that starts nothing → 0, via both routes (suppress_run,
and assigning into backlog), so a client can tell "no run" apart from
"old backend omitting the field"
- batch → the real aggregate, asserted against a mixed selection where
updated=3 but runs_started=2; the divergence from `updated` is the point
- absent on GET, guarding the pointer+omitempty contract that keeps a
write-scoped fact off every read path
Each count is cross-checked against agent_task_queue so the assertions
track real enqueues rather than the predicate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(issues): drop runs_started + result toast, keep silent completion (MUL-5010)
Final scope: the dialog only removes the preview wait; it does not add a
post-submit result toast. Per the confirmed decision, revert everything that
existed solely to report "N runs started" and keep completion silent, exactly
as it was before this PR — the assignee and any run already surface through
the issue's normal updates.
Removed:
- the success toast on submit and its two i18n keys (all four locales)
- the `runs_started` response field on IssueResponse, the single/batch write
counting, and dispatchIssueRun's bool return (back to void)
- the `runs_started` type/schema/api-client additions and the onSuccess strip
- server/internal/handler/issue_runs_started_test.go (only served the field)
Kept: preview-trigger removal and the instant-usable dialog — note box and
buttons live on the first frame, agent/squad handoff-note version gate resolved
locally, and "确认指派" copy. Net diff vs base is now frontend-only; the backend
and packages/core files are byte-identical to base again.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* docs(issues): fix stale headline comment referencing removed result toast (MUL-5010)
The result toast was removed with runs_started, but the headline comment
still said "the real one arrives in the result toast". Reword to state only
what remains true: the copy names no run count because the run is conditional.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): run Codex unsandboxed on Windows to stop reject-by-policy (MUL-4957)
Windows has no Landlock/Seatbelt-equivalent filesystem sandbox that the
daemon configures, so the per-task `sandbox_mode = "workspace-write"` it
wrote was unenforceable. Worse than having no sandbox, it pushed Codex
into rejecting non-safe mutation commands "by policy": `multica issue
create` fails with "was rejected by policy" because Codex can neither
sandbox the command nor (under approval_policy = "never") escalate it to
the daemon's auto-approver, so the request never reaches the approver.
Mirror the existing macOS fallback and give Windows danger-full-access so
those commands run. Also generalize the danger-full-access warn log so it
no longer hardcodes "on macOS" and only surfaces the macOS-specific
upgrade hint on macOS (new codexSandboxPolicy.Hint field).
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): correct Windows sandbox rationale and respect user windows.sandbox (MUL-4957)
Addresses two review must-fixes on #5672:
1. Correct a false security fact. The comments and log Reason claimed
Windows has no filesystem sandbox backend. Codex 0.144.5 does ship a
native Windows sandbox (windows.sandbox = "unelevated"/"elevated"); it
is experimental with open upstream reliability bugs, so the daemon
defaults to danger-full-access as a deliberate compatibility choice.
Enabling the native sandbox is tracked as separate follow-up work.
2. Stop silently downgrading users who opted into isolation. The fallback
was unconditional. Add codexSandboxPolicyForConfig: on Windows an
explicit windows.sandbox = unelevated|elevated keeps workspace-write so
Codex enforces task isolation with the user's chosen backend;
danger-full-access applies only when windows.sandbox is absent,
disabled, or unparseable. This is also the branch point for a future
native-sandbox rollout (flip the default; callers unchanged).
Adds fixture tests locking the priority (user opt-in kept vs. unconfigured
fallback) plus predicate coverage for codexSandboxPolicyForConfig.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): fail closed on undecidable Windows sandbox config, honor -c windows.sandbox (MUL-4957)
Second review round on #5672. Two must-fixes.
1. Undecidable config no longer fails open. The old bool detector collapsed
"unparseable / invalid value / failed copy" into "unconfigured" and then
loosened to danger-full-access. Replaced with a tri-state
(absent/native/undecidable): only exact-lowercase unelevated|elevated (the
sole values Codex accepts — verified: any other value makes Codex refuse to
load the config) counts as native; any other present value, unparseable
TOML, a read error, or a missing per-task config when a shared
~/.codex/config.toml exists (i.e. the copy failed) is undecidable and fails
closed to workspace-write — it never loosens — logged at error level.
2. windows.sandbox set via `-c`/`--config` custom args is now honored. Such
args never land in config.toml, so config-only detection silently
downgraded those users' isolation. The effective Codex args (daemon
defaults + profile-fixed + per-agent custom_args) are threaded through
PrepareParams/ReuseParams/CodexHomeOptions into the sandbox decision and
scanned for a windows.sandbox override (inline, two-token, quoted, spaced;
last-wins).
Also drops issue-status-bound source comments (openai/codex#24098 has since
closed). Adds unit coverage for config/args classification, the fold
precedence (undecidable > native > absent), and the copy-failed fail-closed
path.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): fail closed on config-sync errors and honor shell-quoted -c windows.sandbox (MUL-4957)
Round-3 review must-fixes:
1. resolveWindowsSandboxState now takes the config.toml sync error and a
tri-state shared-config presence instead of re-stat-ing inside. A failed
sync (stale/absent per-task copy) or an un-stat-able shared source is
undecidable and keeps workspace-write, closing the fail-open where a failed
sync was read as "unconfigured". Splits IO from the decision so the paths
are unit-testable without faulting the filesystem.
2. The Windows sandbox decision consumes agent.NormalizeCodexLaunchArgs (the
shared helper buildCodexArgs now uses) so a shell-quoted -c windows.sandbox
opt-in is normalized identically to launch, instead of being missed by a
raw-token scan and silently downgraded.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): abort when the Codex sandbox block cannot be written (MUL-4957)
Round-4 review must-fix: ensureCodexSandboxConfig failures were warn-and-continue,
so a computed fail-closed workspace-write policy could stay only in memory while
config.toml kept a stale danger-full-access from a prior run — the decision
failed closed but the effective config failed open.
prepareCodexHomeWithOpts now returns the error, which blocks startup on both
paths: fresh Prepare fails the task, and Reuse leaves env.CodexHome unset, which
configureCodexTaskShellEnvironment already refuses to start.
Regression covers the full reuse scenario (stale danger-full-access + failed
config sync + failed managed-block write); it fails with "got nil" without the
fix.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
* fix(desktop): commit staging env with explicit VITE_APP_URL (MUL-5025)
Staging desktop dev relied on a hand-maintained local .env.staging, where
the web-origin var was misnamed VITE_WEB_URL. Desktop only reads
VITE_APP_URL, so appUrl silently fell back to the API-origin derivation
and copy-link URLs pointed at multica-api.copilothub.ai (404).
Track apps/desktop/.env.staging with the correct names (mirroring the
mobile precedent), and fill in mobile's EXPO_PUBLIC_WEB_URL now that the
staging web host is committed rather than living on a teammate's machine.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* chore(desktop): mark staging envs internal, clarify VITE_APP_URL semantics (MUL-5025)
Per review: state in both tracked .env.staging files that the staging
environment is internal (not a public support target), and spell out that
VITE_APP_URL is the web app origin for copy-link/open-in-browser URLs,
never the API host.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* refactor(markdown): single canonical sanitize schema for both renderers (MUL-4922)
Phase 1 of the RichContent convergence: collapse the duplicated security
base shared by the two product-level Markdown chains.
Chat (packages/ui/markdown/Markdown.tsx) and Issue/Comment
(packages/views/editor/readonly-content.tsx) each carried a verbatim fork
of the rehype-sanitize schema and urlTransform, and the forks had already
drifted: readonly whitelisted <mark> for `==highlight==`, chat did not.
A security-relevant allow-list maintained in two places means every future
XSS fix has to land twice, and missing one is a hole — this is the hardest
reason for the sweep, ahead of the user-visible feature drift.
- Extract markdownSanitizeSchema + markdownUrlTransform into
packages/ui/markdown/sanitize.ts and export from the package index.
Both chains now import the single copy; no local forks remain.
- The canonical schema is the union, so chat gains the <mark> tag name.
This is the one intentional behavior delta: <mark> is inert and admits
no attributes, and chat needs it anyway once ==highlight== converges.
- Annotate the schema as rehype-sanitize's Options: exporting it makes the
previously-inferred hast-util-sanitize type unnameable across packages.
Adds a cross-surface contract test that runs one set of security fixtures
(script, event handlers, javascript: href, data:image vs data:text/html,
mark, slash://) through BOTH surfaces and asserts identical outcomes —
the mechanism that stops a third fork from growing back.
Code-block rendering is deliberately not asserted cross-surface yet: chat
highlights with Shiki, readonly with lowlight, so emitted class tokens
still differ. Converging them is the RichCodeBlock phase and needs the
highlight-engine decision first; only the schema-level allow-list is
shared here.
Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
pnpm vitest run in packages/views (228 files, 2665 tests, all passing).
Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(rich-content): one RichContent renderer for Chat and Issue/Comment (MUL-4922)
Phase 2+3 of the convergence. Chat now renders agent output through the same
product renderer as Issue descriptions and Comments, so a ```mermaid fence an
agent emits is a diagram in Chat — not a dead code block.
Before this there were two product Markdown chains. Issue/Comment had Mermaid,
HTML preview, lowlight code, product Mention/Attachment/LinkHover; Chat went
through a generic renderer with no Mermaid/HTML dispatcher at all. Chat is
where agents emit the most diagrams, so the gap was most visible exactly where
it mattered.
Architecture
- packages/views/rich-content/ holds the ONE product renderer: a single
ReactMarkdown pipeline, one sanitize config, one components map, one fenced
-code dispatcher. Public API is content/attachments/density/phase — no
`surface` prop, no renderMention override, no custom code-renderer hook,
because each is a door a per-surface fork walks back through.
- `density` is CSS only; `phase` is lifecycle only. Neither switches parser,
plugins or the semantic DOM, so all surfaces emit the same blocks.
- ReadonlyContent is now a ~40-line compatibility wrapper (was 501 lines);
its consumers are untouched. views/common/markdown.tsx is deleted.
- Leaf components (Mermaid, HTML preview, lowlight code) stay in editor/ and
are imported by direct path, so Tiptap keeps reusing them and Chat does not
pull in the editor's Tiptap graph. Moving them is a later mechanical commit.
Streaming fence gate
Rich blocks upgrade only when the fence is CLOSED, judged by a real CommonMark
parse (mdast) rather than a `startsWith("```")` scan. A half-streamed fence
renders as source, so Mermaid never parses a partial diagram and no iframe is
created for HTML still arriving. Closedness — not `phase` — is the gate, so a
settled-but-malformed fence stays source too. The gate returns offsets only; it
never renders, because a gate that rendered would be the second renderer this
change exists to delete. Verified that source offsets survive rehype-raw +
sanitize + katex before relying on them.
Live -> persisted row identity
The live timeline moved out of Virtuoso's Footer into a real row keyed
`task:<taskId>`, and persisted assistant rows key on the same task instead of
`message.id`. Completion is now an in-place data swap through one
AssistantMessage component, so an already-rendered diagram or iframe stays
mounted and is not re-run. The process fold previously relied on that remount
to re-collapse, so the collapse is now expressed directly.
Notes
- Chat code blocks move from Shiki to lowlight (Howard's ratified choice),
matching Tiptap/Readonly and the existing hljs CSS. Ligature suppression
carries over via code.css instead of utility classes.
- Chat message tests now stub react-virtuoso: real Virtuoso renders its Footer
but no data rows under jsdom's zero-height viewport, and the live timeline is
a row now.
Tests: five-surface Mermaid parity (Chat user / live / persisted, Issue
description, Comment), streaming open->closed->settled with mount counting,
live->persisted no-remount, htmlbars/mermaidx not misdispatched, sandbox and
data-URI security regressions, CommonMark fence edge cases (shorter closer,
tilde, indented, nested, in-list), and source-level import boundary guards.
Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 240 files / 2743 tests all passing.
Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(rich-content): drop dangling export, add export guard + lazy rich blocks (MUL-4922)
Two review follow-ups.
1. Dangling package export
`packages/views` still exported "./common/markdown" after the file was
deleted in the RichContent convergence. TypeScript resolves against the
source tree so typecheck and unit tests both passed; the subpath would only
fail when a consuming app bundled it. No consumer imports it, so this was
latent rather than broken in practice.
Removes the entry and adds packages/views/rich-content/package-exports.test.ts,
which walks every workspace package.json and asserts each export target
exists (wildcards checked by directory prefix). Scoped repo-wide because the
failure mode belongs to package.json, not to this package. Verified the guard
actually fails by re-adding the dangling entry before committing.
2. Near-viewport lazy shell for Mermaid / HTML
Implements the deferred performance contract rather than requesting an
exemption. LazyRichBlock defers each rich leaf until it is within 800px of
the viewport, then latches: once mounted it is never unmounted, so scrolling
past does not re-run Mermaid, rebuild the sandboxed iframe, or discard the
viewer's pan/zoom state.
The stable-size requirement is handled by reserving the block's expected
height before AND after mount, so a block never measures 0px off-screen and
then jumps — the churn that makes a virtualized list mis-estimate item sizes.
The reservation is not a local guess: reservedMermaidHeightPx() reuses the
existing session layout cache (real height on a cache hit, else the documented
280px skeleton) and HTML_BLOCK_PREVIEW_HEIGHT_PX is the pixel twin of the
preview iframe's fixed h-[480px]. Both are exported from the leaves so there
is one source of truth per block type.
Environments without IntersectionObserver (jsdom, SSR) mount eagerly, which is
today's behaviour; rendering nothing would not be.
Verified in real Chromium (jsdom has no IntersectionObserver, so unit tests
only exercise the eager fallback):
- Non-virtualized Issue/Comment with 25 diagrams: 3 mounted, 22 deferred on
load; after scrolling, mounted grows 3 -> 6 and never drops, confirming the
latch. This is where the win is real.
- Chat gains less: Virtuoso already caps the DOM to ~4 rows, so only 1 of 4
shells was deferred. Stated rather than overclaimed.
- Live -> persisted handoff re-checked with the shell in place: scrollTop
220 -> 220 (delta 0), the tagged diagram DOM node survived, and the run
reached the persisted state. Contract 1 is not regressed.
Tests: 6 lazy-shell cases (defer, mount-on-approach, latch/no-unmount, equal
reserved height before and after mount, root margin exceeding the chat list's
own overscan, no-IntersectionObserver fallback) plus 3 export-guard cases.
Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 242 files / 2752 tests all passing.
Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(rich-content): SSR-deterministic lazy state, mention a11y, drop type casts (MUL-4922)
Three review blockers.
1. Hydration mismatch in the lazy shell
LazyRichBlock derived its initial `mounted` state from feature detection
inside useState. On the server (no window) that resolved true and rendered
the whole Mermaid/HTML subtree; in the browser (IntersectionObserver present)
the hydration pass resolved false and rendered a placeholder — different
markup for the same component, and an SSR path that silently bypassed the
lazy gate. `"use client"` does not opt a component out of Next's server
render, so this was reachable.
Initial state is now unconditionally false on both sides. The eager fallback
for environments without IntersectionObserver moved into the effect, which
never runs on the server, so the first committed frame is identical
everywhere and the latch is unchanged.
The first version of the SSR test passed against the buggy component: jsdom
always provides `window`, so server and client took the same detection branch
and the mismatch was unobservable. The suite now removes IntersectionObserver
for the duration of renderToString to reproduce the real asymmetry. Verified
by reinstating the bug: 2 of the 3 SSR tests fail and React raises its own
"Hydration failed" error.
2. Project mention lost keyboard access
The unified renderer inherited the readonly surface's `<span onClick>` around
ProjectChip, while Chat had previously used AppLink — so converging the
surfaces regressed Chat from a focusable anchor to a mouse-only span. A span
and an anchor are visually identical and behave the same under a mouse, which
is why only an assertion on the emitted element catches it.
Now rendered through AppLink, which also owns plain-click, modifier-click and
the desktop new-tab adapter, so none of that is reimplemented. The wrapper
keeps only stopPropagation, matching IssueMentionCard.
Adds project-mention-a11y.test.tsx using the REAL AppLink and
NavigationProvider — mocking AppLink to emit an anchor would assert the mock.
Covers anchor + href, chip's nearest interactive ancestor, Tab focus, Enter
activation, click, and modifier-click labelling. All 6 fail on the old span.
3. Type suppressions in the production renderer
`as never` on the plugin lists and `as NonNullable<Components[...]>` on the
code/pre overrides silenced real type errors, against the repo's strict-TS
rule. Replaced with react-markdown's own types: RichCode/RichPre now derive
their props from `ComponentPropsWithoutRef<tag> & ExtraProps`, and the plugin
lists use `satisfies NonNullable<Options["remarkPlugins" | "rehypePlugins"]>`
so a bad plugin tuple still fails to compile.
Also removed the remaining casts this exposed: hast property reads went
through `as string` even though hast property values are a union, so a
non-string `data-*` attribute would have been typed as a lie — replaced with
a runtime-narrowing helper. `toHtml()` already returns string; its cast was
redundant. Node position reads are narrowed in one helper. No cast or
ts-ignore remains in production rich-content.
Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 243 files / 2780 tests all passing.
Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(rich-content): cached-height hydration, scroll-root + recycle latch, CDN reactivity (MUL-4922)
Three review blockers.
1. Cached reserved height still reached the first frame
The previous fix made `mounted` deterministic but left the *height* reading
sessionStorage during render: RichFenceBlock called reservedMermaidHeightPx()
inline. Server has no sessionStorage so it emitted 280px, while a browser with
a warm cache emitted the real height — a differing style="min-height:…" on the
frame React hydrates, which React reports as an attribute mismatch and does
not repair. Same bug class as the one already fixed, one layer up.
RichFenceBlock now splits into Mermaid/HTML components (so the height hook is
never conditional) and reserves the skeleton default on the first frame,
adopting the cached height in an effect. Zero-shift on a warm cache is kept;
only the read moves after hydration.
The earlier SSR suite passed a fixed reservedHeightPx straight to the lazy
shell, bypassing this path — it could not have caught this. New tests drive the
real RichFenceBlock with a real prefilled sessionStorage entry, and simulate
the server by removing sessionStorage for the renderToString call (jsdom
provides one, so without that the "server" takes the browser branch and the
mismatch is invisible).
2. Wrong observer root, and a latch that died with the row
The IntersectionObserver set only rootMargin, so it clipped against the
viewport — but Chat scrolls inside its own element (Virtuoso
customScrollParent). Expanding the viewport box says nothing about a nested
scroller, so Chat blocks only loaded once already visible and the preload was
effectively dead there. Surfaces now publish their scroll container through
RichContentScrollRootProvider and the observer uses it as `root`; page-scrolled
surfaces keep the viewport root.
Separately, the mount latch was component state, so Virtuoso recycling a row
discarded it and scrolling back re-ran Mermaid, rebuilt the iframe and dropped
viewer pan/zoom — the per-pass cost the shell exists to prevent. The latch moved
to a module-level registry keyed by a hash of the content, bounded at 500
entries with oldest-first eviction. Restore happens in a layout effect (before
paint, so no placeholder flash) and never in initial state, keeping the
server/client first frame identical. Scope was not narrowed.
3. CDN config arriving late never reprocessed content
preprocessMarkdown read configStore.getState().cdnDomain imperatively and
RichContent's memo depended only on `content`, so content rendered before the
async config landed kept legacy CDN links as plain anchors permanently.
cdnDomain is now an explicit parameter: RichContent subscribes via
useConfigStore and puts it in the memo deps, while the Tiptap editor — which
genuinely preprocesses once at load — reads the store at its own call sites. No
fallback branch.
Each fix was verified by reinstating its bug and confirming the new tests fail
(2/5, 2/14 and 1/3 respectively) rather than trusting a green run.
Also fixes a false positive in the boundary guard: the Tiptap check read raw
file text instead of using the suite's stripComments helper, so a doc comment
mentioning RichContent counted as a violation.
Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors),
packages/views vitest 245 files / 2793 tests all passing.
Co-authored-by: multica-agent <github@multica.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The create-issue dialogs wire pickers with open={cond ? true : undefined}. Base UI latches a controlled open={true} and does not treat a later undefined as closed, so the project dropdown remains visible after selection. Normalize the picker to an always-boolean open state, matching the other field pickers.
Co-authored-by: 余剑剑 <clydeyu@lilith.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
When agent.model equals the model the Hermes ACP session already reports
as current, Multica was still replaying session/set_model. Hermes'
set_model re-runs provider auto-detection on the model id, which for a
provider:model id whose parsed provider matches the session's current
provider (e.g. a named custom endpoint exposed as "custom") can mis-route
to a different provider — custom:deepseek-v4-pro resolves into the
OpenRouter catalog and the turn fails with an auth error. Only the retry
via session resume, where the corrupted provider no longer matches the
custom: prefix, succeeds.
Capture the runtime's reported currentModelId from session/new and
session/resume and skip the redundant switch when it already equals the
requested model. An empty/unparsable current model falls through and
still sends set_model, preserving prior behaviour. The explicit
provider:model id is never rewritten. Upstream: NousResearch/hermes-agent#59089.
MUL-5029
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
Align the project list view control with the issue list: instead of a
button that flips table <-> cards on click, the trigger now opens a
dropdown menu with a 'View' header and a radio group of view modes
(Table / Cards). Using DropdownMenuRadioGroup keeps it trivial to add
future view modes as new menu items.
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
The windows-execenv job's TestPrepareIsolated_WindowsKillsDescendantBeforeRetry
flakes on the Windows runner: its helper subprocess ends in a bare select{},
which the Go runtime can reap with 'all goroutines are asleep - deadlock!'
(exit status 2) once every goroutine is parked with no wakeup source. That
races the parent's Job Object kill, so PrepareIsolated returns 'helper failed'
instead of the context.Canceled the test asserts.
Block on a timer-backed sleep loop instead: a pending timer is a wakeable
source, so the runtime never declares a deadlock, and the process still dies
the instant the Job Object tears the tree down.
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): live-resort board/list on updated_at drift (MUL-5016)
An open Kanban board / list sorted by "Updated date" did not re-sort in
real time when a card's updated_at advanced — it only picked up the new
order on the next fetch (navigation, invalidation, etc.).
Two triggers now re-sort live:
- comment:created: a new comment bumps the parent issue's updated_at
server-side (MUL-5009), but the WS handler only invalidated the
per-issue timeline cache. It now also invalidates loaded updated_at-
sorted issue-list/board/flat keys via invalidateUpdatedAtSortedIssue
Lists, so the commented card re-sorts (and an off-window card can
surface). Only updated_at-sorted keys are touched.
- same-status field edit: applyIssueChange already reconciled flat
windows on updated_at-sort drift but the bucketed board only marked
stale on membership/status change. It now marks an updated_at-sorted
bucketed key stale whenever the patch advances updated_at, mirroring
the flat-window rule. Deferred to onSettle for mutations (WS flushes
immediately), respecting the existing timing contract.
Extracted patchChangesAnyIssueField shared helper. Tests cover the
coordinator drift/targeting and the comment:created wiring.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): cover assignee-grouped boards and property/metadata events in updated_at re-sort (MUL-5016)
Addresses Elon's review of #5671.
Must-fix 1: invalidateUpdatedAtSortedIssueLists missed assignee-grouped
boards. They fold the sort into their filter bag (issueAssigneeGroupsOptions
does `{ ...filter, ...sort }`) rather than a standalone sort key, so the old
bucketed+flat enumeration skipped them. Replace it with a single predicate
invalidateQueries over issueKeys.all(wsId) that matches any query key with an
object part carrying sort_by: "updated_at" — covering status boards, flat
tables, and assignee-grouped boards (workspace + My Issues) with one rule.
Must-fix 2: custom-property and metadata edits also bump issue.updated_at
server-side (SetIssuePropertyValue / DeleteIssuePropertyValue /
SetIssueMetadataKey / DeleteIssueMetadataKey) but flow through
issue_properties:changed / issue_metadata:changed, not applyIssueChange.
Their handlers patched cards in place and only invalidated property/My-Issues/
grouped windows, so an updated_at-sorted workspace board or flat table stayed
in the old order. Call invalidateUpdatedAtSortedIssueLists from both
onIssuePropertiesChanged and onIssueMetadataChanged. Both are committed-only
paths (WS event + mutation onSuccess); the optimistic leg still uses
patchIssueProperties, so no premature refetch.
Tests: grouped-board targeting in cache-coordinator; updated_at vs position
re-sort for onIssueMetadataChanged / onIssuePropertiesChanged (incl. the
optimistic leg not refetching) in ws-updaters.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): bump issue updated_at when a comment is added (MUL-5009)
A new comment now counts as activity on its issue and advances
updated_at, so the "Updated date" Kanban/list sort surfaces
recently-discussed cards — not only cards whose status changed.
Applies to all three comment-creation paths (user/agent HTTP,
agent task delivery, and the child-done system comment) via a
best-effort TouchIssue query. The bump never fails an already-
persisted comment; it self-heals on the next activity if it errors.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): make comment updated_at bump atomic (MUL-5009 review)
Address Elon's review. Move the updated_at bump into CreateComment as a
leading data-modifying CTE so the comment insert and the timestamp bump
commit or roll back together — closing the non-atomic window where a
comment could persist while updated_at stayed stale. That window also
skewed the daemon GC TTL, which reads issue.updated_at to reclaim
done/cancelled workdirs.
Centralizing the bump in the query drops the three per-caller TouchIssue
calls and guarantees any future comment entrypoint inherits it.
Also refresh the now-stale gc.go / gc_test.go comments that asserted
'CreateComment does not bump issue.updated_at'.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): make comment/issue workspace match a query-level guarantee (MUL-5009 nit2)
The touch CTE now RETURNING id, workspace_id and the INSERT SELECTs from
it, so the comment insert depends on the issue actually existing in the
passed workspace. A mismatched (issue, workspace) pair matches 0 rows in
the CTE, the dependent INSERT selects nothing, and the :one query returns
pgx.ErrNoRows — no mis-attributed comment is written and the issue is not
touched.
CreateComment is now the single carrier of the 'a comment belongs to an
issue in the same workspace and always bumps it' invariant, so no future
caller can break it by passing the wrong workspace. Signature unchanged;
no migration or foreign key.
Add TestCreateComment_WorkspaceMismatchPersistsNothing (error returned,
no comment persisted, updated_at unchanged).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): bound pre-start task preparation
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): isolate pre-start env preparation
Run execution-environment Prepare and Reuse in a killable helper process so a timed-out attempt cannot keep writing after retry. Add FIFO lifecycle and squad Stage retry regression coverage.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): terminate Windows prepare process trees
Assign the pre-start helper to a kill-on-close Job Object before releasing its request, wait for all job members to exit on cancellation, and add a Windows runtime regression job.
Co-authored-by: multica-agent <github@multica.ai>
* ci: target Windows prepare tree regression
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
The auto-save mount effect only cleared `mountedRef` in its cleanup and never
re-set it to true on setup. Under React StrictMode the initial
setup/cleanup/setup cycle therefore leaves `mountedRef.current` pinned to false
for the component's whole life. A successful save then never reaches the
terminal branch (`succeeded && mountedRef.current`), so the status is stranded
on "saving" and the success toast never fires — the settings save indicator
spins forever even though the PATCH returned 200.
Set `mountedRef.current = true` on every mount so the flag reflects the live
component. Shared by all auto-saved settings (repositories, GitHub, etc.).
Add a StrictMode regression test asserting onSuccess still fires.
The display-settings trigger showed the current sort mode (e.g. "Manual")
as its label, which reads as an unclear button name. Show a fixed
"Display" label with the sliders icon instead, matching the existing
"Display settings" tooltip. The sort mode remains visible inside the
popover's Ordering section.
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
Classify outbound replies by task origin (chat_input_task_id) before consulting a channel binding, so web/mobile direct-chat completions and failures stay inside Multica even when the session was originally created from Lark or Slack. Channel-created tasks continue to deliver. Closes#5644.
* feat(views): support the send shortcut in manual issue create (MUL-4931)
Agent create has had Cmd/Ctrl+Enter all along; manual create had no submit
shortcut at all, in either the title or the description.
Reuse the configurable `send` action rather than hardcoding the chord, so a
rebound or unbound shortcut follows the user's setting and the IME guards and
Shift+Enter replay come along for free:
- Description: pass `onSubmit` to ContentEditor, which already carries the
extension.
- Title: add an opt-in `onSubmitShortcut` to the shared TitleEditor. Plain
Enter stays inert there — #5532 removed that trigger a day ago because it
created from half-typed titles — and a single-line title has no newline to
trade Enter against, so the chord path refuses a plain-Enter `send` binding.
Hosts relying on plain-Enter submit (create-project, autopilot-dialog) keep
`onSubmit` and are untouched.
Also fixes a real double-create: `submitting` is state, so two chord presses in
one tick both read the stale value and fired two creates. A ref flips
synchronously and single-flights both create panels.
Accessibility: the empty-title Create button moves from native `disabled` to
`aria-disabled`, so it stays focusable and keyboard/SR users can finally reach
the "Enter a title to create" tooltip. Keycaps are decorative, keeping the
accessible name "Create Issue". Empty-title chord submits now focus the title
instead of silently no-oping.
Widen the `send` setting description across all four locales — it claimed to
cover only chat, comments, replies, feedback and prompts.
Tests: real-ProseMirror coverage for the title chord (every other editor test
mocks Tiptap, so nothing verified extension ordering), plus chord/plain-Enter/
empty-title/upload-gate/single-flight/keycaps/aria-disabled cases. The
single-flight test dispatches both presses inside one act(); it fails against
the old state-based guard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(views): cover quick-create single-flight + aria-disabled visuals (MUL-4931)
Review follow-up on #5583.
The quick-create ref guard shipped without a regression test, so the claim that
both create panels were covered was only true of manual create. That path files
a real issue, so a double-fire is a duplicate issue rather than a glitch. Add
the same-tick regression: both presses dispatch inside one act(), and it fails
against the old state-based guard (expected 1 call, got 2).
The Button base only styles native `disabled` (disabled:opacity-50 /
disabled:pointer-events-none), so the aria-disabled empty-title button stayed a
fully lit, pressable-looking primary. Add local aria-disabled opacity, cursor,
and press-animation styles, with no pointer-events-none — that would break the
tooltip hover and the click that focuses the title. Verified against compiled
Tailwind output: aria-disabled:active:translate-y-0 and the base's
active:not-aria-[haspopup]:translate-y-px have equal specificity and the
override emits later, so it wins without !important.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Board and Swimlane crashed with "Maximum update depth exceeded" on first
paint of the Issues route. During cold load the member/agent/squad
directory queries are unresolved, so useActorName's `= []` defaults
allocated a fresh array every render and its `getActorName` memo changed
identity each render. BoardView's `groups` (and SwimLaneView's
`laneGroups`) list `getActorName` as a dep, so they churned every render
and re-fired the column-resync effect without end; react-virtuoso
escalated the loop into the reported crash. This predates MUL-4797 — it
was exposed when board/swimlane columns were virtualized with a real
<Virtuoso>.
- Share stable empty references for the loading directory snapshot so
getActorName is referentially stable across cold-load renders (root
cause).
- Equality-guard the shared column setter in useDragSettle so a
content-equal rebuild returns the previous reference and cannot spin
the resync effect (defense-in-depth).
Regression coverage: useActorName stability during cold load; the
column-setter equality guard; and Board (40 cards) + Swimlane rendered
with the REAL react-virtuoso under pending directories, which reproduce
"Maximum update depth exceeded" without the fix and pass with it.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): add configurable table view
Co-authored-by: multica-agent <github@multica.ai>
* test(issues): cover table columns in page fixture
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): make table column picker interactive
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): repair quick create and virtualize table rows
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): keep pinned table cells opaque
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): anchor full-width table rows
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): consolidate table controls
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): harden table pagination and export
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): add table quick search
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): make table window filters, selection, and export authoritative
Round-2 review fixes for the issues table (MUL-4797):
- Send the agents-working filter as a server ids facet so matches on
unfetched pages surface and total/pagination/export agree; a present-
but-empty id list yields an empty window instead of an unfiltered one.
- Reset surface selection when the membership window changes and act on
selection ∩ visible rows in the batch toolbar, so batch actions,
Export selected, and the count all share one authoritative set.
- Materialize the full flat window while table grouping is active, and
suspend hierarchy nesting / parent-based grouping until the window is
complete so structure cannot reshuffle as pages arrive; suppress
header facet-count badges while the table window is partial.
- Resolve actor directories and the property catalog at export time and
fail the export instead of writing Unknown* actors or dropping
configured property columns on cold/errored lookups.
- Append a unique id tie-break to the list/grouped ORDER BY and mirror
it in compareIssuesForSort so offset pages are stable across
same-timestamp ties.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): bound table structure window and align chip/transport/selection
Round-3 review fixes for the issues table (MUL-4797):
- Cap whole-window materialization at TABLE_STRUCTURE_MAX_WINDOW (1000):
below it the remaining pages load automatically — hierarchy applies
without scrolling to the last page — and above it grouping/hierarchy
suspend with an explicit toolbar notice instead of triggering an
unbounded workspace download from a persisted view option.
- Give the agents-working chip the authoritative in-window running set
(the ids-facet window query, shared key with the filter-on state) so
its badge can no longer say 0 while the filter would find matches on
unfetched pages; falls back to loaded-row scoping elsewhere.
- Route ids-facet windows through a new POST /api/issues/query twin —
hundreds of running-issue UUIDs overflow the ~8 KB GET request-line
budget of common proxies. The body carries the same key/value pairs;
the handler rebuilds the query string and delegates to ListIssues.
- Reset surface selection during render (key-change pattern) instead of
a post-commit effect, so no frame ever pairs new membership with the
old selection.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): harden table auto-pagination against errors and stale totals
Round-4 review fixes for the issues table (MUL-4797):
- Stop the structure materialization loop (and the scroll sentinel) when
the window query is in error state — a persistently failing page left
hasNextPage true and isFetchingNextPage false after every attempt, so
the ungated effect refired forever. Resuming is an explicit toolbar
Retry. The advancement decision now lives in a pure, tested
shouldAutoLoadNextStructurePage helper.
- Make the structure ceiling a hard stop: the ceiling check reads the
LATEST page's total (pagination already advances on it, so a stale
small page-1 total could re-open unbounded materialization), and the
loop additionally halts on loaded count >= ceiling regardless of any
reported total.
- Drive the working (ids-facet) window to completion — it is inherently
bounded by the running set — and treat it as the chip's authoritative
scope only when complete, so >100 running issues no longer under-count
as a single page.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): make working-window pagination capped and unknown-aware
Round-5 (final) review fixes for the issues table (MUL-4797):
- The working (ids-facet) window now advances through the same
shouldAutoLoadNextWindowPage gates as the structure loop — it shares
the main table's cache key while the agents-working filter is on, so
an uncapped chip-driven loop re-opened the very ceiling the table just
enforced. An over-ceiling window stops after page one.
- A cold-load failure of the flat window is an ERROR state, not an empty
workspace: isEmpty only claims empty on a successful zero-result
fetch, and the surface renders a dedicated failed-to-load state with a
reachable Retry (the in-table Retry never mounted without data).
- The chip scope is now tri-state honest: a COMPLETE window (or an empty
running set) yields a precise count, keepPreviousData carries the
last-known-complete set across re-keys, and everything else — cold
resolving, failed, over the ceiling — presents as an explicit unknown
('Agents working: —') instead of a number derived from whichever
incomplete window happened to be loaded.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): single pagination owner and placeholder-honest chip scope
Round-6 review fixes for the issues table (MUL-4797):
- Exclude placeholder data from the working-window completeness gate:
on a re-key (running set or facet change) keepPreviousData leaves the
OLD key's rows visible, and pairing them with the new task snapshot
published a precise-looking number for a scope nobody fetched. The
scope now reads unknown until the new key resolves.
- Make the shared table query single-owner while the agents-working
filter is on: the chip's background loop no longer answers the same
render snapshot as TableView's structure loop, and every auto caller
(structure loop, working loop, scroll sentinel, retry) now uses
fetchNextPage({cancelRefetch: false}) so a concurrent responder
no-ops instead of cancel/restarting a fetch whose HTTP request is not
abortable — which had been duplicating every offset.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): isolate Linux Codex git metadata (MUL-4925)
Co-authored-by: multica-agent <github@multica.ai>
* refactor(daemon): address isolated-checkout review nits (MUL-4925)
- rename sameFilesystemPath -> sameResolvedPath (it compares resolved
paths for equality, not same-device), with a clarifying doc comment
- prune earlier tasks' agent/* branches when reusing an isolated
checkout so a long-lived reused workdir stops accumulating one local
branch per checkout; deleteLocalBranches now takes a keepBranch arg
and the prune is non-fatal
- cover the prune in TestCreateWorktreeReusesIsolatedGitMetadata
* fix(repocache): preserve user branches on reuse (MUL-4925)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): add v0.4.4 (2026-07-17) release entry
Adds today's user-facing changelog entry across en/zh/ja/ko: a new autopilot schedule editor, an interactive diagram viewer, plus the day's improvements and fixes.
Co-authored-by: multica-agent <github@multica.ai>
* docs(changelog): use 自动化 for autopilot in zh, matching the standard term
Per Bohan's review: the Chinese standard for autopilot is 自动化 (as used elsewhere in zh.ts, e.g. 定时自动化 / 触发式自动化), not 自动驾驶. Normalizes all 10 occurrences in the changelog, including the new v0.4.4 entry and earlier historical entries, for consistency.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(task): auto-retry transient provider stream cut like chat (MUL-4910)
Claude Code's "API Error: Connection closed mid-response" is a transient
network cut. In unattended issue runs it fell through to
agent_error.unknown / process_failure — neither in retryableReasons — so
the task terminated. Interactive chat only appeared resilient because the
CLI's own in-process retry usually recovers first; there is no
chat-specific retry in Multica. Both paths share the same
finalizeStreamResult -> Classify -> retryableReasons pipeline.
- classify: route "connection closed" / "mid-response" to
agent_error.provider_network, before the exit-status rule so the
"exited with error: exit status N" variant also lands here.
- retry: add provider_network to retryableReasons. It is resume-safe, so
the retry child inherits the session and continues the truncated
conversation instead of restarting.
Tests cover the new classification (incl. exit-status variant) and the
retry/resume flags. Note: mirror the substrings into the MUL-1949 offline
backfill SQL to keep in-flight and historical taxonomies aligned.
Co-authored-by: multica-agent <github@multica.ai>
* feat(task): defer provider_network's final retry ~5s — three-tier (MUL-4910)
Follow-up to the immediate-retry fix: make the connection-closed retry a
three-tier schedule — first run + immediate retry + one retry deferred ~5s —
so a blip that survives the immediate retry gets a short cooldown before the
final attempt instead of firing back-to-back.
Reuses the existing deferred/fire_at primitive (the comment-routing escalation
mechanism) rather than adding new infrastructure: CreateRetryTask gains an
optional fire_at; when set, the child is inserted 'deferred' and the existing
PromoteDueDeferredTasksForRuntime sweeper — already run promote-first on every
claim poll — flips it to 'queued' at fire_at. No migration, no claim-query
change, no daemon change.
- retryAttemptCeiling: raise provider_network's ceiling to 3 (other reasons
keep max_attempts=2); applied in both retryEligible and
MaybeRetryFailedTask's budget pre-check so the primary and sweeper paths agree.
- retryDelayForAttempt: only provider_network's final attempt is deferred (5s);
every other retry — including provider_network's first — stays immediate.
- FailTask + MaybeRetryFailedTask pass fire_at and skip the queued
broadcast/notify for a deferred child (promotion emits them at fire time).
Timing: the deferred child fires on the first claim poll at/after fire_at, so
>= 5s; on an otherwise-idle runtime it can stretch to the poll interval — the
same behaviour deferred escalations already have.
Tests: pure schedule/eligibility coverage (TestProviderNetworkRetrySchedule)
plus a DB test asserting fire_at controls deferred-vs-queued, attempt=3, and
resume-safety.
Co-authored-by: multica-agent <github@multica.ai>
* fix(task): persist reason-aware retry budget; respect max_attempts=1 disable (MUL-4910)
Addresses the pre-merge review must-fix: retryAttemptCeiling raised
provider_network to 3 unconditionally, which (1) overrode the
max_attempts=1 "auto-retry disabled" contract and (2) persisted a
self-contradictory child (attempt=3, max_attempts=2) that leaks to the
task API, so a naive attempt < max_attempts consumer would misjudge the
budget.
- retryAttemptCeiling now returns taskMaxAttempts unchanged when it is <= 1
(disabled stays disabled) and only ever WIDENS otherwise (max(col, 3) for
provider_network) — a higher configured budget is kept.
- CreateRetryTask takes an optional max_attempts; FailTask and
MaybeRetryFailedTask write the reason-aware effective ceiling into the
child so the whole retry chain self-describes (attempt=3, max_attempts=3).
NULL inherits the parent column, so non-provider_network reasons are
unchanged.
Tests:
- pure: ceiling widens to 3, keeps a higher budget, and never revives a
disabled (max_attempts=1) task; eligibility rejects the disabled case.
- DB (end-to-end FailTask): default budget → deferred final child at
attempt=3/max_attempts=3; first failure → immediate child; max_attempts=1
→ no child. Plus CreateRetryTask persists the passed budget / inherits on NULL.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
Replace the free-form trigger-config form with a structured schedule
editor built on an orthogonal cron model: separate frequency, time, and
day-of-week/day-of-month dimensions map to and from cron expressions via
a dedicated grammar and mapping layer, with validation and a
human-readable describe() summary. The grammar suite drives the editor
against a combinatorially generated corpus of 51,755 distinct cron
expressions - every token form of every field, crossed - each judged
against a reference robfig/cron v3 parser.
Add a server-side /autopilot/cron-preview endpoint (plus schema and
React Query hook) so the editor shows upcoming run times, and echo
wildcard-carrying cron lists correctly instead of collapsing them.
Supporting pieces: timezone-aware formatting helper, segmented-toggle
and debounced-value utilities, a reworked time-input, and refreshed
en/ja/ko/zh-Hans locale strings.
* feat(editor): standard Mermaid viewer with pan/zoom, exports and a real dialog (MUL-4908)
Mermaid's fullscreen view was a bespoke lightbox: no visible close button, no
canvas interaction, and a toolbar that scrolled away with wide diagrams. Escape
also stopped working once the iframe took focus, which is why it read as
"impossible to close".
Replace it with a viewer built on the shared Dialog, so it inherits the
backdrop, focus trap, scroll lock, focus restore and Escape handling that HTML
preview already had.
The diagram stays in an `sandbox=""` iframe — isolation is not relaxed to buy
interactivity. Instead the iframe is `pointer-events: none` and pan/zoom is
applied from the host document as a transform on its wrapper. That inversion is
also what keeps Escape working: the iframe can never take focus.
- Viewer: fit-on-open, drag pan, wheel/pinch/keyboard zoom (25%-400%) anchored
at the pointer, Fit / 100% / Reset, and a header toolbar that cannot scroll
away. Panning is clamped so the canvas can never be lost.
- Export: `.mmd`, `.svg` and `.png`. Requires `htmlLabels: false` — browsers do
not rasterize Mermaid's default HTML-in-foreignObject labels through an
`<img>`; verified in Chromium that they paint zero pixels AND taint the
canvas, so PNG export silently produced nothing.
- Inline: toolbar lifted out of the scroll container, natural-size rendering
(small diagrams centered, wide ones scroll at a readable size with edge
fades), copy source, and a parser message on the error fallback.
- Theme switches no longer close the viewer or discard zoom/position; the
previous diagram stays on screen until its replacement is ready.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(editor): stop Mermaid drags selecting text and misfiring the viewer (MUL-4908)
Two gesture defects on the inline diagram, reported by Naiyuan.
1. Dragging a diagram started a native text selection. The iframe is a replaced
element, so the whole diagram box got painted with the selection highlight
and the drag ran on into the surrounding comment text. Fixed with
`user-select: none` on the inline scroller and the viewer canvas.
Deliberately NOT by preventDefault-ing pointerdown: verified in Chromium that
it also drops the default focus, which silently kills the viewer's keyboard
controls (+/-, 0, arrows).
2. Any drag ended in a `click`, so trying to look along a wide diagram opened
the viewer on top of the user. The inline diagram had no drag handling at
all — only `onClick`. Suppressing the selection alone would have made this
fire more reliably, not less, so both had to land together.
Past a 5px threshold the gesture is a drag for good: releasing no longer
taps, and a horizontal drag pans the scroller instead. This holds even when
the diagram has no room to scroll, so an unscrollable diagram cannot open on
release either. A still click, a sub-threshold jitter, and the expand button
all still open the viewer.
Touch is left alone — it already pans natively and its vertical drags belong to
the page; the browser announces its takeover with pointercancel. Mouse and pen
have no native drag-to-scroll and are panned here.
Verified in Chromium: still click opens; a 150px drag pans and does not open; a
drag on an unscrollable diagram does not open; 3px jitter still opens; and no
gesture selects text.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The Chat tab (and floating chat window) both render the shared
ChatMessageList, re-keyed by activeSessionId so it fully remounts on
every session switch. The Virtuoso had no initial-position prop, so a
fresh mount rendered from the top; the only downward scroll driver was
followOutput, which reacts to post-mount data growth and is gated on
isNearBottom. Whether it landed at the bottom depended on a race
(cached sessions resolve synchronously and stuck at the top; fetched
ones sometimes caught a growth tick), producing the inconsistent
top-vs-bottom behavior.
Pin the newly-mounted list to the newest message with
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }} so switching
sessions always lands at the bottom. align: 'end' also bottom-aligns a
last message taller than the viewport.
Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
Self-hosted upgrades to v0.4.3 failed closed on migration 198's VALIDATE of the strict attribution constraint, because the legacy rows migration 190 exempted were only backfilled out-of-band on cloud. Registers a pre-198 preMigrationHook that idempotently mirrors originator_user_id into accountable_user_id in batches before VALIDATE, with FOR UPDATE + repeated predicate to avoid clobbering concurrently-written rows, so a stuck-at-197 instance auto-heals on migrate up with no manual SQL. Originator-NULL rows are left untouched. Verified with unit + concurrency + end-to-end tests against real Postgres.
Fixes#5544