Commit Graph
735 Commits
Author SHA1 Message Date
Jiayuan Zhangandmultica-agent 8ade96e671 MUL-6584: establish Public API v1 foundation (#7450)
* feat(api): establish public v1 contract foundation

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

* fix(plugins): close public API contract gaps

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

* fix(issues): enforce revision guard during update

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-23 22:47:37 +08:00
Jiayuan Zhangandmultica-agent 5bf10d485c MUL-6582: add durable scheduled Plugin hooks (#7448)
* MUL-6582 feat(plugins): add durable scheduled hooks

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

* MUL-6582: add schedule-pulse example plugin

Ship a real scheduled plugin that records each unique delivery_id in
workspace storage, so a host retry does not look like a second pulse.
The cmd/server test installs this example manifest and drives two
scheduler replicas through a dropped first attempt.

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

* MUL-6582 fix(migrations): make schedule indexes retry-safe

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-23 17:37:21 +08:00
Jiayuan Zhangandmultica-agent 9622dd55ac MUL-6583: fix archived inbox group counts (#7447)
* fix(inbox): align archive operations with issue groups

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

* perf(inbox): narrow archived group scan

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-23 16:58:18 +08:00
aa4c504c78 fix(issues): re-rank an issue when its status moves it to a new column (#7422)
position ranks an issue inside its own (workspace, status) column, but a
status change carried the old column's value over unchanged. The value
that put an issue on top of Todo is roughly zero, and lands it below
every hand-dragged issue in Done — so agent-completed work sank into the
middle of a long Done column and read as "the task disappeared".

Re-rank to the top of the destination column on both status-write paths,
reusing the MIN(position) - 1 policy new issues already get. An explicit
position still wins, so cross-column drag-and-drop keeps the slot it
dropped on, and a same-status write leaves position alone.

The re-rank reads MIN(position) for the destination (workspace, status),
which idx_issue_status could only serve with a heap fetch per issue in
the column — paid on every completion against the column that grows
without bound. Add (workspace_id, status, position) to make it an
index-only min.

Closes #7410

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-23 12:36:10 +08:00
1cc46b2697 MUL-6516 fix(channels): enforce durable context boundaries for /new (#7362)
* fix(channels): enforce new context boundaries

* fix(channels): address context boundary review findings

* fix(channels): enforce causal new context boundaries

* fix(channels): scope outbound provenance to target

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-22 11:27:39 +08:00
Bohan JiangandRAVN fa797d0063 MUL-6547 fix(daemon): resolve claim project context once, workspace-scoped (#7405)
Collapses the project-context resolution that had been copied across the
issue, chat and quick-create claim paths (and would have been copied a
fourth time) into one workspace-scoped, fail-closed resolver, and hydrates
run_only autopilot project context on top of it.

- Project and resource reads are both workspace-scoped, so a dangling or
  cross-tenant project reference can no longer lift another workspace's
  repository URL, local directory path, title or description into a claim.
- A failed read is no longer treated as "there is no project". Transient
  failures reject the claim and preserve the task for the stale-dispatched
  reclaim rather than silently falling back to workspace repos.
- Source rows (issue, chat session, autopilot run, autopilot) fail closed
  the same way. Previously a read error skipped the branch entirely and,
  because taskToResponse seeds the runtime workspace, sailed through the
  backstop isolation check — dispatching an agent with no issue context or,
  for chat, no input at all, bypassing the empty-input guard.
- Workspace isolation is resolved authoritatively per branch and checked
  before any context is hydrated. For run_only autopilots that check had
  been a tautology, comparing the runtime workspace against itself.
- run_only autopilots now receive their configured project's repositories
  and local_directory, the same contract issue-bound and quick-create tasks
  already had.

Absorbs #7396. Its inline implementation is replaced by the shared
resolver; the author's regression test is carried over unchanged.

Co-authored-by: RAVN <[email protected]>
2026-08-22 11:18:02 +08:00
f45b591623 MUL-6469: host plugin artifacts, bind installs to immutable versions (#7321)
* feat(plugins): host plugin artifacts, bind installs to immutable versions

A plugin used to be a URL. The manifest was frozen at install, but the surface
script was fetched from the author's server every time a panel opened, with no
integrity check — so an administrator consented to a manifest while the browser
ran whatever that host served that day, inside the scopes already granted. The
author's uptime was our uptime, every panel open leaked the reader's IP and
"who read which issue" to the author, and publishing at all required a public
HTTPS domain, which is why the repo's own deploy-sentinel example never ran.

The author now uploads an artifact bundle and Multica stores it:

- plugin_package / plugin_package_version / plugin_package_file. A version is
  insert-only, and the (package_id, version) unique index is what makes
  immutability a database rule rather than a convention.
- An installation names one version. Publishing another changes nothing for a
  workspace until an administrator upgrades, which is a second consent.
- The bundle is validated once, at publish: the manifest parses, every file it
  declares is present and loadable, and a surface entry with a top-level import
  is refused with the line named. That failure used to surface months later in
  a reader's browser.
- The host inlines the surface script into the document it generates, so the
  CSP names no remote script origin at all. `connect-src 'none'` now means it:
  a surface with no net: scope cannot reach anywhere, including its author.
- Install-by-URL is gone rather than kept alongside. Two paths would make "is
  this code frozen?" depend on how the plugin was installed.
- MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary
  immutable version instead of being a second render path; re-publishing an
  unchanged version lands as `+dev.N`.

Hook endpoints and MCP servers remain on the author's own infrastructure.

Migration 376 drops existing installations: they name a source URL that is no
longer a concept, and the code they would run was never published here.
plugins_v1 has never left its feature flag and holds no production data — the
same call migration 344 made.

Closes MUL-6469

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

* fix(plugins): serialize publish/install/delete, correct the isolation claim

Review of #7321 found five problems. Four are fixed here; the fifth is real,
needs its own design pass, and is now tracked and described honestly in the code
instead of claimed away.

Migration prefix collision. main took 376 for agent_task_durable_work_dir while
this branch was open, so `migration prefix 376 is reused` failed backend-tests.
Rebased and renumbered the six migrations to 377-382.

TOCTOU between delete and install/publish. Relationships are application-owned
by repository policy, so nothing made "the version this installation names still
exists" true across statements: `delete counts zero installs` -> `install reads
the version` -> `delete commits` -> `install commits` left an installation whose
panel 404s forever, and a publish racing a delete left a version whose package
row was gone. Publish, install and delete now hold a (workspace, plugin key)
advisory lock for their whole transaction, and delete counts installations
inside it. Both interleavings have regression tests that reproduce the bug with
the lock removed.

Failed publishes moved package state. upsertPackage ran before the version
transaction, so a republish that lost the unique index had already renamed the
package, and a first publish that failed afterwards left a plugin with zero
versions. It now runs inside the same transaction.

The top-level import check was wrong in BOTH directions. It read line prefixes,
so it missed `  import x` and `/* c */ import x` — and it refused a valid file
whose template literal contained the word at a line start, which is ordinary in
a surface that renders code samples. A refusal blocks a publish with no way
around it, so that false positive was the worse half. Replaced with a scanner
that skips comments and string/template literals; where it is imprecise it is
imprecise safely (`/` always reads as division), so a mistake can only cost a
detection, never invent one.

Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self`
navigation by design, no shipped CSP directive covers it, and contentWindow is
unchanged across it — so a hostile artifact can still reach its author once and
hand the replacement document the bridge port. Closing it needs an embedder
frame-src policy or a handshake a navigated document cannot complete; both are
decisions of their own. What lands here is damage control, labelled as such: the
generated document beacons on pagehide from a listener the plugin cannot detach,
and the embedder drops the bridge and unmounts the frame. The "a surface cannot
reach its author" wording is removed from the SDK README and the tests.

Closes MUL-6469
Refs MUL-6485

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

* fix(plugins): harden surface validation and error reporting

* fix(migrations): renumber plugin package migrations

* fix(plugins): validate classic surfaces and reset failures

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 16:03:44 +08:00
115bfab759 MUL-6523: persist task message batches in one statement (#7377)
* perf(server): persist task message batches in one statement

/api/daemon/tasks/{id}/messages is the hottest write path in the system:
every running task POSTs its buffered transcript every 500ms. The handler
was paying 3 + N database round trips per batch — GetAgentTask, the
workspace lookup inside requireDaemonTaskAccess, a SECOND identical
GetIssue/GetChatSession in the handler body, and then one
`INSERT ... RETURNING` per message, each in its own implicit transaction.

Two changes, both on that path:

- Take the workspace id from requireDaemonTaskAccessWithWorkspace, which
  already resolved it to authorize the daemon, instead of re-querying it.
  Broadcast reach is deliberately unchanged: the id is still only used for
  issue- and chat-backed tasks, so autopilot / quick-create tasks keep
  their current (non-)streaming behaviour.
- Add CreateTaskMessages, which expands the whole batch from one jsonb
  document via jsonb_to_recordset and inserts it with a single statement.
  A jsonb document rather than parallel arrays because `input` is itself
  jsonb and a text[]/uuid[] column cannot express a per-row NULL from Go;
  it also stays clear of the ~64k bind-parameter ceiling a multi-row
  VALUES list would hit.

Per batch this takes 3 + N round trips down to 3, and collapses N commits
into 1. Measured locally against Postgres 17 (200 iterations per shape,
same rows): 2.1ms -> 0.7ms at 3 messages, 7.0ms -> 0.8ms at 10, 80ms ->
1.1ms at 50.

Atomicity is the other half of the fix, not a side effect: the daemon does
not retry this endpoint, so a loop that failed halfway left a permanent
hole in the transcript. One statement makes a batch all-or-nothing.

The NUL sanitizer calls stay exactly where they were and matter more now —
jsonb rejects \u0000 for the whole document, so an unsanitized byte would
fail the entire batch rather than one row (GH #7098).

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

* fix(server): guarantee task message publish order, test the real query

Review follow-ups on the batch insert.

Blocking:

- Realtime ordering had no contract. `INSERT ... RETURNING` defines no row
  order, but the publish loop walked the result as-is, so a batch could
  reach subscribers scrambled where the per-message loop always published
  in request order. CreateTaskMessages now returns through an ordered CTE
  (`WITH inserted AS (INSERT ... RETURNING *) SELECT * FROM inserted ORDER
  BY seq`), putting the ordering in the query rather than asking every
  client to re-sort. Verified it is not a no-op: with the ORDER BY removed,
  the new test reports the published order as [3 1 2] for a request listing
  seq 3, 1, 2 — i.e. the bug was real, not theoretical.

- The atomicity test copied the batch SQL by hand, so it could have passed
  while CreateTaskMessages was broken. It now calls the generated query.
  Both tests were also rewritten onto the repo's shared fixtures — dbfx for
  rows, testutil.JSONRequest / WithURLParams / Call(...).Want(...) for the
  handler — instead of open-coded INSERT/cleanup pairs and recorder
  quartets.

- New TestReportTaskMessagesPublishesInSeqOrder subscribes to the event bus
  and asserts the published seq order, with the request deliberately listing
  messages out of seq order so array order cannot pass for seq order.

Non-blocking:

- A failed `json.Marshal` of a message's input is no longer swallowed. A
  tool_use row that silently loses its arguments is worse than a 500 the
  daemon logs, because nothing anywhere records the loss.

- Corrected the atomicity claim in the query and test comments: one
  statement buys CONSISTENCY, not completeness. Without a retry a failed
  batch is now lost whole rather than leaving a prefix; actually closing the
  gap needs a retry plus a (task_id, seq) uniqueness rule, which is out of
  scope here.

The CTE makes sqlc name the result CreateTaskMessagesRow rather than reusing
the table model, so the publish site converts explicitly — a compiler-checked
cast that fails loudly if the query ever stops returning the whole row.

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

* perf(server): pass task message batches as arrays, not a JSON envelope

Second review round. The jsonb-document parameter was a regression on large
messages: content and output had just been decoded out of the request, and
wrapping them back into JSON made the server escape every byte a second time
and Postgres parse the envelope out again. Measured against the per-row
insert it replaced, at one message: ~1.0x at 8KB, 1.46-1.48x at 128KB,
~3.0x at 1MB — worst exactly where the old path was cheapest, and those
messages are the least likely to be batched.

CreateTaskMessages now takes parallel arrays. NULLIF(value, '') is what makes
a per-row NULL expressible through the non-nullable []string sqlc gives a
text[] parameter, and it reproduces the single-row query's
pgtype.Text{Valid: x != ""} mapping exactly. input stays a text parameter
cast to jsonb in SQL — it is the one column that genuinely has to be parsed.

The arrays are zipped by several single-argument unnest calls in one SELECT
list, which expand in lockstep (PG10+ SRF semantics). The tidier
unnest(a, b, ...) form is equivalent but sqlc's analyzer only knows the
single-argument signature and fails to generate for it.

Measured after the change, same harness, two runs:

  one message, 8KB      0.98-0.90x of the per-row insert
  one message, 128KB    0.99-1.04x   (was 1.46-1.48x)
  one message, 1MB      1.59-1.67x   (was 2.99-3.02x)
  3 messages, 512B      2.7-3.2x faster
  10 messages, 512B     7.7-8.2x faster

128KB is now parity. 1MB is still above parity because any batch shape makes
Postgres materialize the array and unnest it before the insert, which a
direct single-row parameter skips; closing that needs a len == 1 fast path,
which review explicitly preferred not to have for the extra code path. A real
1MB single message is also unlikely: tool_result output is truncated to 8KB
in the daemon (internal/daemon/daemon.go). The durable answer for oversized
payloads is a body/batch cap on this endpoint, which is still a follow-up.

Also from review:

- The conflicting row in the atomicity test is built with dbfx.Insert instead
  of CreateTaskMessage, so test setup no longer shares code with the code
  under test.
- The ordering test runs on its own event bus and restores the shared one, so
  it stops leaving a listener behind on a bus that has no unsubscribe.
- The per-batch probe is Debug, not Info. At Info it was ~2 lines/second/task
  in production; it was added at Info in #7371 only to count batch sizes,
  which is answered.

Re-verified the ordering test still fails without the ORDER BY (reports
[3 1 2]) against the new query shape.

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 15:17:02 +08:00
YYClaw 5e2d349ec4 MUL-6426: refactor(dingtalk): safely retire group routing and enhance discovery (#7241)
Retire mutable DingTalk group routing: inbound messages always target the installation's configured Agent, and observed groups become read-only inventory in dedicated tables with persisted activity counters.

Migrations 382-389 clean stale cross-Agent bindings, create and backfill dingtalk_group_presence and dingtalk_bot_identity, and install a mirror trigger so writes from an older process during a rollback window are not lost. The legacy dingtalk_group_route table and its indexes are retained for expand/contract; a later release drops the trigger and table together.

Closes #7239
2026-08-21 14:26:48 +08:00
bae3a324fc MUL-6504 fix(daemon): stop chat tasks holding the local_directory mutex they never write to (#7378)
* fix(daemon): stop chat tasks holding the local_directory path mutex (MUL-6504)

A chat turn bound to an in_place local_directory queued on that path's
mutex, so every message to a squad leader sat behind whatever coding task
was building in the same repo — measured at 23-67 minutes on a self-hosted
install, with cancelling the chat by hand as the only way out (#7344).

The carve-out that was supposed to cover this keyed on IsLeaderTask, and
its own comment describes a chat turn exactly ("may create child issues or
comments, but should not hold the path mutex"). It never fired: the server
does not write is_leader_task on the chat-task insert path, so it is false
on every chat turn ever dispatched.

The reported one-line fix — returning no assignment for chat — would have
freed the lock by also moving chat out of the user's directory: that same
return value drives the agent's working directory and the GC-meta stamp.
A "look at my repo" chat would have stopped seeing the repo.

So split the two decisions instead. localDirectoryAssignmentForTask keeps
answering where a task runs; the new localDirectoryLockExempt answers
whether it queues for it. Chat resolves, validates and binds exactly as
before, and skips only the mutex.

What the mutex protects is unchanged: two coding runs interleaving edits
into one tree. It was never a general write barrier — the user's own
editor and any external script write to that tree unsynchronised — so a
conversation whose occasional write is the same risk class as Cmd+S does
not need a slot in it. An exempt turn is told its directory is shared, via
a per-turn block that stays after the cached prompt prefix.

Tests: chat skips the mutex while a coding task holds it AND still
resolves an assignment; worktree chat keeps its assignment; two issue
tasks still serialise; the leader carve-out is unchanged.

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

* feat(chat): explain a waiting_local_directory hold in the status pill (MUL-6504)

wait_reason was written end-to-end except for the last hop: the daemon
stamped the path and holder, the column stored it, the realtime payload
carried it, and the UI read only the status — as the type comment said
outright. A parked chat showed "Waiting for local directory" with a timer
and no way to tell a one-minute wait from a forty-minute one, or whether
cancelling was safe. That ambiguity is what pushed the reporter into
cancel-by-id and cost them 36 minutes of a worker's progress.

Surface it. The daemon now names the directory by its resource label or
basename instead of its absolute path: this text reaches every client on
the session and lands in screenshots, which is the same contract
handler.relativeWorkDir already enforces for the sibling work_dir chip.
The absolute path stays in the daemon's logs, where an operator debugging
a wedged lock looks for it.

wait_reason is written once on the way into the hold and never cleared, so
both the server DTO and the client cache gate it on the status still being
waiting_local_directory — otherwise a task running fine for ten minutes
keeps claiming to be held by another one.

Servers predating the field send nothing and render today's bare label.

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

* fix(server): strip legacy absolute-path wait reasons before clients render them

Surfacing wait_reason created a leak that did not exist while the field was
being ignored. Daemons before the display-name change send
`local_directory /Users/<name>/repo (held by task abc12345)`, and a new
server paired with one of those would now put a home path — and the
account name in it — into the StatusPill and any screenshot of it.

Sanitize at the write boundary rather than at each reader. It is the only
place wait_reason is written, so both the REST payload and the websocket
event read through it, and the column itself never stores a path.

While here, put wait_reason on the task:waiting_local_directory event.
The payload never carried it, so the field the client type has always
declared was undefined in practice and the reason only arrived on the
follow-up refetch — the pill spent that round-trip saying nothing useful,
which is the gap the hint exists to close. Already sanitized, so the
socket cannot carry what the REST payload would withhold.

Dropping the whole string rather than editing it: an absent reason is a
case clients already handle — older servers never sent one — and it
renders as the plain waiting label, which is the pre-change behaviour.

Also notes the known mixed-script rough edge in the holder clause: it is
English inside a localized "Waiting for {reason}" label. Fixing it means
structured fields and a client-side join, which is not worth it for one
parenthetical.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 13:56:18 +08:00
1dc135598e MUL-6343: Enforce recently created issue windows (#7305)
* MUL-6343: enforce recently created issue windows

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

* fix(issues): address creation window review

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

* fix(issues): bound table window ID cache

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 12:55:57 +08:00
Bohan Jiangandmultica-agent 8c9b7503a1 MUL-6146 fix(rerun): stop cancelling an in-flight agent run on manual rerun (#7282)
* fix(rerun): stop cancelling an in-flight agent run on manual rerun (MUL-6146)

RerunIssue cancelled every active task for the (issue, agent) pair before
enqueueing the replacement, including 'running' and
'waiting_local_directory'. That made a manual rerun destructive: asking an
agent for another pass silently killed the pass it was still working on.

Neither status appears in idx_one_pending_task_per_issue_agent_v2, so a
fresh queued row can be inserted alongside an executing one, and
ClaimAgentTask's per-(issue, agent) serialization already holds it there
until the active run reaches a terminal state. Narrow the cancel to rows
that have not begun executing and let the queue do the rest.

Rows that have not started are still cancelled and replaced rather than
reused, which keeps the new run attributed to the rerunning member
(MUL-4302 §5) instead of inheriting the attribution of whoever created the
pending row.

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

* fix(rerun): skip auto-retry when a manual rerun already holds the pending slot (MUL-6146)

Letting a manual rerun queue behind a running task opened a collision the
previous commit did not account for. If that running task then failed for a
reason whose retry child is created immediately (timeout, runtime_recovery,
codex_semantic_inactivity, skill_bundle_unavailable, or provider_network's
first attempt), CreateRetryTask inserted a SECOND queued row for the same
(issue, agent) and violated idx_one_pending_task_per_issue_agent_v2.

That insert shares FailTask's transaction, so the unique violation rolled the
parent's failed status back with it: the daemon's terminal callback saw a 500
and the task was left stuck in 'running'.

Guard both auto-retry paths with hasRunnableSuccessor. When something is
already pending for the pair, skip the retry — a runnable successor is exactly
what the retry was for, and the rerun is it. The FailTask check runs inside the
failure transaction so a concurrently committing rerun cannot take the slot
between check and insert.

runtime_offline and provider_network's final attempt were never affected:
retryDelayForAttempt defers those, and a deferred child is outside the index.

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

* fix(rerun): make the retry insert yield the pending slot instead of raising (MUL-6146)

The previous commit guarded both auto-retry paths with a successor check and
claimed the in-transaction check prevented a concurrent rerun from taking the
slot. It does not: hasRunnableSuccessor is a plain count at READ COMMITTED and
takes no lock, and rerun's cancel and enqueue are separate commits, so a rerun
can always commit between the check and the insert. The unique violation then
aborts FailTask's transaction, which also carries the parent's failed status,
leaving the task stuck in 'running' — the very bug the guard was meant to fix.

Move correctness into the insert: CreateRetryTask now yields via ON CONFLICT DO
NOTHING, so it can never abort its caller's transaction. That makes the policy
explicit — whoever already holds the slot keeps it, so a deliberate human rerun
wins over an automatic retry. Both callers read the resulting zero rows as 'no
retry created' and still commit their own work; this also stops the pre-existing
workspace-teardown fence from aborting a failure the same way.

For the reverse order, RerunIssue re-clears and enqueues once when a retry beat
it into the slot, so the human action still wins. Bounded to one extra attempt.

The successor checks stay as an optimisation, with comments corrected to say so
rather than claiming a mutual exclusion they never provided.

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

* fix(rerun): fence deferred promotion and recognise the normalized slot-taken error (MUL-6146)

Two gaps left by the previous commit, both reachable now that a manual rerun can
sit in the pending slot while a task is still running.

Deferred retries escaped the conflict fence entirely. runtime_offline and
provider_network's final attempt arm the retry as a 'deferred' row, which is not
covered by idx_one_pending_task_per_issue_agent_v2, so CreateRetryTask's ON
CONFLICT never fires and the row is created alongside the rerun. Promotion then
flipped it to 'queued' unconditionally and hit the index — and because the claim
loop promotes before it claims, that error blocked every claim on the runtime,
starving the rerun the operator was waiting for. Both promote queries now skip a
row whose (issue, agent) slot is occupied, and promote at most one row per pair
so a single statement cannot collide with itself. Skipped rows stay deferred and
promote on a later tick once the slot frees, so no retry is lost.

RerunIssue's reclaim only matched the raw pgconn violation, but
enqueueMentionTaskWithCommentPlan normalizes it to the bare
ErrDuplicatePendingTask sentinel — the path taken by every rerun whose target is
not the issue's current agent assignee (squad leader, an agent re-fired by
task_id after being displaced, a mentioned agent). Those never entered the
reclaim branch, so losing the race surfaced as a hard error. pendingSlotTakenErr
now matches both shapes.

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

* fix(rerun): cancel superseded deferred retries and tolerate promotion slot conflicts (MUL-6146)

Keeps the one product invariant this change exists for: one rerun click means
exactly one more run.

The previous commit only declined to promote a deferred retry while the slot was
occupied, and a test froze that as correct. It is not. Once the manual rerun
starts running it stops occupying the queued slot, so the stale retry promotes
and runs the agent a SECOND time — duplicate comments, duplicate side effects,
duplicate cost, and nothing anywhere to signal it. That also contradicted this
file's own 'a manual rerun replaces the pending plan' and FailTask's 'a runnable
successor already exists, so no retry is needed'.

A superseded deferred retry is now cancelled before promotion instead. Scope is
deliberately tight — retry clones only (retry_of_task_id set), never
assignee-fallback escalations or channel-media pending rows, which are supposed
to coexist with an active primary.

Promotion also tolerates the unique violation now. The in-query NOT EXISTS fence
cannot see an enqueue that has not committed yet, so promotion can still collide;
that is self-healing (the next tick sees the committed row) but the error used to
fail the claim outright, and for the batch path it failed every runtime in the
set. One contended row now skips promotion for a tick instead.

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-20 18:12:30 +08:00
20ceaccc43 MUL-6471: fix(daemon): let pi and opencode reach custom providers (#7312)
* fix(daemon): let pi and opencode reach custom providers (MUL-6471)

A self-host setup whose pi/opencode providers point at an
OpenAI/Anthropic-compatible gateway could not run a Multica task on either
runtime, while the same work succeeded when the CLI was invoked by hand in
the same container (GH #7300). Gateway-style providers register model ids
that themselves contain a slash — `claude/claude-opus-5` under provider
`multica-anthropic` — and both failures came from treating that slash as a
provider boundary, or from having no provider at all.

pi: buildPiArgs split the model id on the first slash and passed the leading
segment as --provider, so a slash-shaped id became `--provider claude --model
claude-opus-5`. pi answers an unknown --provider with a hard error before it
ever looks at --model. The selector now goes to --model whole and --provider
is never synthesized: pi's own resolver accepts `provider/id`, a bare id, and
an id containing a slash, so passing less is strictly more capable. Verified
against pi 0.67.2 — the old argv reproduces `Unknown provider "claude"`, the
new one resolves to provider=multica-anthropic, model=claude/claude-opus-5.

opencode: --model is `provider/model`, and an id persisted without its
provider is rejected outright (`UnknownError`, reproduced against opencode
1.18.14). The daemon cannot invent the missing segment, so it asks the
runtime's own catalog: an id that exactly one provider claims is promoted to
the catalog's canonical selector, and anything uncertain — a static fallback
catalog, an already-canonical id, two providers exposing the same bare id —
is passed through untouched for the CLI to judge.

Two reported side effects fall out of the same root cause. `thinking_level`
was dropped for both runtimes because the capability lookup is keyed on the
model id and missed every catalog entry; it now matches. And `starting agent`
logged entry.Model, the env-var tier alone, so every task whose model came
from agent.model announced itself with an empty model — the resolution now
happens before the log.

The task row's `model: null` is not part of this: it is read from the run's
usage rows, which a run that fails before its first provider call never
produces.

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

* fix(daemon): gate selector qualification on the runtimes that can use it

Review follow-up on MUL-6471. The first pass ran catalog discovery for every
task that pinned a model, on the argument that the path already takes one
memoized lookup for thinking_level / service_tier. That argument does not
hold: cachedDiscovery deliberately skips the cache for empty and fallback
results (#3729, MUL-5549), precisely so a transient failure can retry. A
logged-out or failing runtime would therefore pay its full discovery ceiling —
15s for opencode/pi/omp, 30s for openclaw — on every task, including runtimes
whose bare model ids could never be rewritten anyway.

Qualification is now an explicit runtime capability. ModelIDsAreProviderQualified
is true only where Model.Provider is literally the ID's own prefix: pi builds
`provider + "/" + id`, opencode reads the provider back off the id. Runtimes
carrying a bare id next to a display-name Provider — claude's "anthropic",
copilot's "openai", dsh's "DeepSeek" against a `deepseek-official/...` id —
never reach discovery. Built-in runtime identities resolve through their
protocol family, so a pi-family fork like omp inherits the capability from its
descriptor rather than a name check.

The daemon side moves into qualifyTaskModel so the call boundary is testable.
TestQualifyTaskModelSelectorsAndDiscoveryBoundary pins both halves: pi,
opencode and omp resolve to the selector their CLI accepts, and claude, codex
and an empty model never reach listModels at all.
TestModelIDsAreProviderQualifiedMatchesCatalogShape keeps the capability
honest by deriving the expected answer from the real catalog producers instead
of restating the list — it fails if the predicate and the emitted ID shape
ever disagree.

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

* fix(daemon): read a runtime's model catalog at most once per task

Second review round on MUL-6471. Two findings, one root cause: the daemon
treated catalog discovery as cheap when cachedDiscovery deliberately makes it
anything but — an empty or fallback result is never memoized (#3729,
MUL-5549), so every read costs a logged-out or timing-out runtime its full
15-30s ceiling again.

A single task could pay that twice. qualifyTaskModel read the catalog to
promote the model selector, then ValidateThinkingLevel read it again to check
the level — back to back, on exactly the reporter's configuration (pi or
opencode plus thinking_level=high). The two now share one lazily-loaded,
task-scoped catalog: resolveTaskModelSelection owns it, and the validators
take that loader through ValidateThinkingLevelWith / ValidateServiceTierWith
rather than reaching for ListModels themselves. The global immediate-retry
contract is untouched — the reuse lives and dies with the task.

The second finding removes the read entirely for pi. Qualification is not a
launch requirement there: buildPiArgs already proves pi's resolver takes a
canonical selector, a bare id, and an id containing a slash, so a pi task with
no capability override now starts without spawning a discovery subprocess at
all. ModelIDsAreProviderQualified is replaced by
ModelSelectorMustBeProviderQualified, which states the contract that actually
justifies the cost — the CLI refuses an unprefixed id — and is therefore true
only for opencode and its DevEco fork. That also retires the catalog-shape
claim the old predicate made, which deveco was already a counterexample to.

The resulting matrix, pinned by
TestResolveTaskModelSelectionReadsTheCatalogAtMostOnce: pi/opencode with a
thinking level qualify and validate on one read; pi, omp, claude and
unpinned-model tasks read nothing; opencode without an override still reads,
because it cannot launch otherwise; codex without a model still fails both
checks closed without reading. A failed read is not retried inside the task.
Verified the count assertion bites by removing the memoization and watching
the pi and opencode cases report 2 reads.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 18:01:53 +08:00
cb6b0c885f MUL-6396: remove unused task message clipping flag (#7292)
* refactor(realtime): remove unused task message clipping flag

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

* refactor(realtime): remove task message clipping consumers

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 15:24:15 +08:00
9900e96686 MUL-6458: feat(issue-statuses): sync the status catalog over the realtime channel (#7287)
* feat(issue-statuses): sync the status catalog over the realtime channel

An admin editing the workspace status catalog was invisible to every other
tab and device for up to the query's 5 minute staleTime: the protocol had no
issue_status event at all. Other clients kept offering a status the server
had archived, and kept painting a renamed one under its old name.

Server: one `issue_status:changed` event, published after create, edit,
archive and reorder. One event rather than a verb per write because every
client answers all four the same way — re-read the catalog — and reorder has
no single row to name. Archive and reorder publish after the commit, and the
already-archived no-op publishes nothing.

Client: the event invalidates the catalog and nothing else. Issue caches are
deliberately not dragged along — a row stores the status KEY and resolves its
name and colour from the catalog at render time, so refetching the catalog is
what repaints the boards.

The catalog mutations now install the row (or, for reorder, the list) the
server returned instead of invalidating, because the event refreshes the
writing tab too. That keeps a catalog edit at ONE catalog read per client
rather than two for the admin who made it, and drops the workspace-wide
board/list/table refetch a rename used to trigger.

Reconnect recovery invalidates the catalog as well, so an edit missed while
disconnected is not held behind the staleTime.

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

* fix(issue-statuses): stop a slow mutation response from reverting a newer catalog

Installing a mutation's response body into the cache is a last-write-wins
merge by arrival order, and the HTTP response and the realtime frame are
independent channels — the realtime refresh is debounced 100ms on top. So
this ordering is legal: A renames, A's response stalls, B renames, A's
refetch picks up B's version, A's response lands and rolls the row back to
its own older snapshot. Nothing corrects it until the next event or the
5 minute staleTime, in exactly the concurrent-editing case this feature
exists for. Reorder was the worst of them: its response is the whole
catalog, so one late arrival reverted every concurrent edit at once.

No mutation writes its response body any more. The realtime refetch is what
converges the cache, and it is ordered correctly by construction — the event
is published after the write commits, so the refetch can only read
post-commit state. What each mutation writes locally is instead something
that stays true whatever else landed in between:

- create inserts only when the id is absent. Holding it already means a
  refetch read it back, so the cached copy is never the older one.
- update and reorder keep their optimistic patches and add nothing on
  success.
- archive patches only archived_at. It is monotonic — there is no unarchive,
  and the server refuses to edit an archived row — so it is safe on any row,
  where the full snapshot would revert a rename that raced it.

Regression tests drive each case through a controlled promise: the newer
catalog lands while the write is still in flight, and the assertion is that
the newer one survives.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 14:32:42 +08:00
b4bfe083be MUL-6407: mint UUIDv7 primary keys for append-heavy tables (#7230)
* perf(db): mint UUIDv7 primary keys for the append-heavy tables

Random v4 primary keys scatter every insert across the whole primary-key
B-tree, so each row dirties a different page. On the tables that grow
continuously — the task queue, messages, comments, activity and audit rows —
that is pure write amplification. UUIDv7 puts a big-endian millisecond
timestamp in the leading 48 bits, so consecutive inserts share a page.

The 12 tables converted here are the append-only hot paths:

  agent_task_queue, activity_log, inbox_item, comment, issue, chat_session,
  chat_message, autopilot_run, webhook_delivery, task_token,
  channel_inbound_audit, sys_cron_executions

Ids come from the new pkg/dbid, which returns a pgtype.UUID ready to assign to
a sqlc params struct. Upsert-dominated tables (task_usage, agent_runtime, ...)
are deliberately left alone: v7 only buys locality for rows that are actually
inserted. Lease and claim tokens keep gen_random_uuid() — they must stay
unpredictable and carry no timestamp.

No migration, and no backfill. Every converted INSERT binds its id as
COALESCE(sqlc.narg('id')::uuid, gen_random_uuid()), so the column default stays
reachable: a caller that passes no id inserts exactly as it does today. Tables
will hold a mix of v4 and v7 ids permanently, which the uuid type and every
parser in the repo already accept.

Verification: - pkg/dbid: version/variant, uniqueness, and a non-decreasing timestamp
    prefix; a shape test asserting every converted query keeps the COALESCE
    fallback; and a source scan asserting every production params literal mints
    an id, so a call site added later cannot silently fall back to v4.
  - internal/handler: round-trips issue, comment, activity_log, inbox_item,
    chat_session, chat_message, agent_task_queue, task_token and
    channel_inbound_audit against Postgres and asserts the stored id is v7,
    plus one case proving an unset id still inserts (as v4).
  - internal/scheduler: covers the one hand-written INSERT, asserting the
    execution id is v7 while lease_token stays v4.
Co-authored-by: multica-agent <[email protected]>

* fix(db): address UUIDv7 review feedback

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

* fix(db): cover Telegram audit UUIDv7

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 13:42:44 +08:00
e1be1625cb MUL-5959: feat(agent): add Dim (DimCode) ACP runtime (#6675)
* feat(agent): add Dim (DimCode) ACP runtime

Add Dim (dimcode, the `dim` CLI) as a first-party agent runtime, driven
over the ACP (Agent Client Protocol) transport via `dim acp`.

Key integration points:
- New dim backend (pkg/agent/dim.go): spawns `dim acp`, performs the
  ACP initialize/session/new handshake, and raises the runtime's
  hardcoded read-only permission preset to full-access (plus agent mode)
  via session/set_config_option before the first prompt — without it every
  file write is denied by a capability rule. Model override uses
  session/set_model; the model catalog is read from session/new.
- Session resume is intentionally skipped: Dim binds sessions to the
  creating process, so a later process's session/load is rejected with
  "held by another process" even after a clean session/close. The backend
  always starts a fresh session and reports ResumeRejected so the daemon
  classifies the run correctly. A best-effort session/close keeps Dim's
  own session list free of orphaned entries.
- Registration: SupportedTypes whitelist, New() factory, launch header,
  daemon probe (MULTICA_DIM_PATH / MULTICA_DIM_MODEL), default agent
  command name, protocol_family CHECK migration (255), model discovery,
  MCP config support, metrics label, and runtime display.
- Docs/UI: provider logo, landing i18n (en/zh/ja/ko), providers and
  install-agent-runtime docs, README, CLI_AND_DAEMON, SELF_HOSTING.
- Tests: dim backend unit tests (fresh session + resume behavior), shared
  ACP deliverable case, logo and MCP-support tests.

* feat(views): use official DimCode app icon for Dim provider logo

Replace the placeholder inline SVG with the official DimCode desktop
client app icon (from /opt/DimAgent/resources/build/icon.png, resized
to 128px), imported as a static asset the same way the Qwen Code mark
is handled.

* test(agent): add real dim ACP smoke tests (agentintegration)

Two gated tests behind MULTICA_RUN_REAL_AGENT_SMOKE=1:

- TestDimRealACPSmoke: full end-to-end against real `dim acp` —
  initialize, session/new, set_config_option (permission/mode), prompt,
  and assert completed output.
- TestDimRealResumeRejected: pass a fake ResumeSessionID, assert the
  backend starts a fresh session (no session/load), and reports
  ResumeRejected=true so the daemon classifies the run correctly.

Verified against dimcode 0.3.2 and 0.3.8.

* fix: renumber dim migration 265 → 271 (upstream added 265-270)

* fix: renumber dim migration to 272 (upstream added 271)

* fix(agent): dim cross-run resume via session/load + review fixes

Address review feedback on the Dim ACP runtime:

- #1 Deliver runtime brief to Dim: add dim to the AGENTS.md provider
  list (verified dim 0.3.8+ reads AGENTS.md from the session cwd).
- #2 Cross-run session continuity: dim 0.3.10+ releases its per-process
  session lock ~5s after the owning process exits, so resume now goes
  through the standard ACP session/load (same as traecli/kiro/grok)
  instead of always starting fresh. A loaded session retains its
  permission/mode, so set_config_option runs only on fresh sessions.
  Add a two-run regression (TestDimRealCrossRunResume) proving run B
  recalls context established only in run A.
- #3 Process lifecycle races: the deferred cleanup now cancels the run
  context before cmd.Wait (a child ignoring stdin EOF no longer hangs
  Result delivery) with a bounded force-kill fallback; the success path
  waits for the final prompt notification with a grace window instead
  of a non-blocking read that could miss the last message.
- #4 Isolate model discovery by executable path (discoveryCacheKey).
- #6 Real smoke test now writes a sentinel file to prove full-access is
  effective; a best-effort session/close is sent before teardown.
- #7 gofmt.

* test(agent): add dim process-lifecycle regressions (review #3)

- TestDimCleanupKillsHangingChild: a child that ignores stdin EOF and
  SIGTERM after an early failure is force-killed within
  dimProcessWaitTimeout, so Result still closes.
- TestDimPromptMissingNotificationStillCompletes: when session/prompt
  returns without stopReason (onPromptDone never fires), the bounded
  final-notification wait falls through and Result completes instead of
  hanging.

* fix(migrations): renumber dim runtime_profile migration 272 → 273 (upstream added 272)

* fix(agent): review round 2 — process tree, quiescence, fail-closed, version check

Address all remaining blockers from the second review:

- #1 Require dim >= 0.3.10 (version check at initialize); bounded retry
  on session/load when the lock is not yet released instead of silently
  starting fresh; integration test no longer sleeps before resume.
- #2 Process-tree cleanup: configureProcessGroup + signalProcessGroup +
  waitProcessGroupGone; cmd.Cancel returns nil so we own all signalling;
  regression asserts the descendant PID is gone.
- #3 Notification quiescence: onActivity + waitForACPNotificationQuiescence
  (same as hermes); Dim equivalent of the late-final-notification test.
- #4 Permission fail-closed: set_config_option runs on BOTH fresh and
  resumed sessions; on config failure, session/close is sent before
  returning so a partially configured session is not resumed; regression.
- #5 Migration renumbered 273 → 274 (upstream added 273).
- #6 AGENTS.md mapping regression, two-executable cache isolation test,
  session/close unit assertion, smoke test now executes a command.
- #7 README.md + CLI_AND_DAEMON.md updated with 22 CLIs including Dim.

* chore: remove temporary review actions doc (not for PR)

* fix(migrations): renumber dim migration 274 → 310 (upstream added 274-309)

* review: fix retry break scope, dedup isACPSessionNotFound comment, update stale notes

* fix(agent): pass *exec.Cmd to signalProcessGroup/waitProcessGroupGone (upstream signature change)

* fix(agent): close session on set_model failure + resume regression test

Audit-found gaps from self-review:
- set_model failure now sends session/close before returning (same as
  set_config_option failure) — reviewer #4 said 'permission, mode, or
  model'.
- Add TestDimConfigFailThenResumeReestablishes: run A config fails →
  session/close sent → run B resumes and re-applies set_config_option
  (fail-closed), completing successfully.
- Fix two stale comments that contradicted the code (config block now
  runs on both fresh and resumed sessions).

* fix(agent): add WaitDelay, use labeled break in retry loop

Self-audit improvements:
- Add cmd.WaitDelay=10s for consistency with claude.go (hard backstop
  if a process somehow survives SIGKILL).
- Use labeled break (break loadRetry) so runCtx cancellation during the
  retry delay exits the for loop directly, not just the select.
- Update PR description: migration 273→310, set_config_option now
  re-applied on both fresh and resumed sessions.

* fix(migrations): renumber dim migration 310 → 313 (upstream added 310-312)

* fix(migrations): remove stale 313 dim migration (replaced by 314 after upstream added dsh at 313)

* fix: DSH compatibility + thinking levels + reviewer round 3

Systematic fix of all 39 items from the compatibility gap analysis:

Rebase + migration:
- Rebase to latest upstream/main (resolves all DSH conflicts)
- Migration 314_runtime_profile_add_dim (whitelist includes both dsh + dim)
- Remove stale 313 dim migration

DSH + Dim coexistence (provider lists, counts, docs):
- SupportedTypes, config.go, metrics labels: both dsh + dim
- i18n (4 files): count 23, lists include DSH + Dim + Oh-My-Pi
- README.md/README.zh.md: count 23
- CLI_AND_DAEMON.md: 23-row table
- environment-variables.mdx (4 langs): MULTICA_DIM_PATH/MODEL callout
- display.ts, mcp-support.ts, types/agent.ts, provider-logo.tsx: both dsh+dim

Thinking levels (was MISSING — dim supports thought_level):
- dim.go: call applyACPEffortOption after set_model
- thinking.go: add dim to acpCatalogThinkingProviders
- dim.go: retain sessionResult for effort option

Reviewer round 3:
- Version check fail-closed: empty/malformed → reject (not allow)
- Cache test through ListModels call site with fake executables
- set_model failure regression test
- Test companions: sidecar_manifest_test, runtime_config_test add dim

Code quality:
- agent.go: fix unreachable duplicate return (rebase artifact)
- dim.go: labeled break in retry loop, WaitDelay, session/close on set_model fail

* fix: deliverable test fake version + provider-logo rebase artifact

* fix(migrations): renumber dim migration 314 → 315 (upstream added 314_workspace_mcp_config)

* fix(migrations): renumber dim migration 315 → 319 (upstream added 315-318)

* fix: Windows process tree, MinVersions, retry tests, migration 327

Address all 5 remaining blockers from review round 4:

1. Windows process-tree ownership: use startOwnedProcessTree instead of
   cmd.Start(), releaseProcessGroup in cleanup defer. This attaches the
   child to a Job Object on Windows (no-op on Unix), ensuring descendants
   are captured and terminated.

2. Migration renumbered 319 → 327 (upstream added 319-326).

3. Add dim: 0.3.10 to MinVersions in version.go so the daemon registers
   old Dim binaries as offline and refuses triggers (defense in depth on
   top of the ACP agentInfo.version check in dim.go).

4. Session-lock retry contract tests: success after bounded retries,
   exhaustion without falling through to session/new. Fake script
   supports DIM_LOAD_HELD_N (held for N calls then succeed) and
   DIM_LOAD_HELD_ALWAYS (always held).

5. README.md:206 20→23 runtimes. PR description updated.

* test: add missing reviewer-requested regressions (round 4)

- Windows Job Object regression: dim_windows_test.go proves the Dim
  backend captures and terminates descendants via startOwnedProcessTree
  (Windows-only build tag; companion to TestStartOwnedProcessTree).
- MinVersions registration: add dim test cases to TestCheckMinVersion
  (0.3.10 ok, 0.3.9 rejected, invalid rejected).
- Retry cancellation: TestDimSessionLoadRetryCancelled cancels the
  context during the retry delay, asserts no session/new fallback.

* fix: sync upstream Command signature changes + restore dim additions

Upstream changed ListModels/discoverXxxModels/detectCLIVersion to accept
Command (struct with Path+Prefix) instead of string. The previous rebase
kept our old-signature versions, causing widespread compile failures.

Restored all affected files from upstream, then re-applied dim-specific
additions:
- agent.go: dim in SupportedTypes, New(), launchHeaders
- hermes.go: isACPHeldByProcess + -32002 in isACPSessionNotFound
- thinking.go: dim in acpCatalogThinkingProviders
- models.go: dim case in ListModels + discoverDimModels
- version.go: dim in MinVersions
- All dim test files preserved (dim_test.go, dim_integration_test.go,
  dim_windows_test.go, models_test.go dim cache test)

* fix: rebase to latest main, migration 327→341, resolve DetectVersion conflict

* fix: remove unused os/exec import in dim_windows_test.go

* fix(migrations): renumber dim migration 341 → 342 (upstream added 341)

* fix: use Command.exec instead of exec.CommandContext (upstream GH #7046)

Upstream's TestOnlyLaunchGoSpawnsRuntimeProcesses requires all backends
to build processes through Command.exec (launch.go), so a custom runtime's
fixed_args are carried into the subprocess. Dim was the only backend still
using exec.CommandContext directly.

* fix: launch prefix policy + remove empty test files + deterministic retry tests

Address all 4 findings from review round 5:

1. Add "dim": dimBlockedArgs to launchPrefixBlockedArgs so protocol-breaking
   flags (--help/--auth-setup/--remote) are filtered from fixed_args.
2. Remove 77 zero-byte *_test.go files accidentally added at repo root.
3. Make retry tests deterministic: TestDimSessionLoadRetryCancelled waits
   for the first session/load request before cancelling (no fixed sleep);
   TestDimSessionLoadRetryExhausted asserts exactly 4 load attempts.
4. PR description will be updated separately.

* fix: rebase to latest main, migration 342→343 (upstream added mcode at 342)

* chore: trigger CI

* chore: refresh PR

* fix: restore mcode that was lost during merge conflict resolution

Merge took 'ours' side which predated mcode. Restore mcode in:
- SupportedTypes, New() factory, launchHeaders
- agent_supported_types_test.go want map
- metrics/labels.go
- config.go defaultAgentCommandNames
- migration 343 up/down whitelists

* fix: restore mcode probe in agents_probe.go + agent-cli-command-names.txt

* fix: restore all remaining mcode references lost during merge

Files fixed:
- README.md: mcode row in runtimes table
- mcp-support.test.ts: mcode assertion
- config.go: mcode in Agents comment + error message
- runtime_config.go: mcode in AGENTS.md case
- version.go: mcode in MinVersions
- version_test.go: mcode test cases

Verified: no file has fewer mcode references than upstream.

* fix: restore mcode in environment-variables docs + CLI_AND_DAEMON ACP list

- environment-variables.{mdx,ja,ko,zh}: restored 'MiniMax Code' in the
  QwenPaw model-variable sentence
- CLI_AND_DAEMON.md: added MiniMax Code to ACP-family list + loadSession
  fallback description

Verified: every changed file now has >= mcode references vs upstream.

* fix(migrations): renumber dim migration 343 → 344 (upstream added 343)

* fix(migrations): renumber dim migration 344 → 348 (upstream added 344-347 plugin migrations)

* fix(migrations): update down migration comment 344 → 348

* chore: retrigger CI (flaky TestOpenclawDiscoveryCacheConcurrentPreparations — upstream test, passes locally x5)

* fix(migrations): renumber dim migration 348 → 352 (upstream added 348-351)

* fix: Dim launch-prefix regression test + version.go comment repair (review #6)

- Add TestDimLaunchPrefixFiltersBlockedFlags: proves allowed prefix
  reaches command before acp, and --help/--auth-setup/--remote/-h are
  stripped from Dim's launch prefix.
- Add dim to TestLaunchPrefixReachesACPFamilies family list.
- Repair version.go: dim's cross-run session/load comment was appended
  to mcode entry during a rebase conflict; restore two independent
  accurate comments.

* fix(migrations): update down migration comment 348 → 352

* chore: retrigger CI (backend-tests stuck 50+ min)

* fix(migrations): renumber dim migration 352 → 362 (upstream added 352-361)

* fix: use logAgentCommand for dim argv logging (upstream redaction requirement)

Upstream's TestOnlyLaunchGoLogsAgentCommandArgs enforces that all
runtime argv logging goes through Config.logAgentCommand so sensitive
args are redacted. dim.go was still using Logger.Info directly.

* fix(migrations): renumber dim migration to 370

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 12:54:46 +08:00
4bf3c73a15 feat(plugins): agent integration — skill resources, agent trigger, mcp transport (MUL-6350 PR 4) (#7242)
* feat(plugins): skill resources into the existing skill table (MUL-6350 PR 4, 1/3)

A plugin's skill resource becomes an ordinary row in the skill table. Not a
plugin-owned copy, not a bundle, not an artifact with a digest: the previous
plugin system built all of that across fourteen tables and, at the end of it,
delivered one SKILL.md that this table could already hold.

The only thing the platform has to remember is which installation contributed
which skill, so uninstall removes exactly those and nothing a person wrote.
That is one nullable column.

The upsert is scoped to rows this installation owns, so a plugin claiming a
name somebody already used fails the install instead of silently replacing
their work — and the test asserts the human's content survives. Upgrade prunes
what the new manifest no longer declares, or a renamed skill would leave its
predecessor behind with nothing to attribute it to.

Fresh install is now a transaction too. It was a single INSERT; skills have to
land in the same commit, because an installation whose skills half-arrived is
worse than one that failed — the missing half is invisible.

Skill resources flip on here rather than with the rest of PR 4: nothing
executes, so it does not wait for the daemon-side MCP server that the agent
trigger and mcp transport still need.

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

* feat(plugins): agent trigger — hooks as MCP tools (MUL-6350 PR 4, 2/3)

The fourth call site, and the only one that is not the host deciding to call
something: an agent reads a tool description and chooses. That choice is why
hooks may be reached from an agent at all — the alternative, a hook that must
run before or after every turn, is a third party holding the product's main
loop open, and no such position exists anywhere in this design.

The daemon renders the tools but does not call the plugin. A tool call goes
back to Multica, which makes the signed request. Two reasons, and the second
matters more: the signing secret is derived from the deployment key and putting
it on every laptop running an agent would hand out a credential that can
impersonate the server to every plugin backend; and routing through the server
keeps the rate limit, circuit breaker, `net:` destination check and invocation
record on one code path for all four triggers instead of a daemon-side copy
that would drift.

A failing hook is a TOOL error, not a transport error. The agent reads it,
works around it, and finishes the issue — an unreachable plugin endpoint must
not fail somebody's task. The server returns 200 with a status for the same
reason: a non-2xx would look to the daemon like the broker itself is broken.

TestPluginToolNameSeparatorCannotBeForged caught a real flaw in my first
naming scheme. Sanitizing both halves and joining with an underscore is not
injective — a plugin key uses `.` and `-`, both of which have to become `_`, so
`a.b` and `a-b` collapse together and `a.b_` + `c` collides with `a.b` + `c`.
Two plugins would have been offering the agent one tool name. The prefix now
carries a short digest of the full key rather than a lossy transliteration.

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

* feat(plugins): mcp transport with pinned tool approval (MUL-6350 PR 4, 3/3)

An `mcp` hook adopts tools from an MCP server the plugin author already runs.
The difference from an `http` hook is who decides the shape: an http hook
declares one endpoint in a manifest an administrator read, while an MCP server
decides its own tool list at runtime. So installing the plugin is not the grant
— an administrator discovers the tools and pins a specific set by name and
schema digest, and a tool that appears later is not adopted until approved.

Approved hooks reach the daemon as ordinary remotemcp.Connection values, so the
existing broker proxies them and validatePinnedRemoteMCPTools already refuses to
start when an approved tool went missing or its schema drifted. No new
enforcement path.

Plugin connections carry a `plugin:` contribution-id prefix. They share the
broker and the resolver with a workspace's own Remote MCP connections but keep
their credential in the Plugin's secret storage, which a different route serves,
and the contribution id is all the broker hands back at dial time.

Flips TransportMCP on in HostCapabilities, completing PR 4's capability set.

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

* test(plugins): cover mcp connection startup via the dev-origin seam

The `http` transport has had MULTICA_PLUGIN_DEV_ORIGINS since the hook engine
landed: an operator names exact origins, and endpoints there skip the
public-internet requirement so a plugin author can develop against a server on
their own machine. The `mcp` transport never got it.

Two symptoms, one cause. A plugin author could not point an mcp hook at a local
MCP server, and — for the same reason — the daemon's connection startup could
not be reached by a test, because it validates the endpoint as public HTTPS and
the repo's MCP fixture serves plain HTTP on loopback. That left the layer
carrying the entire approval guarantee at 0% coverage:

  startTaskRemoteMCPBrokers      0.0% -> 59.5%
  validatePinnedRemoteMCPTools   0.0% -> 100.0%

The seam is off unless the variable is set, and a test asserts that: the same
loopback connection is refused when it is empty. It skips only the
public-internet requirement — the net: scope still applies, and the dial-time
host pin still refuses a redirect that changes host.

Tests cover what the approval is for: an approved tool the server dropped, an
approved tool whose schema drifted, and an optional connection that fails
without failing the task. Each was verified by breaking the guard it covers.

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

* feat(plugins): a real example plugin, and the two bugs it found

examples/plugins/deploy-sentinel is an incident-response plugin: it correlates
an issue with recent deploys, files rollback requests under the team's own rules
(refusing deploys past a window, and reasons too short to be evidence), pages
on-call on issue events, and adopts an external metrics MCP server. Four
triggers, both transports, and a skill that tells an agent when to use any of it.

The other examples each demonstrate one mechanism. This one was written to find
out whether the mechanisms compose, and it found two things that no unit test
was ever going to:

1. mcp discovery could not succeed against ANY server. DiscoverMCPHookTools
   passed a nil protocol-version list, and Discover checks the server's answer
   against that same slice — empty means nothing is acceptable. Discover now
   falls back to SupportedProtocolVersions() when the caller passes none.

2. A hook handler had no way to read its own configuration. The manifest's
   config block drives a host-rendered form, the Action API has no config
   endpoint, and the hook body did not carry the values — so a plugin would have
   had to keep a second copy on its own server. The body now carries non-secret
   config. Secret-typed fields are excluded; they live in a separate table and
   are the plugin's credentials for its own services.

Also lets an mcp dev origin trust MULTICA_PLUGIN_DEV_CA, mirroring the http
transport. The manifest validator requires HTTPS, so without this a local MCP
server could not be developed against at all.

plugin_example_test.go installs the real manifest from examples/, not a copy, so
a broken example fails the build rather than rotting quietly.

Migrations renumbered to 368/369 after main advanced. 362 is added to the
legacy-duplicate allowlist: two PRs took that prefix within the same hour and
both merged, and the ledger keys on the full stem, so renaming one would re-run
it everywhere it has already applied. main is red on this today.

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

* fix(plugins): the deploy-sentinel example servers must serve HTTPS

Both .mjs servers used node:http. A hook's transport URL has to be an https://
URL or the manifest fails validation, so the example as shipped could not
actually be installed against a running Multica — the README told you to set
MULTICA_PLUGIN_DEV_ORIGINS to https:// origins that nothing was listening on.

Found by running it: install, config, skill contribution and live MCP tool
discovery all work against the real servers once they speak TLS. They read
TLS_CERT / TLS_KEY and the README carries the openssl one-liner.

MULTICA_PLUGIN_DEV_CA changes which certificate is trusted; it never disables
verification, which is why a self-signed certificate is needed rather than
plain HTTP.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 02:35:29 +08:00
Wu Changming 7fdc854c26 fix(issues): copy durable local path for worktree tasks (MUL-6378) (#7176)
A worktree-mode local_directory task runs in a disposable git worktree that
Finalize removes on every exit path, so the task's `work_dir` pointed at a
directory that no longer existed. "Copy local workdir path" handed the user a
path they could not `cd` into.

The daemon now reports a `durable_work_dir` handoff instead of having clients
infer one:

- `removeLocalWorktreeDir` returns an error and confirms removal with `Lstat`,
  so a broken symlink cannot be mistaken for an absent directory. Finalize
  fails when removal cannot be confirmed.
- `durable_work_dir` is written only after Finalize succeeds and the worktree
  is verifiably gone. A preserved worktree, an in-place run, a running task, a
  squad-leader task, and any older daemon all leave it unset, keeping the real
  `work_dir` authoritative.
- The field travels on the complete, fail, and cancel-ack callbacks and is
  persisted on the task row (migration 362, additive nullable column). Writes
  use COALESCE for replay idempotency; the cancel-ack path CASes on
  `status = 'cancelled'` so a late ack cannot touch a completed or failed row.
- The server derives `relative_durable_work_dir` through the same
  home-stripping helper as `relative_work_dir`; the absolute value is reserved
  for explicit clipboard actions and is never rendered.
- Clients select the durable path only for a terminal task that carries the
  field. The issue actions menu and the transcript dialog share one resolver,
  and the label and toast say the task worktree was reclaimed, naming the
  delivery branch when one exists.

The stored path is a point-in-time delivery snapshot: later renames or
detachments of the project resource do not rewrite historical tasks.
2026-08-20 01:30:58 +08:00
67c6aa05c4 MUL-6166: feat(telegram): add Telegram channel integration (#6944)
* feat(telegram): add Telegram channel integration

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

* chore(telegram): rebase onto main and renumber migrations to 366-367

349/350 collided with main's agent_task_queue_chat_terminal_resume_index
and agent_task_queue_chat_retired_session_index. Renumber the Telegram
origin pair to the next free prefixes, update the cross-references inside
the SQL comments, and point telegram_origin_migration_test.go at the new
filenames. Contributor-facing behavior is unchanged.

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

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
2026-08-19 23:04:26 +08:00
659bb41abd MUL-6350: rebuild the plugin system (3/4) — hook engine (#7182)
* feat(plugins): hook engine — signed outbound calls, trigger-decided identity (MUL-6350)

The direction reverses here. PR 1 and 2 only ever ran inbound: a sandboxed
surface asked the host, and the host acted on the signed-in user's session, so
nothing left our infrastructure on a plugin's say-so. A hook leaves, and every
new check exists because of that.

Destination: the target host must be inside the granted `net:` set, resolve to
a public address, and be re-checked at dial. Body: HMAC-SHA256 over
timestamp.body, so a receiver can tell our call from anyone else's and a
captured one expires.

Identity follows the trigger, not the plugin. A ui/manual hook runs because a
person pressed something, so its writes stay theirs; an event hook has nobody
behind it and writes as the installation — hence the fourth author_type.

Two credentials, moving opposite ways, which is what decides how each is
stored. The install token goes plugin -> host and is only ever verified, so it
is hashed. The signing secret goes host -> plugin and must be reproduced to
sign with, so it is derived from the deployment key and stored nowhere.

Event hooks never block the host: Bus.Publish runs listeners inline on the
publishing request's goroutine, so the dispatcher hands off to a worker pool
immediately. Three attempts, then a circuit breaker, then silence.

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

* feat(plugins): manual hook actions, ui trigger, and a worked hook example (MUL-6350)

The host owns the manual menu entry rather than letting a surface draw one,
because the trigger decides identity: a manual call acts as the person who
picked it, so the entry has to live where the host can prove somebody did.

The ui trigger goes through the bridge for the same reason a surface cannot
fetch its own backend directly — routing through the host is what makes the
call signed, rate limited, bounded to the granted net: domains and recorded.

buildActorNameResolver gains a plugin branch. Without it an event-written
comment rendered as "System", which is the one attribution that is actively
misleading: it reads as the platform having said something.

examples/plugins/triage-notify carries the half that does NOT run in Multica —
a reference handler showing the four things a receiver must do, including the
replay guard the host cannot do for it.

TestShippedExamplesParseAndInstallOnThisHost closes the gap that let an example
drift: nothing checked that shipped documentation still parses, or that it
declares only what this build can run.

TestCheckCapabilitiesReportsEveryUnavailableContribution rewritten the way the
other two were in PR 2 — it named today's unshipped set, so flipping ui/manual/
event on turned it red with a message about the flip rather than about the gate.
It now derives its expectation from HostCapabilities and survives a flip while
still failing if the gate stops checking a category.

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

* feat(plugins): modal surface, invocation cleanup, honest capability guards (MUL-6350)

TestShippedHostCapabilitiesRunTheSurfacesTheHostMounts caught me flipping the
modal surface on without a renderer — an install that succeeds and then never
appears, which is the exact failure that guard exists to prevent. Built the
mount point rather than weakening the guard, and rewrote it to name the host
code behind each line so the next flip has to change both together.

plugin_invocation was missing from workspace teardown and from uninstall.
TestWorkspaceDeletionManifestCoversPublicSchema caught the first; the second
would have left rows naming an installation that no longer exists. Deleted by
workspace_id rather than through installation ids, so a record whose plugin was
already uninstalled does not outlive the workspace it describes.

A modal opens because a person picked it from the issue menu, never on the
plugin's own initiative — same sandbox as a panel, differing only in where it
appears.

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

* test(plugins): prove the event path never blocks, and contain worker panics (MUL-6350)

Writing the never-blocks test found a real bug rather than confirming one.
Bus.Publish recovers panics in its listeners so one bad handler cannot kill the
request that published; moving delivery onto a worker goroutine stepped outside
that protection, where a panic is not a failed hook but a dead process.
runGuarded restores the guarantee the hand-off gave up.

The bridge now takes an EventSink rather than the concrete dispatcher, so the
vocabulary mapping — which internal event becomes which published one — is
asserted directly instead of through a live endpoint. That mapping is a
published contract; the internal names are not.

The backpressure test stops the workers rather than racing them, so it
reproduces a saturated pool without depending on timing, and asserts the exact
overflow instead of "something was dropped".

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

* fix(plugins): two hook-contract bugs a live run found (MUL-6350)

Both were invisible to the tests because the tests made one call each, and a
real handler does not.

The callback token was single-use. The reference handler reads the issue,
decides, then posts a comment — and the second call died on a spent token.
Read-then-write is the floor for a handler that does anything with what it
read, so a single-use grant made the callback unusable.

The trade that decides it is not one call versus several. It is this token
versus the installation's standing token: a handler that cannot finish with the
callback gets written against the install token instead, which never expires
and is not scoped to an invocation. A control that pushes authors toward the
stronger credential is worse than the looser one they will actually use. The
grant is now invocation-scoped — minutes, this installation's scopes, the actor
fixed at dispatch — and revoked as soon as the call it was issued for returns.

The hook body also omitted the issue. The host resolves and permission-checks
it, then said nothing, so a handler had to read a client-supplied field —
unvalidated for ui/manual, and absent entirely for event, where there is no
client. issue_id now travels in the body.

Also adds MULTICA_PLUGIN_DEV_CA: a hook URL must be HTTPS, so an author testing
against localhost needs its certificate trusted by something. A named CA file
rather than InsecureSkipVerify, which survives into production behind a config
flag nobody remembers and cannot be spotted by reading the code.

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

* fix(plugins): enforce the callback token's issue scope, sweep invocations (MUL-6350)

Addresses Fabel 5's review. The first item is the one that mattered.

The callback grant's issue narrowing existed only in two doc comments and the
PR description — pluginTokenCaller never read grant.IssueID, so a ui/manual
token was worth every issue in the workspace its actor could see, for the whole
five minutes it lived, on read and write alike. Now carried into the caller and
checked in pluginIssueForUser, after resolution so an identifier and a uuid for
the same issue agree. 404 rather than 403: "you are scoped elsewhere" confirms
the id exists.

DeleteExpiredPluginInvocations was generated and never called, while the table
comment and the PR both said TTL-swept. A sweeper now runs on the dispatcher's
lifecycle — same concern, bounded resources for something a third party drives.

Event subscriptions bypassed the read scopes. issue.* pushes the description
and comment.created pushes the body — the same content the Action API refuses
without issues:read / comments:read. Subscribing was a way to receive what
reading was not granted. Enforced at manifest validation so the consent screen
shows the read scope a subscription implies.

Migration 349's ADD CONSTRAINT copied 107's shape onto a table that is no
longer 107's size: it revalidated every comment row under ACCESS EXCLUSIVE.
NOT VALID plus a 352 that VALIDATEs under SHARE UPDATE EXCLUSIVE costs nothing
and stops nothing.

Plus the half-built breaker UI (a plugin could be enabled and silently
delivering nothing), redirect refusal on the dev hook client (a 302 would have
replayed the signed body and the callback token onward), and omitting
callback_url rather than sending a relative one.

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

* fix(plugins): the invocation sweeper must not touch the database at construction (MUL-6350)

CI panicked in cmd/server: the sweeper I added last round fired immediately on
its own goroutine, and that test builds a router over a Queries whose pool was
never opened. pgxpool dereferenced the nil pool and the process died.

Two things were wrong, and only one of them is the obvious one.

The sweep no longer runs at construction. It waited for nothing and gained one
hour of retention on a cold start against a 7-day TTL; the cost was a goroutine
reaching for the database before the process is known to have one.

The nil check was also the wrong instrument. sqlc's Queries wraps an executor,
so a non-nil Queries over an unopened pool passes every comparison available at
that layer and only fails inside pgxpool — no amount of nil-checking here can
see it. The guard is a recover, for the same reason runGuarded exists: on a bare
goroutine a panic is not a failed sweep, it is a dead server. I had already
fixed that class once for delivery and then reintroduced it in the sweeper.

The regression test builds the exact shape CI hit — a dispatcher over db.New(nil)
— and fails if either the construction-time sweep or the recover comes back.
cmd/server cannot reproduce it locally, where DATABASE_URL supplies a real pool.

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

* fix(plugins): gate the event dispatch path behind plugins_v1 (MUL-6350)

Answering "is this still behind the flag" turned up the one place it was not.

Every other plugin surface is refused by a handler that reads the flag off a
request. An event hook has no request: the dispatcher is subscribed to the bus
at startup and delivers on a worker. Nothing in it consulted the flag, so
turning plugins off hid the UI and refused the Action API while the OUTBOUND
calls kept going — fail-open on the one path that reaches a third party.

It only bites after a plugin has been installed, because installation is
flag-gated, so a deployment that never enabled plugins was never exposed. A
workspace that enabled it, installed something, then turned it off was.

The check reads the flag per delivery rather than once at subscription, so the
flip takes effect immediately instead of at the next restart. It also removes
the flag-off cost: every dispatched event used to run a
ListWorkspacePluginInstallations query to discover there was nothing to call.

Nil flags read as disabled — a deployment that never wired the flag service must
not get outbound hooks by omission.

The test lives in internal/handler because it needs a real installed plugin to
mean anything: with the flag on the endpoint is called, with it off nothing
leaves while the same installation stays enabled and still declares the hook.
Removing the check makes it fail on the second half.

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

* perf(plugins): stop parsing event payloads on the publishing goroutine (MUL-6350)

Found while answering whether merging can affect existing users, and the answer
was "in one measurable way it can".

The bus listener passed issueIDFromPayload(e.Payload) as an argument, so a JSON
marshal plus unmarshal of the full event payload — for issue.created that is the
whole issue body, description included — ran inline on the goroutine of whatever
request published. Seven event types, every workspace, and the feature flag was
checked far downstream on the worker, which then threw the result away. A
deployment with plugins switched off paid for all of it.

The listener now hands the payload over unexamined and the id is extracted on
the worker, past the flag check. It also fits the rule this file already
claimed: the publishing request does the least it possibly can.

Not moved: the status_changed lookup, which is a map read, not a parse.

TestPublishingDoesNotParseThePayload counts MarshalJSON calls during Publish and
fails if any happen — restoring the old argument makes it fail with the count.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 19:29:15 +08:00
0c0baf0505 perf(realtime): stop the task:message fanout from flooding every client (MUL-6396) (#7227)
* perf(realtime): stop the task:message fanout from flooding every client (MUL-6396)

`task:message` is broadcast to every client in the workspace for every run,
and the client cached all of it: the handler's `(old = [])` default built a
timeline entry on first sight, so each client accumulated the full transcript
of runs its user would never open, held for the life of the run. Tool input is
unbounded on the wire, so a single large Write shipped its whole body to every
open client and was retained there.

Three changes, each useful on its own:

- Client drops frames for tasks no mounted view is rendering. Presence in the
  query cache is the signal, which registers when a view mounts and before its
  fetch resolves, so a frame landing mid-backfill is still kept. Rows are
  persisted before they are broadcast, so a dropped frame costs nothing —
  whoever opens the task next fetches it.

- Server clips oversized `input` / `output` out of the broadcast copy and marks
  it `truncated`. Persisted rows and the REST list responses are untouched. A
  client seeing the flag repairs that task's timeline from the DB, once per
  flush window rather than once per frame.

- Client coalesces surviving frames into one cache write per 100ms window,
  turning a burst of streamed messages into one merge and one render. The
  window is fixed, not a resetting debounce, so a continuous stream still
  flushes instead of being starved until it pauses.

Mobile keeps its own per-session updater and was already scoped to mounted
sessions, but shares the wire format, so it refetches on `truncated` too.

The remaining half of the batching idea — one WS frame per reported batch
instead of one per message — is deliberately not here. The client identifies
itself on the upgrade URL but the server does not retain that version, so a
shape change would silently break every older client. It belongs with the
client scope-subscription work that `listeners.go` is already waiting on.

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

* fix(realtime): keep broadcast clipping off the wire, and stop a late fetch dropping live seqs

Addresses both must-fix findings from review.

1. Clipping the broadcast copy is not something an existing client can ignore:
   it changes what `input` / `output` mean, and every deployed client already
   consumes them. A build without the `truncated` reader caches the clipped
   copy under staleTime:Infinity and never refetches, so its execution log
   would stay incomplete until the window is reloaded — the PR even proposed
   shipping the server half first, which is exactly backwards.

   Clipping now sits behind MULTICA_CLIP_TASK_MESSAGE_BROADCAST, off by
   default, so the reader ships first and the flip is a later, revertible ops
   step. Capability routing would be the principled fix, but the hub does not
   retain the client_version it is handed at upgrade and frames cross nodes
   through the Redis relay as serialized bytes — the same protocol work
   per-task scope routing is already waiting on.

2. Not caching unopened tasks made first open of a live task fetch where it
   previously found a pre-seeded cache. That fetch replaced the array
   wholesale, so any frame written while it was in flight was dropped — and
   with staleTime:Infinity nothing would ever fetch it again.

   Every write to the timeline cache now folds through one rule, installed as
   the query's own structuralSharing: union by seq, server data winning on
   conflict, rows the response never mentioned kept rather than deleted. That
   subsumes the backfill's hand-rolled eviction, so the three write paths
   (fetch, backfill, realtime batch) can no longer disagree.

Also documents why mobile's truncated branch is unreachable today: its
task:message handler gates on chat_session_id, which the server's payload does
not carry, so mobile appends nothing at all. Pre-existing, reported separately,
and the branch stays so whichever side is fixed first is not left caching
clipped rows.

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

* fix(realtime): re-check the timeline is still held before writing a batch

Holding was verified when a frame entered the batch, but the write happens up
to a window later — and `setQueryData` does not postpone garbage collection.
query-core arms that timer when the last observer leaves and never re-arms it
on write, so closing a transcript while its run keeps streaming has the entry
collected mid-window with a batch already in flight.

Writing then rebuilt the entry holding only that batch. Under the app-wide
`staleTime: Infinity` the next open reads the stub as fresh and never fetches,
so everything before it is missing until the window is reloaded — silent,
permanent, and not self-healing. Agent runs routinely outlive the 10 minute
gcTime, so "opened a running task, closed it, it kept going" reaches this on
an ordinary day rather than in a race.

The flush now re-checks immediately before each write and drops the batch if
the entry is gone, dropping its repair fetch with it. `backfillTaskMessages`
guards its own post-await write for the same reason. Dropping costs nothing:
the rows are persisted, so the next open fetches the whole timeline — which
the regression test asserts rather than just checking the entry stays absent.

Also corrects the doc comment on isTaskMessageTimelineHeld, which described
the entry as a mounted view. It means "opened at some point and not yet
collected", and the bounded tail is one gcTime.

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

* docs(realtime): describe the task:message gate as it actually behaves

The overview still said frames are dropped unless a mounted view holds the
task, and that the old behaviour retained a transcript for the life of the
run. Neither matches the code: the gate is timeline-entry presence (opened at
some point, not yet collected), and writes do not postpone GC, so retention is
bounded by gcTime rather than the run.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 19:25:05 +08:00
ea07a29138 perf(db): generate task message IDs as UUIDv7 (#7220)
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 17:17:28 +08:00
Deoanddeo 66af64b9a6 feat(issues): support filtering custom properties by "no value" (#7156)
Add a "No value" (__none__) option to the custom-property filter for
checkbox/select/multi_select/actor properties. Selecting it matches issues
where the property is unset — the inverse of picking a value — so teams can
express "everything that is NOT parked", e.g. exclude a `挂起` Hold checkbox.

Backend:
- parsePropertiesFilterParam compiles "__none__" into a
  {"__none__": "<defId>"} marker; propertiesFilterPredicate renders it as a
  key-absence disjunction (NOT (i.properties ? $n)) instead of jsonb
  containment. The static ListOpenIssues unroll recognizes the same marker.
- Property table facets report an unset "__none__" bucket so the filter menu
  shows a real count for the option.

Frontend:
- issueMatchesPropertyFilters matches unset issues when "__none__" is selected
  (OR-ed with other values within the definition, AND-ed across definitions).
- PropertyFilterOptions offers a "No value" option for every property type;
  filter chips render it via pickers.custom_property.no_value_label.
- i18n keys added for en/zh-Hans/ja/ko.

Co-authored-by: deo <deo@deo的macbook\032pro.tyaio>
2026-08-19 17:03:06 +08:00
5d5fe64672 MUL-6343: Add semantic issue activity timestamps (#7193)
* feat: add semantic issue activity timestamps

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

* fix(migrations): register issue activity index cleanup

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

* fix(issues): address last activity review

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

* fix(issues): close last activity review gaps

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

* fix(issues): harden activity rollout safeguards

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 16:08:40 +08:00
3c3e77b00c MUL-6362: redact secrets from provider command logs (#7206)
* fix(agent): redact provider command values

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

* fix(agent): preserve safe command diagnostics

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 16:07:53 +08:00
3460f9aeaf MUL-6342: enforce entitlement-backed autopilot quotas (#7194)
* feat(autopilot): enforce entitlement run quotas

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

* chore(deploy): expose entitlement policy config (#7195)

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

* fix(autopilot): address quota review findings

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

* fix(autopilot): finish quota review nits

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

* refactor(autopilot): simplify quota schema

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 15:51:26 +08:00
eb2a94e860 MUL-6368: Prevent cross-agent task and workdir contamination (#7205)
* fix(daemon): isolate task identity and workdir reuse

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

* test(llm): use validated retry override in contract

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

* fix(daemon): address task isolation review

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 15:30:19 +08:00
24d8c521a6 MUL-6364: make the LLM client retry budget explicit, wired, and validated (#7201)
* feat(llm): make the LLM client retry budget explicit, wired, and validated

Config.MaxRetries was a bare int that nothing outside tests ever set. The
production wiring in handler.New never passed it and no MULTICA_LLM_* env var
fed it, so the field was configurable in name only and every deployment ran on
the SDK default.

Its semantics were also inverted. Because the zero value and an explicit 0 are
the same int, "0" fell into the unset branch and silently produced 2 retries,
while a negative — clamped to 0 to dodge the panic in option.WithMaxRetries —
was the only value that actually disabled retries.

- MaxRetries becomes *int so unset, 0 (disabled) and N (exact) stay distinct.
  New always passes the budget to the SDK explicitly, including the default, so
  the enforced policy and the reported one cannot drift apart.
- Add MULTICA_LLM_MAX_RETRIES as the single configuration source. Non-numeric,
  negative and above-ceiling values fail the boot instead of being corrected
  into something that looks configured. The ceiling of 5 is a latency budget:
  backoff reaches ~21s at 6 retries while the internal callers of this layer
  time out after 8s and 20s.
- Report the effective policy at startup via Client.RetryBudget, whose fields
  are counts and a fixed enum so the line cannot carry a key or gateway URL.
- Document the four states in .env.example and the environment-variable docs,
  and record what the budget does not cover: the GenerateJSON parameter
  negotiation and a stream that breaks after ChatStream returns.

Tests pin every state by upstream request count, so the unset-versus-zero
collapse cannot come back unnoticed.

Refs MUL-6364, closes #7154

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

* fix(llm): reject invalid retry budgets at the client boundary and wire self-host

Review found the retry budget still reachable in two invalid shapes, and never
reachable at all on the main self-hosted deployment path.

- docker-compose.selfhost.yml declares the backend environment as an explicit
  allowlist and mapped none of the MULTICA_LLM_* variables, so every value
  .env.example and the docs told a self-hoster to set was dropped before the
  container: the LLM layer read as unconfigured no matter what they wrote. Map
  all four, and assert propagation in scripts/selfhost-config.test.sh, plus a
  drift guard so a future MULTICA_LLM_* documented without a mapping fails the
  script instead of silently going nowhere.
- Config.MaxRetries took a *int, so a negative still reached llm.New, which
  warned and used the default instead. Warning is not silence, but substituting
  a different budget is still the implicit correction #7154 asked to remove.
  It now takes *RetryOverride, whose field is unexported and whose only
  constructor rejects negatives — the invalid state is unrepresentable, so New
  has no correction branch left to take.
- The budget is a ceiling, not a quota: only retryable failures consume it, and
  a success or the caller's deadline can end a call sooner. Say "at most N"
  rather than "exactly N" in the field docs, .env.example and all four
  environment-variable docs.

The negative-value test now asserts rejection instead of pinning the fallback
as contract.

Refs MUL-6364, closes #7154

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 15:00:56 +08:00
bb8a04f3a1 docs(llm): disclose what the server-side LLM layer sends, and test the zero-request opt-out (MUL-6369) (#7199)
* docs(llm): spell out what the server-side LLM layer sends upstream (MUL-6369)

The two MULTICA_LLM_* variables are the only switch governing whether chat
content reaches an external model, but nothing said so: the .env.example
comment named chat titles as an example consumer, the docs page said only
that callers 'use local fallback logic', and neither said what is sent.
An operator whose policy forbids chat content leaving the deployment had
no way to confirm that leaving the variables empty is sufficient, which is
what GitHub issue #7162 reports.

Document the layer where it is configured: both consumers (chat
auto-titling, follow-up questions) and exactly what each sends, plus the
fact that leaving the key and base URL empty is a supported configuration
that costs nothing but the LLM-written title and the suggestion buttons.
The package doc carries the same inventory next to the code, so a new
consumer is added in the file that documents them.

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

* test(llm): pin the zero-upstream-request and single-entry-point contracts (MUL-6369)

The documentation added in the previous commit makes two promises that were
true only by accident. An unconfigured client returning ErrNotConfigured was
never asserted to do so without dialing, and 'every server-side LLM call goes
through pkg/llm' held only because no one had imported the SDK elsewhere yet.

TestUnconfiguredClientMakesZeroUpstreamRequests drives every exported call
path through a transport that counts and refuses, then proves the counter can
move for a configured client so the zero-count assertions cannot pass
vacuously if the HTTPClient seam is ever dropped.
TestOutboundLLMTrafficGoesThroughThisPackage parses the server module and
fails on an OpenAI SDK import outside this package, because the consumer
inventory is only auditable while this package is the only door.

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

* fix(llm): scope the privacy claim, and guard the consumer inventory (MUL-6369)

Addresses both must-fixes from review. They land together because they share
the same surfaces: the package doc is the inventory the operator copy is
written from, and it names the tests that hold each half.

1. The disclosure claimed more than the code guarantees. The copy said these
   variables are the deployment's only switch for sending chat content to a
   model, and that emptying them means the server sends nothing to any
   provider. What is actually guaranteed is narrower: server/pkg/llm makes no
   request. Running an agent reaches a model by an entirely separate path —
   the daemon executes an AI coding tool under that tool's own credentials,
   with MULTICA_* stripped from its environment. For a privacy document that
   gap matters more than it would anywhere else: an admin who reads the old
   wording concludes emptying two variables stops all model traffic, which is
   the kind of wrong belief #7162 is about. Every surface now says "this layer
   sends nothing" and names the agent path explicitly — .env.example, the four
   docs locales, the package doc, the test comments.

2. The inventory had no guard. The import test proved the OpenAI SDK has one
   door; it said nothing about how many callers walk through it, so a third
   feature calling GenerateText would ship undisclosed with every test green
   while the docs kept naming two consumers.
   TestDocumentedConsumersAreTheOnlyCallers scans production call sites and
   requires the set to match the published list in both directions: an
   undocumented caller fails, and so does a documented one that stopped
   calling, since an inventory that overstates what is sent misleads too. The
   import test is renamed TestOpenAISDKIsImportedOnlyByThisPackage to claim
   only what it checks.

Also drops the package doc's stale "drafting a quick-create issue" example,
which named a consumer that does not exist and contradicted the inventory two
paragraphs below it.

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

* fix(llm): correct the MULTICA_* claim about agent environments (MUL-6369)

Review caught a factual overreach in the previous fix. The copy said MULTICA_*
variables are dropped from the agent's environment rather than passed to it,
which is not what mergeEnv does: it drops the namespace the daemon INHERITED,
then appends the task-scoped values daemon.go assembled — MULTICA_SERVER_URL
and MULTICA_TOKEN reach the child by design, and claude_test.go asserts it.

The guarantee an operator actually needs is narrower and still sufficient: the
daemon does not forward these MULTICA_LLM_* settings to the AI coding tool, and
agent runs use that tool's own credentials. Say that instead, and name the
task-scoped injection so the sentence does not contradict the subprocess env
contract. Applied to .env.example, the four docs locales and the package doc.

Also splits methodNameCollisions out of documentedConsumers, per the same
review's non-blocking note. A method-name collision quieted by adding a file to
documentedConsumers would have corrupted a list operators read as 'what sends
my chat content somewhere' in order to silence a test — a bad trade the guard
should not invite. Empty today; the repository has no collisions.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 14:59:24 +08:00
Bohan Jiang 4040345e09 perf(ci): path-filter the backend jobs to skip irrelevant PRs (MUL-6389) (#7198)
Adds a `backend` path filter to the `changes` job and gates backend-tests, go-vulnerability-scan and windows-execenv on it, so a PR that never touches server/ no longer pays for a `go test -race` run, govulncheck and a Windows runner.

The filter is deliberately wider than `server/**`: four Go tests read three files outside the module (packages/core/runtimes/cli-version.ts, scripts/agent-cli-command-names.txt, scripts/go-test-with-agent-cli-guard.sh), and each must be listed or the PR that breaks such a contract is exactly the PR that skips the test.

Steps are gated rather than jobs, matching the existing frontend/sqlc-check pattern: the aggregate check reports a genuine green instead of staying pending, and still fails when a job goes missing for a reason other than the path filter.
2026-08-19 14:39:33 +08:00
YYClaw 4b8873df3a feat(issues): add revision-aware concurrency guards (#7038)
* feat(issues): add revision-aware concurrency guards

* fix(concurrency): decouple text edits from aggregate revisions
2026-08-19 14:06:59 +08:00
76a90f602c MUL-6324: Reduce chat resume query load (#7151)
* fix(server): reduce chat resume query load

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

* fix(server): address chat resume review

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 12:40:04 +08:00
7e92c58f53 fix(db): regenerate DingTalk sqlc output (#7177)
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 02:03:12 +08:00
YYClaw 6377dc2656 feat(dingtalk): show linked identities to workspace admins (#7138) 2026-08-19 01:37:51 +08:00
97195885fa MUL-6350: rebuild the plugin system (2/4) — Action API + Surface + SDK (#7157)
* MUL-6350 feat(plugins): Action API and via-plugin attribution (2/4, part 1)

The server half of PR 2. A surface can now read and write through Multica, but
nothing renders one yet — the iframe host, SDK and example plugin follow.

Every Action API call passes three checks in order:

  1. the installation exists and is enabled — a surface left open in a stale
     tab stops working the moment an admin disables the plugin, rather than
     merely disappearing from the settings page;
  2. the installation holds the scope the endpoint needs, and the refusal names
     the missing scope so an admin can act on it;
  3. the SIGNED-IN USER may touch the resource.

Three is what makes the model safe, and it is deliberately not implemented
here: the workspace comes from the installation row rather than a client
header, membership is checked against that workspace, and the issue lookup is
scoped to it. A plugin therefore inherits exactly the reach of the person using
it, with no second copy of the permission rules to drift.

There is no plugin credential anywhere in this path. The iframe holds no token;
it posts a message to the host page, and the host re-issues the call on the
user's own session with a header naming which installation is speaking.

comment.via_plugin_id is the other half of that identity story: permission-wise
a plugin-mediated write is the user's, so author_type/author_id are unchanged,
but audit-wise it stays attributable to the plugin that produced it. Modeled on
comment.quick_action_id — no request field sets it, so it cannot be forged by a
member posting normally.

Two deliberate narrowings, both additive to widen later:

  - PATCH issues covers title and description only. Status, priority, assignee,
    parent, project and stage carry dispatch, catalog or hierarchy semantics
    that would need a second, drifting copy of those rules here.
  - A plugin-posted comment does not run @mention trigger dispatch. A plugin
    able to post a mention could start agent runs, and spend the workspace's
    budget, from a surface the user only meant to click a button in.

issue_panel and sidebar_panel flip on in HostCapabilities so an install stops
failing on a surface the host is about to be able to render.

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

* MUL-6350 feat(plugins): surface host, bridge and SDK (2/4, part 2)

The half a user can see. An installed plugin's issue_panel now renders on the
issue page and can read, write and store through the Action API landed in the
previous commit.

Isolation reuses the convention already in this repo for untrusted HTML
(packages/views/editor/code-block-iframe.tsx): sandbox="allow-scripts" and NOT
allow-same-origin, which the HTML spec calls out as the pairing that defeats the
sandbox. The frame gets an opaque origin — no cookies, no localStorage, no
access to the embedder, and no storage shared between two plugins. That is why
no separate plugin origin domain is needed.

The host generates the document rather than pointing the frame at the author's
HTML. That is what makes the net: scopes real: CSP is decided by whoever emits
the document, so an author-emitted page would carry its own server's policy and
the scope the admin approved would be a claim nobody checks. Here connect-src is
derived from the granted scopes, and a plugin with no net: scope gets
connect-src 'none' — it cannot phone home at all.

Identity on the bridge is bound by a MessagePort, not by event.origin: every
sandboxed frame reports the opaque origin "null", so comparing origins would
authenticate nothing. The host creates one channel per iframe, transfers one end
in, and only listens on the other.

Also here: @multica/plugin-sdk (zero runtime deps, no @multica/core or
@multica/ui import, since it ships to third parties), the hello-panel reference
plugin, and the bridge's own refusals — a path outside the Action API never
becomes a request, and a resize is clamped.

A local: install cannot render a surface: its files live on the server's
filesystem with no URL a browser could fetch, and the panel says so rather than
showing an empty frame.

Tests cover what a reviewer cannot see by reading the components: the CSP
derived from scopes, entry resolution refusing local and plaintext sources,
attribute escaping, the sandbox attribute never gaining allow-same-origin, the
bridge's path allowlist and status pass-through, and disabled installations not
mounting.

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

* MUL-6350 fix(plugins): give the SDK package a vitest config and protocol tests

A new workspace package inherits the repo's `test` script, so `vitest run` ran
with no config and no test files and exited non-zero — frontend-test went red
on a package that had nothing wrong with it.

Rather than only adding `passWithNoTests`, the guards now have the coverage they
warrant: they are the only thing between a hostile message and the bridge
handlers on both sides, so anything that is not exactly the agreed shape has to
fall through instead of being coerced into something close enough. Includes the
response/event mix-up, which would otherwise resolve a pending call with a theme
payload or apply a response as design tokens.

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

* MUL-6350 fix(plugins): make the surface flow actually work end to end

Running a real plugin against a live stack found five defects that every test in
this PR passed straight through. Each one produced the same symptom — a blank
panel — and none was visible from reading the code.

1. The handshake raced in both directions. The host connected on the iframe's
   load event, which for a srcdoc frame can fire before React attaches the
   handler; and a guest that announced once could announce before the host was
   listening. The surface now announces until it is answered, and the host
   matches event.source against the frame it created. Origin cannot be used for
   this — every sandboxed frame reports "null".

2. The bridge was reused across a document change. close() is terminal, and the
   theme effect changed srcDoc, so the second connect() returned immediately on
   a permanently closed bridge. One bridge per rendered document.

3. The theme was read on first render, when the ref is still null, so every
   surface was built with no tokens at all.

4. X-Multica-Plugin-Installation was missing from corsAllowedHeaders, so the
   preflight failed and every Action API call from a browser client died as
   "Failed to fetch". The comment above that list warns about exactly this.

5. activity_log kept a via_plugin_id column in generated code from an earlier
   draft of migration 348 that no longer creates it — every issue timeline
   returned 500. Regenerated.

Also from seeing it rendered: frame-ancestors is dropped from the meta CSP
(ignored there, and the browser warns), and string config fields can declare
multiline so a value that is a list of lines gets a textarea instead of a
single-line input the user cannot read.

MULTICA_PLUGIN_DEV_ORIGINS lets an operator name an origin a manifest may be
served from while a plugin is being built, and a surface entry may resolve over
plain HTTP from loopback — what browsers already treat as a secure context.
Without both, a surface cannot be developed at all: there is no way to serve one
over public HTTPS from a laptop.

examples/plugins/release-checklist is the plugin this was verified with: a
per-issue definition-of-done checklist with workspace-configured items,
per-issue state in plugin storage, strict-mode gating, and a sign-off comment
that lands as the user with via_plugin_id set.

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

* MUL-6350 fix(plugins): close the bridge hijack, fix the capability gate, sync the comment path

Review fixes for #7157.

The security one first: the SDK accepted a bridge-init from any window and let a
later init replace the port. Sibling surfaces are mutually opaque, but
`parent.frames[i]` is an allowed cross-origin access — so another plugin on the
same issue page could deliver its own MessagePort, become a surface's "host",
feed it fabricated data and read everything it wrote, including whatever it put
in storage:user. The host already made exactly this check on the readiness
signal; the guest half was missing. Both examples carried the same hole, and
examples get copied.

The capability gate had flipped to enabling sidebar_panel with no mount point
anywhere — a plugin declaring one would install cleanly and then never appear,
which is the silent failure the gate exists to prevent. Turned back off, and a
test now asserts the shipped set matches what the host actually mounts, so the
next flip has to come with its renderer.

The two red tests were red for a real reason but were also written to break on
any capability flip. Rewritten to test the thing rather than today's set: the
message is asserted against an explicit empty Capabilities{}, and the live gate
against a kind that genuinely is not shipped (a hook). Added the other half —
a shipped surface must install against the real HostCapabilities.

A plugin-posted comment now publishes comment:created and re-opens a resolved
thread, like every other comment path. Skipping mention dispatch was deliberate;
skipping these two was not, and it is why the demo needed a page reload to see
the comment appear.

Corrected an overclaim rather than pretending to fix it: "no net: scope means it
physically cannot send data out" was wrong, because script-src necessarily
allows the author's own origin. net: bounds THIRD-PARTY destinations. img-src
and font-src are narrowed to inline data, which removes the two side channels
that need no scripting at all; the rest cannot close without re-hosting
third-party code, which this design rules out.

Smaller ones from the same review: the surface-error listener now checks window
identity too (any frame could light every panel's failure banner), the bridge
takes a method allowlist instead of any string, surfaces[].platforms is enforced
rather than decorative, and the iframe is keyed on the issue so a bridge created
for a new issue is not left waiting on a guest that already stopped announcing.

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

* MUL-6350 fix(plugins): drop the SDK's jsdom dev dependency and stub the window

`pnpm add -D jsdom` wrote a partial lockfile update: it resolved against a warm
local store, so `--frozen-lockfile` passed here and failed in CI on a vitest
peer key that was never written.

Rather than re-resolve, the dependency is gone. The SDK touches exactly
`window.addEventListener` and `window.parent`, so the handshake tests run on a
hand-built EventTarget in the node environment. A package that ships to third
parties is better off carrying no test-only dependency at all, and the tests
still fail without either guard they cover.

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

* MUL-6350 fix(plugins): keep a test runner out of the SDK package

A new importer that pulls vitest through the catalog resolved to a peer key CI
could not satisfy on a clean install, so every frontend job died in
`pnpm install --frozen-lockfile` before running anything. Two attempts to
re-resolve it locally produced a lockfile that looked correct here and stayed
broken there — my store is warm, CI's is not.

So the SDK stops carrying a test runner at all. It ships as TypeScript source to
third parties and now declares only typescript plus the two workspace configs;
its handshake and protocol tests move to packages/views, its only consumer in
this repo, where the toolchain already exists. The lockfile delta for this whole
PR is now four workspace links and a typescript pin — nothing that can resolve
differently on a cold install.

The tests themselves are unchanged and still fail without the guards they cover.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 01:06:25 +08:00
6a17c00144 MUL-6350: rebuild the plugin system (1/4) — cleanup and foundations (#7144)
* MUL-6350 refactor(plugins): reset the plugin system to Action / Hook / Resource foundations

The V1 plugin system shipped 14 tables, an append-only revision log per
relationship, a capability-snapshot compiler, a per-task execution manifest
pinned by an INSERT trigger on agent_task_queue, release signing, trust tiers,
and a bundled catalog — and never left its feature flag. It holds no production
data, so this removes it outright instead of migrating it forward.

What replaces it, per the design in MUL-6338:

- A plugin relates to Multica in exactly three ways. Action (plugin calls
  Multica), Hook (Multica calls plugin), Resource (a static contribution with no
  call at all). "Who triggers" and "what capability is called" are orthogonal:
  a hook is declared once and lists which triggers may invoke it.
- We never execute third-party code. A plugin runs only in a sandboxed iframe in
  the user's browser or on the author's own server.
- Distribution is "install by URL, admin reads the scopes". No signature, no
  trust tier, no publisher verification — the scope list IS the trust decision.

This change is the foundation, with no user-visible surface yet:

- Decouples remote MCP first. RemoteMCPConnection/RemoteMCPTool/DigestBytes move
  into pkg/remotemcp, where they belonged; the protocol layer and daemon broker
  survive intact and dormant, ready to be rewired for the agent trigger.
- Drops all 14 plugin_* tables, their triggers, the manifest-composition
  function, and agent_task_queue.plugin_execution_manifest_id. Adds three:
  plugin_installation (one row per workspace+plugin, holding the manifest
  snapshot the admin consented to), plugin_storage, plugin_secret.
- Defines the manifest schema in full, once — surfaces, hooks with their
  triggers and events, resources, and a closed scope list. Parsing stays strict
  (DisallowUnknownFields, no trailing JSON). Contribution kinds this build does
  not ship yet fail installation loudly rather than installing half-working.
- Two-step install: preview parses and returns the scopes without writing, then
  install requires consent matching the manifest exactly. Manifest fetches reuse
  the remote MCP SSRF guard; MULTICA_PLUGIN_DIR adds a local source for
  self-hosting and plugin authors.
- Storage quotas are enforced on write and never by eviction. Secrets live in a
  separate encrypted table with no read path that returns a value.

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

* MUL-6350 fix(migrate): register the plugin index builds for invalid-index cleanup

Every migration that builds an index CONCURRENTLY must be registered in
concurrentIndexCleanups, or an interrupted build leaves a permanently INVALID
index that the `IF NOT EXISTS` retry records as success. TestEveryConcurrentUp\
BuildHasCleanup caught all three new plugin index migrations.

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

* MUL-6350 fix(plugins): address review — byte quotas, version bound, secret pruning, script entry

From the review on #7144, plus one contract change the surface design forced.

- Storage quotas were accounted in characters and enforced in bytes. The usage
  query and the size_bytes it reports now use octet_length, and so do the
  plugin_storage column checks. A UTF-8 value could otherwise spend up to a
  quarter of the budget it appeared to.
- manifest version had no length bound while plugin_installation.version caps
  at 64, so a legal-but-long semver failed at INSERT as an opaque install
  error instead of a parse error naming the field.
- Upgrading pruned config values the new manifest dropped but left the matching
  plugin_secret rows. Ciphertext nothing can reach is exactly the residue
  pruneConfig exists to prevent; upgrade now prunes both in one transaction.
- SetConfig wrote secrets and config across two tables with no transaction, so
  a failed config write could leave a saved form half-applied.
- A duplicate first install mapped a unique-index violation to 502; it is 409.
- net: is now an exact host match. It was a suffix match in the hook transport
  check while the same scope becomes an exact-host CSP connect-src in the
  surface runtime — one scope string cannot mean two things across the two
  places the consent screen claims to describe.
- surfaces[].entry must be a .js/.mjs script, not an HTML document. The host
  generates the surface's document so it can attach the CSP derived from net:;
  a plugin-authored document would carry its own server's policy and net: would
  be a claim rather than a control.

Tests: the three storage limits get a canonical unit matrix plus DB-backed
coverage of the byte counting and the exclude-the-candidate-key replacement
semantics; upgrade-time secret pruning is pinned end to end (verified failing
without the fix); and the admin-only routes now have a real middleware-level
403 check, which every direct-handler test bypassed.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 19:15:56 +08:00
e3a40b0b8d feat(MUL-6243): custom issue status UI — picker, board, filters, settings (#7103)
* refactor(views): widen IssueStatus to open key, resolve category at render sites

Board columns, swimlane columns and hidden-column panels are CATEGORIES,
not status keys, so adding a custom status never adds a column. Retyped
those to IssueStatusCategory and made every presentation lookup
(STATUS_CONFIG, i18n labels, status icons) resolve the key to its
category first. Status filters stay in concrete keys and map back to
columns via the catalog.

MUL-6243

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

* feat(views): catalog-driven status picker and per-status labels

The picker now offers the workspace catalog's active statuses grouped by
category, instead of the 7 hardcoded built-ins, and falls back to the
built-ins until the catalog lands so a cold render is unchanged.

Built-in names always resolve through i18n, never through the catalog's
server-seeded English name — otherwise every non-English workspace would
see 'In Progress' where it used to see '进行中'. Custom names come from
the catalog. That rule lives in one place: useStatusLabel.

Applied it to every surface that shows an issue's OWN status (detail
activity feed, table group headers and CSV export, hover card, inbox),
which previously showed the category label and so could not tell two
statuses in the same category apart.

MUL-6243

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

* feat(views): show custom statuses on cards and in filters

Board columns and list sections are categories, so two issues in the
same column can sit on different statuses with nothing to tell them
apart. CustomStatusChip is that signal, and it renders nothing for a
built-in — a workspace with no custom statuses looks unchanged.

The status filter now lists the workspace catalog instead of the 7
hardcoded built-ins. Without this an issue moved onto a custom status
could not be narrowed to at all. Picker and filter share one
useStatusOptions hook so the two lists cannot drift apart.

MUL-6243

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

* feat(settings): issue status catalog management with drag reorder

Owner/admin CRUD for custom statuses, organised by CATEGORY rather than
as one flat list. Category is not decoration here — it is the behavior a
status inherits, and picking it is the one moment an admin can, since it
is immutable afterwards. Built-ins are shown but locked: each is its
category's canonical definition.

Drag reorder writes intra-category positions starting at 1, because the
built-in is seeded at 0 and PATCH on it is a 403, so it always heads its
category. The optimistic cache write re-SORTS rather than only writing
positions, or the drag would visually undo itself until the refetch.

custom_issue_statuses is now published to the frontend, so the tab can
hide creation when the deployment has the rollout gate off instead of
offering a button that 403s.

MUL-6243

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

* fix(issues): close the category contract end to end

Elon's review found the board only half-converted: the UI targeted
categories while the server still paged by concrete status key, so a
card on a custom status was dropped twice — it never came back from the
server, and the row filter would have discarded it if it had.

1. New `status_category` group kind (and compound secondary axis).
   Grouping rewrites the column with a static CASE built from the
   catalog; paging expands the category to concrete keys and stays on
   `status = ANY(...)`, so (workspace_id, status) is still usable. A
   workspace with no custom statuses gets exactly `i.status` back.
   Row filter, facet totals and swimlane cells all fold by category now.

2. Column visibility split from the status filter. Hiding a column used
   to write the other six built-in keys into statusFilters, so hiding
   Backlog silently dropped every CUSTOM status in other categories.
   Display state is now its own field. The saved-view baseline also
   stopped deleting custom status filters on reopen.

3. The board/list right-click menu reads the shared catalog options, so
   every entry point that can set a status offers the same set.

4. Remaining presentation consumers resolve a category instead of
   guessing: gantt bars were keyed by IssueStatus and went colorless on
   any custom key; hover card, sidebar, search, tab strip, inbox row,
   create toast and the activity feed all resolve explicitly now.

5. Reorder is one atomic statement behind a new endpoint, and the client
   submits ACTIVE rows only. The per-row PATCH loop half-committed when
   an archived row rejected mid-sequence.

Also: one catalog observer per card instead of three, and the stale
tasks/todo.md draft is gone.

MUL-6243

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

* fix(issues): atomic reorder, category behavior sweep, rollout-safe grouping

Second review round.

1. Reorder is now one transaction holding the catalog's SHARED lock. The
   old shape validated ids, THEN ran the UPDATE — an archive committing
   in that window left the UPDATE silently skipping the archived row
   while the earlier rows were rewritten, and still returned 200. The
   authoritative active set is read under the lock and the payload must
   match it EXACTLY: a partial order would assign positions from the
   array index and collide with the rows it left out. Affected rows are
   checked against the payload before commit.

2. Swept the Web behavior consumers that still compared raw keys, so a
   custom category actually inherits behavior: gantt hide-completed,
   batch backlog parking, done/cancelled strikethrough, the mark-done
   button, sub-issue progress, search cancelled-demotion and mention
   demotion/dimming. issueBehavesAs is the one predicate they share, and
   it fails toward showing more and asking more. Two glyph sites (gantt
   row, swimlane lane) were still painting custom in_review as Todo.

3. Rollout safety. status_category is a contract this PR introduces, and
   the default-off creation flag does not guard the request path, so a
   new Web build would have 400'd against any un-upgraded pod — for every
   workspace. The client now sends it only once the catalog confirms the
   workspace HAS a custom status, which can only be true if the fleet
   already serves this version. Everyone else keeps the exact request
   they made before.

   That also uncovered a real defect: plain status grouping 500'd on a
   workspace holding custom statuses, because the descriptor rejected any
   non-built-in value and failed the whole response.

4. One catalog read per resolve instead of three, and the common path
   reads none at all. Pinned with observable scan-count tests rather than
   an assertion that the SQL string matches.

5. A custom-key filter no longer routes on a guess. categoryOf answers
   todo for anything unloaded, so a saved qa filter paged the todo column
   while the query still restricted status=qa — empty on every cold load,
   permanent if the catalog fetch failed. Unresolved custom keys now
   contribute no column until the catalog is authoritative; facet counts
   drop unknown keys rather than inflating todo.

MUL-6243

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

* fix(issues): swimlane category axis, catalog tri-state, tests with teeth

Third review round.

1. Swimlane was only half-converted. The compound spec sent
   secondary=status_category unconditionally — the same rolling-deploy
   hole the board/list branches had — and the client row matcher then
   compared the raw key, so a qa row the server correctly folded into the
   in_review cell was thrown away again. Both now follow the same
   hasCustomStatuses switch, and the matcher knows which axis it is on.

2. The cold-load fix did not actually make the surface wait. Returning an
   empty column set is indistinguishable from "narrow to nothing", so the
   surface fetched nothing and rendered an empty board with no spinner;
   on catalog failure that was permanent, with no retry. Resolution is
   now pending / resolved / error: pending and error suspend fetching AND
   hold loading, error surfaces isStatusCatalogError. Fetching is
   suspended rather than left to fall back to all columns, which would
   briefly show the UNFILTERED board to someone opening a saved qa view.
   The group contract also joins the branch cursor identity, so a status
   cursor cannot ride into a status_category request.

3. Two tests were false-green and are replaced:
   - The reorder race archived BEFORE calling reorder, so the active-set
     check caught it even with the lock deleted. It now reproduces the
     real interleaving (reorder parked on the lock, archive commits, then
     reorder proceeds) and fails without the shared lock — with a 200,
     which is the actual hole.
   - The catalog read count read pg_stat_user_tables, whose counters flush
     asynchronously, and asserted delta <= 1 — vacuous at zero. It now
     counts through an injected querier and asserts exactly 1 read and 0
     category-expansion reads.

Also: plain status grouping 500'd on a workspace holding custom statuses
(the descriptor rejected non-built-in values and failed the whole
response) — that is the path the Table view and every client fallback
use. And the chat mention items dropped status_category, so a custom done
status in Current/Recent rendered as an active Todo.

MUL-6243

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

* fix(issues): give the status catalog failure a real user path

Fourth review round. One group, both halves of it.

A failed status catalog suspends row fetching, because a custom status
filter cannot be routed to a column without it. That left isLoading and
isEmpty both false, so IssueSurface fell straight through to the content
branch and rendered an empty view with no explanation and no way out —
the controller's isStatusCatalogError had no consumer.

The catalog now carries its own `retry` (React Query's refetch) rather
than just a failure flag, the controller re-exposes it, and IssueSurface
renders an explicit error state ahead of loading/empty/content. Component
test covers the full loop: catalog rejects, error appears with no row
requests issued, clicking retry succeeds, fetching resumes.

The blocking condition is also narrowed. A BACKGROUND refetch can fail
while the last successful catalog is still cached, and error-before-loaded
meant that perfectly serviceable snapshot got a retry screen in front of
it. Only a failure with no data behind it blocks now; otherwise the last
successful catalog keeps resolving.

That rule lives in status-behavior.test.ts, where it can be asserted
directly. The component test asserts the wiring — it was originally
written to claim the rule too, but reverting the narrowing did not fail
it, so the claim moved to the layer that actually holds it.

MUL-6243

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

* fix(issues): accept custom status keys in table filters.statuses

Filtering a board, list or table by a custom status 400'd the whole
request: `filters.statuses` carries the exact keys the user picked, and
the table query still validated that list against the 7 built-ins. Every
surface errored out instead of narrowing — the one thing the filter work
in this PR exists to do.

The predicate is a parameterized `status = ANY(...)`, so an unknown key
simply matches nothing; the check is now non-empty and length-bounded.

Found while writing the post-deploy verification steps, which is where
the gap became obvious: "filter by QA" was step one and it 400s.

MUL-6243

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 18:52:11 +08:00
5f4ba41d13 MUL-6323 fix(views): stop blaming the daemon when the backend is the stale half (#7126)
* fix(views): stop blaming the daemon when the backend is the stale half (MUL-6323)

A self-hosted user on macOS Desktop could not pick worktree mode, and the
dialog explained why: "the Multica on that machine is v0.4.28; parallel needs
0.4.24 or newer". v0.4.28 was the newest release at the time. There was no
version left to upgrade to (#7113).

Nothing has gated on a version since MUL-5707 moved all three gates onto the
declared local-worktree-v1 capability. The floor survived only as the number
this copy interpolated, so every capability miss — whatever caused it — was
reported as a version shortfall the user had already cleared.

Underneath was a second problem the copy could not express. The capability is
recorded by the SERVER at registration, and servers before v0.4.25 write
runtime metadata with no capabilities key at all. Desktop ships its own
renderer AND its own daemon and updates both together, so the client routinely
runs ahead of a backend the operator upgrades by hand: the daemon advertises
the capability, the old backend drops it, and the UI blames the machine that
was already newest. Upgrading it changes nothing.

So the gate now answers with LocalWorktreeSupport instead of a boolean, and
the discriminator is the PRESENCE of the capabilities key rather than its
contents — a capability-aware server always writes it, null when the daemon
sent no header, so a row without it can only have come from an older server.
The two negatives get their own copy pointing at the half that can actually be
fixed, and no message quotes a daemon version any more; the server's own 422
was rewritten the same way in #6904.

Also records capabilities on runtime-profile registration failure rows. Those
are written after the successful runtimes of the same request, so until the
first heartbeat they are the newest row for the daemon — which is the row
every worktree gate reads. A failed custom profile briefly made a capable
daemon look incapable, and would now have made a current backend look stale.

Removes localWorktreeSupported and MIN_LOCAL_WORKTREE_CLI_VERSION from the
frontend: the first had no callers left, the second only fed the copy this
change deletes.

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

* fix(views): ask the live backend whether it records capabilities (MUL-6323)

Review follow-up. The first pass read a missing `capabilities` key as "this
backend predates the handshake", but the key's absence only proves the server
was old WHEN THE ROW WAS WRITTEN. Upgrading the backend does not rewrite
runtime metadata and heartbeats touch only last_seen_at, so an upgraded
deployment would keep being told it was too old — and upgrading it again would
change nothing. The daemon does not re-register on a timer, so that state can
sit there indefinitely.

The question is now asked of the server actually in front of us, via
/api/config's server_version, and the stale-row case gets its own answer: both
halves are fine, the machine just has to register again. An ABSENT version
reads as capability-aware, which is the load-bearing default — /api/config
omits the field on the managed cloud by design and leaves it empty for
unstamped dev builds, while the field itself shipped in v0.4.2, 23 patches
before the handshake. So every self-hosted release that could genuinely be
blind still names itself, and cloud users are never told to upgrade a backend
they do not run.

Replaces the cross-side version-floor test rather than deleting the contract.
It pinned MIN_LOCAL_WORKTREE_CLI_VERSION against a frontend twin that this
branch removed, and the number it guarded is one the UI no longer shows. What
the two sides must still agree on is the capability token: the daemon
advertises that exact string, the server stores it, the UI looks for it, and a
typo on either side disables worktree mode for everyone with no error anywhere.

Also corrects a comment that claimed no version numbers appear in this copy —
the backend floor does, deliberately, because it is what an operator upgrades
past.

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

* fix(views): stop assuming an unnamed backend records capabilities (MUL-6323)

Review follow-up. Reading an absent server_version as "capability-aware" was
right for two of its three sources and wrong for the third: a self-hosted
server older than v0.4.2 — where the field itself shipped — reports no version
AND predates the handshake, so it records no capabilities either. Such a
deployment got told to restart Multica on the machine, which re-registered
against the same blind server and wrote the same key-less row. A loop whose
only real exit, upgrading the backend, the copy never mentioned.

So awareness is three-valued now, and an unnamed server is resolved from
evidence rather than assumption: if ANY runtime row in the workspace carries a
capabilities key, some capability-aware server wrote it and servers move
forward, so the deployment is aware whoever the row belongs to. That settles
the managed-cloud shape — no version reported, several machines, one of them
registered before the handshake — without a version to read. The converse is
never inferred; every row missing the key is equally what an old self-hosted
server and a one-machine workspace look like.

When nothing identifies the backend the UI says so and names both remedies,
still failing closed. Guessing wrong in either direction sends someone to
upgrade a backend they do not run, or to restart a machine that was never the
problem.

Also rewrites the re-register copy, which explained itself in the vocabulary of
the handshake rather than the user's.

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

* refactor(views): let the server answer whether a machine can run worktree (MUL-6323)

The client-side gate kept growing because it was trying to predict an answer
the server already gives. Each round added a state: capability present or
absent, then the backend's own version to interpret the absence, then evidence
from other rows to interpret an unnamed backend. Three layers of inference over
data the backend wrote, to avoid asking the backend.

So it asks. The UI no longer blocks worktree mode on capability at all — only
on `not_git`, which is local, certain and already checked by the picker. The
mode stays selectable, the save is validated server-side, and the rejection
lands in the dialog's existing errorMessage slot with the wording #6904 wrote
for it. That deletes the awareness tri-state, the workspace-wide evidence scan,
the server-version plumbing, three UI reasons and four copy strings in four
languages.

This also fixes the reported case outright rather than explaining it: a
self-hosted backend older than the handshake runs the version gate it shipped
with, which accepts a v0.4.28 daemon, so the user simply gets worktree mode
instead of an instruction to upgrade something.

The capability signal survives for one job — deciding whether to PRESELECT
parallel mode — where being wrong costs a radio button rather than a blocked
user. A machine that has not advertised is not preselected into a mode whose
rejection would fail the whole project creation, but it stays selectable,
because an old row is not proof the machine cannot.

For any of that to be true the bundled create path had to actually ask: POST
/api/projects attaches resources in one transaction and never ran the worktree
gate that POST/PUT /resources have always run, so it would happily store a mode
the machine cannot run and leave the claim gate to cancel the task later. It
runs the same gate now, before the transaction opens.

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

* fix: refuse worktree mode until the server says it will honour it (MUL-6323)

Review caught a safety regression in the previous commit, and it is the worse
half of this issue rather than a variant of it.

Deferring to the server assumed the server would object. Releases before
v0.4.25 do not: `validateLocalDirectoryRef` unmarshals the ref into a struct
with no ExecutionMode field and re-marshals it, so `execution_mode: "worktree"`
is silently DROPPED and answered 201. The daemon then reads a ref with no mode,
treats that as in_place by design, and edits the working copy the user asked it
to stay out of. No save gate, no claim gate, no error — on the exact
new-Desktop-old-backend pairing this issue is about. My earlier claim that such
a server would run its own version gate was wrong: worktree shipped in v0.4.25,
so v0.4.24 has no concept of the mode at all.

The client cannot tell that success from a real one, so it asks first.
/api/config now carries local_worktree_supported, a property of the build: if
this code is running, the save gate is running with it. Absent — which is what
every too-old server sends — blocks the mode with copy naming the server, and
the schema parses the missing key to false rather than undefined so a consumer
cannot forget to coerce it.

That keeps the simplification: one declared boolean from the live server, no
inference over rows or version strings, and per-machine capability still left
to the server's save-time gate.

Also clears review nits: a comment still naming the removed localWorktreeSupport
and stray blank runs in two test files.

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

* fix(views): rename a local directory without resending its ref (MUL-6323)

Review follow-up, and the same failure through a door the last commit left
open. The client now refuses worktree mode when the server cannot promise to
honour it — but renaming an EXISTING worktree resource sent the whole
resource_ref back, capability check or not.

The server replaces the ref with whatever it can parse. On a backend rolled
back below v0.4.25 — documented as supported while the runtimes stay current —
that means execution_mode is dropped and answered 200, and the next task edits
the working copy the resource asked to isolate. The user changed a display
name; nothing told them isolation went with it.

So rename stops sending the ref at all. The update endpoint has always treated
an omitted resource_ref as "keep the stored one", including in v0.4.24, so the
mode survives on every server version and no capability check is needed for a
rename. It also closes the general shape of this: any unrelated edit that
resends a ref gets it re-normalised by whatever the server understands today.

The label therefore moves to the top-level column, which is what the API
already offered for exactly this, and display now reads that first and the
ref's copy second — so rows created before this keep the name they were given
until someone renames them.

Also corrects wording review flagged as misleading: the config flag is absent
on every server that predates the SIGNAL, not only on those that predate the
fix. Releases between the two do gate the save but cannot say so, and are
treated as unable — the client cannot tell them apart, and only one of the two
guesses is safe.

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

* fix(server): converge the two homes of a local directory's display name (MUL-6323)

Review follow-up. Renaming stopped resending the ref so old servers cannot
strip execution_mode — but that left the name with two writable homes: the
top-level label column (what newer clients write) and the copy inside
resource_ref (what desktop <= v0.4.28 both reads first and writes renames
into). One row could show different names on different devices, permanently,
and clearing a name resurrected the ref copy.

The server is the only writer that sees both client generations, so it now
converges the copies on every local_directory write:

- a label-only rename patches the ref's copy too — as a raw-map edit around
  the other keys, so execution_mode and fields written by a NEWER server
  survive byte-for-byte (re-marshaling through this binary's struct is the
  exact silent-drop this PR exists to close);
- a resent ref whose embedded label CHANGED is the legacy rename shape, and
  the column follows it; an unchanged embedded label (the mode dialog spreads
  the stored ref back) never overwrites a column rename — the write heals the
  row toward the column instead;
- clearing removes both copies, while a column that was merely never set
  keeps its hands off the ref: for rows created by older clients the ref
  copy IS the name.

The display-order helper moves to its own pure module shared by the resources
section and the chat hint (review nit), with the read-order matrix pinned in a
node-environment test beside it.

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

* fix(server): tell a rename apart from a ref that went stale (MUL-6323)

Review follow-up. Convergence read "the ref arrived with a different label" as
"an old client renamed this", but that is also what a stale snapshot looks
like. The execution-mode dialog captures the whole ref when it OPENS and sends
it back on save, so: device A opens the dialog, device B renames the folder,
device A saves a mode — and the server writes B's rename back to the old name,
from a dialog that has nothing to do with naming.

Only a ref whose SOLE difference is its label counts as a rename now. Anything
else is an edit carrying a name it happens to have been holding, so the row
keeps whatever it is called today — in both homes, which also heals the stale
copy the request brought. Compared as decoded values, and unknown keys count:
a difference this binary cannot interpret is still a difference, and reading
such a request as a pure rename would be a guess.

The same classification fixes a second complaint from the other direction. An
old client's rename resends the ref, so the worktree capability gate refused it
whenever the machine's runtime registration had drifted — failing an edit that
changes nothing about what runs, which is exactly what that gate's comment
already promised label-only updates would be spared. A rename skips it; the row
already says worktree, and the claim gate is what keeps it from running
somewhere that cannot.

TestProjectResourceUpdateLifecycle pinned the old reading for a request that
changes path, daemon and label at once. No client sends that shape — renames
change only the label, the mode dialog changes only the mode — and honouring an
embedded label there is indistinguishable from honouring a stale one, so it now
pins the conservative answer. Callers that mean to rename have the top-level
label field.

Also softens a claim review flagged as overstated: the label patch preserves
every unknown key and value, not the raw bytes, which JSONB does not promise
either.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 18:49:01 +08:00
Bohan Jiang 4d5a679b9b fix(openclaw): unblock the OpenClaw runtime on slow hosts (MUL-6321) (#7142)
Fixes #7112.

Task preparation ran `openclaw config file` and `config get agents.list --json` with a hardcoded 5s deadline; on a 2020 Intel MacBook Pro those take 8.4-10.9s and 4.3s every time, so the OpenClaw runtime was unusable there with no way to raise the limit.

- Deadline 5s -> 30s, overridable via MULTICA_OPENCLAW_CLI_TIMEOUT / backends.openclaw.cli_timeout, clamped to [1s, 60s] so the worst case (4 serial calls) stays inside the 5-minute preparation budget.
- Config discovery cached per daemon profile (on disk, since preparation runs in a one-shot helper process), invalidated by binary identity, OpenClaw path env, active config stat, and a 60s TTL. Only complete successes are cached; any cache fault degrades to a live CLI run.
- The timeout is classified structurally into a new platform-side, non-retryable reason (runtime_cli_timeout) instead of being text-matched into agent_error.provider_network, which showed "check your network" copy and auto-retried a deterministic local failure. Copy added for web/desktop/mobile; a NormalizeDaemonReason shim upgrades reports from older daemons.
2026-08-18 18:33:23 +08:00
a1cbd7c2ef MUL-6354: tighten build and generation guardrails (#7150)
* chore(build): tighten repository hygiene guardrails

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

* chore(ci): remove snapshot ignore check

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

* chore(ci): address repository hygiene review nits

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 18:32:19 +08:00
afd05ca221 MUL-6063: recover terminal delegated task failures (#6844)
* fix: recover terminal delegated task failures

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

* fix(tasks): make delegated failure recovery durable

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

* fix(tasks): bound delegated failure recovery

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

* fix(tasks): address delegated recovery review nits

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Sol-Boy <[email protected]>
2026-08-18 15:36:10 +08:00
c175530284 MUL-5879: fix(agent): surface every in-flight built-in Hermes tool call (#7127)
* fix(agent): emit ACP tool use when the start frame carries the command (MUL-5879)

Hermes' tool_call start frame carries the whole invocation in content but
no rawInput, which fell through to the kimi deferral path: nothing was
emitted until tool_call_update completed. An in-flight Hermes tool was
therefore invisible in run-messages, so a stalled tool rendered as a
blank run with no visible tool call at all (GH#6583). It also kept the
daemon's in-flight tool counter at zero, since that only advances on
MessageToolUse, so Hermes tool calls were never charged to
AgentToolWatchdog and were judged by the much shorter AgentIdleWatchdog.

Emit at the start frame when its content already holds complete args.
The discriminator is the shape of the text rather than the backend,
because nine ACP backends share this client: only an unterminated JSON
object or array can be a mid-stream kimi fragment. Rendering is
unchanged - the same text produces the same Input map the deferred path
built at completion - only the timing moves.

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

* fix(agent): narrow early ACP tool-use emit to the terminal invocation

Review of the first commit was right: content-shape was the wrong
discriminator. In ACP, `content` is display output produced by the tool
call and `rawInput` is the input, and a later update may still supply the
real rawInput - so treating any complete non-JSON content as the
invocation mis-records it and freezes the wrong value.

Hermes makes this concrete: it suppresses rawInput for its whole polished
tool set and renders each one differently. `terminal` renders
`$ <command>` (the invocation), but `write_file` and `patch` render
prose like "Preparing write to <path>", and the browser/web/media tools
render their own summaries. Only the terminal rendering is the command.

Recognise exactly that shape and defer everything else, so the nine
backends sharing this client keep their previous behaviour. Also let a
rawInput arriving on the update win over buffered start-frame text, which
is what makes the deferred path record the real input rather than the
rendering.

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

* fix(agent): surface every in-flight Hermes tool, not just terminal

Second review was right that the previous commit only fixed the terminal
subset. Hermes omits rawInput for its whole polished tool set (~40 tools:
write_file, patch, read_file, execute_code, web_*, browser_*, ...), so
those calls still stayed invisible while running and still never advanced
the daemon's in-flight tool counter - leaving long browser/code runs on
the 30-minute idle budget instead of the 2-hour tool budget.

Gate the early emit on a dialect flag instead of the frame's content.
Hermes sets it because its start frame is the only place input ever
appears: build_tool_call attaches rawInput for ordinary tools and omits
it for polished ones, and build_tool_complete never sends rawInput at
all, so waiting cannot yield more input. The other eight backends sharing
this client leave the flag false and keep deferring - Kimi genuinely does
stream its args across updates.

Content is still not passed off as input: only the terminal command
rendering becomes Input, and every other polished tool reports none.

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

* fix(agent): scope the Hermes tool-start dialect to the built-in binary

The hermes backend also serves custom runtime profiles declaring
protocol_family: hermes (jcode is the documented example), so switching
toolStartCarriesFinalInput on unconditionally applied a Hermes-Agent-only
observation to unrelated implementations. A custom runtime whose start
frame has no rawInput and whose later update supplies the real one would
have had Input=nil recorded at start with emitted=true, and
emitDeferredToolUse would then drop the real input on completion.

Scope it to Config.BuiltinRuntime, the same signal
acpToleratesOmittedMcpCapabilities already uses to fence a
vendor-verified exception, so an unset caller fails closed onto the
deferred path.

Cover it at the construction layer rather than with a hand-built client,
since the leak was in how the client is built: one turn driven through
hermesBackend.Execute against an ACP stub whose start frame carries
display text only and whose completion frame carries the real rawInput,
asserted both ways round.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 15:35:45 +08:00
22db0b0a2c MUL-5651: preserve tasks through runtime network partitions (#7129)
* fix(runtime): preserve tasks through network partitions

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

* fix(runtime): bound reconnect retry waiting

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 15:30:48 +08:00
XuSt 0b9dcc6799 fix(agent): preserve Pi RPC stdin on Windows (#7111)
Windows Pi model discovery reused the task-execution launcher rewrite to
`powershell -File pi.ps1`. That path suits normal JSON/task execution, where the
prompt is written to stdin and stdin is then closed. Pi RPC model discovery
instead needs a bidirectional session and sends requests while keeping stdin
open, so npm PowerShell wrappers that wait for stdin EOF made discovery time out
and fall back to the legacy model table without thinking metadata.

- Route Windows Pi RPC invocations through `powershell -Command "& '<pi.ps1>' @args"`
  so the wrapper receives forwarded args while stdin stays live.
- Keep ordinary Pi task execution on the existing `-File` path.
- Add a Windows launcher argv regression test plus an end-to-end test that drives
  a real PowerShell host and asserts the RPC responses arrive before stdin EOF,
  verified on both Windows PowerShell 5.1 and pwsh in `windows-execenv`.

Fixes #7108
2026-08-18 14:46:08 +08:00
de902a4aab MUL-6273: feat(agent): add MiniMax Code ACP runtime (#7035)
* feat(agent): add MiniMax Code ACP runtime

* fix(agent): terminate mcode process trees

* fix(agent): address mcode review blockers

* test(agent): keep mcode model contract API-stable

* fix(migrations): resolve mcode prefix collision

* fix(agent): launch mcode through runtime command

Assisted-by: MiniMax Code

* fix(migrations): renumber mcode migration

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 12:44:27 +08:00
14c2e4e831 MUL-6243 feat(issue-status): board fetches by category; archive retires without migration (#7084)
* feat(issue-status): board fetches by category; archive retires without migration (MUL-6243)

Product decisions from the issue thread:

1. Board fetches by CATEGORY, not per status. PAGINATED_STATUSES becomes
   PAGINATED_CATEGORIES and the cache buckets by category, so the board's
   fan-out stays fixed at 7 parallel requests however many custom statuses a
   workspace defines — instead of one more request per status added. Adds
   status_category / status_categories filters to the issue list endpoints,
   backed by the existing issue_effective_status() SQL function.

3. Archiving retires a status from FUTURE use only. It no longer refuses while
   issues still carry it: those issues keep their status and keep resolving to
   their category's behavior (Effective ignores archived_at), while Resolve
   still rejects the status so nothing new can be assigned to it. The exclusive
   catalog lock stays — it is what makes "no NEW assignment after archive"
   exact rather than approximate.

Also lands the frontend catalog foundation: packages/core/issue-statuses with
useIssueStatuses(wsId) (React Query, keyed by wsId per the state rules) and a
pure buildIssueStatusCatalog for non-React callers, plus IssueStatusCategory as
a distinct type from the status key and status_category on issue payloads.

NOT included, and deliberately: widening IssueStatus to string. That change
forces every Record<IssueStatus, …> presentation lookup in packages/views to
resolve a key to its category first, and it is the first task of the follow-up
PR. Everything here is the groundwork that makes it mechanical.

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

* fix(issue-status): restore the status index, make category authoritative, keep archived resolvable

Review round 1 on #7084 (MUL-6243):

1. Category filtering wrapped issue.status in issue_effective_status(), which
   makes the (workspace_id, status) index unusable — and the board switched to
   it unconditionally, so every workspace paid for it immediately. Categories
   are now expanded to concrete status keys (issuestatus.ExpandCategories) and
   filtered with an indexed status = ANY(...). Measured on a 100k-row table
   with the production index: seq scan cost 27013 -> index-only scan cost 6.07.
   A workspace with no custom statuses expands each category to exactly its own
   key, reproducing the pre-feature query. Archived keys are included, since
   issues left on them still belong in their category's column.

2. status_category was only filled for built-ins, so a custom-status issue
   arriving over the websocket was silently dropped by addIssueToBuckets and a
   cross-category update was skipped by the cache coordinator, drifting
   off-window totals permanently. List, grouped, get, create, update and batch
   responses now carry an authoritative category via a per-request Resolver
   (archived included), and the client invalidates instead of no-oping when a
   category is unresolvable.

3. The catalog was fetched without archived entries, so after archiving a
   status the issues still on it lost their name and colour and had their
   category guessed as todo — contradicting the decision that archiving leaves
   existing issues untouched. Resolution now includes archived entries;
   activeStatuses / inCategory expose only assignable ones to pickers.

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

* fix(issue-status): category on every payload exit, one resolver per request, no same-category duplication

Review round 2 on #7084 (MUL-6243):

1. Category fill was incomplete and N+1. The create WEBSOCKET payload was
   unfilled — only the creator's HTTP response was, which is too late for the
   other tabs the event exists to serve. GetIssue, the open_only list, search,
   the duplicate-conflict response, the table rows, the GitHub PR update and
   the onboarding shim were also unfilled. Grouped and batch called the
   single-response helper per row, so each custom-status row hit the catalog
   separately.

   Every exit now goes through newStatusCategoryFiller, a request-scoped
   closure over ONE Resolver: built-in statuses still cost no query, and a page
   of custom-status rows costs one catalog read rather than one per row.

2. A status KEY change within one category duplicated the card and inflated the
   total. patchIssueInBuckets compared patch.status (a key) against loc.status
   (a bucket CATEGORY), so in_review -> a custom human_review took the
   cross-bucket path, then deleted from and re-inserted into the same bucket
   from a pre-delete snapshot. It now resolves the destination category first
   and compares category to category; same-category changes replace in place
   and leave the total alone.

   The destination category is resolved from the patch alone — the merged issue
   inherits the previous status_category, which would have kept a genuinely
   moved card in its old column. patchNeedsInvalidation lets the loaded branch
   of the cache coordinator invalidate instead of silently no-oping on a status
   it cannot resolve.

Also drops the unrelated share_link.sql.go churn from an sqlc version
difference, per the review nit.

The four cache cases were verified to fail against the previous logic (card
duplicated, total +1) before the fix was restored.

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

* fix(issue-status): normalize status_category on every cache write; one filler per create

Review round 3 on #7084 (MUL-6243):

Must-fix — a cached entity kept the STALE status_category after a status
change. The server now sends a category on every issue, so a cached entity is
{status: "todo", status_category: "todo"}; an optimistic patch carries only
{status: "done"}, and a bare {...issue, ...patch} moved the card to the done
bucket while the entity still claimed todo. A single update self-heals from the
server response, but the batch API returns only {updated: n} and does not
refetch bucketed lists, so the inconsistency was permanent — and the next
off-window count then decremented the wrong bucket, recreating total drift.

normalizeStatusPatch now rewrites status_category to match status before any
cache write, applied at applyIssueChange's entry (covering the flat, table and
detail spreads), in patchIssueInBuckets, and in the children mutation. An
unresolvable custom key drops the inherited category rather than keeping it;
patchNeedsInvalidation still routes it to a refetch.

Test fixtures now carry status_category for built-ins, matching what the server
actually sends — without that the regression was invisible.

Non-blocking items also addressed:
- CreateIssue builds one filler and shares it between the broadcast payload and
  the HTTP response, so a custom-status create reads the catalog once.
- Background events via IssueToMap now resolve an authoritative category
  (IssueToMapWithCategory) for the task, issue and autopilot publishers. The
  channel router keeps plain IssueToMap and says why: it always creates with the
  built-in "todo", where key IS category.

The "one resolver serves a whole page" assertion was REMOVED rather than kept:
pg_stat counters lag, so a delta around one request passes even with a per-row
filler. Verified that directly before dropping it. The comment now states what
is and is not pinned instead of implying coverage that does not exist.

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

* fix(issue-status): address review nits

- Normalize the single-update children optimistic patch too, matching the batch
  path. It self-healed on onSettled, but it broke the entity/bucket invariant
  for the duration of the request.
- Rename TestListEndpointsCarryStatusCategory: the old name claimed it covered
  "without per row queries", which the test explicitly does not assert.
- Drop the unrelated gofmt churn in empty_claim_cache.go.
- Add a background-event assertion: IssueToMap leaves a custom status
  uncategorized, IssueToMapWithCategory resolves it — the reason the publishers
  use the latter.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 03:00:21 +08:00
446080bd57 MUL-6243 feat(issue-status): per-workspace custom issue statuses over 7 canonical categories (#7065)
* feat(issue-status): per-workspace custom issue statuses over 7 canonical categories (MUL-6243)

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

* fix(issue-status): address review — normalize all status consumers, close archive race, gate rollout, split PK index

Review round 1 (MUL-6243 / PR #7065):

1. Behavior normalization was incomplete. Adds issue_effective_status() as a
   SQL mirror of issuestatus.Effective so the six SQL consumers the Go resolver
   cannot reach (duplicate guard, open-issue list, sub-issue progress, project
   stats, completed-inbox archive, open_only) resolve categories too. Wires the
   resolver into the task-failure sweeper and the stage-barrier helpers.

2. Archive TOCTOU. Archive now runs its in-use census and the archive itself in
   one transaction under an exclusive catalog lock; writes that target a custom
   status take the shared side. Built-in writes stay lock-free — a built-in can
   never be archived.

3. Rollout safety. Creating a custom status is gated behind the default-off
   CustomIssueStatuses flag, so a value old pods cannot interpret cannot exist
   until the fleet is updated. Readers ship ungated and are safe.

4. Primary key index. Follows the repo convention (327-329): table without a
   PK, unique index built CONCURRENTLY in its own migration, attached via
   PRIMARY KEY USING INDEX, and registered for invalid-index cleanup.

5. Status normalization vs stored value. Write paths now persist the canonical
   key from Resolve, so 'HUMAN_REVIEW' no longer validates and then fails the
   column format constraint as a 500.

Also fixes the two CLI tests failing in CI: they asserted the pre-MUL-6243
contract that an unknown-but-well-formed status is rejected locally.

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

* fix(issue-status): close the archive race in both orderings; resolve categories in batch barrier and CLI progress

Review round 2 (MUL-6243 / PR #7065):

1. The archive race was only half closed. Handlers resolved the status BEFORE
   opening a transaction, so an archive could commit in that window and the
   write would still land on an archived key. assertIssueStatusStillActive now
   takes the shared catalog lock and RE-RESOLVES inside the caller's
   transaction, which is what actually makes the status provably active at
   write time. Applied to create, update, batch, and both description-merge
   branches, which previously bypassed the guard entirely. A losing writer gets
   409 rather than a silent partial update.

2. The batch stage-barrier prefilter still compared literals, so a batch that
   moved the last child onto a custom done status left childDoneCompleted empty
   and skipped the parent notification. It now resolves categories first.

3. multica issue children --output json counted only literal done/cancelled.
   ListChildIssues now emits status_category and the CLI reads it, falling back
   to status for older backends.

TestArchiveCommitsInsideTheWriteRaceWindow interleaves the two operations so the
writer validates against an active status and the archive commits before the
write. Verified it fails without the re-resolve (issue stranded on the archived
status) and passes with it.

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

* refactor(issue-status): address review nits — honest race-test naming, one catalog read per list

Review round 3 nits (MUL-6243 / PR #7065):

1. TestArchiveRaceBothOrderings claimed more than it proved. Its 'archive wins'
   cases archived first and only then sent the request, which the pre-flight
   check alone rejects. Split into TestWritesRejectAnArchivedStatus (the
   sequential case, named for what it is) and TestArchiveRefusesAfterACommitted
   Write, and extended TestArchiveCommitsInsideTheWriteRaceWindow — the only
   test that truly interleaves archive between pre-flight and write — from
   create alone to all five write endpoints. Re-verified by deleting the
   in-transaction re-resolve: 4 of 5 fail with issues stranded on the archived
   status (create is guarded in the service layer and checked separately).

2. ListChildIssues / ListChildrenByParents issued one category lookup per
   custom-status child. issuestatus.Resolver now amortizes that to a single
   catalog read per request, lazily on the first custom key, so built-in
   statuses still cost zero queries however long the list is.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-17 18:32:02 +08:00
adaianddamon.zhou 4383497898 fix(agent): fail a Pi task when Pi ends the turn on an error without retrying (#7059)
Pi reports a provider call it could not complete by ending the turn with
stopReason=error and the provider text in errorMessage. When it also
decides the error is not worth retrying — 401, 429 for an exhausted
quota — it emits neither a standalone error event nor auto_retry_end,
and exits 0. Those two events were the only failure signals the adapter
recognized, and piMessage discarded stopReason at decode time, so the
run was reported completed with empty output and no error. The task was
never retried and nothing alerted; a production task closed in 6s while
the gateway was returning 429.

Decode the two fields and remember the error of the turn that just
ended. Pi emits the same stopReason before an automatic retry, so this
is cleared on turn_start and only a trailing failed turn survives to
fail the run — a retry that recovers still reports completed.

Co-authored-by: damon.zhou <[email protected]>
2026-08-17 16:25:22 +08:00
mxbao063-bao a17857e638 fix(agent): parse tab-separated Antigravity model catalog (#6654)
Fixes #6562. Related Multica issue: MUL-5856.
2026-08-17 16:18:04 +08:00