Commit Graph
998 Commits
Author SHA1 Message Date
8908fcfbc4 feat(server): reserve the triage status key (MUL-7212) (#8298)
Make `triage` a platform-reserved issue status: it has no catalog row, is not
a category a custom status can declare, and no ordinary write can set it or
move an issue out of it.

- issuestatus: Triage constant; IsBuiltIn covers it, so ValidateKey, DeriveKey
  and firstFreeKey refuse or skip the key; Resolve refuses it with
  ErrReservedStatus; Effective and Category resolve it without a catalog read
- migrations: a barrier file adds CHECK (key <> 'triage') NOT VALID on its own,
  so it commits before the repair file renames a pre-existing custom `triage`
  key per workspace (firstFreeKey rule) across the catalog, issues and saved
  views and VALIDATEs; issue_effective_status() gains 'triage' on its fast path
- writes: status_reserved_for_triage on create/update/batch; issue_in_triage
  when status, project_id or parent_issue_id is written on an issue in Triage;
  merged PRs no longer auto-advance a Triage issue
- status sort ranks triage first; duplicate guard ignores triage issues
- notification label, CLI help, TriageV1 feature flag key
- cmd/migrate forwards notices marked 'migration report: ' to its log

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-14 15:20:19 +08:00
5d7877287c MUL-7340 fix(lark): share bind-session state across replicas so the QR survives the first status poll (#8376)
* fix(lark): serve bind-session status from Postgres so any backend replica can answer (MUL-7340)

The device-flow install session lived only in RegistrationService's
in-process map, so GET /lark/install/{session_id}/status could be
answered by exactly one backend process — the one that served begin. Any
other replica, or the same one after a restart, returned 404, which the
dialog renders as "安装会话已失效或丢失,请重新扫码" (install session not
found). The browser's first status poll fires ~5s after the QR appears,
which is exactly when users saw the QR die.

Persist the status projection in a new lark_install_session table and
read it there. The device_code is deliberately NOT persisted: it is a
bearer credential and only the process running the polling goroutine
needs it, so that process keeps its working state in memory and the row
carries just what the browser reads.

A row left 'pending' past expires_at — the owning process died mid-flow —
is now reported as expired from the timestamp rather than polling
forever. Both terminal writes are guarded on status = 'pending', so a
racing expiry and poll result resolve first-writer-wins in SQL instead of
under a mutex.

Workspace teardown drops the table's rows alongside channel_installation.

Co-authored-by: multica-agent <[email protected]>

* refactor(lark): move bind-session state from a new SQL table to the shared Redis store

Review feedback: the transient scan state does not need its own table.
The repo already has a home for exactly this shape — a short-lived,
cross-replica request whose status a browser polls — in the runtime
update / model-list / local-skill stores: Redis when configured, an
in-process implementation for single-instance development.

Replaces the lark_install_session table, its sqlc queries, the two
migrations and the sweep with an InstallSessionStore interface plus a
Redis and a memory implementation, and drops the workspace-teardown
entry the table needed. PostgreSQL keeps what it should own: the
committed Bot installation and the installer binding.

First-writer-wins now falls out of SetNX rather than an UPDATE guarded on
status = 'pending'. The record splits across an immutable key written by
Create and a terminal key written once, so a racing expiry and poll
result resolve without a read-modify-write.

Retention is the store's TTL, sized per session as the QR window plus
SessionTTL so the dialog can still read a terminal outcome recorded at
the last second — not a fixed constant that would drift from Lark's
expires_in.

A Redis error surfaces as an error, never as not-found: not-found is
terminal in the dialog, so a blip must not permanently kill a live scan.
Without Redis the service keeps the in-process store and the router logs
a warning, because that configuration reproduces MUL-7340 on more than
one replica and should not be chosen silently.

Co-authored-by: multica-agent <[email protected]>

* fix(lark): make the bind-session terminal write atomic and retried

Two failures found in review of the Redis store.

1. A terminal write that failed was logged and dropped. By the time
   markSuccess runs, the installation and the installer binding are
   already committed in Postgres, so discarding that write left a user
   who had successfully bound watching the dialog sit on 'pending' until
   the QR expired and then report failure. The write is now retried with
   backoff — 8 attempts over roughly a minute, enough to ride out a Redis
   failover rather than just a dropped packet — and the session is held
   until it settles. A store that reports the session gone stops the loop
   immediately, since no retry can fix that.

2. The record spanned two keys whose TTLs were set by separate commands.
   A SETNX that landed followed by a failed EXPIRE left the terminal
   status under the base key's original, much longer retention, and the
   retry could not repair it because SETNX then reported that someone
   else had won. A session that finished early lost its terminal field
   first and read as 'pending' again.

   The record is now a single hash written by Lua, so status and
   retention move together or not at all. The terminal script writes the
   status only when none is set — the same first-writer-wins guarantee —
   but runs EXPIRE unconditionally, which is what makes a retry
   self-repairing rather than a no-op. Create is scripted too, so a
   failure can no longer leave a key with no TTL behind.

Also drops a stale comment referencing the removed SQL file.

Co-authored-by: multica-agent <[email protected]>

* docs(lark): correct the retry budget and the atomicity claim

Re-review nits on #8376, both accuracy rather than behaviour.

The retry budget did not add up to the minute the comment claimed: 8
attempts is 7 waits totalling 25.4s, and the 15s cap was never reached.
The intent — ride out a Redis failover, not just a dropped packet — was
the right one, so raise it to 10 attempts (9 waits, 55.4s, cap now in
play) and state the arithmetic plus the per-attempt timeout on top. Also
say plainly that this is a bounded recovery window, not a guarantee.

The store comment implied Lua atomicity made the terminal write
all-or-nothing. Redis does not roll back a script that fails partway
through, so atomicity buys isolation from other clients, not recovery.
What actually removes the original two-key bug is one key with one
expiry, plus a retry that keeps the first status and re-runs EXPIRE
unconditionally. Comments now say that.

The test claiming a 'mid-script' failure injected before execution.
Renamed to what it covers, and added the case that was missing: the
script runs on the server, the client loses the reply, and the replay
must keep the first outcome AND re-assert retention.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-14 15:19:06 +08:00
Rusty RavenandBohan Jiang b8d03bc3f4 MUL-7326 fix(service): coalesce duplicate EnqueueTaskForIssue on the assignee path (#5914) (#6304)
* fix(service): coalesce duplicate EnqueueTaskForIssue on the assignee path (#5914)

Follow-up to #5958, which mapped the duplicate-pending-task race to
ErrDuplicatePendingTask on the mention path (enqueueMentionTaskWithCommentPlan)
but left the issue-assignee path untouched. enqueueIssueTaskWithCommentPlan is
the second write point of the idx_one_pending_task_per_issue_agent unique
index, so a concurrent duplicate there still logged at ERROR and surfaced as a
generic create-task error (HTTP 500 leaking the raw constraint name).

- enqueueIssueTaskWithCommentPlan: reuse isDuplicatePendingTaskErr and return
  the existing ErrDuplicatePendingTask sentinel (slog.Debug, not Error) on the
  race — identical treatment to the mention path.
- enqueueSingleCommentTrigger (issue-assignee case): switch the plain slog.Warn
  to the shared logCommentEnqueueFailure helper so the now-typed sentinel is
  downgraded to debug and resolveCommentTriggerEnqueue can coalesce, matching
  the sibling squad-leader and mention cases.
- Add TestEnqueueTaskForIssueCoalescesDuplicatePendingTask alongside the
  mention-path regression: a second EnqueueTaskForIssue for the same
  (issue, agent) returns ErrDuplicatePendingTask, does not leak the constraint
  name/SQLSTATE, and leaves exactly one pending task.

Fixes the remaining EnqueueTaskForIssue case called out in #5938.

* test(handler): cover the issue-assignee source in the queued-winner race

Bohan-J review on #6304, must-fix 1 and 2.

Table-drive TestCommentEnqueueRaceQueuedWinnerFoldsLoser over the two
trigger sources that can lose the duplicate-pending-task race on an
issue whose agent is the assignee. The mention source has been covered
since #5958; the issue-assignee source — the half #5914 left behind —
had no handler-level coverage at all, so the comment.go hunk could be
reverted with the whole handler suite still green.

The new subtest pins both hunks: the status assertion guards the
service change (blocked -> coalesced), the log assertion guards the
handler change (benign race no longer surfaces as WARN).

Also correct the pendingSlotTakenErr doc comment, which this PR made
wrong: both paths behind RerunIssue now normalize the unique violation
into ErrDuplicatePendingTask, so the raw-violation half is a defensive
net rather than the issue-assignee path's shape.

---------

Co-authored-by: Bohan Jiang <[email protected]>
2026-09-14 14:47:25 +08:00
686957e743 MUL-7271: test(server): cut slow Go test time further (follow-up to #8295) (#8318)
* test(server): reduce slow unit test waits

Co-authored-by: multica-agent <[email protected]>

* test(server): cut slow Go unit test time further

Follow-up to the first pass on MUL-7271. Test-only changes plus a few
package-private seams whose production values are unchanged.

- Re-executed race-built test binaries no longer pay the race runtime's 1s
  atexit sleep (GORACE=atexit_sleep_ms=0 for child processes only).
- pkg/agent: event-driven or shorter test-only bounds for handshake, drain
  and idle graces; redundant timeout variants merged.
- daemon/execenv: git fixtures copied from a template repo built once; tests
  that re-ran the same worktree lifecycle folded into their supersets;
  ForkLock-guarded fake-CLI writes so parallel tests cannot hit ETXTBSY.
- handler/service: cap and paging tests shrink their caps instead of seeding
  thousands of rows; lock waits probe pg_locks instead of sleeping; one
  shared pgxpool per service test binary; cheaper cron and RSA fixtures.
- wecom: the phishing-link sweep reuses one parser and derives CRLF from LF
  with a sampled cross-check; fake sockets acknowledge instead of waiting out
  the ack timeout.
- migrate: the migration corpus is parsed once; isolated DB tests run in
  parallel.

Co-authored-by: multica-agent <[email protected]>

* test(server): remove transport timeout unit tests

Co-authored-by: multica-agent <[email protected]>

* test(server): restore two non-timeout tests and make a bound load-bearing

Follow-up to the review of #8318. All test-only; no production code changes.

- daemon: restore the three read-liveness cases that the transport-timeout
  sweep took with it. They assert that a ping, a pong and an application
  frame each EXTEND the read deadline, so a healthy wakeup socket survives
  without traffic of our own — the deadline never fires on the happy path,
  which is why they are not transport-timeout tests. Verified they fail
  (all three) when the extension calls are removed from wakeup.go, and pass
  in 0.57s together. The pure "times out without peer traffic" case stays
  deleted.

- pkg/agent: restore the parent-DEADLINE half of the opaque-env redaction
  pair. codex.go treats a parent deadline and a parent cancellation as one
  ending; only the Canceled arm was left covered. The fake now writes the
  secret to stderr before reading the handshake, so the check no longer
  races our own initialize write, and the budget is 2s against a measured
  430-465ms launch. Verified it fails when the !contextEnded guard is
  removed.

- pkg/agent: TestRunCollectQuietDoesNotWaitWhenTheAnswerIsIn had its bound
  widened to a literal 5s, which the shrunk 750ms drain grace can never
  reach — the test could no longer fail. It now raises the drain grace to
  10s for its own duration and asserts under 3s: healthy runs take
  0.19-0.44s, and a broken short-circuit takes 10.19s and fails.

- wecom: gofmt the import block in relay_settle_budget_test.go.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: J <[email protected]>
2026-09-14 14:33:50 +08:00
7dafc0cd62 MUL-7240: Unify issue lifecycle into four stored categories (#8261)
* feat: simplify issue status categories

Co-authored-by: multica-agent <[email protected]>

* feat: adopt four stored issue lifecycle categories

Co-authored-by: multica-agent <[email protected]>

* fix: preserve visible work when upgrading saved category preferences

Co-authored-by: multica-agent <[email protected]>

* docs: align lifecycle comments with stored and wire contracts

Co-authored-by: multica-agent <[email protected]>

* test: align mobile grouping expectations with four categories

Co-authored-by: multica-agent <[email protected]>

* fix(issues): group user views by concrete status keys

Co-authored-by: multica-agent <[email protected]>

* fix(settings): simplify status editor copy and category selector

Co-authored-by: multica-agent <[email protected]>

* feat(statuses): allow ordering built-ins and unify row actions

Co-authored-by: multica-agent <[email protected]>

* feat(statuses): configure custom status icons independently of category

Co-authored-by: multica-agent <[email protected]>

* fix(statuses): dismiss built-in status notice

Co-authored-by: multica-agent <[email protected]>

* fix(issue-status): require moving issues before archive and polish controls

Co-authored-by: multica-agent <[email protected]>

* fix(ci): renumber issue status migrations after main updates

Co-authored-by: multica-agent <[email protected]>

* fix(statuses): isolate archive inspection and align catalog behavior

Co-authored-by: multica-agent <[email protected]>

* fix(statuses): respect injectable catalog for table grouping

Co-authored-by: multica-agent <[email protected]>

* refactor(statuses): simplify built-in skill and runtime guidance

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-14 12:57:24 +08:00
Bohan Jiang a9e82c7973 MUL-7282: fix(comments): delete only the selected comment and keep its replies (#8323)
Deleting a comment removed every reply under it, through the parent_id ON DELETE CASCADE. Now delete removes only that comment: one with replies becomes a tombstone (deleted_at set, body/attachments/reactions/resolution cleared) so the replies keep their parent, and one without replies is removed along with any tombstone ancestor left empty.

Tombstones are read-only and are never agent input. Every write to a comment's children takes its owners first (issue -> comment -> child), and a comment commits together with the attachments it was posted with. Clients only promise that replies are kept when the server declares comment_delete_keep_replies_supported, and then delete through DELETE /api/comments/{id}/keep-replies, which older servers do not route.
2026-09-13 19:13:43 +08:00
Dante 8bf3046f22 MUL-7275: fix(skills): normalize Windows archive entry paths (#8297)
* fix(skills): normalize Windows archive paths

Signed-off-by: Dante <[email protected]>

* fix(skills): reject ambiguous archive paths

Signed-off-by: Dante <[email protected]>

* fix(skills): skip Windows ZIP directory markers

Signed-off-by: Dante <[email protected]>

---------

Signed-off-by: Dante <[email protected]>
2026-09-13 17:42:33 +08:00
Yushan 3551e72e76 MUL-7232 feat(daemon): add dsh desktop(self-hosted) support (#8237)
* feat(daemon): discover the DSH CLI bundled in DeepSeek Harness Desktop

DeepSeek Harness Desktop ships its CLI inside the .app and never installs
`dsh` onto PATH, so a GUI-launched daemon missed it on exec.LookPath — and the
login-shell sweep could not rescue it either, because no rc file has a reason
to know that path. Only a hand-made symlink made dsh visible at all.

Add the app-bundle fallback the Codex provider has had since #5205. The
candidate is a Node script rather than a native binary, so it counts only while
it is executable: an entry discovered without the executable bit would be
advertised as a healthy runtime and then fail on every spawn.

The Multica runtime profile gate still applies — a bundled CLI without the
profile is not a runtime.

* fix(daemon): report and install the missing DSH runtime profile

probeAgentCLIs is documented as pure discovery, but dsh's runtime-profile gate
lived inside it — one layer above the verdict machinery that turns a drop into
a /health skipped_agents reason and a daemon-log line. A dsh binary without the
profile therefore vanished from the runtime list with nothing anywhere saying
why, which is the shape the DSH reports in #7721 and #6936 run into.

Move the gate down to probeBuiltinRuntime and give it its own verdict: dsh is
now discovered like any other CLI, and failing to use it is a reported,
actionable drop. The verdict is deterministic, so it demotes immediately rather
than waiting out a confirmation window — it is a fact about the CLI's
environment, not about bytes mid-write. The executable is checked first,
because probeDshMulticaProfile cannot tell "the profile refused" from "there
was nothing to run", and would otherwise blame a missing profile for a path
that vanished under us.

Reporting is only half of it: DSH is the one built-in whose CLI is not enough
on its own, and no other provider needs anything installed into the agent's own
home. MULTICA_DSH_PROFILE_BUNDLE now names what to install — an npm spec, a
directory or a tarball, candidates tried in order. Unset stays the default and
means "do not touch the user's DSH installation"; there is nothing to point it
at yet either, because Multica's own bridge is unpublished (#6936).

DSH Desktop ships the pnpm that `dsh plugin` forwards to inside its own
runtime-commands directory and injects it only for the processes it spawns, so
the install prepends that directory to PATH. Without it the command fails with
"pnpm not found on PATH" on exactly the machines this is for. The install runs
once per daemon, and the discovery loop is kicked when it finishes so dsh comes
online immediately instead of at the next tick.

* fix(daemon): keep a registered dsh runtime in step with its runtime profile

Two states have to agree: the Multica runtime profile is installed, and a dsh
runtime is registered. Neither disagreement corrects itself, and both leave the
server routing work into a CLI that cannot start.

When the profile is removed, dsh stays discovered and keeps its runtime, so
nothing else in the discovery loop looks at it again. Observed in the field: a
profile removed a minute after startup stayed live for three minutes and a chat
task failed against it. When a profile is installed by hand, the runtime is
missing but the converge backoff has already doubled to agentConvergeMaxBackoff
(30m) over the rounds that could not register it, so the fix stays invisible
for up to half an hour.

Compare the two states on every tick and force a round on disagreement, in
either direction. A round forced for a registered provider has nothing missing
to register, so convergeRuntimeRegistrations now consumes the verdict map it
used to discard — safe because demoteUnusableRuntimes owns the claim barrier
and the seq-stamped hold, and stays the single owner of demotion.

The profile's presence is a stat of the manifest DSH itself looks for, so the
check costs one syscall per tick; the authoritative --probe stays inside the
probe round the check forces.

* docs(runtime): document the DSH runtime-profile variables

The two variables this branch adds were undocumented, and the install guide
still told readers to run `dsh plugin --profile multica add <bundle>` without
ever saying what <bundle> is — the hole #6936 was filed against.

Document MULTICA_DSH_PROFILE_BUNDLE and MULTICA_DSH_PLUGIN_PATH next to the
other MULTICA_DSH_* variables, and name the bundle in the DSH setup step: what
implements the protocol, where the bridge lives, and that the daemon reports
the missing profile on /health rather than registering a runtime that cannot
start. All four locale files, so the translations do not drift.

* refactor(agent): export the DSH stdio protocol version

The daemon's runtime-profile probe accepts or rejects a profile on this exact
number: one answering `--probe` with a protocol this backend does not drive
must not be registered. That decision was about to be made against a second
copy of the constant in internal/daemon, and a second copy is one that can
drift from the code actually speaking the protocol.

Export it so the probe reads the number from its only honest source. No
behaviour change: the value is still 1.

* fix(redact): mask credentials carried in URL userinfo

The connection-string rule covers postgres, mysql, mongodb, redis and amqp
only, so `https://user:token@registry/...` passed through untouched. That shape
is not hypothetical for agent output: npm and pnpm accept an authenticated
registry URL as a package spec, and both the package manager and the registry
echo the request URL back in their error text, which is how it reaches a log
line without anyone logging the spec itself.

Add a scheme-agnostic userinfo rule. The scheme and host survive — which
registry refused the request is the part an operator reads, and it is not the
secret — and userinfo requires both a colon and an `@` before the next path
separator, so `https://host:8443/p`, `ssh://git@host/p` and a bare `a:b` in a
path are all left alone. Tests cover both directions.

* fix(daemon): decide the DSH runtime-profile verdict on evidence

probeDshMulticaProfile returned a bare bool, so a timeout, a non-zero exit,
unparseable output, a future protocol version and a genuinely absent manifest
all collapsed into "the profile is not installed" — a verdict that demotes a
live runtime and, with a bundle configured, installs over the user's DSH home.
This rebuilds the verdict layer around what was actually observed.

Classification. The probe now returns one of OK / missing / incompatible /
unavailable. The frame decides, not the exit status: a profile that printed a
valid answer has answered, whatever the process did on its way out — which also
stops a process-tree lifecycle error from being read as a protocol failure.
With no usable frame, a cancelled ROUND stays transient (the daemon is shutting
down; that says nothing about the profile) and otherwise the manifest decides.
A manifest that IS present keeps every failure transient, which is what
protects a working install during an upgrade.

Timeouts are not transient by themselves. Asked for a profile it does not have,
DSH does not refuse `--probe` — it hangs, printing nothing, until something
kills it. Measured: still running after 10s, zero bytes on both streams. So a
timeout is the ordinary way a missing profile presents, and treating it as
transient made the profile undetectable on exactly the host the automatic
install exists for. The manifest is the same file DSH's own loadProfile
consults before refusing to boot, so a confirmed-absent one is evidence.

Order. The profile probe now runs only after version detection succeeds.
exec.LookPath is far too weak a proxy for "this CLI works": on Windows the CLI
is a .cmd shim it resolves happily and which exits 9009 — cmd.exe's "command
not found" — when what it forwards to is missing. Probing the profile first
reported such a machine as missing its runtime profile, a repair for a problem
it did not have.

Demotion. An incompatible profile is reported and never demoted: this daemon
can be the stale side of a protocol skew, and tearing down a live runtime
because THIS build is behind is a destructive answer to a version question.
The confirmation window that gated the not-executable verdict now gates every
condemnable verdict, keyed per verdict so a provider moving between states
starts a fresh clock rather than inheriting one earned by another problem.

Convergence. A registered dsh whose profile was removed forces a round on every
tick, because condemning it takes two of them and nothing else schedules the
second: with dsh still registered nothing is missing a runtime, so an unforced
tick returns without probing at all. The mirror direction — a profile with no
runtime — stays throttled to one forced round, since no round is guaranteed to
resolve it and forcing would bypass agentConvergeMaxBackoff forever.

Offline cause. A missing profile reaches the server as a structured
`dsh_profile` code rather than a bare offline, and carries whether the daemon
is installing the profile right now — the one case where queueing is right.
That claim is withdrawn if the install gives up: the runtime row is written
once, by the demotion that also removes the runtime from the index, so nothing
later would correct it and the work would queue forever behind an install that
already stopped.

Provisioning. Runs under the daemon's lifetime through processtree, so the
pnpm/node descendants cannot outlive a shutdown and keep writing into the DSH
home. Success is the manifest on disk, not the package manager's exit status,
and a candidate that exits 0 without producing a profile is a failed candidate
so the next one still gets its turn. The bundle spec no longer reaches a log
line — an npm URL can carry credentials and a path a username — and captured
output is redacted and bounded on a rune boundary. The PATH merge matches the
variable case-insensitively, because Windows spells it `Path` and an exact
`PATH=` miss left the package manager with only the plugin directory under Go's
case-insensitive de-duplication.

Startup. A daemon that registered nothing continues when it has just begun
installing the profile itself. On a host whose only provider is a dsh without
one, failing here is a deadlock rather than a fail-fast: the probe starts the
install, the error kills the process milliseconds later, the install's process
tree dies with it, and the next start repeats all of it. Nothing else is
forgiven. Because such a daemon tracks no workspace, a finished install picks
the workspace up before converging.

Repair. The reported repair names the missing package and carries no command:
the install needs a bundle only the operator can choose, and Repair.Command is
rendered to the user inside a shell fence as the line to run, so a placeholder
there is a paste-and-fail instruction.

Tests. The classification matrix, "manifest present + timeout / non-zero exit /
malformed output / future protocol ⇒ no demotion, no provisioning", the 9009
shim, the forced-round sequence, the withdrawal, the startup exception, the
empty install, and the case-insensitive PATH merge. Two existing tests read the
developer's real ~/.dsh and one fake dsh could not answer `--version` at all —
both now pinned to the test's own directory and to the shape the feature is
actually about.

* feat(daemon): discover the CLI shims DSH Desktop generates

The macOS fallback pointed at one path inside the app bundle, and Windows had
none at all — every candidate there was a /Applications path that cannot exist,
so a Windows host running DSH Desktop reported no dsh whatsoever. Replace it
with one layout that serves both platforms.

The CLI is not in the install directory. DSH Desktop generates shims under its
own per-user data directory — ~/Library/Application Support/DSH Desktop on
macOS, %APPDATA%\DSH Desktop on Windows — and on Windows that is roaming
application data even though the app installs to Program Files or
%LOCALAPPDATA%\Programs. One lookup therefore covers both install modes, since
a per-machine install still generates these shims per user.

Payload directories are content hashes (and, under host-commands, a hash plus a
generation uuid), so they are enumerated rather than composed. Only directories
holding a bin/ are accepted, so a stray file or half-extracted download is
skipped; modification time orders them so an upgrade's new payload wins over an
abandoned one, and the name breaks ties because ReadDir order is not a promise
and the daemon must not pick a different CLI on different rounds.

host-commands outranks cli, and that is load-bearing: nothing here executes a
candidate, so a broken-but-present shim is indistinguishable from a working one
at this layer — and on the install that prompted this, the cli shim was exactly
that, resolvable by exec.LookPath and exiting 9009. Ranking is the only lever.

Generated shims also outrank the macOS app-bundle script, which is entered
through `#!/usr/bin/env node` and so only works where node happens to be
installed; a shim runs the Desktop app's own runtime by absolute path and needs
nothing on PATH. That gap was latent on macOS too.

executableCandidate had to learn the platform difference for any of this to
work: Go synthesizes a regular file's mode from the read-only attribute and
never sets an executable bit on Windows, so the Unix test rejected every
candidate and the whole fallback would have been dead code there. Windows uses
exec.LookPath, the same check the launcher makes, so a candidate accepted here
is one that can actually start — which is also why the Node script has no
Windows counterpart and .js never appears in that list.

Only default locations are covered, and nothing reads an install receipt or the
registry, so a relocated install still needs MULTICA_DSH_PATH.

The path builders take goos/env/home as arguments rather than reading
runtime.GOOS and os.Getenv, so the Windows layout is asserted from any host —
the alternative leaves it testable only on Windows, which in this package has
meant untested.

* fix(server): refuse a missing DSH runtime profile under its own reason

A runtime taken offline for a missing profile reached the server as a bare
offline, so runtimeVerdict classified it as waitable/runtime_offline and
assignments and @mentions bound to that agent queued forever — even though,
with no automatic install configured, that wait never resolves on its own.
That contradicts the semantics settled in MUL-6164: when a human has to
intervene, refuse the trigger and say how to fix it.

Blocked, but not as runtime_unusable. The two causes have opposite repairs: an
unrunnable CLI is reinstalled, while a CLI missing its runtime profile is
working perfectly and reinstalling it changes nothing. Sharing one code meant
every client told DSH users to reinstall a CLI that was never the problem — the
chip and toast said "reinstall it there", and the durable system comment left
on the issue opened with "its CLI is installed but cannot be executed on that
machine", blamed a blocked npm postinstall, and rendered `dsh plugin --profile
multica add <bundle>` inside a bash fence as the line to run on that machine. A
placeholder presented as a command is the shape of instruction people paste
verbatim and then report as broken.

So: a new runtime_profile_missing reason code, carried through readiness,
dispatch and the localized copy in all four languages. The notice branches on
the reason rather than on whether a repair command happened to be present, and
the profile text names what is missing, says the CLI is fine, and points at the
two ways to supply a real bundle without printing a command to copy.

An install the daemon is running right now is the exception: that wait does end
by itself, so the work queues instead of being refused.

RuntimeBlockedNeedsNotice replaces three inline comparisons, because a code
added to one admission path and not the others is a trigger that vanishes with
no trace on exactly the surfaces that have no response for the user to read.

* docs(runtime): document DSH profile provisioning and shim discovery

Automatic provisioning is a new, persistent feature surface, and it had no
product surface: an admin had no documented way to configure it, and the one
paragraph that existed attached the install semantics to the wrong variable —
the sentences describing what MULTICA_DSH_PROFILE_BUNDLE does followed the
MULTICA_DSH_PLUGIN_PATH sentence, so "the variable is unset by default and the
install it enables is bounded" read as being about a variable that enables no
install, and PLUGIN_PATH was then described twice.

Environment variables, in all four languages: the install semantics move onto
the variable they belong to, and now state the bounds — at most once per daemon
lifetime, only on a confirmed-absent profile, failures logged with the package
manager's own output and retried only after a restart, a timeout or an
unsupported protocol version reported and never installed over. Plus how to
diagnose (search the daemon log for `DSH runtime profile`) and how to roll back
(delete $DSH_HOME/profiles/multica). MULTICA_DSH_PLUGIN_PATH is described once,
and Windows and Linux are named as the platforms with no bundled pnpm
directory, where the daemon uses whatever is on PATH unless this points at one.

Install guide, in all four languages: what to actually put in the variable.
Multica's bridge is not on a public npm registry yet (multica#6936), so a
self-hosted team builds it from the repository and points at the build
directory, or at the tarball npm pack produces when the daemon host is not the
build host; a package name becomes the simplest option once it is published.
With a warning that belongs on this value specifically — it is installed into
every daemon host's DSH home without further confirmation, so an unvetted
third-party package is a supply-chain decision, not a convenience.

Also documents where the daemon looks for a Desktop-managed CLI, including the
detail that trips people on Windows: it is roaming application data, not the
install directory. And the one exception to "the daemon must detect at least
one built-in CLI before it will start" — a daemon that has just begun installing
the profile itself starts with no runtimes, because otherwise a host whose only
tool is a DSH needing that profile could never finish the install.

* fix(daemon): find DSH Desktop's bundled pnpm on Windows too

The automatic install shells out to `dsh plugin`, which forwards to pnpm, and
the daemon inherits no path to the one DSH Desktop ships. The lookup composed a
single macOS path and was commented as macOS-only, so on Windows the install
failed with "pnpm not found on PATH" unless the operator set
MULTICA_DSH_PLUGIN_PATH by hand — on exactly the hosts the automatic install
exists for.

Both platforms bundle it under runtime-commands in the app's own per-user data
directory, but not in the same shape: macOS 2.0.5 keeps a flat
runtime-commands/bin, while a Windows install keeps
runtime-commands/generations/<id>/bin. Composing either path leaves the other
broken, so the lookup now shares the enumerator with CLI-shim discovery and
searches both shapes at both levels. A Desktop version that moves between them
is then a version this daemon keeps working with rather than one it silently
stops supporting.

Sorting is pooled per tier rather than per directory. Doing it per directory
made the answer depend on the order ReadDir returned DSH profile names, which
is the same non-determinism the name tie-break exists to remove: the newest
generation has to win even when it belongs to a profile that sorts later. An
existing test caught this while the enumerator was being extracted.

An explicit MULTICA_DSH_PLUGIN_PATH now replaces the search instead of merely
being tried first. An operator who pinned a directory is bypassing the bundled
pnpm on purpose, and quietly falling back to it would defeat that.

Documented in all four languages, where the previous text stated the opposite —
that Windows has no bundled directory.

* fix(server): bound the in-flight DSH install claim instead of trusting it

`installing` tells the server to queue triggers rather than refuse them, so a
claim that outlives its install recreates exactly the failure the structured
reason was added to prevent: work queued behind something that already stopped.

The withdraw cannot be made to guarantee that. Three paths leave the claim on
the row. The verdict reads dshInstallInFlight when it is built, but the wait is
recorded later, when the demotion knows the runtime ids — an install that fails
in between finds an empty list, and the `installing` written afterwards is never
withdrawn. The corrective Deregister is best-effort, and during shutdown it runs
on an already-cancelled lifecycle context. And a daemon restarted mid-install
does not track the row at all.

Bound the claim instead. SetAgentRuntimeOfflineWithReason stamps updated_at, and
the install it describes is itself bounded by the daemon's provisioning timeout,
so honouring `installing` only inside a window comfortably longer than that
covers a real install while capping every way the claim can be left behind. A
row with no usable timestamp cannot be bounded and is not honoured: refusing
during a real install costs one retry the notice already asks for, while
honouring a stale claim costs work that queues until it expires.

The withdraw stays as the fast path — this is the floor under it.

* refactor(daemon): scrub URL credentials in provisioning output, not globally

The rule was added to pkg/redact's shared pattern list, where redact.Text runs
over every agent's task messages, comments and error text — so a fix aimed at
one package manager's output changed what every provider emits. The gap is real
and still worth closing in that list, but widening a shared filter is its own
change with its own blast radius, and it does not belong in a PR about DSH.

Move it to dshProvisionOutput, which is the only place this PR needs it: a
bundle spec can be an authenticated npm URL, and both the package manager and
the registry echo the request URL back in their error text. The shared redactor
still runs after it for the token shapes it already knows.

Behaviour for the provisioning log line is unchanged; behaviour for every other
provider returns to main's. Same assertions as before, moved next to the code
they now cover, plus the false-positive cases — a port, an scp-style git remote,
a bare colon in a path — and one asserting the host survives, since which
registry refused the request is what an operator reads and is not the secret.

* fix(daemon): let a converge round condemn dsh only

convergeRuntimeRegistrations started consuming the verdict map it used to
discard, so that a round forced by a DSH profile mismatch could reach demotion —
a registered dsh whose profile was removed leaves nothing missing a runtime, so
the round has no registration left to do and would otherwise return having done
nothing.

But it acted on every provider's verdict, not just dsh. On main, demotion is
reached from refreshAgentVersions alone, on its own ten-minute cadence, and that
is the schedule every provider's verdict was tuned against. Claude Code, Codex
and the rest would have moved onto a different one as a side effect of a DSH
fix — a behaviour change for providers this PR has no reason to touch.

Narrow it to dsh. It is the only built-in with a precondition that can change
without any version changing, which is why it is the only one that needs a round
it can be forced into; every other provider keeps main's behaviour exactly.
2026-09-12 11:25:56 +08:00
d44932089b refactor(daemon): drop the reused-workdir prompt block now that checkout keeps work (MUL-7034) (#8316)
The "Files from an earlier run" per-turn block was added in #8291 because a
fresh session in a reused workdir had no memory of the work there, and
re-running `multica repo checkout` reset an existing checkout and deleted it.
MUL-7284 (#8309) fixed that in checkout itself: an existing checkout holding
uncommitted, untracked or unpushed work is kept, and the CLI reports it
("Kept the existing checkout ... N uncommitted files, M unpushed commits").
The brief documents that behavior and --fresh, and the server's continuity
notice already says the earlier attempt's context did not come back. The block
only repeated those, so remove it, its two runTask insertion points, and its
tests. prompt.go and runTask are back to their pre-#8291 form.

The server still offers an automatic retry its parent's workdir only to
daemons that make that safe. The capability now names what they guarantee:
reused-workdir-notice-v1 becomes checkout-keeps-work-v1. Neither string has
shipped in a release, and a new daemon talking to an old server, or the
reverse, just gets a fresh directory as today.

The end-to-end runTask test keeps its reuse assertions: a fresh-session run
offered a prior workdir continues in it, and falls back to a fresh directory
once that one is gone.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-11 17:18:03 +08:00
da7fc439a8 MUL-7281: support universal Redis clients and cluster mode (#8300)
* feat(redis): support standalone and cluster clients

Co-authored-by: multica-agent <[email protected]>

* fix(redis): use one global Redis configuration

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-11 16:37:42 +08:00
99d879976f MUL-7267: fix(autopilot): backfill legacy trigger creators from the autopilot creator (#8293)
* MUL-7267: fix(autopilot): backfill legacy trigger creators from the autopilot creator

Since MUL-6951 a schedule/webhook dispatch acts as the trigger's creator
and fails closed without one. Migration 449 left created_by NULL for
triggers that predate published_by (migration 189) and were never edited,
so every run of those triggers has been recorded as skipped since the
upgrade: webhook deliveries still read "dispatched" and schedules still
advance, but nothing executes (#8284).

Migration 467 fills those rows from the autopilot's creator, the principal
automatic dispatch was admitted as before MUL-6951, when that creator is
still a member of the workspace. Existing member creators are never
rewritten, and dispatch still re-validates membership and invoke access on
every run.

Co-authored-by: multica-agent <[email protected]>

* MUL-7267: docs(autopilot): state the trigger principal backfill as an accepted widening

Review follow-up. The 467 comment claimed no trigger gains an authorization
it did not pass before the upgrade; that holds for admission only. A
pre-MUL-6951 automatic run carried no originator, while a filled trigger
now runs, and delegates, as the autopilot's creator. Say so explicitly, as
449 does.

created_by is also no longer "written once at creation" for every row: for
a legacy trigger it is a principal inferred by backfill (449, then 467),
not proof of who created it. Override the created_by_id column comment in
467 (the down migration restores both of 449's comments), regenerate
models.go, and align the ResolveAutopilotTriggerPrincipal and dispatch
attribution comments. No behavior change.

Co-authored-by: multica-agent <[email protected]>

* MUL-7267: docs(autopilot): name the trigger principal consistently at call sites

Second review follow-up. The admission, dispatch and attribution call sites
still called the automation principal "the trigger's creator" and spoke of a
"recoverable" or "provable" creator. For a legacy trigger, created_by is a
principal inferred by backfill, not proof of who created the trigger. Refer
to it as the trigger's created_by principal and point at
ResolveAutopilotTriggerPrincipal for the legacy semantics instead. No
behavior change.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-11 15:41:17 +08:00
Bohan Jiang 676245af63 MUL-7034: fix(server): keep the parent workdir when an automatic retry must start fresh (#8291)
A task that failed with codex_semantic_inactivity is retried with a fresh
provider session, which is right: the conversation is what got stuck. But the
retry also lost the parent's workdir, so it started in an empty directory and
could not see anything the failed attempt had done. Manual Retry already keeps
the workdir for the same failure (MUL-4869); only the automatic path dropped it.

Server: CreateRetryTask stops nulling work_dir (session_id is still nulled and
force_fresh_session still set). The claim handler never read the child's own
work_dir, so on both the issue and chat branches it now hands it back as
prior_work_dir for an automatic retry (retry_of_task_id set) that must start
fresh, with prior_session_resume_unavailable. A force_fresh task without retry
lineage, such as the Lark fresh-session command, still inherits nothing.

Daemon: a fresh session in a reused directory has no memory of the work there,
and the brief's `multica repo checkout` resets an existing checkout, deleting
uncommitted changes. The daemon now appends a per-turn "Files from an earlier
run" block whenever it actually reused a prior workdir without resuming its
session, including the fresh retry after a rejected resume. It is keyed to what
the daemon did, so a GC'd directory gets no warning, and it never enters the
byte-stable brief.

The server offers the parent's workdir only to daemons advertising
reused-workdir-notice-v1; an older daemon keeps getting a fresh directory.

Closes #7998
2026-09-11 14:32:31 +08:00
b566b5fd4e perf(inbox): ship a 200-character preview of comment notifications in the lists (#8294)
A comment notification stores the full comment it was raised for, and both
inbox lists returned it verbatim. A several-thousand-character agent reply
travelled over the wire, through JSON parsing and into the client cache for
every subscriber, only to be rendered as a single truncated line.

The main and archived list responses now cap the body of a `new_comment`
notification on an issue at 200 characters, the ellipsis included. The
database keeps the full text, and `details.comment_id` is untouched.

The scope is exactly the rows whose full body no client reads from the list.
Opening an issue-backed notification renders the issue itself and lands on the
comment through `comment_id`; everywhere else, web, desktop and mobile show
that body as one line. That holds for builds already installed, so none has to
change: the field, its type and the endpoints are the same. Other types keep
their full body — issue-less notifications render it in the detail pane from
the list cache, and Quick Create failures refill the composer from `details`.
Single-item responses are unchanged.

Truncation walks code points and stops at the limit, so a multi-byte character
is never split and the work per row is bounded by the preview, not by the
comment's length.

Sampled on 264 comments from this workspace's recent issues: half exceed 200
characters (median 203, p90 3,277, max 12,251), and capping cuts their text by
86%. A list of comment notifications built from them shrinks 64% raw and 69%
gzipped; an inbox's overall saving depends on its share of comment rows.

Refs MUL-6967.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-11 14:12:02 +08:00
importcpp 1500abe357 MUL-7272: fix(comments): preserve deferred worker reply handoffs (#8288)
A worker's plain reply that arrived after the squad leader's run was claimed,
but before it started, was never delivered. Creation deferred it to completion
reconcile, and reconcile only replays explicit agent mentions, so the leader
never received another run for it.

Record such replies in the claimed run's planned inputs. On completion, replay
the assigned-squad-leader route only when the reply was recorded there, after
rechecking current routing and permissions. Timestamp-only agent replies,
self-replies, notes and suppressed replies still earn no follow-up.

RegisterPlannedCommentForActiveTask now also rechecks status on the UPDATE
target, so a registration that waited on a completing row misses and enqueues
a successor instead of attaching an obligation to a completed run.
2026-09-11 14:09:28 +08:00
d40585a004 MUL-7250: fix(server): suppress no-op issue metadata writes (#8287)
* fix(server): suppress no-op issue metadata writes

Co-authored-by: multica-agent <[email protected]>

* fix(server): narrow issue metadata no-op reload

Co-authored-by: multica-agent <[email protected]>

* test(server): trim metadata no-op coverage

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-11 13:34:33 +08:00
51803b3b3b MUL-7259: fix(server): stop reporting a failed workspace lookup as a deleted task (#8279)
* MUL-7259: fix(server): stop reporting a failed workspace lookup as a deleted task

requireDaemonTaskAccessWithWorkspace had two ways to emit
`404 task not found`. #2127 hardened the first one — only pgx.ErrNoRows
from GetAgentTask may produce it, because the daemon acts on that body by
killing the running agent (shouldInterruptAgent -> isTaskNotFoundError).

Three lines below, the workspace resolution emitted the identical body
whenever it came back empty, and ResolveTaskWorkspaceID swallowed every
error from GetIssue / GetChatSession / GetAutopilotRun / GetAutopilot on
its way there. A DB timeout on any of those links was therefore reported
to the daemon as a deletion, which cancelled a healthy in-flight run and
discarded work the agent had already finished (GH #8272).

Split the two outcomes apart at the source. ResolveTaskWorkspaceIDChecked
returns ("", nil) only when every link it walked was looked up
successfully and found absent, and ("", err) when a lookup could not be
completed at all. The handler maps the first to 404 and the second to
500, so the daemon retries instead of interrupting.

ResolveTaskWorkspaceID keeps its signature and best-effort contract for
the eight broadcast callers, where skipping a broadcast on a blip is the
existing (and correct) behaviour.

ListTaskMessagesByUser had the same conflation on both lookups and gets
the same treatment: a reader must not be told a task does not exist
because the database blinked. Cross-workspace access stays a 404 so a
foreign task remains indistinguishable from a missing one.

Co-authored-by: multica-agent <[email protected]>

* MUL-7259: docs(server): correct two comments flagged in review

- daemon_task_lookup_test: isTaskNotFoundError requires both a 404 and the
  "task not found" body, so a 5xx carrying that body would not interrupt
  the daemon. Say so, and state the body assertion for what it is: keeping
  the cancel string out of the response entirely in case either half of
  that check is relaxed.
- ResolveTaskWorkspaceIDChecked: the doc opened by saying a failed lookup
  is reported even when a later link resolves, contradicting both the code
  and its own next clause. A later link that resolves returns its
  workspace and drops the earlier error.

Comment-only; no behaviour change.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-11 12:57:16 +08:00
aac4aa3f44 MUL-7248: fix agent log duration timestamps (#8271)
* fix(transcript): preserve agent event timing

Co-authored-by: multica-agent <[email protected]>

* fix(transcript): keep timestamp fallbacks consistent

Co-authored-by: multica-agent <[email protected]>

* fix(migrations): resolve duplicate 458 prefix

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-10 22:18:16 +08:00
5132ac99eb MUL-7235: show cancellation actor in run history (#8249)
* MUL-7235: show cancellation actor in run history

Co-authored-by: multica-agent <[email protected]>

* fix(issues): simplify failed run recovery UI

Co-authored-by: multica-agent <[email protected]>

* Revert "fix(issues): simplify failed run recovery UI"

This reverts commit 1b337bbe46.

Co-authored-by: multica-agent <[email protected]>

* fix(tasks): keep cancellation attribution consistent

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-10 21:24:35 +08:00
3dcfdaea41 feat(issues): simplify board defaults and sorting (#8256)
* feat(issues): simplify board defaults and sorting

Co-authored-by: multica-agent <[email protected]>

* fix(issues): address board review feedback

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-10 21:12:00 +08:00
Bohan Jiang 116761e505 MUL-7072 fix(github): retire reference-only PR links (#8245)
A bare body mention of an issue key claimed nothing, yet auto-link wrote a link row for it and flagged it reference_only — filtered out of the PR list, the close aggregate and the review dedup head SHA. The row had no reader, and once the PR went terminal the preserve gate froze the flag, so adding `Closes MUL-1` to a merged PR's body could never surface the PR on its issue. With no unlink surface anywhere, the one repair available from GitHub was the one that could not work.

Write no row for a passing mention, delete the historical hidden rows (migration 458), and drop the column's reads and writes; the column itself goes in a follow-up release that re-runs the delete as a mop-up. The visible set of links is unchanged, except that a post-merge `Closes` now links the PR (without retroactively completing the issue). An open PR whose claim is downgraded in place loses its link, which is what hiding used to accomplish.

Docs, the four locales, and the agent-facing platform skill are updated in step.
2026-09-10 17:16:50 +08:00
c03633d64a MUL-7202: fix(usage): distinguish unreported agent runs (#8241)
* fix(usage): distinguish unreported agent runs

Co-authored-by: multica-agent <[email protected]>

* fix(usage): handle dashboard rollup lag

Co-authored-by: multica-agent <[email protected]>

* fix(usage): preserve nonterminal usage totals

Co-authored-by: multica-agent <[email protected]>

* fix(usage): expose truncated coverage details

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-10 16:26:38 +08:00
importcpp f539dd06cc perf(issues): select batch stage barriers in linear time (#8217) 2026-09-10 14:54:21 +08:00
e04465be0b feat(transcript): tell the reader when a tool output was truncated MUL-7168 (#8212)
* MUL-7166 MUL-7167 feat(transcript): tell the reader when a tool output is incomplete

A tool_result preview is cut at 8 KiB and the remainder is never uploaded.
Nothing recorded that, so the run transcript rendered a truncated preview
exactly like a complete one — a reader checking what an agent did could take
the first 8 KiB of a 300 KB log for the whole thing.

The daemon now reports whether the preview dropped bytes, and the viewer says
so. Two separate claims, kept separate:

- Per step: a "truncated" badge next to the tool name when the record
  provably lost bytes.
- Per run, once: a caveat when the run contains output whose completeness
  nobody measured.

The flag is tri-state end to end (NULL/absent = unknown, false = complete,
true = truncated) and `output_truncated` is nullable with no default and no
backfill. Historical rows cannot be reconstructed, so they stay unknown; the
same applies to messages from an older installed daemon, which is why the
batch insert carries the value through the query's text[]/NULLIF encoding
rather than a []bool that cannot express the third state.

Display clipping is now worded as what it is. The inspector's 8000-character
clip said "... (truncated)", the same word the badge uses for bytes that are
gone, when copy still yields the whole stored text.

Verified: go test ./internal/handler ./internal/daemon ./internal/util
./internal/migrations, pnpm test (4981 views + core), typecheck, lint. Both
states captured in a local environment against real API + DB rows.

Co-authored-by: multica-agent <[email protected]>

* refactor(transcript): move the completeness note to the end of the output

The truncation indicator was a warning badge beside the tool name and a
run-level banner above the step list. Both announced the caveat before the
reader had a reason to care, and the badge's colour gave a routine 8 KiB cut
the weight of a failure.

"Was that all of it?" is a question you only have once you reach the bottom of
an output, so the answer goes there: one muted line at the end of the body,
in the same place the display-clip line already sits.

The unknown case follows the same rule rather than keeping its own banner —
the inspector shows one step at a time, so a note there appears once per
opened step, not once per turn in a list. That was the whole objection to the
per-turn disclaimer this design replaced; a run-level bar was one way to avoid
it, and putting the note where the evidence is another.

`outputCompleteness` replaces `hasUnknownOutputCompleteness`: the same rules
(tool results only, empty output excluded, false is a measurement) now answer
per item instead of per run.

Co-authored-by: multica-agent <[email protected]>

* fix(transcript): stop the display-clip line promising the whole output

"copy to get the whole record" reads as "the full output is in the database".
It is not: the daemon cuts at 8 KiB before reporting, and copy returns the
stored record — the same bytes, minus the 8000-character render clip. The
first reader of this string took it the other way, which is the whole reason
the truncation note exists.

Says what copy actually returns: everything that was saved.

Co-authored-by: multica-agent <[email protected]>

* fix(transcript): leave a stored tool result with one remark, not two

The render clip cut at 8000 characters, but a stored tool result is already
capped at 8192 bytes upstream — so on an ASCII output the clip could only ever
shave a further ~190 characters, and it did so under a note that already said
the rest was not saved. Two grey lines, the second reporting a rounding error
on the first.

The clip now sits above the source budget, where a tool result cannot reach it.
Tool input keeps it: input is persisted whole and has no server-side budget, so
it is the body that actually needs bounding — and it never carries a
completeness note, so that path stays a single remark too.

The marker also stops mentioning copy. It described the clip's reversibility,
which is only interesting next to a truncation note that is no longer beside it.

Co-authored-by: multica-agent <[email protected]>

* fix(transcript): say why the output is short, not where it stops

"Output ends here" reads two ways: the command produced this much, or we cut
it. Those are opposite facts about the agent's run, and the note exists to
settle exactly that question.

States the cause instead: the output was too long, so only the beginning was
kept. "Kept" rather than "shown" — the rest was never uploaded, and wording it
as a display limit invites the assumption that the whole output is in the
database and merely hidden.

The unknown note loses the same phrase for the same reason.

Co-authored-by: multica-agent <[email protected]>

* fix(transcript): address review on the completeness note

Three fixes from review of #8212, plus the frequency question it reopened.

A malformed `output_truncated` deleted the whole run from the screen. The
field parsed with `z.boolean().optional()`, so a string or a number failed its
row, the row failed the array, and `parseWithFallback` handed the viewer an
empty transcript. It now catches at the field, degrading to unknown. The old
test claimed this but passed a malformed top-level object, which exercised a
different path; the replacements fail without the catch.

The note appeared before the reader could have wanted it. `ToolDetailSurface`
collapses its own body, so a footer rendered outside it sat over a faded,
half-shown output — answering "was that all of it?" to someone still reading.
The surface now owns the note and withholds it until the body is revealed.

The render clip went back to 8000 characters for tool input. Raising it to
20000 was collateral: the goal was to stop re-clipping a tool result, which is
already capped at 8192 bytes upstream, and skipping the clip for `tool_result`
alone does that without touching how an unbounded input renders.

Unknown-ness moves back to the run. One daemon produces every message in a run,
so either it measured that run's outputs or it measured none of them — a fact
about the run, stated once, rather than a remark repeated on each step opened.

Co-authored-by: multica-agent <[email protected]>

* fix(transcript): drop the notice for records that predate the flag

A record from before the flag existed now says nothing. The viewer speaks only
when a daemon measured an output and found bytes missing.

The column stays tri-state. Nothing renders differently for NULL and false
today, but they are not the same fact, and defaulting NULL to false would write
"this daemon measured the output and stored all of it" onto rows where nobody
measured anything. The original lengths are gone, so that claim could never be
corrected — a lie the schema would keep telling long after this UI changed
again.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-10 14:40:45 +08:00
9a3d92da12 MUL-7183: reduce issue search normalization work (#8204)
* perf(search): conditionally normalize issue descriptions

Co-authored-by: multica-agent <[email protected]>

* test(search): guard conditional description skip

Co-authored-by: multica-agent <[email protected]>

* refactor(search): align multi-term guards

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-09 17:17:50 +08:00
importcpp e8d32d0f1b MUL-7176 perf(issues): reuse status catalogs across child completion checks (#8192)
* perf(issues): reuse status catalogs across child completion checks

Share per-workspace resolvers across completion guards, sibling stage scans, and progress summaries within one notification pass. Preserve raw statuses and existing wake behavior; cover single/batch paths, failures, isolation, and fresh-pass reads.

* fix(issues): abort child notifications on catalog read failure

Expose cached resolver errors without changing existing fallback callers. Resolve notification statuses before stage checks and comment creation, skipping affected parent groups when the catalog is unavailable. Cover transient failures, parent guards, queued runs, late summary reads, workspace isolation, and fresh-pass recovery.

* fix(issues): skip child notifications on unresolved status keys

Fail closed when a successfully read catalog lacks a status needed by a completion notification. Keep display fallback behavior and the one-read-per-workspace optimization unchanged.

Cover parent rows newer than the catalog, unknown and deleted keys, parent-group isolation, and fresh-pass recovery. Document best-effort notification semantics.
2026-09-09 16:56:54 +08:00
9d0cab0eac fix(issues): fail safely when pagination counts are unavailable (#8201)
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-09 16:00:03 +08:00
importcpp f5f0b604f6 perf(webhooks): reuse workspace status catalogs for PR close checks (#8191)
Resolve custom issue statuses once per workspace mirror pass using the existing request-scoped Resolver. Preserve built-in fast paths and existing close-intent decisions, with provider and query-count regressions.
2026-09-09 14:36:42 +08:00
b5a7ee1e0e fix(comments): remove delayed assignee fallbacks (#8174)
* fix(comments): remove delayed assignee fallbacks

* fix(comments): retire fallback retry descendants

Co-authored-by: multica-agent <[email protected]>

* fix(comments): preserve fallbacks with merged comments

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-08 22:51:19 +08:00
200dcb44cc MUL-7146: lowercase comment content once per search row (#8164)
* perf(search): lowercase comment content once

Co-authored-by: multica-agent <[email protected]>

* refactor(search): clarify lowered comment projection

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-08 17:01:29 +08:00
7a9dc633d2 fix(search): extend statement timeout to eight seconds (#8159)
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-08 15:46:48 +08:00
df99afdab3 fix(issues): hide quick-create runs from activity (#8154)
Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-08 14:32:23 +08:00
Jiayuan Zhang 028f3b4437 feat(issues): show agent execution inline in comment threads (#8131)
* feat(issues): show agent runs inline with comment replies

* fix(issues): unify run comment projection and preserve disclosure focus

* refactor(issues): replace nested run cards with inline status rows

* fix(issues): align thread roots with reply content

* style(issues): remove thread toggle background

* style(issues): group reaction picker with comment actions

* feat(issues): show readable issue references in run summaries

* style(issues): move completed run logs into comment headers

* style(issues): balance comment block vertical spacing

* style(issues): shimmer live agent progress text

* style(issues): unify comment action hover feedback

* style(issues): reveal comment actions on block hover

* fix(issues): move published reply activity into the header

* fix(issues): settle comment UI as soon as replies are published

* fix(issues): reveal comment log buttons on hover

* fix(issues): defer run placement until trigger comments arrive

* fix(issues): isolate agent comment queues by thread

* fix(issues): display pending runs in execution order

* fix(ci): align integration tests with thread-scoped queues

* fix(issues): fit run actions to narrow comment columns
2026-09-08 12:58:16 +08:00
importcppandmultica-agent 261522e3e3 MUL-6788: perf(server): batch claim-path attachment and runtime lookups (#7696)
Replaces two per-item read loops on the self-host daemon paths with the batch
queries the repo already had, and pins down what each one does on failure.

- buildClaimedTaskResponse: one ListAttachmentsByChatMessageIDs for the turn's
  unanswered messages instead of one query per message. Output order is
  unchanged (message order, then created_at within a message). A read error
  now fails the claim with error_chat_attachment_load and leaves the task
  dispatched for redelivery, rather than starting the run with the user's
  files silently missing.
- DaemonDeregister: one batched runtime read instead of one point read per id.
  A read error returns 500 before any write, so a transient failure can no
  longer report a successful deregister while every runtime stays online until
  the liveness sweep reaps it. Missing and differently-cased ids keep their
  previous per-runtime semantics.
- Both batch reads go through service.RuntimeLookup.GetMany, which bills
  multica_agent_runtime_lookup_total one result per requested id, so
  ClaimTasksByRuntime — the busiest agent_runtime reader, serving /tasks/claim,
  /claim and the WebSocket claim RPC — stays attributed rather than becoming
  invisible next to once-per-shutdown deregisters. The agent-list presence
  projection remains an explicitly documented exception.

Covered by new handler and service tests: attachment ordering, both fail-closed
paths, mixed existing/missing/case-variant deregister ids, and per-id metric
classification on the batch claim endpoint.

Closes #7694

Co-authored-by: multica-agent <[email protected]>
2026-09-08 11:59:16 +08:00
Jiayuan Zhang bd6fb86173 feat(issues): consolidate thread navigation in the sidebar outline (#8120)
* feat(issues): consolidate thread navigation in the sidebar outline

* fix(auth): reject sessions whose user no longer exists
2026-09-07 18:12:23 +08:00
Bohan JiangandCatCatUncle 3ac53a68a6 MUL-7108 fix(autopilot): judge every autopilot write as the human it acts for (#8107)
Autopilot write endpoints judged the request's authenticated user, which under
a mat_ task token is the RUNTIME OWNER. Any member could therefore @ an agent
running on someone else's machine and spend that machine owner's autopilot
rights — pause a rule, re-point a webhook, rotate a token, or grant themselves
a collaborator row.

Every write on this surface now resolves the human it acts FOR (a member acts
for themselves, an agent for its run's originator), through one seam, and
stamps that same human into created_by / published_by / granted_by and the
rule versions. The gate and the row can no longer name two different people.

Two bypasses of that gate are closed with it:

- The webhook credential was broadcast to the whole workspace on
  autopilot:updated, handing every member the token GetAutopilot had just
  redacted for them. All five trigger fan-outs now carry a redacted copy; the
  HTTP caller still gets the live value.
- Agent identity itself was forgeable: Auth stripped only X-Actor-Source, so a
  member could replay an observable (agent_id, task_id) pair — both returned by
  GET /api/issues/{id}/task-runs — and be resolved as that agent. Auth and
  DaemonAuth now strip both headers, leaving the mat_ branch as the only writer
  of agent identity.

That last part is MUL-3428, reported and fixed by @CatCatUncle in #4313; it is
carried here because this change cannot be correct on top of a forgeable actor.

Behavior tightening, no migration: the only path it breaks is a member changing
an autopilot they hold no grant on. A run with no originator can no longer
write here.

Co-authored-by: CatCatUncle <[email protected]>
2026-09-07 16:46:07 +08:00
728d26f0b5 MUL-6211: feat(agents): expose usage in task history (#7085)
* feat(agents): expose usage in task history

* fix(agents): surface task usage query failures

* perf(agent): bound task usage history queries

* fix(agents): paginate task usage history safely

* fix(migrations): repair interrupted keyset index builds

* refactor(agents): make task usage opt-in

* docs(agents): clarify task usage availability

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Multica Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-07 14:59:40 +08:00
60408ef252 MUL-7090 fix(autopilot): let an agent trigger a manual run for the human it acts for (#8099)
* fix(autopilot): let an agent trigger a manual run for the human it acts for

MUL-6951 replaced canCreatorInvokeAgent, which judged automatic dispatch by the
autopilot CREATOR and needed no trigger, with ResolveAutopilotTriggerPrincipal,
which resolves the FIRING TRIGGER's creator. The manual entry point passes no
trigger by construction, so the resolver returned nothing on its first line and
every manual trigger from a non-member actor was refused. Scheduled runs kept
working — they have a trigger row — which is what made this read as a
configuration problem rather than a regression. Reported as #8078.

The refusal was also self-contradicting: requireAutopilotWrite had already
resolved a real human from the request (for a task token, the runtime owner the
middleware binds) and confirmed they may trigger this autopilot. Three lines
later memberActorUserID discarded that human for not being a "member" actor, and
the empty identity fell into the automation branch.

TriggerAutopilot now resolves the human the caller acts FOR:

  - a member acts for themselves, judged exactly as before;
  - an agent acts for its run's ORIGINATOR, which is how every other invoke
    surface judges an agent actor (MUL-3963 / canInvokeAgent), including this
    file's own private-leader gate on save.

Reading the runtime owner instead would have been a smaller diff and the wrong
grant: it makes every agent a standing proxy for its machine's owner, so anyone
who can talk to the agent inherits that owner's autopilot rights. The originator
must ALSO hold write access, so an agent can only press a "Run now" that the
human ordering it could press themselves.

No resolvable human is refused outright (403). The alternative was the
workspace-broad exception invokeAgentDecision grants unattributed agent/system
principals on a public_to-workspace agent, which would have admitted an
originator-less chain here too. Rejected per Bohan: a manual trigger is by
definition somebody's decision, so a chain arriving with no human at its top
means something upstream dropped it, and that should surface.

Two things this does NOT change, both deliberate:

  - requireAutopilotWrite still judges the request's authenticated user. That it
    resolves the runtime owner for an agent is pre-existing and shared by all
    eight autopilot write endpoints; narrowing it is a separate change with its
    own blast radius, filed separately.
  - The run's attribution stays direct_human to the resolved human rather than
    delegation. attributionForIssueTask treats a valid actor as direct_human by
    contract, shared with assign/promote/rerun, so labelling this one path
    delegation means threading a new parameter through TaskService's enqueue
    chain. The human on the hook is correct either way; only the label is
    coarser. Filed as follow-up rather than half-applied to one execution mode.

Two independent defects the same report exposed:

  - The skip reason blamed "the trigger records no owner" on a path that has no
    trigger, sending #8078's reporter hunting a broken trigger owner for a day.
    It now branches on whether a trigger exists.
  - `multica autopilot trigger` printed "Autopilot triggered" and exited 0 for a
    skipped run, so the operator — and any agent running it for them — read
    success. It now mirrors the web client's runNowToastKind whitelist: only
    issue_created and running are success, everything else prints failure_reason
    and exits non-zero. --output json still writes the run to stdout first.
    This is the half that made the bug silent, and it covers every skip reason,
    not just this one.

The MUL-4525 admission test asserted that (no actor, no trigger) must be refused
and called it "automation dispatch" — but that is exactly the argument pair a
manual trigger produced, so the test pinned the regression and would have
blocked the fix. Rewritten to assert the no-principal phrasing and, explicitly,
that the reason does not blame a trigger row that does not exist.

Verification (real PostgreSQL 17): new handler suite covers agent-with-
originator (dispatches), agent-without-originator (403, no run row), originator
lacking write (403, no run row) and the untouched member path. The acceptance
test was confirmed to fail against the pre-fix handler with exactly the reported
symptom — status skipped, invocation_not_allowed. internal/service and
internal/handler pass; the one internal/handler failure (TestPluginRoutes-
RequireWorkspaceAdmin, a duplicate user email) and the cmd/multica failures
reproduce identically on a clean tree.

Refs #8078

Co-authored-by: multica-agent <[email protected]>

* fix(autopilot): judge a manual trigger by the ordering human alone

Addresses Elon's review of #8099. Two must-fixes, both confirmed against the
code before changing it.

Must-fix 1 — the endpoint still required TWO humans to hold the grant.
requireAutopilotWrite ran in front of the originator check, and under a task
token it judges the RUNTIME OWNER. So the effective rule was "runtime owner AND
originator", not "the human who ordered it" — the opposite failure from the one
#8078 reported, and it broke a case a member is plainly entitled to: U created
the autopilot and may invoke an agent running on O's machine, O holds no grant,
so U could press Run now themselves but could not ask the agent to. It also
contradicted the docs this PR shipped, which already promised originator-based
authorization.

requireAutopilotTriggerInvoker is now this endpoint's only write gate.
requireAutopilotWrite is not called alongside it. Workspace tenancy for the
authenticated caller is unaffected: /api/autopilots sits inside the router's
RequireWorkspaceMember group, so dropping the second check loses no isolation —
it only stops a third party's machine ownership from vetoing the ordering
human's own rights. The other seven autopilot write endpoints are untouched and
stay with MUL-7108.

Authorization also moved ahead of the status check, so an unauthorized caller
cannot tell an active autopilot from a paused one.

The positive test could not have caught this: it let testUserID be runtime
owner, autopilot creator and originator at once. TestTriggerAutopilot_
RuntimeOwnerNeedsNoGrant now separates all three — the ordering human creates
the autopilot, the runtime owner is a plain member with no grant — and was
verified to fail against the previous commit with exactly the 403 Elon
described.

Must-fix 2 — the actionable refusal never reached the person running the
command. The handler named the cause, but FormatError collapses every 403 into
generic "no access" copy, so `calling run records no originator` died in the
response body. That is the same silence #8078 was about, one layer up.

Both refusals now carry a stable code (autopilot_trigger_no_originator,
autopilot_trigger_forbidden) via writeErrorCode, and cli.ServerErrorCode reads
it. The trigger command opts those two out of the generic copy and prints
guidance. The branch is on the code, never on the English sentence: copy edits
and translation must not silently disable it. The opt-out is per-code, so every
403 this command has not named keeps the deliberately vague copy that stops a
refusal confirming a resource exists — pinned by a test.

The two 403s deliberately share one sentence and one code whether the ordering
human is a non-member or merely ungranted, so the endpoint cannot be used to
probe workspace membership.

Verification (real PostgreSQL 17):
- TestTriggerAutopilot_RuntimeOwnerNeedsNoGrant fails against the previous
  commit's handler and passes now; the other four trigger tests still pass.
- Both 403 tests assert the code, not just the status — the CLI's output
  depends on it.
- New cmd/multica test runs the real cli.FormatError over both refusals, since
  the defect being guarded lives in FormatError's collapse, and asserts an
  un-opted-in 403 still gets the generic copy.
- New internal/cli TestServerErrorCode covers missing code, non-JSON, malformed
  JSON, prose in `code`, and a non-HTTP error.
- internal/service passes. Pre-existing unrelated failures reproduce identically
  on a clean tree: TestPluginRoutesRequireWorkspaceAdmin and
  TestCommentSourceContextLifecycle in internal/handler, four TestCLIConfig_*
  in internal/cli.
- gofmt, go vet, git diff --check clean.

Docs corrected at both sites to state the contract explicitly: the machine
owner's grants are not consulted, only workspace membership.

Refs #8078

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-07 14:57:37 +08:00
c01f60fb03 MUL-7055: extend search statement timeout to 5 seconds (#8098)
* fix(search): extend statement timeout to five seconds

Co-authored-by: multica-agent <[email protected]>

* fix(search): address timeout review nits

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-07 13:58:26 +08:00
7b60248d7e MUL-7074 fix(channels): derive chat titles from the current user instruction (#8059)
* fix(channels): derive chat titles from the current user instruction

* docs(channels): record the CommandText title-source invariants

Comment-only follow-up to the title-source fix. Three things the change
relies on were true but implicit, and each would fail silently:

- Router's CommandText fallback assigns the already-enriched Text, so a
  future enriching adapter that leaves CommandText empty reinstates #8058
  with every title test still green. State the invariant where the
  fallback lives.
- AppendInput's contract still described CommandText as /issue-parsing
  input only, and did not say that a ForceFresh caller must pass the
  directive together with its body — passing a bare /clear silently
  yields an empty title.
- The missing-current-text case asserts the known Body fallback. Mark it
  as a recorded gap so it is not read as a contract to preserve.

No behavior change.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-07 13:52:47 +08:00
940493409e MUL-7085: fix(chat): publish cancellation events when archiving agents (#8073)
* fix(chat): publish cancellation events when archiving agents

* fix(chat): align archive cancellation side effects

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-07 13:37:39 +08:00
d3f1e64ea8 MUL-7055: refactor issue search to candidate-first pipeline (#8041)
* perf(search): use candidate-first issue search

Co-authored-by: multica-agent <[email protected]>

* fix(search): address candidate-first review feedback

Co-authored-by: multica-agent <[email protected]>

* fix(search): address review nits

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-07 13:11:03 +08:00
70ea4c8f0d MUL-7050 perf(search): remove unused exact search counts (#8033)
* perf(search): remove unused exact issue count

Co-authored-by: multica-agent <[email protected]>

* perf(search): remove unused project count

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-04 16:53:05 +08:00
YYClaw d5cd0f3009 fix(mcp): improve server configuration and management (#8021)
Separate inline renaming from configuration replacement across agent settings and the workspace MCP library.

Renaming a shared server previously submitted the name and configuration together, so changing a label overwrote the stored connection details. Rename is now a name-only update that preserves the configuration and agent assignments, while replacing a write-only entry is an explicit action that states a complete new configuration is required.

Also unifies transport terminology and row presentation across both surfaces, validates on explicit save with field-level errors and focus on the first invalid input, keeps the dialog header and footer reachable while the body scrolls, and preserves existing server names that predate the current name grammar.

Closes #7996
2026-09-04 15:49:34 +08:00
2aad9842a9 MUL-6891, MUL-7048: fix(agents): preserve presence for hidden private runtimes (#7830)
* fix(agents): preserve presence for hidden private runtimes

Co-authored-by: multica-agent <[email protected]>

* test: trim hidden-runtime regression fixtures

Co-authored-by: multica-agent <[email protected]>

* fix(agents): use coarse hidden runtime availability

Co-authored-by: multica-agent <[email protected]>

* fix(agents): refresh unstable availability

Co-authored-by: multica-agent <[email protected]>

* fix(agents): omit visible runtime availability

Co-authored-by: multica-agent <[email protected]>

* fix(agents): poll projected availability on all clients

Co-authored-by: multica-agent <[email protected]>

* fix(runtimes): stop availability polling once every projection is offline

Polling now runs at 30s only while at least one agent's projected
runtime_availability is online or unstable. A stable offline projection
stops polling; recovery is already covered by daemon:register and
reconnect invalidating the agents query.

Co-authored-by: multica-agent <[email protected]>

* fix(agents): publish heartbeat recovery refreshes

* fix(agents): keep projected availability polling

Co-authored-by: multica-agent <[email protected]>

* fix(agents): bound recovery and projection polling

Co-authored-by: multica-agent <[email protected]>

* fix(agents): stop polling stable offline projections

Co-authored-by: multica-agent <[email protected]>

* fix(agents): align projected presence signals

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: worker-opencode <[email protected]>
Co-authored-by: Sol-Boy <[email protected]>
2026-09-04 15:39:38 +08:00
23171842fd MUL-6986 docs(squads): drop the comment pointing at a deleted source map (#8029)
The squad operating protocol's ownership comment ended by saying the same
ownership was recorded in multica-squads/references/squad-source-map.md.
#7968 deleted that file along with the other seven source maps, so the
sentence now points at nothing.

Deleted rather than repointed. The nearest surviving file,
multica-platform/references/squads.md, states the agent-facing BEHAVIOR (post
one comment when the activity write fails), not which file owns the rule.
Ownership is maintainer-facing evidence, and keeping that out of the shipped
skill payload is exactly what #7968 was for — so this comment, sitting next to
the const it describes, is already the right and only home for it.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-04 15:33:53 +08:00
2efe1b2a43 MUL-6984 fix(daemon): let workflow step 2 alone decide the comment scan (#7972)
* MUL-6984 fix(daemon): let workflow step 2 alone decide the comment scan

The brief's step 2 called the catch-up scan mandatory while the per-turn
hints in the same context made it conditional ("Need cross-thread
background?", "Only if you need context from the other threads", "If your
reply depends on thread context"). Step 2 now owns the modality; the hints
carry facts and this turn's exact commands and hand the scan over as the
wide read. Hint dispatch keys on PriorSessionID before the delta so a run
whose resume the daemon dropped takes the fresh-session path instead of the
resumed delta hint. The assignment pointer drops the parenthetical that
implied only assignment turns treat the read as mandatory.

Measured on 537 comment-triggered runs: 1 in 10 scans opened a thread the
prompt had not named; 0 of 36 non-scanning runs did; scan median 434 B.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): only waive the comment scan on a delta the server actually computed (MUL-6984)

Addresses review on #7972.

[P1] NewCommentCount == 0 was read as "nothing else was said on this issue",
and used to tell the agent its scan is already answered. The producer in
buildClaimedTaskResponse is best-effort: it writes the count fields only when
the anchor lookup and the count query both succeed AND the count is positive.
A failed anchor read, a failed count query, a cold start with no prior run, a
true zero, and an old server that never sends the fields therefore all arrive
as the same zero — and only the true zero looked at the issue. On a resumed
run a single failed count query was enough to delete the mandatory scan
exactly when the run could least afford it.

The claim now carries new_comments_delta_known, set on the success path
whatever the count. The count fields still stay suppressed at zero (there is
no delta hint to render from a zero), so the flag is the only thing that
separates a checked zero from an unchecked one. Absent on old servers, which
reads as "not authoritative" and keeps the scan — the safe direction in both
mixed-version pairings. The resumed path with no authoritative delta now
renders BuildResumedUnknownDeltaCommentsHint, which keeps the session facts
and hands the scan over as a command instead of claiming it is answered.
Workflow step 2 is tightened to match: only an explicit server report waives
the scan, and silence is not that report.

[P2] The warm path was selected on PriorSessionID != "" alone. MUL-5305 lets
the server withhold a more recent Codex session whose rollout is missing and
return an OLDER fallback session instead: PriorSessionResumeUnavailable is
true while PriorSessionID is non-empty. That older session may resume cleanly,
but it is not the turn this run continues from, so the run was taking the warm
path — and on an empty delta could skip both the full trigger-thread read and
the scan — on the strength of context it does not have. It now requires
!PriorSessionResumeUnavailable and reconstructs from the record instead, which
is what the continuity notice already tells the user happened.

Tests cover the states the existing suite could not see: an older fallback
session with every other warm-path precondition satisfied, resumed-with-delta-
unavailable versus resumed-with-authoritative-zero as siblings, and a
server-side computed zero carrying the flag. Each was verified against the
un-fixed code, where it fails. TestBuildPromptResumedNoDeltaDoesNotForceThread-
Read now sets the flag: its subject is the waiver branch, which is exactly the
branch that requires an authoritative answer.

Co-authored-by: multica-agent <[email protected]>

* docs(handler): correct the new-comment delta's scope on the claim wire (MUL-6984)

Review should-fix on #7972. NewCommentCount's doc comment still described it as
"trigger-thread comments since last run", while the query that produces it
(CountNewCommentsSince: "ACROSS THE WHOLE ISSUE, every thread, not just the
triggering one"), the daemon-side mirror field, and the NewCommentsDeltaKnown
comment directly beneath it all say issue-wide. One struct carrying two
mutually exclusive readings of the same field is worse than either.

Also records on NewCommentsSince that it is suppressed together with the count
at zero, so the next reader does not mistake a non-empty anchor for the signal
that the server looked — NewCommentsDeltaKnown is that signal.

Comment text only.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): stop calling an older-fallback resume a fresh session (MUL-6984)

Under MUL-5305 the server hands back an OLDER session with the continuity
gap flagged, and the daemon resumes it (ResumeSessionID is not gated on the
flag). The cold hint opened with "This run starts a fresh session on this
issue" and the readable continuity notices said "you are on a fresh one";
both were false in that state, and a test pinned the first.

The cold hint now states nothing about the session — none of the four runs
it serves (first run, dropped resume, explicit fresh, older fallback) makes
a different decision on it, and all four reconstruct from the issue record
with the same two reads. The three readable notices say the run "does not
continue" the lost session and that the memory gone is "from the turns
that did not come back", true in both the fresh and the fallback case.

TestBuildPromptOlderFallbackSessionRequiresReconstruction (renamed) asserts
the reconstruction commands and the notice, and bans "fresh session" and
"fresh one"; the resumed-path negative anchors now key on a phrase only
the cold hint carries.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-04 15:06:00 +08:00
7f635526ff MUL-7016: Route daemon workspace sync reads to replica (#8022)
* MUL-7016 route daemon workspace sync reads to replica

Co-authored-by: multica-agent <[email protected]>

* test(handler): remove redundant daemon read routing test

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-04 14:30:44 +08:00
0242f37154 MUL-6986 refactor(skills): merge built-in skills into one platform skill and drop source maps (#7968)
* MUL-6986 refactor(skills): merge built-in skills into one platform skill

Built-in skills are read on the user's machine, which has no checkout of
this repo. Half the shipped payload by bytes was a source-location index:
eight `references/*-source-map.md` files (103KB, 1084 lines) whose content
was `internal/handler/github.go:687` style citations, plus "verification
commands" like `go test ./internal/service` and
`grep -n 'func derivePRState' internal/handler/github.go`. Four SKILL.md
bodies also carried source paths and Go identifiers, and every one of them
pointed the reading agent at its source map ("When in doubt, read that
file"). One source map even leaked an internal branch name.

That was not an oversight: CLAUDE.md required updating a source map in the
same PR as any behavior change, and this test file asserted all seven
existed. The reasoning against it was already written at the top of this
file — anything inside a skill directory is shipped to agent machines, so
evals must stay in _test.go — it just was never applied to the maps.

The nine skills were also split along backend modules, not along how the
work arrives. An agent has one entry point ("I am doing something on the
Multica platform"), so nine descriptions competed in the always-loaded
listing with overlapping boundaries, a cross-domain task activated three
skills, and Quick start / Core model / CLI / Side effects were restated in
most of them.

- Delete all eight source maps.
- Merge the eight platform skills into `multica-platform`: an 81-line
  routing SKILL.md (positioning, the eight shared invariants that were
  duplicated across the old bodies, and a table naming one reference per
  domain) plus `references/{issues,mentions,agents,squads,autopilots,
  projects,runtimes,skill-import}.md`. This is the layout the skill spec
  documents for `references/` — a large skill split by domain, loaded on
  demand — rather than an evidence layer for source.
- Strip every source path, Go identifier, test name, migration ordinal and
  tracker citation from the payload; the behavior contracts themselves are
  unchanged, restated in user-observable terms.
- Scope `multica-onboarding` to Mika. It is one agent's procedure, and
  every agent was paying for its description and its files.
- Add TestBuiltinSkillPayloadCarriesNoSourceReferences so this cannot
  regress, and re-home the ~100 contract anchors onto the reference that
  now owns each one. Anchors match with whitespace collapsed, so a
  paragraph reflow no longer reads as a deleted contract.
- Point the brief's two skill references and CLAUDE.md at the new layout,
  and add one recall line to the brief's Skills section: eight
  descriptions became one, so an agent has one name to guess instead of
  eight. It is emitted only when a listed skill resolves to exactly
  `multica-platform`, so a workspace skill taking that slug silences the
  pointer rather than aiming it at a stranger's skill.

Payload per ordinary agent goes from ~211KB across nine skills to ~100KB
in one; the always-loaded description budget from 1,616 chars to 229.

Co-authored-by: multica-agent <[email protected]>

* MUL-6986 fix(skills): resolve the brief's platform pointer from the built-in

Review found the brief's recall line claimed a fail-closed behavior it did not
have. The claim was that a workspace skill taking the `multica-platform` slug
would silence the pointer; in fact the workspace skill is listed first, wins the
bare slug, and the pointer named it — handing the agent a stranger's skill as
the source of Multica's platform contracts, while the built-in sat at
`multica-platform-multica` and became unreachable.

Documenting that would have left a recall hole, and recall is the one thing this
merge may not cost: eight descriptions became one, so the agent's path to a
platform contract has to stay intact.

Fixed at the source instead. Built-in skills now declare `Source` at load time
(the bundle builder already derived it for the refs path, but the older inline
claim path appended them raw), the daemon carries `Source` into the brief's view
of a skill instead of dropping it, and the pointer resolves the slug from the
built-in's own entry. A collision now points at the built-in wherever it landed.
No bundle hash changes: the bundle builder normalizes `Source` to this value
before hashing.

Also adds TestPlatformSkillDescriptionNamesEveryDomain, which fails if a
reference ships without its trigger word in the description — the merge's recall
guarantee as a test rather than an assertion.

Review nits:
- TOCs for projects.md and runtimes.md, both over the 100-line bar.
- Inlined the two reference-to-reference links in mentions.md, so the routing
  file's "they do not depend on each other" is true.
- Example issue keys use MUL-123, not MUL-2759 — a real internal issue.
- Fixtures naming the deleted skills now name live ones.
- Recorded where the description boundary sits: task nouns are trigger wording,
  body section headings are the inventory the cap rules out.

Co-authored-by: multica-agent <[email protected]>

* MUL-6986 fix(skills): keep the brief's skill pointers resolvable across daemon skew

The runtime brief is assembled by the DAEMON, from a binary the user installs on
their own schedule. Deploying a backend does not rewrite it, and backend
upgrades do not force a daemon upgrade, so renaming the built-ins left two brief
lines — the metadata write discipline and the sub-issue playbook — naming
`multica-working-on-issues`, a skill this server no longer ships. The window is
open-ended, and a pointer to a skill that is not installed is worse than no
pointer: the agent hunts for it and may skip the contract when it misses.

Both skew directions happen, and each needs the fix on the side that is still
deployable:

- Old daemon, new backend. That daemon's brief text is frozen, so the fix has to
  come from the server: a daemon that does not advertise `platform-skill-v1`
  additionally receives a redirect stub under the old name, carrying nothing but
  the new location. A current daemon receives nothing extra, so the stub retires
  itself per install as daemons update — no cleanup release to remember, and no
  cost paid by daemons that never needed it. Gated on capability, never on a
  version string, matching how every other daemon skew is handled here.

- New daemon, old backend. The server has no idea the merge happened, so this
  side has to cope. The brief now resolves both pointers from the skills the
  task actually received: the merged skill if present, else the pre-merge one,
  else no pointer at all. The `--status todo` / `--stage <N>` / secrets rules
  stay unconditional either way — only the "read more here" clause is
  conditional.

Resolution matches on built-in source as well as slug, so a workspace skill that
sanitizes to the same name cannot be advertised as the source of Multica's
platform contracts.

The redirect stub is deliberately a signpost with no contracts of its own: a
stub that grew content would rot against references/issues.md precisely on the
installs that cannot be updated from here, and a test caps its size and forbids
supporting files. It also rides through the template, strict-frontmatter and
source-leak suites like any other shipped payload.

Co-authored-by: multica-agent <[email protected]>

* MUL-6986 fix(skills): route by domain, not by count, and test the redirect wiring

Review must-fix: the routing text turned "load references on demand" into "load
exactly one". `SKILL.md` said to open "the ONE reference that matches your
task", the brief said "open the one reference its routing table names", and a
test pinned the singular.

That is wrong for the tasks the merge was argued on. Creating a squad, assigning
it an issue, then writing a mention needs squads.md, issues.md AND mentions.md;
wording that implies one would have the agent act on contracts it never read —
exactly the recall loss this work is not allowed to cause. Routing is now by
domain: open what the task touches, usually one and sometimes a few, and never
all eight out of uncertainty. The tests pin the domain framing and reject the
singular forms.

Also adds the wiring test the review found missing. The service test passed the
decision in as a bool and the brief test hand-built a skill set, so both ends
were covered while the middle — X-Client-Capabilities on the claim, through the
handler, into the delivered payload — was not. A break there would have left
every existing test green while an old daemon silently lost its redirect.
Covered across both claim branches, since each assembles the built-in set
separately, and verified to fail when the wire is cut. The daemon's capability
header test now pins platform-skill-v1 alongside the worktree gate.

Non-blocking review cleanups:
- The invariant claimed every domain has `list` and `get`; there is no
  `runtime get` and a mention is not a gettable entity. It now points at the
  read-only commands the opened reference actually names.
- Two comments still said the `multica-` prefix means a built-in slug can never
  collide with a workspace skill. This PR ships the fix for that exact
  collision, so they now say what is true: the prefix namespaces, nothing
  reserves it server-side, and the brief resolves pointers from built-in source
  rather than trusting the bare name.
- Recorded the allowed-tools tradeoff: merging eight skills merged their tool
  declarations, so the router declares Bash(git *) / Bash(gh *) for domains that
  do not need them. Dropping them would regress the issue workflow that runs
  `gh pr`; today the daemon's global permission mode means this grants nothing
  the agent did not already have.

Co-authored-by: multica-agent <[email protected]>

* MUL-6986 docs: drop the CLAUDE.md payload rule that a test already enforces

CLAUDE.md is loaded into every task in this repo and asks to stay short:
rules there should be hard to infer from code or easy to get wrong. This one
was neither — TestBuiltinSkillPayloadCarriesNoSourceReferences fails CI on
every class it named, and its failure message already carries the rule and the
reason at the moment someone violates it, which is both cheaper and impossible
to drift from.

The line above it stays: nothing enforces "update the reference in the same
PR" when a CLI flag changes.

Co-authored-by: multica-agent <[email protected]>

* MUL-6986 refactor(skills): accept the skill-name collision instead of handling it

Product decision: a workspace skill whose name sanitizes to exactly
`multica-platform` is rare enough not to design around. Half-handling it was
costing more than the risk — the brief resolved collisions dynamically while the
redirect stub could not (the stub is server-authored text, and the final slug is
only assigned when the daemon writes it to disk), so every review round found
another place that "did not handle it". Either handle it everywhere or nowhere;
this takes nowhere, and states the assumption where the pointers are.

Removed:
- builtinSlug matches the exact name again, instead of built-in source plus a
  slug prefix.
- SkillContextForEnv.Source, the line carrying it through convertSkillsForEnv,
  and the Source stamp on loaded built-ins. All three existed only for the
  collision check, and dropping the stamp also removes a wire-facing change:
  the bundle builder derives "builtin" from an empty id/source exactly as it
  did before.
- The three collision test cases.
- The "the prefix does not guarantee uniqueness, so resolve from source"
  narrative, replaced by the assumption it stands in for: `multica-` is the
  platform namespace, pointers name built-ins by bare name, and nothing
  reserves the prefix server-side yet. When that becomes real the fix is to
  reject the prefix at skill create/import, not to make every pointer
  defensive.

Kept, because it is version skew rather than collision and its window has no
upper bound: the `platform-skill-v1` capability, the redirect stub for daemons
that predate the merge, the brief's new-then-legacy-then-none resolution, and
their tests.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-04 13:51:12 +08:00
cc09c3c12a MUL-7008: Relax healthy WebSocket claim polling to 3 minutes (#7983)
* MUL-7008: relax websocket claim fallback polling

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): harden websocket claim polling

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): harden deferred claim scheduling

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-04 13:01:07 +08:00
49098deb6f MUL-6984 refactor(prompt): give each duplicated instruction one owner (#7971)
* refactor(prompt): give each duplicated instruction one owner (MUL-6984)

Four cases where the same data or rule was rendered two or three times into
one context window, following the sibling-block removal in #7937. Each is
resolved by picking the surface that owns it and pointing the others there —
no behavior change beyond what an agent stops reading twice.

The rule used to pick the owner is the one MUL-5377 already set for issue
tasks: the runtime brief holds what is true of this KIND of task (stable, and
it is the prompt-cache prefix), the per-turn user message holds what is true
of this RUN, and the workdir sidecar holds nothing. Autopilot, quick-create
and squad-leader paths had never been cleaned up against it.

Autopilot run data (run id, autopilot id, title, source, trigger payload,
instructions) was rendered in the brief, the per-turn message, and the
sidecar. Now per-turn only; the brief keeps the run-only framing and the
shared issue-command guard. This also settles a contract violation: those are
per-run values, and the brief promises not to carry any. MUL-5696 already
caught the two copies drifting on the guard sentence and fixed that one line
with a shared constant. The payload is now capped at 8 KB with an explicit
truncation notice — it is written by whatever system fired the trigger, so
nothing here bounds it, and a clipped JSON object read as a whole one is
worse than a short one.

The squad leader no_action rule was written four times: the Squad Operating
Protocol, the per-turn prompt, brief workflow step 4, and brief ## Output.
The four had already drifted — only some carried the MUL-6622 escape hatch
that keeps a FAILED `squad activity` call from ending the turn in silence.
The protocol is now the single statement (it is the only document sent
exclusively to leaders); the two brief sites and the per-turn reply imperative
carry the EXCEPTION to their own imperative and point back at it.

Quick-create's five run/output rules were in the brief's Workflow, the
brief's ## Output, and the per-turn "Output format" block; the
--description-file /tmp guidance was in the brief's Available Commands and
per-turn. The brief owns both now — its own comment says these guardrails
must hold "even if the user message is missing" — and per-turn renders only
the field values the modal picked.

.agent_context/issue_context.md is deleted outright. Nothing read it: the
rendered CLAUDE.md never mentioned .agent_context, no per-turn message
pointed at it, and no provider discovers it. Its writer's own comment said
"the runtime brief already carries every fact this file would". With it gone
the directory is created only when the default provider needs
.agent_context/skills, so a local_directory task no longer leaves an empty
managed directory in the user's checkout.

Compatibility: prompt text only. No wire field, CLI flag, or DB column
changes, so no server/daemon version coupling. Tests follow the text to its
new owner rather than being deleted: the protocol's no_action rule is now
pinned in internal/handler, the quick-create rules in the brief's own suite,
and the payload cap has its own coverage including the rune-boundary cut.

Co-authored-by: multica-agent <[email protected]>

* fix(prompt): drop the autopilot payload cap, pin the moved invariant (MUL-6984)

Addresses review on #7971.

The 8 KB trigger-payload cap is removed. Its rationale was wrong twice over:
webhook ingress already bounds a body at 256 KiB
(handler.maxWebhookBodyBytes), so the payload was never unbounded from
Multica's side; and because de-duplication made the per-turn message the
payload's ONLY rendering, truncating there dropped task input with no way
back — GET /api/autopilots/{id}/runs/{runId} returns the full payload but no
CLI command exposes it, and `autopilot runs` lists runs without payloads. A
legitimate 9 KB webhook would have silently lost fields the autopilot
instructions might name, and the "JSON is incomplete" notice only prevents
misreading, it does not recover anything. The cap belongs with an agent-facing
read-back command and a threshold taken from real payload sizes, not as a side
effect of removing duplicates. Rendering it once instead of twice is still a
strict improvement over the pre-PR behavior.

TestBriefByteIdenticalAcrossRunsForEveryKind could not see the invariant this
PR actually moves: its autopilot row pins run-1 / ap-1 and every variant
mutates only the generic resume / initiator / connected-app fields, so putting
the title, source, payload or description back in the brief left it green.
TestAutopilotBriefByteIdenticalAcrossRunScopedFields adds a second and third
run that vary all of them — including a different autopilot id — asserts the
brief is byte-identical, and asserts the brief contains none of those values.
That second half matters: byte-identity alone also holds if the values reach
no surface at all. Verified against a deliberately reintroduced field, where
it fails as intended. TestBuildPromptAutopilotRunOnly gains the source and
payload assertions so the per-turn message is pinned as their sole owner.

Contract comments that still described the deleted sidecar are corrected:
agent.MinHandoffCLIVersion (the gate always measured the opening prompt — no
provider read the sidecar), the handoff_note field docs on both sides of the
claim wire, the claim handler's note, TaskContextForEnv.HandoffNote, and
errPathPreExists's namespace list. The Reuse path keeps its issue_context.md
mentions, now labelled as cleanup of manifests written by older builds rather
than of anything this code still writes.

Co-authored-by: multica-agent <[email protected]>

* refactor(squad): keep the protocol's ownership note out of the prompt (MUL-6984)

Review nit on #7971. The Squad Operating Protocol ended with a paragraph
saying it is the single statement of the no_action rule and that the rule
changes here — true, and worth writing down, but addressed to whoever
maintains the text. That const is sent to the model on every leader turn, so
the leader paid ~40 tokens per turn for a sentence it cannot act on.

Moved to the const's doc comment, next to the other constraints a maintainer
reads before editing it. The skill side already records the same ownership in
multica-squads/references/squad-source-map.md.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 18:57:35 +08:00