Commit Graph
421 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
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
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
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
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
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
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
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
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
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
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
patrickstar179 8cf2acbaa7 feat(share-link): workspace share-link invite system (#6833)
Adds workspace share-link invites: owner/admin can create a link with a target role (member/admin) and expiry, share it, and revoke it; invitees see the workspace and inviter before joining.

MUL-6070
Co-authored-by: patrickstar179
2026-08-17 14:39:32 +08:00
4d495056e8 [MUL-6139] Expand Plugin skills and hosted MCP connections (#6920)
* feat(plugins): add remote MCP runtime

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

* chore(plugins): add private Exa example

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

* docs(plugins): remove private plugin guide

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

* fix(plugins): stabilize remote MCP tool digests

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

* fix(plugins): preserve remote MCP notification status

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

* fix(plugins): make Exa example runnable end to end

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

* fix(codex): record MCP tool calls in agent log

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

* chore(plugins): add community skills example

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

* feat(plugins): support multi-file skill bundles

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

* feat(plugins): add Mobbin MCP example

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

* chore(plugins): bundle full Matt Pocock skill set

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

* feat(plugins): add remote MCP OAuth connections

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

* fix(plugins): gate enable on remote MCP readiness

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

* fix(plugin): authenticate remote mcp credential broker

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

* fix(plugins): harden remote mcp oauth flow

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

* refactor(plugins): use a single feature flag

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

* fix(plugins): clean up remote MCP OAuth state

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

* chore(plugins): remove Matt Pocock example

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-16 04:54:32 +08:00
2c0912b6ec MUL-5421: make workspace MCP servers an explicit per-agent assignment (#6976)
* feat: make workspace MCP servers an explicit per-agent assignment

#6961 shipped workspace MCP as a shared document every agent inherited
automatically. That is the wrong shape: a workspace MCP server should behave
like a workspace skill — a library entry that reaches an agent only when
someone gives it to that agent, with a per-agent on/off toggle. Creating one
now gives it to nobody, so nothing propagates implicitly.

Model (mirrors `skill` + `agent_skill`):

  - `workspace_mcp_server` — the library, keyed by id so renaming is safe
  - `agent_mcp_server` — the assignment, carrying its own `enabled` toggle
  - the agent's own `mcp_config` stays what it was: servers private to it

At claim time the effective set is runtime servers < (assigned AND enabled
workspace servers + the agent's own), with the agent's own entry winning a name
collision. 318 drops `workspace.mcp_config`: leaving it would keep a second,
silently inherited source alive. No data migration — converting each workspace
document into library entries would ASSIGN them to every agent, which is the
behaviour being removed, and the column shipped in the same unreleased cycle.

The write-only secret contract, the legacy-container normalization, and the
transport guards from the previous round are all preserved; the resolver is
re-pointed at the assignment list rather than a workspace document.

UI: workspace Settings → MCP is now a library (adding says so, and renaming is
allowed because assignments key off the id); the agent's MCP tab replaces the
auto "Inherited from workspace" list with the servers actually assigned to it,
a picker to add more from the library, and a per-assignment toggle.

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

* fix: fence the MCP writes, stop laundering unknown transports, gate on canEdit

Three findings from review:

1. Neither new table carries a foreign key (repo rule), so nothing in the
   database catches a write landing after the row it depends on is gone. Create
   and assign now run in a transaction that joins the workspace teardown fence
   (#5219), and assign additionally holds the target server FOR SHARE while
   DeleteWorkspaceMcpServer holds it FOR UPDATE — so an assignment either lands
   before the delete and is swept, or waits and finds the server gone. Driven
   by two two-transaction race tests, verified to fail without the locks.

2. `mcpTransportOf` classified `{"type":"websocket","url":"wss://…"}` as http,
   because an unrecognised type fell through to the url check. That told the
   settings UI the guided form could edit the entry, and saving from it rewrites
   the protocol to `type: "http"` — the failure the client-side guard was added
   to prevent, reintroduced from the server. An explicit type is now returned
   verbatim; inference from command / url happens only when none was declared,
   which is lossless.

3. The agent MCP tab showed the picker, toggles, and remove button to members
   who cannot edit the agent, where every click 403s. It now takes `canEdit`
   like SkillsTab and hides the write affordances, keeping the inventory
   readable.

318 keeps dropping `workspace.mcp_config` with no data migration and no
expand-contract window, now recorded as an explicit maintainer decision (Bohan,
2026-08-14): 314 merged in this same cycle and was never deployed, so there is
no stored document to preserve and no old binary reading the column. The
comment spells out what this file would have to become if that ever stops
holding.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 19:49:37 +08:00
b30a3ae55c MUL-6164 feat(runtime): take an unusable agent CLI offline and refuse its triggers with the fix (#6966)
* fix(daemon): take a runtime offline when its CLI cannot be executed (MUL-6164)

A CLI that cannot exec was classified as a transient probe failure, so the
runtime stayed online and every task it claimed died at fork/exec. The user in
MUL-6164 hit the same error once per task with no signal that the runtime was
the problem — an npm placeholder stub passes exec.LookPath, so nothing else the
daemon checks can tell it apart from a working install.

ENOEXEC (and its Windows spelling, ERROR_BAD_EXE_FORMAT) is a fact about the
bytes on disk, not a hiccup: it will reproduce until someone reinstalls. Give it
its own probe verdict and route it down the track below-minimum already uses —
claim barrier, seq-stamped hold, deregistration — so the provider goes offline
with its reason instead of collecting failed tasks. Recovery needs no new
machinery: a demoted provider has no runtime, which puts it back in
providersMissingRuntimes, and the first healthy probe clears the hold and lets
converge register it again, with no restart.

One thing the below-minimum track did not need: a confirmation window. The
repair we tell users to run overwrites the bin entry in place, so a probe landing
mid-copy sees a truncated file. Demotion therefore requires two sightings a
window apart; a genuinely broken install pays one extra probe round, and a
spurious verdict would in any case recover on its own.

detectBuiltinRuntimes' second return value is now "providers condemned this
round, with the evidence against them" rather than "below-minimum versions" —
same shape, both kinds, and the value is the reason /health already shows.

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

* feat(runtime): refuse triggers for an unusable agent CLI instead of queueing them silently (MUL-6164)

Taking the runtime offline stopped the per-task failures, but it replaced them
with silence: the next @mention or assignment queued as if the machine were
merely asleep, and nothing ran for two hours until queued_expired. Waiting only
makes sense when waiting is a plan — a sleeping laptop comes back on its own; a
CLI that cannot be executed does not.

dispatch/reason.go already draws exactly that line between runtime_offline
("bring the machine back, queued work waits") and agent_runtime_required
("nothing will ever claim this"), and warns that collapsing the two sends users
to fix the wrong thing. This adds the third member of that family,
runtime_unusable, and routes the new verdict to it end to end:

- The daemon reports WHY on deregistration — a stable code plus the repair
  command read from the package's own manifest, since only that side knows the
  path. Stored on agent_runtime.metadata, which registration overwrites
  wholesale, so a repaired runtime drops the explanation structurally.
- AgentReadiness now returns a verdict (available / waitable / blocked) instead
  of a bool plus prose, and the direct-agent trigger paths start consulting it —
  blocking ONLY on the blocked verdict, so an offline machine still queues.
  agentReadinessReasonCode, which re-derived the same code from agent state, is
  gone: one source, no drift.
- Every path that refuses without a human watching the response leaves the
  reason on the issue: an assignment, or another agent's @mention. The
  interactive surfaces already carry the code back to the composer, which now
  localizes it in all four languages.

Below-minimum keeps today's behaviour deliberately. It is equally futile to wait
on, but changing when THAT refuses a trigger is a separate product decision.

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

* fix(runtime): keep the unusable verdict through a late register, and repair Windows (MUL-6164 review)

Two ways the refusal path failed to reach the user.

The verdict could be erased by an older answer. A register whose request was
still in flight across the demotion upserts the runtime row afterwards, and that
upsert overwrites metadata wholesale — including the reason just stored. The
local hold refuses the response correctly, but the cleanup that took the revived
row offline again sent no reason, so the server ended up "offline, cause
unknown". Every admission path reads that as "the machine will come back" and
queues, which is exactly the behaviour this change exists to remove. The
demotion record now keeps the structured cause and the revived-row cleanup
re-attaches it per row. The regression test drives the real interleave — a
register held inside the server handler across the verdict — and fails without
the fix.

The repair command was POSIX-only. Windows had just gained detection
(ERROR_BAD_EXE_FORMAT), so a Windows user was correctly refused and then handed
`cd 'C:\...' && node install.cjs`: cmd.exe treats the quotes literally and
PowerShell 5.1 has no `&&`. The command is now rendered for the shell of the
machine that reported it — Set-Location on two lines for PowerShell, with
PowerShell's own quote escaping — and carries that shell so the notice fences it
truthfully instead of labelling everything bash.

Also corrected the notice's own docs: it is written for every author, not only
unattended ones. The chip and toast carry the reason code but not the repair
command, so a human needs the durable copy too. The behaviour was already this;
the comment claimed otherwise.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 18:56:40 +08:00
736838af86 MUL-5421: workspace-level MCP configuration (resolution contract, write-only API, Settings UI, CLI) (#6961)
* feat(server): resolve a workspace-level MCP config under each agent's

Adds workspace.mcp_config and layers it beneath the agent's own config on
every claim, so a workspace's shared MCP servers no longer have to be
re-entered on each agent (GH #6062).

Resolution contract:
  - agent config absent (NULL/null) -> inherit the workspace document
  - agent declares >=1 server       -> union by server name, agent wins
  - agent declares no server ({})   -> opt out entirely

The last rule is what keeps shared credentials from silently reaching an
agent that was deliberately given none: "managed but empty" already means
"this agent runs with zero MCP" under the three-state contract from #3545,
and it keeps meaning exactly that.

The merged document keeps the AGENT's top-level keys, so a legacy `mcp`
container survives untouched, and a shared server is skipped when the agent
already declares that name under either spelling. On malformed input the
agent's config is returned unchanged: a bad shared document must never take
away servers an agent runs with today.

Read on every claim like agent.mcp_config, so an admin's edit lands on each
agent's next task with nothing to restart.

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

* feat(server): add the workspace MCP configuration API

GET /api/workspaces/{id}/mcp-config is member-visible so an agent's settings
view can tell what it inherits, but the document itself follows the same
visibility rules mcp_config already has on agents: agent actors never read it,
the workspace always-redact setting wins over any role, and otherwise only the
owner/admin who may write it can read it back. The coarse server_count stays
visible either way — it carries no credential material.

PUT is owner/admin and replaces the whole document (so removing a shared
server is expressible), with an explicit null to clear the layer. It is
shape-validated at the edge, unlike the agent column: one bad document here
would otherwise fail-soft on every agent's claim for the whole workspace.
Agent actors are refused even when their PAT satisfies the role gate.

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

* feat(cli): add `multica workspace mcp get|set`

Reuses resolveMcpConfig so the workspace surface agrees with `agent update`
on what a valid document is, on the three mutually exclusive input channels,
and on 'null' meaning clear. Table output prints server names and transports
only — never entry bodies — so a summary stays safe to paste into a ticket;
the JSON default is how an admin reads the document itself.

Documents the new inheritance in the agent-creation built-in skill (an agent
with a private server no longer has to re-list the shared ones, and `{}` is
now the only way to give an agent no MCP at all) and adds the command to the
CLI reference.

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

* fix(server): keep a legacy agent's own MCP servers when the workspace shares some

The resolver wrote shared servers into `mcpServers` and left an agent's legacy
top-level `mcp` entries beside them. The daemon's runtime merge only falls back
to `mcp` when `mcpServers` is ABSENT, so as soon as a workspace shared anything,
an OpenCode agent that stored its servers under the legacy container ran with
the shared set and silently lost every private server of its own.

Fold both containers into the canonical `mcpServers` when merging, and drop the
legacy key so the daemon can never read a second, stale copy. Nothing changes
for an agent whose workspace shares no servers: that path still passes the
agent document through byte-for-byte.

The regression guard runs the real chain — ResolveWorkspaceMcpConfig into the
daemon's mergeRuntimeAndAgentMcpConfig — because the two halves only agree if
the resolver emits the container the merge reads, which is exactly what broke.
Verified it fails against the previous resolver.

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

* feat(server): make the shared MCP configuration write-only

Upstream #6062 asks for shared secrets to be write-only and never echoed back;
the previous read path handed the whole document to owner/admin, which also tied
"audit what is shared" to "read the credentials".

GET now returns only a non-secret inventory — name, transport, enabled — for
every role. Not a redaction: there is no document field and no `_redacted` flag,
because nothing secret is in the payload to gate. A url counts as secret here; a
Composio-style session url is a bearer credential on its own.

That removes the client's ability to read-modify-write, so editing one server
gets its own endpoints: PUT/DELETE /mcp-config/servers/{name} merge server-side
under a row lock (two admins editing different servers can no longer lose one
another's write). Deleting the last server clears the column rather than storing
an empty document, so agents fall back to their own config instead of inheriting
a managed-empty one. The whole-document PUT stays for the declarative CLI path.

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

* feat(cli): add `workspace mcp add|remove`, and stop printing the document

`workspace mcp get` now renders the server inventory in both output formats —
there is no document to print, and the previous JSON default would have written
whatever the endpoint returned straight to a terminal.

Since the document can no longer be read back, `set` (full replace) is not
enough to change one server: `add <name>` and `remove <name>` map to the
per-server endpoints. `add` takes the entry through the same three mutually
exclusive secret-safe channels as `--mcp-config`, generalized here so both
surfaces agree on what a valid payload is; `null` is rejected for a single
entry, where it is far likelier to be a mistake than an intent to clear.

Adds the CLI test coverage this surface was missing: endpoint and body per
command, `null` clear, mutual exclusion, name escaping in the path, and a guard
that a regressed server returning the document cannot get it printed.

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

* docs: correct the workspace MCP contract in the built-in skill and CLI reference

- "`{}` is the only way to opt out" was wrong: any config declaring no servers
  opts out, which is what the implementation and tests do.
- The source map pointed the API row at workspace_mcp.go; the handlers live in
  workspace_mcp_api.go.
- Document the write-only read path, the legacy-container normalization, and the
  new per-server commands.

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

* feat(web): manage workspace MCP servers from Settings

The user journey for this feature is a settings screen, not the CLI. Adds a
workspace Settings → MCP tab that lists the shared servers and adds, replaces,
and removes them one at a time through the per-server endpoints, reusing the
agent tab's McpServerDialog for the form/JSON editor.

Because the stored configuration is write-only, an edit opens an empty form and
REPLACES the entry rather than prefilling it. The screen says so instead of
implying the blank form is the saved state. Members see the inventory (nothing
in it is secret) but no write affordances.

The agent's MCP tab gains the matching "Inherited from workspace" section, with
an Overridden badge on a shared server the agent shadows by name, and an
explicit note when the agent's managed-but-empty config opts it out of the
workspace layer entirely — otherwise the effective server set is impossible to
reason about from the agent's own screen.

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

* fix(client): stop the write-only boundary leaking through the clients

The server never returns the stored document, but neither client enforced that
on its own side, so a regressed (or hostile) backend could still push secrets
into the frontend and onto a terminal:

- the two workspace-MCP zod schemas were `.loose()`, so unknown fields — a
  top-level `mcp_config`, or `url` / `headers` / `env` inside a server entry —
  survived parsing into the app object and the query cache. These are now the
  only schemas in that file that strip: forward-compatibility is the right
  default everywhere else, but here keeping unknown keys is the hole.
- the CLI decoded into `map[string]any` and re-encoded it, so the DEFAULT JSON
  output printed whatever arrived. It now decodes into a named struct, which is
  what makes leaking impossible rather than merely unlikely.

The old "never prints secrets" test forced `--output table` and so missed the
default path entirely. Replaced with a poisoned-response fixture (secrets at the
top level AND inside a server entry) asserted across both output formats and on
the write path; verified it fails against the previous renderer.

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

* fix(web): stop a workspace MCP edit from silently becoming an add

The shared dialog lets the name be edited and this API has no atomic rename, so
renaming `linear` to `linear-v2` upserted a second server, left the original in
place, and still toasted "updated". Editing now pins the name (the dialog gained
a `lockName` prop) and the upsert targets the entry that was opened.

Also seed the edit form's transport from the safe summary: the entry cannot be
read back, and `formFromConfig({})` defaults to HTTP, so editing a known stdio
server opened the wrong form.

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

* fix(web): show the agent's real effective MCP set

Two ways the agent tab was stating things it did not know:

- With `mcp_config_redacted`, the viewer cannot tell an explicit empty document
  (opted out) from one that overrides some names — but the tab listed every
  workspace server as inherited anyway. It now says inheritance cannot be
  determined instead of guessing.
- The runtime list marked a server "overridden" using the agent's names only.
  The real precedence is runtime < workspace < agent, so a shared server hides a
  same-named runtime one too; that was under-reported. Overrides are now computed
  from workspace + agent names, and from the agent's alone when it opted out.

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

* docs: correct the opt-out claim and apply the zh terminology conventions

An agent whose config declares no servers opts out of the WORKSPACE layer; it
does not run with zero MCP. The daemon still merges the runtime's own local
servers under whatever the claim carries — which the daemon test on this branch
already asserts, so only the prose was wrong (skill doc, CLI help, UI copy).

The new zh-Hans strings also used `agent` / `url` / `header` raw; per
conventions.zh.mdx those are 智能体 / URL / 请求头.

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

* fix(i18n): restore the zh-Hans quick-actions strings this branch clobbered

My earlier terminology pass ran a `re.sub` for `description` /
`delete_description` with count=1, which matched the FIRST such keys in the
file — `quick_actions`, not `mcp` — so Quick Actions ended up describing shared
MCP servers and its delete confirmation talked about sharing. A user-visible
regression in an unrelated feature, and one no key/type check can catch because
both values stayed valid strings.

Restored both to their origin/main text. Audited all eight locale files the
branch touches the same way; these two were the only unintended changes.

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

* fix(web): never edit a non-stdio/http MCP server through the rewriting form

The guided form only expresses stdio and http, and saving from it REWRITES the
entry — `configFromForm` emits `type: "http"` for anything non-stdio. The server
classifies and returns `sse`, so editing an SSE server opened the Streamable
HTTP form and saving silently changed its protocol, which can break the server
outright.

Entries the form cannot express now take the JSON path, which round-trips what
is typed, and the Form tab is disabled rather than merely unselected — switching
to it would rewrite them just the same. The rule keys off the saved config when
there is one and off the safe summary's transport when there is not, so it
covers both the workspace tab (write-only, summary only) and the agent tab,
where the same rewrite was already reachable for a saved SSE entry.

Also fixes the garbled `multica workspace mcp get|list-only` in the built-in
skill, which reads as a shell pipe.

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

* fix(web): judge form-editability by the entry's explicit type, not its display bucket

The previous guard asked `mcpTransport(config)` whether the form could express a
saved entry, but that is a lossy DISPLAY classifier: it buckets anything with a
`url` as http regardless of `type`. So `{"type":"websocket","url":"wss://…"}`
still looked form-editable and `configFromForm` rewrote it to `type: "http"` on
save — the very rewrite the guard exists to prevent. SSE happened to be caught
only because the classifier has a dedicated branch for it.

Saved entries are now judged on the explicit `type`, allowlisting exactly what
`configFromForm` writes back unchanged (local / stdio / remote / http /
streamable-http), with the no-type case still inferred from command / url. The
summary path keeps using the transport string, which the server emits from a
closed set.

Tests cover both unknown-type spellings on the agent side and pin the common
shapes to the form — this is a guard against rewriting entries, not a general
retreat to the JSON editor. Verified the websocket case fails against the
previous check while sse passed, which is why the earlier test missed it.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 18:28:26 +08:00
b5a48eca13 fix(agent tasks): prevent duplicate self-assignment work (MUL-6168) (#6956)
* fix(agent tasks): avoid duplicate self-assignment runs

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

* fix(agent tasks): address duplicate-run review

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

* fix(agent tasks): resolve sibling-run nits

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-14 13:57:09 +08:00
31645cc510 [MUL-6125] Ship Private Skill Plugin developer loop (#6900)
* [MUL-6125] Ship private Skill Plugin developer loop

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

* [MUL-6125] Address Private Plugin review findings

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 17:49:43 +08:00
96bf122f2c MUL-6107: preserve task history during runtime GC (#6894)
* MUL-6107: preserve task history during runtime GC

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

* fix(server): address runtime GC review

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

* fix(server): finish runtime GC 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-13 15:31:49 +08:00
c9727e6b2b feat(daemon): worktree mode for local_directory resources (MUL-5707) (#6759)
* feat(daemon): worktree mode for local_directory resources (MUL-5707)

Local directory resources serialise every task on a per-path mutex, so two
tasks on one folder queue behind each other. This adds an opt-in worktree
mode: each task gets its own git worktree of the user's repo inside the
daemon's env root, so tasks run concurrently and deliver their work as a
branch instead of editing the user's working copy.

Opt in per resource with execution_mode=worktree (default stays in_place,
so existing resources are unchanged). Requires a git repo with at least one
commit; a non-git directory fails with an actionable message rather than
silently falling back to serial execution.

Design points that make the mode safe to use:

- The agent starts from what the user sees, not HEAD. Tracked edits are
  captured with `git stash create` (which writes a commit object without
  touching the index, working tree, or stash list) and replayed into the
  worktree; untracked non-ignored files are copied, bounded at 2000 files /
  200MB with the skipped count logged at warn level.
- That replayed state is committed as a baseline, so "did this task change
  anything?" has an exact answer and `git diff <baseline>..<branch>` is
  precisely the agent's work.
- Nothing is silently discarded: leftovers are committed to the branch
  before the worktree is removed, including when the task fails partway.
- A task that changes nothing leaves no branch behind.
- Sidecars stay out of the branch by running the existing
  CleanupRuntimeConfig + CleanupSidecars pass over the worktree before
  finalizing. The .git/info/exclude approach repocache uses is not usable
  here: a linked worktree resolves info/exclude to the user's own common git
  dir, so it would change what `git status` hides in their checkout.
- Worktree-mode env roots do not get the local_directory GC exemption, so
  they age out normally instead of accumulating one env root per task.
- `git worktree prune` on create self-heals registrations orphaned by a
  crashed daemon.

Adds --execution-mode to `project resource add/update`, and updates the
docs and the projects-and-resources built-in skill.

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

* fix(daemon): close data-loss and isolation gaps in worktree mode (MUL-5707)

Review follow-up on #6759. Six issues, all on failure or skew paths.

1. Preserve work when the final commit fails. Finalize logged a warning and
   then ran `git worktree remove --force`, destroying the agent's changes.
   commit.gpgSign with no key available to the daemon triggers this, and
   --no-verify does not disable signing. Finalize now returns an error and
   deliberately keeps the worktree — it stays registered, so `git worktree
   list` points at the work — and the daemon fails the task instead of
   reporting success with a branch that is missing the changes.

2. Fail closed on an unsupported execution_mode. Treating an unknown mode as
   in_place was wrong: execution_mode is how a user asks for ISOLATION, so
   the "safe" fallback would edit the working copy they asked us to stay out
   of. An unrecognised mode now fails the task naming the version skew.

3. Keep Codex conversations alive across turns. The per-issue session store
   was keyed on LocalWorkDir != "", so worktree tasks — which also never
   reuse an env root — got a fresh empty CODEX_HOME per turn and silently
   lost the thread. Covered by a two-turn regression test that fails without
   the fix.

4. Fail closed on an incomplete snapshot. A failed stash create/apply, a
   failed untracked copy, or hitting the copy bounds all degraded to "agent
   sees HEAD" with only a daemon log line. A half-replayed tree looks like a
   working checkout, so nothing downstream questions it while the agent reads
   different code than the user has. All of these now abort the prepare and
   tear the partial worktree back down.

5. Do not snapshot a directory mid-mutation. The same real path can be
   attached to another project as an in_place resource; snapshotting under a
   running in_place task captured a half-written tree plus that task's
   in-flight .agent_context/.multica sidecars, which then reached the branch
   and could feed the agent another issue's brief. Worktree prepare now takes
   the per-path mutex for the snapshot alone (released before the run, so
   tasks still overlap), and the untracked replay skips Multica's own
   sidecars.

6. Stop the test helper from masking regressions. gitRun skipped on any git
   failure, including post-finalize assertions, so a lost branch could report
   as a skip. Git availability is now checked once at repo setup; every later
   git failure is fatal.

Also rebased onto main, keeping both MulticaConfigRoot and LocalWorktree in
the Environment initialiser.

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

* fix(daemon): close sidecar-commit and wait-visibility gaps in worktree mode; gate worktree saves on daemon version (MUL-5707)

- runTask: register the sidecar-cleanup defer immediately after the Finalize
  defer. Previously two early returns (temp-dir setup, StartTask failure) sat
  between them, and on those paths Finalize auto-committed the sidecars
  Prepare had just written — delivering a branch whose only content is
  Multica's own runtime files (in_place: leaving them in the user's tree).
- runTask: give the worktree snapshot wait the same plumbing as the in-place
  acquire — waiting_local_directory status, resourceWaitTasks accounting, and
  a cancellation poller. The holder can be an hours-long in_place task; the
  wait was invisible and a user cancel went unnoticed until the 5-minute
  prepare timeout.
- isMulticaSidecarPath: match sidecar dirs as whole path segments at any
  depth, not only at the repo root. An in_place resource pointed at a
  SUBDIRECTORY leaves its sidecars below that subdirectory; replaying them
  committed another issue's brief into the delivered branch.
- handler: refuse saving execution_mode=worktree while the owning daemon's
  registered cli_version is below MinLocalWorktreeCLIVersion (422,
  daemon_version_unsupported). An old daemon json-skips the field and runs
  tasks IN PLACE — the daemon-side unknown-mode refusal cannot protect a
  daemon that predates the field, so the server has to. Residual (documented):
  a daemon downgraded after the save needs a claim-time gate.
- copyUntrackedFiles: read the -z listing raw instead of TrimSpace-ing it,
  which corrupted filenames beginning/ending with whitespace into paths that
  silently vanished from the replay.
- docs + skill: describe the save-time version gate; drop the stale claim
  that old runtimes fail the task themselves.

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

* feat(views): UI switch for local_directory execution mode (MUL-5707)

Worktree mode shipped CLI/API-only, which for its target user — someone
running several tasks at once from Desktop — means it does not exist. This
adds the switch to the resources panel.

The choice is framed by what the user gets back, not by the mode
identifiers: "Edit this folder directly" (one task at a time, changes land
in your working copy) vs "Run in parallel, isolated" (tasks run at the same
time and each hands back an agent/… branch). `in_place` / `worktree` stay
visible as a secondary hint for anyone cross-referencing the CLI or docs.

Two entry points, matching where the decision is actually made:

- The add-a-folder flow now asks for the mode after the folder is picked and
  validated, instead of creating the resource and leaving the mode to be
  discovered later.
- Every local_directory row has a mode control. It is deliberately NOT gated
  on being the owning machine — switching the mode only rewrites a field, so
  it works from the web app too, unlike rename or the folder picker.

Both blocked states are handled where the user chooses, not after they
commit:

- Non-git folder: the desktop validator now reports whether the path is
  inside a git working tree (walking up for `.git`, so a subdirectory of a
  repo counts), and the option is disabled with that reason. Unknown — an
  older desktop build, or an existing row — stays permissive; the daemon
  re-checks authoritatively.
- Daemon too old: the version floor is mirrored from the server as
  MIN_LOCAL_WORKTREE_CLI_VERSION and checked against the runtime for the
  resource's OWN daemon, not the machine the browser happens to be on. A
  server rejection is rendered inline in the dialog rather than as a toast,
  so the dialog stays actionable.

A "Parallel" badge on the row is always visible (not hover-only): without it
there is no way to tell whether tasks on a folder edit it directly or hand
back a branch, which is the first thing anyone asks when a task queues.

Also adds `execution_mode` to LocalDirectoryResourceRef — the field existed
server-side but had no TypeScript counterpart — and brings the zh/ja/ko
project-resources docs in line with the English ones, which were the only
locale updated when the mode landed.

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

* fix(daemon,handler): close worktree isolation and delivery gaps (MUL-5707)

Second review round. Five must-fix items, all on paths where the mode's
promises could still break.

1. A failed sidecar cleanup no longer ships Multica's own files as the
   user's branch. The cleanup errors were logged and Finalize then ran
   `git add -A`, so a permission/disk/lock failure could deliver a branch
   whose entire content was the runtime brief and issue context. Worktree
   mode now escalates a cleanup failure to an abort: nothing is committed,
   the worktree is kept for inspection, and the task fails with the
   preserved path. In place it stays a warning — no commit, no branch, and
   the leftovers are visible in the user's own tree.

2. The untracked replay no longer drops files silently. `Lstat` failures
   and symlinks were `continue`d without counting, so the caller read a
   partial snapshot as complete. Both now count as skipped, which the
   existing fail-closed check turns into a refused prepare. Non-content
   entries (sockets, FIFOs, devices) stay uncounted deliberately.

3. Prepare rolls back a worktree it created. Any later failure — context
   files, provider home, MCP config — returned without an Environment, so
   nothing downstream knew a worktree existed, leaving a registration and
   a branch no task ever ran in. A deferred Discard now runs unless Prepare
   reached its successful return.

4. The daemon-version gate moved to where it can actually hold. Save-time
   validation cannot see a machine downgraded afterwards, and an old daemon
   json-skips execution_mode entirely — it would run the task in place,
   editing the working copy the user asked to isolate. Claims are now
   checked against the runtime that is actually claiming, and a blocked
   task is cancelled with a reason rather than left to be redelivered to
   the same too-old runtime forever.

5. branch_name is delivered end to end. The daemon sent it, but
   TaskCompleteRequest had no field so it vanished at decode, and the
   failure path never sent it at all. It now has a column, travels on both
   terminal callbacks, and renders as a copyable row in Run details. The
   failure path matters most: worktree mode commits the agent's leftovers
   before tearing the worktree down, so a run that died partway still has
   work worth finding.

Docs (all four locales) and the projects-and-resources skill are updated
for the claim-time gate and the branch surface.

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

* fix(handler,daemon): migration renumber, cancel reasons, branch on every terminal path (MUL-5707)

Third review round.

1. Rebased onto main and renumbered the migration to 285 — main had taken
   273 and is now at 284. sqlc regenerated on top of the rebased queries;
   the conflicts were confined to generated files.

2. The claim gate now persists why it cancelled. It called CancelTask,
   which writes only status='cancelled', and returned the reason as a 4xx
   that reaches the daemon's log and nothing else — batch claim drops the
   task from its response entirely. A run refused for a durable reason the
   user has to act on cannot present as a bare "cancelled", so this adds
   CancelAgentTaskWithReason and records error + failure_reason on the row.

3. branch_name now survives every terminal path, not just the two direct
   callbacks. Three were dropping it:
   - the context-exhausted complete→fail reroute did not copy it;
   - the daemon's complete→fail fallback (server rejected the complete,
     agent had succeeded) did not set it;
   - both cancellation paths discarded the result wholesale, even though
     the worktree is finalized — and its work committed — before the daemon
     learns of the cancel. The branch now rides the cancel-ack and is
     recorded by SetAgentTaskBranchName, which never overwrites a name a
     terminal callback already wrote.

4. Rollout/rollback compatibility. Isolation is enforced by two server-side
   gates, so a daemon downgraded at any point after the resource was saved
   is caught. The gap they cannot close is rolling the SERVER back past this
   change while sub-floor daemons are still connected: an old server has no
   gate and an old daemon ignores execution_mode. That constraint is now
   documented where an operator will meet it (the migration and the docs),
   and the version floor is pinned across server and frontend by test so the
   two gates cannot silently drift apart.

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

* fix(server,views): route claim-gate cancels through TaskService, make cancel reasons visible, harden cancel-ack branch delivery (MUL-5707)

Closes the fourth-round review findings on worktree mode's cancellation
chain:

- Claim gate: cancel through a new TaskService.CancelTaskWithReason instead
  of a raw CancelAgentTaskWithReason query, so a gate-cancelled task gets the
  full cancellation side effects — audit capture, chat settle, agent status
  reconcile, task:cancelled broadcast, NotifyTaskFinished. Added REAL claim
  integration tests: singular claim (422 + row cancelled with reason), batch
  claim (task omitted + row cancelled with reason), and a current-runtime
  control.
- Cancel reasons are now user-visible: execution log rows, agent activity
  rows, and the transcript dialog label a SERVER-cancelled task (persisted
  error/failure_reason) like a failed one, with the actionable text on hover
  and a Reason row in Run details. User-initiated cancels stay a plain
  "Cancelled". New cancelReasonLabel helper + local_directory_error label;
  details_reason locale key in en/zh-Hans/ja/ko; widened
  AgentTask.failure_reason to the open string the wire actually carries.
- Cancel-ack is now a terminal delivery: the daemon retries it on transient
  errors (same schedule as complete/fail), the server fails LOUD (5xx) when
  persisting the branch/error fails instead of warn-and-200, and re-broadcasts
  task:cancelled after a successful write so clients that already refetched a
  branchless row pick up the delivery. Replays are idempotent (never
  overwrite).
- Cancel + Finalize-abort no longer swallows the preserved-worktree pointer:
  the finalize error is wrapped in worktreePreservedError (joined onto an
  existing run error, never displacing it), extracted on the cancel path, and
  delivered via the ack into agent_task_queue.error through the new
  SetAgentTaskErrorIfEmpty query — never overwriting a reason recorded by a
  fail callback or the claim gate. Other run errors on a cancelled run stay
  discarded as before: they are expected noise.
- Docs: the self-hosted rollback constraint callout now exists in zh/ja/ko,
  not just English.

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

* fix(handler): guard cancel-ack writes to cancelled rows; requeue when the claim gate's cancel fails (MUL-5707)

Fifth-round review fixes:

- SetAgentTaskBranchName / SetAgentTaskErrorIfEmpty now carry a
  status='cancelled' CAS. The daemon acks on EVERY terminal status it
  observes, so a late or replayed ack from a stale run could previously
  backfill its branch or preserved-path error into a completed/failed row
  whose own callback — the authoritative channel — recorded nothing, without
  any event invalidating the change. A CAS-refused write is a deliberate
  no-op (still 200: nothing to retry), and the rebroadcast stays guarded by
  the same status check. Regression tests: a late ack against a completed row
  with empty fields and against a failed row leaves both untouched.
- When the worktree claim gate's CancelTaskWithReason fails, the claimed row
  no longer strands in dispatched (daemon refused, nothing to release it but
  stale reclaim). The gate requeues via RequeueTaskAfterClaimFailure — the
  next claim re-runs the gate and retries the cancel — and reports 5xx so the
  singular daemon reads a transient server problem, not a claim refusal; the
  batch path omits the task as before, now with the row requeued. Fault
  injection tests (row-scoped trigger raising on the cancel UPDATE while
  letting the requeue through) cover singular and batch.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 14:52:01 +08:00
mxbao063-baoandmxbao063-bao 8060aa7277 MUL-5947: feat(dingtalk): route groups to different agents (#6660)
Route each DingTalk group discovered by one Stream robot to its own agent, with a revision-fenced cutover so a reassigned group never inherits the previous agent's session, transcript, or pending outbound card.

Co-authored-by: mxbao063-bao <[email protected]>
2026-08-13 14:09:25 +08:00
d467cc9069 MUL-6075: complete Plugin V1 execution chain (#6853)
* feat(server): add Plugin V1 lifecycle foundation

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

* feat(server): complete Plugin V1 execution chain

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

* feat(web): add temporary plugin settings demo

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

* refactor(plugin): keep foundation free of demo content

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 02:29:49 +08:00
71bd844955 MUL-6055 refactor(agents): remove legacy agent templates (#6820)
Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-12 16:09:16 +08:00
c4f0d8cc8c MUL-5999: make workspace teardown index-driven, bounded and fenced (#6754)
* perf(workspace): make teardown task discovery index-driven

Workspace teardown found a workspace's tasks with a three-way
`OR agent_id IN (…) OR issue_id IN (…) OR runtime_id IN (…)`, three times
per delete, and swept task_token with the same shape. PostgreSQL costs
that as a Seq Scan on agent_task_queue — the largest table in the
database (5.08M rows / 8.3 GB in production) — so deleting even a tiny
workspace paid for the whole table, inside the transaction that holds the
workspace row lock and global advisory lock 4246.

Resolve the task set one owner key at a time instead, in the handler, and
reuse that snapshot for every task-keyed step. Measured on a 1.2M-row
synthetic copy of the production shape (937 agents / 109 issues / 12
runtimes): the OR form and a 937-element `= ANY(array)` form both Seq
Scan; single-key equality is a Bitmap Index Scan. A UNION of parent-key
joins is worse than the OR (three hash joins, each seq scanning the
table), which is why that earlier suggestion was dropped.

All three ownership paths are preserved: nothing enforces that a task's
agent, runtime and issue share a workspace, so dropping a path would turn
a performance fix into a tenant-cleanup bug.

task_token needs no loop. Its workspace_id has been NOT NULL since
migration 108, so the workspace key alone covers this workspace's rows,
and the two extra OR arms only ever matched tokens owned by another
workspace whose task or agent lives here — both NOT NULL FKs with
ON DELETE CASCADE, so the later task/agent deletes remove exactly those.

Two indexes make the new shapes index-eligible:

- 273 agent_task_queue(runtime_id): every existing runtime_id index is
  partial, so all-status consumers had none. This also covers the FK's
  own ON DELETE CASCADE probe, which runs per deleted agent_runtime row
  on the ordinary delete path.
- 274 task_token(workspace_id): the column has never been indexed.

Measured block usage for the same workspace, before → after:
agent_task_queue discovery 25,665 → 14,485 blocks; task_token sweep
4,086 → 3 blocks. The point is not the ratio at this size but that the
cost now scales with the workspace being deleted instead of with the
global table.

Refs MUL-5999

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

* fix(workspace): fence, bound and un-cascade the teardown task sweep

Addresses all four blocking findings from the MUL-5999 review.

1. Concurrency (correctness / tenant isolation). The task set is no longer
   a lock-free snapshot reused across steps. Teardown now locks every row
   through which a task can belong to the workspace — its agents, issues
   and runtimes — FOR UPDATE, and holds them for the rest of the
   transaction. Inserting an agent_task_queue row takes FOR KEY SHARE on
   each referenced owner, which conflicts with FOR UPDATE, so a concurrent
   enqueue (or a reassignment onto one of these runtimes) can no longer
   slip a task in behind the sweep. Locking the tasks alone could not do
   that: a row that does not exist yet cannot be locked. This is the same
   technique the handler already uses on the workspace row to keep
   CreateChatSession out of the delete window.

   The per-batch page query also takes FOR UPDATE, so under READ COMMITTED
   PostgreSQL re-checks ownership after each lock: a task a concurrent
   commit already moved elsewhere is skipped rather than deleted on a
   stale claim, and a task we locked cannot move before we delete it.

   Deadlock (40P01) joins lock_timeout (55P03) as a retryable 503, because
   the owner locks can be taken in the opposite order by a concurrent task
   update.

2. Cleanup ownership (no cascade reliance). task_token keeps all three
   explicit paths, split into separate index-driven statements instead of
   one OR: workspace_id in DeleteWorkspaceLeafData, task_id in
   DeleteTaskBatch, agent_id in DeleteTaskTokensByAgent. Nothing in the
   teardown graph now depends on ON DELETE CASCADE to finish the job.

3. Bounded memory. The whole-workspace task array is gone. The sweep pages
   through one owner key at a time with LIMIT 1000 and deletes exactly the
   page it read, so process memory, the array parameter and each DELETE's
   row count are bounded by the batch size rather than by a workspace's
   lifetime task count. The task-keyed arms moved out of the leaf-data
   statement into DeleteTaskBatch, which runs per page, and the separate
   whole-workspace task delete is gone.

4. Migration retryability. The concurrent-index cleanup hooks are now
   derived from a version -> index-name map that covers 257, 261 and
   273-277, so an interrupted CREATE INDEX CONCURRENTLY cannot be mistaken
   for success on retry.

Three more indexes, each an unindexed FK referencing column that the
per-batch statements need and that the FK's own ON DELETE action already
scanned per row on ordinary deletes: task_token(agent_id),
chat_draft_restore(task_id), autopilot_run(task_id).

Plans re-verified on a 1.2M-row synthetic copy of the prod shape with the
child tables populated: page query is a Bitmap Index Scan under LockRows;
task_usage, chat_draft_restore, task_token and autopilot_run arms are all
index scans.

Tests: the ownership-path test now asserts the sweep is self-sufficient
inside the transaction, before any owner row is deleted, so a cascade
cannot stand in for teardown. New tests cover the enqueue / reassignment
fence (blocked writers, unrelated writer unaffected), multi-page sweeps
and the page bound, plus a migration-retry repair test and a check that
every cleanup hook names the index its migration builds.

Refs MUL-5999

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

* fix(workspace): keyset-page the teardown sweep and fail closed on leaks

Addresses the second review round on MUL-5999.

1. Bounded scan, not just bounded result. The page query was
   `owner = $1 ORDER BY id LIMIT n` against indexes that cannot produce id
   order, so the executor had to read the owner's whole task set before
   applying LIMIT — quadratic in pages for a busy owner, which is the case
   this issue is about. Measured on a 550k-row probe: the old shape spent
   11,630 buffers per page (planner chose a full pkey index scan; on other
   statistics it chose an explicit Sort), the keyset shape spends ~2,000
   and a later page costs the same as the first.

   Pages are now `owner = $1 AND id > $cursor ORDER BY id LIMIT n` against
   composite indexes: 273 becomes (runtime_id, id), 278 adds (agent_id, id),
   279 adds (issue_id, id) and 280 drops the single-column issue_id index it
   supersedes so the hot table does not carry both.

2. Owner enumeration is bounded too. The three LockWorkspaceTaskOwner*
   statements no longer return ids at all — they are a set-based write fence
   and now cost the API process nothing. Owners are walked separately in
   keyset pages of 500 (281-283 add the (workspace_id, id) indexes those
   pages need), so a workspace with a huge agent or issue set never has its
   whole owner list held in memory.

   The keyset column is id rather than a natural key on purpose: a cursor
   column must be immutable, and renaming an owner mid-teardown would
   otherwise let it sort behind the cursor and escape the sweep.

3. The fence is no longer the only guarantee. Blocking a concurrent enqueue
   still relies on an INSERT taking FOR KEY SHARE on the owner rows it
   references, which is a property of the legacy FKs rather than a protocol
   every enqueue path opts into. Two things now backstop it:

   - every owner is re-swept until a pass finds nothing, and teardown fails
     closed (rolling the whole transaction back) if tasks keep arriving,
     instead of committing a partially cleaned tenant;
   - a schema-contract test asserts the three owner FKs still exist, so a
     migration that removes them turns the missing fence into a red build
     rather than a silent hole.

   An explicit deleting-state protocol shared with all seven enqueue paths
   remains the right long-term fix and is out of scope here.

End-to-end teardown, real handler, identical synthetic data, main vs this
branch (1.2M-row agent_task_queue, 200k task_token, 300k task_usage):

  small workspace, 5k tasks:   29,247 ms -> 1,013 ms   (4.38M -> 0.27M blocks)
  busy workspace, 60k tasks:  243,863 ms -> 1,724 ms   (5.76M -> 2.23M blocks)

Refs MUL-5999

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

* fix(workspace): fence task writes in-transaction, independent of the FKs

Addresses the third review round on MUL-5999.

1. The re-scan did not close the window, and the reviewer is right that it
   cannot: a pass that comes back empty returns success, and a task
   inserted after that final scan but before COMMIT is not caught by any
   number of passes. The only thing that closed that window was the
   FOR KEY SHARE an INSERT takes on the agent / issue / runtime rows it
   references — a side effect of foreign keys that workspace_delete.sql
   itself documents as an expand-phase net a later contract removes.

   Migration 284 replaces it with an explicit fence: a BEFORE INSERT OR
   UPDATE OF (agent_id, issue_id, runtime_id) trigger on agent_task_queue
   that takes FOR KEY SHARE on the workspace row behind every owner the row
   references, and raises if one is gone. That conflicts with the FOR UPDATE
   LockWorkspaceForDelete already holds for the whole teardown transaction,
   so a task write against a workspace being torn down blocks until teardown
   finishes and then either proceeds (rolled back) or fails closed
   (committed).

   A trigger rather than a per-call-site check because the fence has to be
   in the same transaction as the write: there are seven INSERTs and several
   ownership-changing UPDATEs across agent.sql, autopilot.sql, chat.sql and
   runtime.sql, several of them single autocommit statements, so a separate
   "take the fence" statement from the caller would be its own transaction
   and protect nothing. In-transaction by construction, covers every current
   path, and cannot be forgotten by a path added later.

   TestTaskWriteFence_BlocksOnWorkspaceLockAlone isolates the fence from the
   FKs: the holder locks ONLY the workspace row — no agent, issue or runtime
   row — so no FK on agent_task_queue has anything to block on, since none of
   them references workspace. Inserts through all three ownership paths and a
   reassignment all block; an unrelated workspace's insert and a status-only
   update still pass. Dropping the trigger fails that test.

   Cost, measured with 5,000 bare inserts: 147 ms without the fence,
   238 ms with it — about 18 us per inserted row for up to three indexed
   lookups and a shared row lock. It does not fire on the hot claim /
   dispatch / complete path, which updates status and timestamps rather than
   ownership.

   The verification passes stay as a self-check, no longer described as the
   concurrency guarantee.

2. Migration 280 is withdrawn. Its down path rebuilt the superseded index
   with CREATE INDEX CONCURRENTLY, and the runner only runs INVALID-index
   cleanup hooks in the up direction, so an interrupted rollback would leave
   an invalid index that a later `down` skips — and rolling back 279 too
   would then leave no usable issue_id index at all. The redundant
   single-column index stays for now; dropping it needs its own migration
   with a direction-aware cleanup. This also settles the stale comment in
   task_usage.sql, which still names the old index and remains correct.

3. Keyset cursors no longer start from the all-zero uuid, which is itself a
   valid uuid and would have made a row with that id invisible forever.
   Every walk now has an explicit first-page query with no cursor predicate.

Refs MUL-5999

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

* fix(workspace): move the task-write fence into the write statements

Addresses the fourth review round on MUL-5999.

1. The trigger is gone. Migration 284 now defines
   lock_task_owner_workspaces(agent_id, issue_id, runtime_id), a predicate
   that every statement writing agent_task_queue ownership calls in its own
   WHERE clause:

       INSERT INTO agent_task_queue (...)
       SELECT ... WHERE lock_task_owner_workspaces($1, $3, $2)

   Nine statements now carry it: the seven task INSERTs (agent.sql x5,
   autopilot.sql, chat.sql), LinkTaskToIssue, and ReassignTasksToRuntime —
   the last through a MATERIALIZED CTE so a bulk UPDATE evaluates the
   VOLATILE predicate once instead of per row. Clearing runtime_id is
   exempt: it cannot move a task into another workspace.

   Calling it from the writing statement keeps the lock in the writer's own
   transaction, which a separate caller-issued statement could not do for
   the autocommit enqueue paths. TestTaskOwnershipWritesCallTheFence is the
   trade for dropping the trigger: it scans the query files and fails the
   build if a statement writes ownership without the fence.

2. The fence no longer defers to the foreign keys. The trigger version
   counted only owner references that still resolved, so a deleted owner
   made itself invisible: with all three gone it returned early, and with
   one of three gone the count still matched. The predicate now compares
   resolved owners against the number of owner arguments supplied and
   returns false on any mismatch, so a stale write is rejected on its own
   terms. TestLockTaskOwnerWorkspaces_RejectsUnresolvableOwner covers each
   shape, including the one-of-three case.

3. Deterministic lock order. The workspace rows are locked with
   ORDER BY w.id, so mirrored cross-workspace writes cannot take the same
   pair in opposite orders. Honest note on the test: 25 mirrored concurrent
   pairs never deadlock either way, because the current plan happens to
   return the rows in a stable order — the ORDER BY is what makes that a
   guarantee rather than an accident of the plan, and the test is a
   regression test for the symptom.

4. The fail-closed test now tests what its name says. The previous one only
   inserted a neighbour-owned task and could not have failed. The
   replacement runs the actual race: a writer calls the real
   CreateAgentTask, blocks on the workspace lock, teardown deletes the
   workspace and commits, and the writer must come back with pgx.ErrNoRows
   — the fence's own verdict. An FK doing the work instead surfaces as
   SQLSTATE 23503, which the assertion rejects explicitly.

Cost: 5,000 predicate calls in 53.7 ms, about 11 us per fenced write, for
up to three indexed lookups and a shared row lock. Status, dispatch and
completion updates do not call it.

Refs MUL-5999

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

* fix(runtime): stop the legacy merge when the task fence refuses

Addresses the P1 from the fifth review round on MUL-5999.

ReassignTasksToRuntime returned only a row count, so a fenced refusal was
indistinguishable from "the old runtime had no tasks". mergeLegacyRuntimes
read both as success and went on to DeleteAgentRuntime(old), where
agent_task_queue's ON DELETE CASCADE silently took the history the
reassignment exists to preserve — and once that FK goes away, the same path
would instead leave tasks pointing at a deleted runtime. Either way the
merge was leaning on the cascade, which is what this PR set out to stop.

The query now returns the fence verdict alongside the count:

    SELECT (SELECT ok FROM fence) AS fence_ok,
           (SELECT count(*) FROM reassigned) AS reassigned_tasks

and the merge is a single helper, mergeLegacyRuntime, that aborts with
errRuntimeMergeFenced before touching anything else when fence_ok is false.
Tasks now move FIRST, because that is the step carrying the fence: nothing
else in the merge — agent reassignment, legacy daemon_id bookkeeping, the
old runtime delete — runs unless the fence agreed. The next daemon
registration re-runs the merge, by which time the workspace is either gone
or its teardown rolled back.

TestMergeLegacyRuntime_KeepsOldRuntimeWhenFenceRefuses covers it: with a
vanished merge target the old runtime and its task both survive and the task
stays on the old runtime, while a merge onto a live runtime still completes.
Removing the abort fails that test — and fails it with SQLSTATE 23503 from
agent_runtime_id_fkey, which is precisely the "an FK is doing the work"
shape the review objected to.

Also replaced the fixed 300 ms sleep in the queued-writer race with a poll
on pg_stat_activity for a backend actually parked on a lock, so the test
cannot pass without the race being set up (it now fails on timeout instead).

Refs MUL-5999

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

* fix(runtime): make the legacy runtime merge a single transaction

Addresses the P1 from the sixth review round on MUL-5999.

The merge ran four autocommit statements, so the fence's workspace lock
ended with the statement that took it. Two ways that left a tenant
inconsistent while daemon registration still reported success:

  * a transient failure on the agent reassignment committed the task move
    and nothing else, leaving tasks on the new runtime and their agents on
    the old one until some later registration happened to retry;
  * in the gap after the task commit, a workspace teardown could lock and
    delete the target runtime — and its UnbindTasksFromRuntime step clears
    runtime_id on exactly the tasks that had just moved. The next merge
    looks tasks up by the OLD runtime id, so that history was detached for
    good.

mergeLegacyRuntime now opens a transaction and runs the whole merge on one
qtx: fenced task reassignment, agent reassignment, the legacy daemon_id
audit write, and the old-runtime delete, then commits. The fence stays the
first statement, so the workspace lock it takes is held until COMMIT — a
teardown cannot start deleting the target mid-merge — and the lock order
against teardown is unchanged. Any failure returns an error and rolls the
whole thing back.

The audit write is no longer best-effort: inside a transaction a failed
statement poisons the rest, so it either lands with the merge or the merge
is abandoned.

TestMergeLegacyRuntime_RollsBackWhenAStepFails injects the failure in the
database rather than through a code seam — a trigger makes the agent move
raise — and asserts the task, the agent, both runtime rows and the audit
column are all exactly as they were, then that the same merge completes
once the injection is removed. Reverting to autocommit fails it with
"task moved to <new runtime>; the task reassignment was not rolled back".

Also took the non-blocking suggestion: the queued-writer race now has the
writer report its own pg_backend_pid() over a channel and waits for that
specific backend to park on a lock, instead of matching statement text
against every backend on a shared test database.

Refs MUL-5999

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

* fix(runtime): fence late writes against the runtime a merge is collapsing

Addresses the P1 from the seventh review round on MUL-5999, which the
reviewer reproduced rather than inferred.

The fence locked the workspace row and nothing else. A task write takes
FOR KEY SHARE there, and so did the legacy runtime merge — and two KEY SHARE
locks do not conflict. So an enqueue against the OLD runtime went straight
through after the merge had scanned its tasks, and DeleteAgentRuntime then
removed that brand-new task through agent_task_queue's ON DELETE CASCADE.
Making the merge one transaction widened the window rather than creating it:
the agent's new runtime_id is invisible until COMMIT, so concurrent writers
keep resolving the old runtime for the whole merge.

Both sides of the contract now lock the owner rows too, in one order:
workspace rows (by id), then agent, issue, runtime.

  * lock_task_owner_rows (migration 284, renamed from
    lock_task_owner_workspaces) takes FOR KEY SHARE on each owner row after
    the workspace rows, and re-checks that they still exist. That is what
    conflicts with a delete.
  * The merge takes FOR KEY SHARE on the workspace and then FOR UPDATE on
    both runtime rows in id order, before the task scan, and holds them to
    COMMIT. FOR UPDATE is the level that conflicts with a writer's KEY
    SHARE; both runtimes in one ordered statement so two merges running in
    opposite directions cannot take the pair in opposite orders. Locking
    fewer rows than asked for means one disappeared, and the merge is
    abandoned.

A late writer therefore either commits before the merge takes the lock — in
which case the scan that follows sees its task and moves it — or waits, and
then finds the runtime gone and writes no row.

TestRuntimeMerge_LateWriteToOldRuntimeIsFencedNotCascaded drives the merge
statement by statement so it can stop where the window was (after the agent
update, before the old runtime is deleted), enqueues against the old runtime
through the generated query, and requires that the writer is parked on a
lock, that it comes back as pgx.ErrNoRows, and that the merge's own history
ends up on the target. It explicitly rejects both 23503 and a row that
lands and then vanishes.

Both halves are load-bearing: dropping the runtime lock from the fence, or
the FOR UPDATE from the merge, fails that test — the latter with "the late
write was rejected by a foreign key (23503), not by the fence".

Cost: 5,000 predicate calls in 74.4 ms, ~15 us per fenced write (was ~11 us
with the workspace lock alone).

Refs MUL-5999

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

* docs(workspace): correct the fence comment and drop a trailing newline

Two review nits, no behaviour change.

lockWorkspaceTaskOwners still described migration 284 as a BEFORE trigger on
agent_task_queue. It became a called predicate, lock_task_owner_rows, in
9fc1d79, and this comment is the place someone reads to understand how the
fence works — so it now says what it is: a predicate every ownership-writing
statement calls in its own WHERE clause, taking FOR KEY SHARE on the
workspace rows and then the owner rows.

Also removed the blank line at EOF in the migration, which git diff --check
was flagging.

Refs MUL-5999

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-12 14:09:48 +08:00
0c69f1f95f fix(daemon): recover in-turn when a resumed session cannot resolve provider auth (#6798)
A resumed Hermes session whose persisted provider identity can no longer
resolve its credentials fails with the SDK's "Could not resolve authentication
method" message. The server-side resume guards already blacklist that session,
so the NEXT turn starts fresh and succeeds — which is exactly why Chat
alternated success / failure instead of staying broken (GH #6777).

Curing the failing turn needs the daemon's in-turn fresh-session retry, which
never fired: nothing identified this error as fatal-to-resume. Add it as a
third form of positive evidence in shouldRetryWithFreshSession, alongside
ResumeRejected and UnresumableHistory.

Deciding in the shared gate rather than in each ACP adapter is what makes it
correct and narrow:

- priorSessionID != "" already proves the run was a resume, which is the whole
  distinction between "the session's provider copy is broken" (curable) and
  "the credentials are wrong" (not curable). An adapter cannot tell those
  apart from the error text alone, which is why Result.ResumeRejected still
  documents auth errors as out of scope.
- The failure surfaces at session/resume, session/set_model (MUL-5029
  provider re-routing, the likelier path on a resumed session) and
  session/prompt; only two carry any resume-failure signal today. The final
  error text carries the phrase on all three.
- tools == 0 is untouched, so a turn that already acted is never replayed.

The phrase now has a single home in taskfailure.AuthMethodUnresolved, shared
with ResumeUnsafeFailure. The SQL guards stay as-is: they are the only
protection for rows written by a daemon too old to have this retry.

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-12 12:48:03 +08:00
6bce42b84a MUL-5983 fix(workspace): stop workspace delete from hanging on advisory lock 4246 (#6710)
* fix(workspace): stop workspace delete from hanging on advisory lock 4246 (MUL-5983)

Deleting a workspace from the latest client did nothing: the confirm dialog
stayed on "Deleting…" forever and the workspace survived.

The teardown transaction takes global advisory lock 4246 (added in #6230) so
the task_usage_hourly rollup cannot write aggregates for a workspace being
torn down, and it waited on that lock with no bound. Nothing above the handler
has a deadline either — the API client's fetch has no timeout — so any holder
of 4246 turns the delete into an infinite spinner.

Two fixes:

- Migration 272 makes the rollup's lock transaction-scoped. Migration 102 used
  the session-scoped pg_try_advisory_lock and released it from an
  `EXCEPTION WHEN OTHERS` handler, but plpgsql's OTHERS does not match
  query_canceled and a session-level advisory lock survives the rollback that
  follows. A cancelled tick therefore handed its pooled connection back to
  pgxpool still holding 4246 — permanently blocking every later tick (usage
  aggregation silently stops) and every workspace delete.

- DeleteWorkspace sets SET LOCAL lock_timeout on its transaction and maps
  SQLSTATE 55P03 to 503. Lock 4246 is also held for whole runs by the backfill
  commands and for up to 25 min by a rollup tick, so contention has to surface
  as a retryable error instead of a request that never returns. The cap applies
  to lock waits only; the teardown work itself stays unbounded.

Both paths are pinned by tests that fail without the fix: the scheduler test
proves a cancelled tick leaks 4246 against the old function, and the handler
test proves DeleteWorkspace blocks on a held 4246 without the lock timeout.

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

* test(workspace): serialise the new 4246 tests with the rollup guard (MUL-5983)

Review found that the two new handler tests take advisory lock 4246 by hand
without joining the cross-binary guard the rollup family uses (42463980), so
`go test` running internal/handler and internal/scheduler in parallel against
one database made the scheduler's lock tests fail. Reproduced locally, then
fixed by calling lockRollupSingleton in both.

Stressing the combined run surfaced a second, older interference: every
workspace teardown takes 4246 in production code (#6230), so any handler
delete test can own the lock for a moment and leave the scheduler's rollup
tests seeing "no work" — TestPgCronConcurrentNoDoubleWrite fails with
winners=0, and this predates the guard fix above. Both rollup tests now treat
a round lost to an outside holder as a retry rather than a verdict; the real
invariants (no leaked lock, no double write) are unchanged.

Also from review: correct the sqlc source comment that still described the
rollup's lock as session-scoped (regenerated), and drop migration 272's claim
that the TTL prune is bounded and only delays the next tick — it has no row
cap and now also delays workspace deletes waiting on 4246.

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

* test(workspace): make the advisory-lock holder release idempotent (MUL-5983)

The 15 s deadline branch releases the 4246 holder early so the blocked
handler can finish, and the defer then released it again. pgxpool's
Release is idempotent but Exec on a returned connection dereferences a
nil resource, so the second call panicked and buried the assertion
failure it was supposed to report. sync.Once makes the cleanup safe from
both paths.

Verified by forcing the deadline branch (lock_timeout temporarily removed
from DeleteWorkspace): the test now reports the assertion cleanly where it
previously ended in a nil-pointer panic.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-11 02:23:11 +08:00
YYClaw 2a19f9ba2c MUL-5873 fix(channels): improve /new and /issue feedback (#6578)
A bare /new now returns an explicit confirmation and persists the fresh-start intent on channel_chat_session_binding.pending_fresh until the next chat task is successfully queued. A bare /issue returns usage guidance and skips issue creation, agent execution, and media resolution instead of inferring a title from chat history.

Closes #6577
2026-08-10 12:05:58 +08:00
Naiyuan QingandClaude Fable 5 984a2c2bff MUL-5954: feat: saved issue views V1 (MUL-4796) (#6516)
* feat(issues): server-backed saved issue views API (MUL-4796)

issue_view table (migrations 262-264: table + two CONCURRENTLY partial
indexes) stores named filter definitions: query jsonb is the shared
identity, display jsonb only seeds a user's first open. Scope model is
workspace / my (with scope_variant, forced private) / project (validated
scope_id, deleted with the project in the same app transaction).

issue_view_preference (migration 265) holds each user's view-bar layout
(hidden + order doc) per surface container, last-write-wins.

CRUD + GET/PUT preference endpoints are gated on the saved_issue_views_v1
feature flag (404 while off, key published to the frontend config).
Authorization: private views 404 for everyone but the owner; shared views
editable by owner or workspace admin; updates use expected_revision -> 409.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(core): saved issue views client core (MUL-4796)

- zod schemas + parseWithFallback for views and preferences (definition
  blobs stay loose records interpreted per definition_version), with
  malformed-response tests per the API-compat rules
- issue-views module: list/create/update/delete + preference queries with
  wsId-scoped keys; preference upsert is optimistic (toggle-grade rollback)
- active-view store (zustand client state, URL is the durable carrier) and
  useActiveIssueView with missing-view detection
- baseline.ts: enum-sanitized FilterSnapshot of a view's query for
  value-level locking, delta chips, and baseline-aware resets
- view-store: clearFilterDimension + resetFiltersTo actions; surface store
  seeding routes server blobs through mergeViewStatePersisted

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issues): three-bar priority icons with a distinct urgent badge

low/medium/high share one three-bar frame (fill level = severity, ghost
bars at 35%); urgent breaks out of the scale as a filled badge with an
exclamation cutout — it is an interrupt, not a fourth bar. All glyphs
share the same 2-14 optical frame.

Board card: PickerWrapper's bare block div gave its inline-flex trigger a
line box (line-height 24px > 20px button), floating icons above the text
midline — the wrappers are flex containers now, and the priority trigger
gets a constant size-5 box so the row's rhythm doesn't depend on the glyph.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(views): saved views UI — view bar, save/edit dialog, locked view mode (MUL-4796)

View bar replaces the built-in tab row on Issues and My Issues: built-in
tabs and saved views render as one flat, per-user ordered row that wraps
instead of overflowing, with a menu for new-view / manage. The manage
dialog does drag reorder (translate-only transform, vertical axis lock),
show/hide (anchor built-in unhidable), and edit/delete for owned views.

Open-view semantics: the view's query is the baseline — its values render
checked-and-disabled in the filter menu, never as chips; chips show only
the user's additions and every reset path (chip x, clear, menu reset,
filtered-empty CTA, board/swimlane hide-column) returns to the baseline,
not to empty. Display seeds once per user (view:<id> surface key) and is
free afterwards. Built-in tab click exits; deleted/revoked views fall back
with a toast; ?view= deep links sync on web via a platform hook.

Save dialog doubles as the edit dialog (PATCH with expected_revision ->
409 toast); manager edits seed from the view definition, in-view edits
from the live panel. Filter chips bar, extracted IssueFilterMenu (virtual
frozen anchor), and the reworked display panel round out the header.

Also: filter menu consistency fixes (closeOnClick, calendar apply,
TableColumnPicker checkbox items), SelectGroup padding, dropdown anchor
passthrough, four-locale i18n.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(views): neutralize workspace tab filter while a saved view is open

The Members/Agents tabs are a coarse assignee-type filter carried in the
surface scope, outside the filter-menu dimensions a view can express. It
kept applying underneath an open view, so the same shared view returned
different rows depending on which tab the user stood on when opening it —
with nothing on screen to explain the difference (tabs dimmed, no chip).

While a view is active the workspace scope now resolves to actorKind
"all": a view's results are defined by the view alone, matching what the
dimmed tabs already imply. Deliberately NOT captured into saved queries —
a view condition must be visible and lockable in the filter menu, and
assignee-type has no menu representation (add the menu dimension first if
that need ever materializes; the loose schema takes the field additively).

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): workspace scope_variant captures and applies the assignee-type tab

A workspace view now saves which built-in tab it was created from
(members/agents, NULL = all) as scope_variant, alongside the existing
my-scope variants. The save dialog shows a scope selector so the
variant can be switched later; scope_type stays immutable. While a
view is open, its own variant drives the workspace scope's
assignee-type axis instead of whichever tab the user stood on.

- migration 266 widens the scope_variant CHECKs (members/agents on
  workspace, NULL = all; my keeps its four variants; project stays NULL)
- UpdateIssueView persists scope_variant; handler validates the variant
  against the view's scope_type on create and update
- issue-surface applies activeView.scope_variant to effectiveScope,
  replacing the blanket tab neutralization
- issues-header seeds the dialog scope from the live tab

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): project pages get a real assignee-type axis and clearer variant copy

The Members/Agents tabs on project pages were decorative: they wrote the
global tab store but the project query never read it. The project scope
now carries actorKind like the workspace scope (assignee_types composes
with project_id server-side), so the tabs filter for real, and project
views join the same optional scope_variant vocabulary — saved from the
tab, switchable in the dialog, applied while the view is open.

- migration 266 (unmerged) widened in place: project allows
  members/agents, NULL = all
- handler treats workspace and project variants identically; new
  TestProjectIssueViewVariant covers persist / normalize / reject
- project scope key gains a per-tab segment (members/agents) so each tab
  keeps its own display state; the unrestricted tab keeps the old key
- save dialog shows the variant selector for project views; the surface
  now provides the dialog's default variant directly (header-side
  injection removed)
- variant labels spell out the meaning (Assigned to members/agents, Any
  assignee, Any relation) and project hints are variant-aware, x4 locales

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-table): project table scope honors assignee_types

The table/rows channel dropped the Members/Agents narrowing on project
pages: the client's project scope never sent assignee_types and the
server's project branch never compiled it, so switching tabs only
changed the highlight while table rows stayed put (the board/list
channel already filtered). Both sides now share the workspace scope's
optional assignee-type narrowing, with a compile test covering the
predicate and the invalid-type rejection.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(issues): single assignee-type mapping + per-page tab state

One core helper pair (assigneeTypesForActorKind / actorKindForViewVariant)
replaces the four inlined tab->assignee_types literal mappings across
query-plan, the table spec compiler, and the view-variant application.

The assignee-type tab store becomes page-keyed (issues / project:<id>,
persisted with a v0 migration carrying the old global tab to the Issues
page): switching tabs inside a project no longer drags the Issues page or
other projects along. The save-view dialog's default variant now comes
from the header's own page tab, restoring the /issues default that the
surface-side injection had dropped.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(issues): converge all issue surfaces onto the table query channel

The legacy list/grouped query layer duplicated the table channel's scope
semantics behind enable-conditions that were provably always false: list
and status-board ride server status branches, every non-status grouping
and swimlane ride server group branches, and the assignee-board /
per-status load-more / flat-list / flat-export / client-side my:any
3-request merge fallbacks could never execute. Delete the whole layer:

- queries: myIssueListOptions, myIssueAssigneeGroupsOptions,
  issueAssigneeGroupsOptions, issueFlatListOptions/ExportOptions,
  fetchAllFlatPages / fetchAllMyFlatIssues / fetchAllMyFirstPages /
  fetchAllMyAssigneeGroups, compareIssuesForSort (server owns ordering)
- mutations: useLoadMoreByStatus, useLoadMoreByAssigneeGroup
- board/swimlane: the never-taken client-paginated columns, property
  pool loader, hidden-column client counts, legacy footer rows
- surface data hook / controller: dead queries and passthrough fields
- gantt keeps its scheduled-only endpoint and now compiles the
  assignee-type tab into it, restoring the change alongside the cleanup

query-plan shrinks to the scope's non-Table residue: scopeKey, the
gantt filter, and create defaults. issueKeys and cache invalidation
prefixes are untouched; mobile's own data layer is unaffected.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issues): review fixes — active view's variant wins in save dialog, stable dialog scope, dead mocks

Post-review pass on the convergence refactor:

- The save dialog's default variant now prefers the OPEN view's
  scope_variant over the page tab: saving a copy while an agents-scoped
  view is open must describe the rows on screen, not the tab underneath.
- The dialog scope prop is memoized on primitive projections — a fresh
  object per header render re-armed the dialog's draft-reset effect, so
  any background refetch wiped a half-typed name.
- Tests: persist migration coverage for the page-keyed tab store; gantt
  assignee_types reaches the request and forks the cache key.
- Dead legacy load-more mocks removed from three view test files; stale
  hidden-columns doc comment; unknown hidden-column totals render no
  count instead of a blank; my-relation plan branch is return-exhaustive;
  leftover blank lines.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issues): keep the grabbing cursor for the whole row drag in manage views

The handle's cursor classes only apply while the pointer hovers the
handle — the vertical-locked drag loses it immediately, and buttons the
pointer crosses flip it to pointer. Mirror the sidebar-resize cursor
contract: an html[data-dnd-dragging] rule forces cursor: grabbing (and
no text selection) document-wide for the drag's duration, toggled by the
DndContext lifecycle with an unmount safety net.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issues): my views apply their relation at the query layer, not by switching the tab

Opening a my-view used to overwrite the user's relation tab with the
view's variant (VARIANT_TO_TAB), so when an open view vanished
(deleted / access revoked) the fallback landed on the view's tab
instead of where the user actually was. Views are now applied the same
way on all three surfaces: while a view is open, effectiveScope
substitutes the view's variant — assignee-type for workspace/project,
relation for my — and the user's own tab state is never touched.
myRelationForViewVariant joins its sibling mapping in scope.ts with
unit coverage.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issues): ease the save-view dialog's expand and validation transitions

The display-defaults section snapped open/closed and the name-required
hint popped in. The panel now runs the Base UI height transition
(--collapsible-panel-height + data-starting/ending-style — the
tw-animate collapsible keyframes only know Radix's variable, so the
accordion's animate-* classes cannot be reused here), and the hint eases
in with the house animate-in vocabulary. Scoped to this dialog: the
shared Collapsible primitive stays untouched because chat/transcript
panels grow while open and a pinned panel height would clip them.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issues): filter chips ease in

Chips popped into the bar and the save-view dialog's embedded list with
no transition. Each chip now enters with the house fade+zoom vocabulary,
keyed per dimension so value edits inside an existing chip don't
re-animate. No exit animation by design.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): drag-reorder the view bar, tab context menu, pin views to sidebar

The bar's tabs (built-ins and saved views alike) now reorder by drag,
writing the same per-user preference document the manage dialog edits —
one list, two editors. Saved-view tabs gain a context menu:

- Edit: visible to everyone, disabled without manage permission (the
  greyed row is the signal); Delete: rendered only for the owner or a
  workspace admin on shared views — mirroring the server's
  canManageIssueView — and routed through the shared confirm dialog now
  extracted from the manage dialog. The manage dialog's own affordances
  follow the same widened rule (admins included).
- Pin to sidebar: pinned_item grows a 'view' type (migration 267; create
  validates read access so foreign private views 404). Sidebar pin rows
  resolve the view's name via the new GET-by-id options, render the
  view-bar icon, activate the view in the store and navigate to its
  owning surface on click. Flag-off clients keep view pins dormant
  instead of auto-unpinning; deleted views follow the existing
  404-auto-unpin pattern.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): review fixes — guard preference wipes, never auto-unpin views

Two data-loss paths from review, plus hardening:

- A drag landing before the views list loaded pruned the preference
  document against an empty list, silently erasing every saved-view
  entry from order and hidden. savePrefs now refuses to write until the
  list has actually loaded (viewsReady threads down from
  useActiveIssueView).
- View pins are exempt from 404-auto-unpin: the detail endpoint also
  404s when the feature flag is switched off server-side, and a client
  that booted with the flag on cannot tell the difference — a flag flip
  would have deleted every view pin permanently. Deleted views' rows
  hide instead.
- POST /api/pins with item_type=view now sits behind the same feature
  gate as every other view endpoint.
- SortableBarTab: drop dnd-kit's aria attributes (they nested a second
  focusable role=button around the tab), move the wasDragged ref into an
  effect, and clear it after a cancelled drag so the next real click
  isn't swallowed.
- Sidebar view pins carry ?view= in their href so a web reload stays on
  the view; scope resolution collapsed to one branch driving both the
  path and the container key.
- Malformed GET /api/issue-views/{id} responses pinned by test as null
  (hide the row), never an error (which alone may unpin).

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): single-row view bar with overflow popover

The bar no longer wraps: tabs that don't fit collapse into a trailing
popover, Linear-style, with two differences — built-ins are first-class
orderable entries, and the whole list (hidden rows dimmed) lives in the
pulled-down panel with drag reorder, per-row actions (edit/delete under
the same permission rule, pin, hide/show) and a new-view entry.

- useSingleRowFit (views/common): hidden mirror row measures natural tab
  widths; ResizeObserver + change-guarded per-commit remeasure computes
  the fitting prefix with trigger reservation. First reusable
  collapse-into-menu primitive in the repo.
- The open view is always visible: when it overflows it takes the
  trigger slot, showing its name + chevron (wider reservation; the
  two-pass promotion settle is monotonic, no oscillation).
- Every tab now shares one max width (max-w-40) and truncates with the
  full name in tooltips/rows.
- ManageViewsDialog retired: the popover carries all of its duties
  (reorder, hide/show incl. built-ins, edit/delete); hiding stays
  impossible for the anchor built-in and still exits a hidden open view.
  DeleteViewConfirm and ViewBarItem move to view-bar-popover.tsx;
  orphaned i18n keys dropped, row action keys added x4 locales.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): restore [layers] menu + manage dialog; 'more' appears only on overflow; fix zero-width bar

The single-row rework overreached: it replaced the existing new/manage
entries and dialog with an all-in-one popover, and the bar's container
collapsed to zero width (content-sized parent + measurement feedback),
rendering an empty header. Back to the intended shape:

- The [layers] menu (new view / manage views) and the manage dialog are
  restored exactly as before; DeleteViewConfirm and ViewBarItem stay
  shared from view-bar-popover.
- The 'more' trigger exists ONLY while tabs overflow, showing just the
  overflowed entries (click to open; per-row edit/delete/pin/hide with
  the same permission rules — those tabs have no rendered surface to
  right-click). The open view still takes the trigger slot when it
  overflows.
- Header view-bar slots gain flex-1 so the fit measures a real width;
  reserve tiers (menu / +more / +promoted) settle monotonically.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): list panel shows ALL tabs with drag reorder; trigger always visible

The pulled-down panel now lists every bar entry (built-ins included) in
bar order with vertical drag reorder — since the bar renders the longest
fitting prefix, reordering in the panel directly decides what sits on
the bar versus tucks away, with a separator marking the current fold.
The trigger no longer appears/disappears with overflow: it is a fixed
part of the bar's right side (still replaced by the open view's own tab
when that view overflows). Row actions (edit/delete/pin/hide) and the
[layers] new/manage entries are unchanged.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): kill the post-drop bounce with a synchronous order mirror

The preference mutation is optimistic but its onMutate is async: on drop,
dnd-kit clears the drag transform in the same frame while the reordered
list lands a beat later, so the dropped tab flashed back to its old slot
and then jumped — the manage dialog never bounced because it mirrors the
new order synchronously in the drag handler. Both bar drag and panel
drag now route through applyMove: set a local order mirror in the same
tick, persist, and clear the mirror once the preference data catches up.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): panel row menu is always visible, not hover-revealed

Hover-reveal made the three-dot row menu unreachable on touch devices;
it is a primary affordance in the list panel, so it renders always.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): long view names truncate/wrap everywhere

The manage dialog's row strip is a grid item — min-width:auto let an
80-char name blow past the dialog frame, carrying the row controls with
it. Pin the wrapper (min-w-0 + overflow-hidden) and the rows (min-w-0)
so labels truncate as designed. The delete confirmation and the save
dialog's scope hint interpolate names/titles into prose — break-words
so an unbroken name wraps instead of overflowing. Audited every other
name surface (bar tabs, promoted trigger, panel rows, sidebar pins,
context menus): all already bounded.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(issue-views): remove the saved_issue_views_v1 feature flag

The feature ships unconditionally. The flag never reached any released
client, so — unlike the retired flags that live on as always-on compat
keys — the key is removed outright on both sides:

- server: the issueViewsEnabled 404 gate drops from every view endpoint,
  the preference endpoints and the view pin type; the key leaves the
  registry and the frontend-public list; gate tests and the per-test
  enable helper go with it
- frontend: the === true checks, the pre-view-bar fallback tab rows in
  both headers, and useActiveIssueView's enabled parameter are deleted;
  the view bar is now the only rendering
- compat holds by construction: a NEW client against an OLD backend gets
  404s on the list/preference queries → empty views, viewsReady stays
  false (no preference writes), sidebar view pins hide without
  auto-unpinning (comment updated to name this as the reason); an OLD
  client against the NEW backend reads an absent flag as false and keeps
  the feature hidden

Full-suite run also surfaced that the workspace deletion manifest never
classified issue_view / issue_view_preference: both now fall with the
other issue roots in DeleteWorkspaceIssueRoots (no-FK policy: explicit
teardown), and the manifest records the decision.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): fit reserve applies even when every tab fits

The all-fit fast path skipped the reserve — a leftover from when the
overflow trigger only rendered on overflow. With the list trigger and
the [layers] menu now permanent, a row whose tabs exactly filled the
container declared everything fitting and overflow-hidden clipped the
trailing chrome. The reserve is now subtracted unconditionally.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): a freshly created view opens itself

Creating a view now activates it on its surface immediately — the view
you just saved is the one you meant to be looking at. Skipped only when
the create response fails to parse (the list refetch still shows it).

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): creating a view no longer bounces the surface to the default tab

Activation raced the list invalidation: the fresh view's id was set
active before the refetched list contained it, the stale list read as
'view deleted', and the missing-view fallback kicked the surface back to
the anchor tab (with an 'unavailable' toast). Two-sided fix: the create
mutation seeds the created view into its scope's list cache before
invalidating, and the missing verdict now requires the list to not be
mid-refetch.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): release-gate fixes — rollback safety, limits, old-client pin compat, revoke sweep

Addresses the pre-ship review blockers:

- CI styles: text-muted-foreground/70 -> text-faint-foreground (solid
  tone rule) in the header hint and the dialog my-note; the chip emoji
  stack's text-[9px] -> text-micro (type-scale rule). apps/web
  text-contrast + type-scale suites pass.
- migration 269 down normalizes workspace/project members/agents
  variants to NULL before restoring the old constraints — verified on a
  scratch DB with real variant data (up -> write -> down).
- view pins are withheld from the legacy /api/pins contract unless the
  client opts in (?include=view): old Desktop builds classified any
  non-issue pin as a project pin and permanently auto-unpinned it on
  404. New clients opt in; regression test pins one and asserts the
  legacy list never leaks it.
- write hardening: MaxBytesReader (128KB) on view create/update and
  preference PUT; isJSONObject rejects JSON null (was a DB-CHECK 500);
  per-owner view quota (100/workspace); list query LIMIT 200. Boundary
  tests for null blob, oversized body, and quota.
- member revoke now sweeps the departed member's PRIVATE views and their
  view-bar preferences in the same transaction (shared views stay, same
  rule as private quick actions) — the migration comments promised this;
  test covers private-gone/shared-stays/prefs-gone.
- sidebar comment no longer claims project ?view= reload support.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): deleting a view sweeps its sidebar pins on every path

View pins never auto-unpin client-side (old-backend safety), so a pin
whose view is gone was invisible in the UI and unremovable forever —
and could leave an empty-looking Pinned group. All three deletion paths
now sweep matching pinned_item rows atomically in the same statement
via CTEs: direct view delete, project deletion (its scoped views), and
member revoke (their private views). Regression tests cover each path.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-10 11:55:05 +08:00
Xichang(Seacen) Zhao cdf93e4914 MUL-5892 fix(chat): cancel queued tasks when a session is archived, and stop telling non-Slack sessions they have no channel (#6595)
* fix(chat): cancel queued tasks when a session is archived, and stop telling non-Slack sessions they have no channel

Two channel-agnostic fixes found while auditing WeCom; both help Lark and
DingTalk equally.

**Archiving left queued tasks claimable.** SetChatSessionArchived deletes the
channel binding but never cancelled the session's in-flight tasks, and
ClaimAgentTask does not read chat_session.status. So the daemon still ran the
turn — and with the binding row already gone, the runtime brief described a
private web chat while the channel adapter still held the chat id and posted
the answer into the group. The person who archived the conversation never saw
it happen.

The cancel now runs first, in the same transaction. DeleteChatSession has
always done this, for the reason its own query comment gives — "so the daemon
doesn't keep running work whose result has nowhere to land" — and archiving
means the same thing.

**The empty-history note lied to every non-Slack session.** The reader is
Slack-only, so WeCom, Lark and DingTalk all land on "this conversation is not
connected to a chat channel", which is false. The reader is an agent deciding
who can see its answer: told it is in a web-only conversation it reasons
differently than told it is in a group whose backlog it cannot read. The note
now names the platform, and the response carries channel_type. Written
channel-agnostically — a per-platform lookup here would go blind the next time
a channel is added, which is how this came to be wrong.

Two tests against a real database. Both drive the real paths, not the helpers:

  task status after archive = "queued", want cancelled — the daemon will still
  run this turn, and with the channel binding already gone its answer goes to
  the room while the brief says private

  a WeCom-bound session was told it is not on a channel: "This conversation is
  not connected to a chat channel, so there is no channel history to read."

* fix(chat): broadcast the archive cancellation instead of only writing it

Review follow-up on the archive-cancel path.

SetChatSessionArchived dropped the rows CancelAgentTasksByChatSession
returns, so the cancellation reached the database and stopped there. None
of the post-commit lifecycle ran: no captureTaskCancelled (the tasks' 24h
mat_ tokens stayed live), no ReconcileAgentStatus (an agent whose only
task was cancelled kept showing 'working'), no task:cancelled (other
clients kept the turn on screen until something else forced a refresh),
and no notifyTasksFinished (the runtime waited for the daemon's next poll
before claiming the next task). The query is not limited to never-started
work — it also matches dispatched, running, waiting_local_directory and
deferred — so this is most visible when a running turn is cancelled.

Keep the rows and hand them to TaskService.BroadcastCancelledTasks after
the commit, which is what DeleteChatSession has always done twenty lines
below.

The empty-history response also read the binding twice, once for
channel_type and once inside noHistoryNote. An archive landing between
the two reads could answer channel_type "wecom" next to a note saying the
conversation is not connected to a chat channel. Resolve the channel type
once and derive both from it; noHistoryNote becomes a pure function of
that value, so the two cannot disagree.

TestArchivingAChatSessionCancelsItsQueuedTasks now queues a running turn
alongside the queued one, marks the agent 'working', mints a task token,
and asserts the post-commit side as well as the status column: agent back
to 'idle', task:cancelled for both rows, token revoked. Without the
broadcast it fails on all three. The history test drives the real
response path on both legs and pins channel_type against the note.

* fix(chat): scope the archive cancel to sessions that were on a channel

Review follow-up. SetChatSessionArchived cancelled on every archive, with no
check for whether the session had ever been bound to a channel. That is wider
than the bug: the failure this fixes is a channel-bound session answering into
a group room after its binding was dropped, and a plain web chat has no room,
no adapter and no late answer. Cancelling there was a new product behaviour
riding along with the fix, and it contradicts what the UI says archive is.

A session row offers Stop or Archive, never both (chat-thread-list.tsx:364-377).
Stop is `danger` behind a confirmation row; Archive fires immediately with none.
Archive is the reversible step: unarchive exists, and hard delete is offered
only once a chat is archived (chat-session-header.tsx:169-186). The two also
cost the user different things. Stop goes through CancelTaskByUser, which hands
the typed prompt back as cancelled_chat_message.restore_to_input or a durable
chat_draft_restore row; CancelAgentTasksByChatSession is a bare UPDATE with
neither, and BroadcastCancelledTasks does capture, reconciliation, the event and
the runtime wakeup but no draft restore. So an unconditional cancel let one
unconfirmed click destroy a prompt that unarchiving cannot bring back.

The cancel now runs only when GetChannelChatSessionBindingBySessionAny finds a
binding, read before DeleteChannelChatSessionBindingBySession erases it. A read
error that is not ErrNoRows fails the archive rather than silently skipping the
cancel — "not bound" and "cannot tell" must not take the same branch.

This also settles the header's Archive item having no isRunning gate: archiving
a running web chat from the dropdown now cancels nothing, so it no longer
bypasses the confirmation the Stop button requires. Gating that item is a
frontend question and stays out of this PR.

The tests are one fixture archived twice. The bound leg is the previous test
with the binding it was always describing — its failure message named an answer
reaching the room, but it never created a room. It keeps every post-commit
assertion (queued + running cancelled, agent back to idle, task:cancelled for
both, token revoked) and adds that the binding is still deleted. The web-only
leg asserts the opposite: both turns still in flight, agent still working, no
task:cancelled, token still live, session still archived. Dropping the cancel
fails the first; making it unconditional again fails the second; reading the
binding after the delete fails the first.

* fix(chat): refuse a debounced chat enqueue onto an archived session

Review follow-up. The archive cancel only reaches tasks that already exist,
and the channel run trigger is debounced by DefaultChatRunBatchWindow: the
message row is persisted on arrival, the task row is created a window later
when flushChatRun reloads the session and calls EnqueueChatTask. Archive
inside that window and the cancel finds nothing, drops the binding and
commits — then the flush enqueues onto a conversation the user closed.
ClaimAgentTask does not read chat_session.status either, so the daemon runs
it: runtime and quota spent on a closed conversation, assistant messages
written into an archived chat, the agent flipped back to 'working', and with
the binding already deleted the brief describes the run as a private web chat.

EnqueueChatTask was the one enqueue path without the guard the direct-send
path and OpenMikaOnboardingChat already take: lock the chat_session, re-read
it, refuse a status that is not 'active' with ErrChatSessionArchived. It now
takes it as its first statement, so the chat_session -> agent_task_queue order
is unchanged.

The lock is LockChatSessionForEnqueue (FOR NO KEY UPDATE), not the FOR UPDATE
the send, delete and draft paths take, and the difference is deliberate. Those
paths want to block INSERTs that reference the row: FOR UPDATE conflicts with
the FOR KEY SHARE an FK insert takes on its parent, which is how
LockChatSessionForDelete keeps a send from slipping a task in between its
cancel and its delete. Here that conflict is the cost, not the point — every
inbound channel message is an INSERT into chat_message FK'd to this row, so
FOR UPDATE would make a room's next message wait for the previous message's
enqueue. Measured on pg17 while the row is held: an append blocks under FOR
UPDATE and does not under FOR NO KEY UPDATE, while SetChatSessionArchived's
UPDATE (status is not a key column) blocks against FOR NO KEY UPDATE in both
directions. Serialisation against the archive is kept, ingestion is not
serialised behind it.

flushChatRun needs no change: it clears the typing indicator before the error
switch, and this error is not one of the two the switch names, so it falls to
the default log branch and posts nothing into a room the archive just unbound.

Three tests. The handler one is the case the existing archive tests cannot
reach — message persisted, debounce not yet flushed, archive, then flush —
asserting no task row and no task:queued; removing the status check fails it
naming the run. The service one holds the lock mode from inside the enqueue
transaction: a concurrent archive must block, a concurrent append must not.
The router one pins that an archived-session flush clears typing and says
nothing.

Also carried over from review: sessionChannelType returned "" for every
binding-lookup failure, so a transient database error answered 200 with the
"not connected to a chat channel" note this PR removes — telling a WeCom or
Lark session it is web-only because a query failed. It returns (string, error)
now, with only pgx.ErrNoRows mapped to empty, and the caller reports the rest.

And the bug's own description was wider than the bug: the outbound senders
resolve their destination through the binding row the archive deletes, so a
late answer has no route left and does not reach the room. The handler
comment, the test file header and the failing assertion now say what actually
happens — a wasted, mis-briefed run against a closed conversation.
2026-08-10 11:45:31 +08:00
Xichang(Seacen) ZhaoandClaude Opus 5 f3a7fce8d8 fix(chat): a predecessor's reply must not seal the next message out of its own batch (#6611)
On a self-hosted tenant a user sent a WeCom message at 18:31:17.7 while the
previous agent turn was finishing. The engine enqueued a task for it at
18:31:20.7 and cancelled the task 27ms later:

    ERR chat claim: task-owned direct task has no user input; cancelling
        task_id=bdc0947e chat_session_id=2fccd3c9 chat_input_task_id=bdc0947e

The database afterwards:

    chat_message "挺好的 正常了" (18:31:17.726997)  -> task_id = NULL
    every other user message in that session        -> task_id set
    select count(*) from chat_message
      where task_id='bdc0947e' and role='user'      -> 0

The user's message was accepted, a task appeared and instantly vanished, and
no answer ever came. On WeCom the typing bubble also spun forever, because the
indicator only unsubscribed on task:failed, not task:cancelled.

Mechanism

Channel messages (Slack / Lark / WeCom) are written durably and unowned on the
ACK path; the 3s silence debouncer later fires one flush per session, and
EnqueueChatTask creates the task, stamps chat_input_task_id = id, and seals the
waiting messages into it with LinkUnownedChannelChatMessagesToTask. That seal
skipped any unowned user row that had a non-user row after it. The rule was
carried over from the pre-ownership trailing-message selector, and it does not
survive the debounce window: a predecessor completing inside that window writes
its reply AFTER the waiting message, so the reply to an already-sealed batch
became the boundary for a message it never saw.

    18:31:04.6  user "测试下"                     unowned
    18:31:07.6  task 6a4ae7f8 created, seals it
    18:31:17.7  user "挺好的 正常了"               unowned, 3s window arms
    18:31:20.2  6a4ae7f8 completes, writes its reply   <- newer than the message
    18:31:20.7  window expires, task bdc0947e created, seal matches ZERO rows
    18:31:20.7  claim finds no input, cancels bdc0947e

Every timestamp above is from the tenant's own tables. The claim guard at
internal/handler/daemon.go:2347 is right to fail closed on an empty input
batch; the batch should never have been empty. Its comment blamed a send path
that does not commit message and task together — SendDirectChatMessage does,
and the channel path does too. Neither was the problem. The seal predicate was.

Fix

The boundary is "already answered", not "something newer exists". A reply can
only be a reply TO a message if the turn that wrote it started AFTER the
message arrived. A turn already running when the message landed sealed its own
input before the message existed, so its reply — however late it lands —
answers an earlier batch and must not move the boundary. The seal now qualifies
each candidate boundary row by its turn's creation time, resolved through
COALESCE(chat_input_task_id, id) so an auto-retry clone is judged by the root
batch it inherited rather than by its own fresh id.

The check is kept, not dropped, and deliberately stays conservative: a non-user
row whose task is NULL or gone still counts as a boundary, so pre-ownership
transcripts are never swept into a new batch, and a message already stranded by
this bug is not resurrected into a turn minutes later — a following turn that
ran past it holds the boundary.

Tests

  internal/handler/chat_channel_debounce_boundary_test.go drives the whole
  failure: message in, claim, run, second message while it runs, predecessor
  completes, window expires, claim. Before the fix:

    --- FAIL: TestChannelChat_ReplyLandingInsideDebounceWindowStillAnswersTheNextMessage
        chat_channel_debounce_boundary_test.go:134: claim = 500 {"error":"chat task has no user input"}
             (task <nil>); task 2a67492b-cd61-45e6-afd6-156079971152 owns 0 user messages and is now "cancelled". The user's message was accepted and never answered.

  internal/service/task_channel_seal_race_test.go pins the same boundary at the
  seal, twice — the sequential ordering the tenant hit, and the tighter window
  where the reply commits on another connection after the enqueue transaction
  has begun (same raceInjectTxStarter the media-deferral race test uses), so
  the seal statement's READ COMMITTED snapshot is the first to see it. Before
  the fix:

    --- FAIL: TestEnqueueChatTaskSealsMessageStrandedByPredecessorReply
        task_channel_seal_race_test.go:78: waiting user message task_id = "", want the new turn "34b62bad-955c-46f0-99e9-2194999d2c10" — a predecessor reply that landed after the message must not seal it out
    --- FAIL: TestEnqueueChatTaskSealsMessageWhenReplyCommitsDuringEnqueue
        task_channel_seal_race_test.go:142: waiting user message task_id = "", want the new turn "989a83e4-e945-4b36-a5b7-69720449e08c"

  The third, TestEnqueueChatTaskLeavesAlreadyPassedMessagesUnowned, passes both
  before and after by design: it is the guard against over-correcting, and only
  a fix that swept too much could break it.

The two transactions cannot interleave more finely than this: CompleteTask
takes chat_session FOR UPDATE and the task INSERT takes the FK's FOR KEY SHARE
on the same row. "Predecessor commits, then the flush runs" is the reachable
interleaving, and it is the one the tenant hit.


Claude-Session: https://claude.ai/code/session_01MKFf7wYM9Q2iaPtoFhG8MV

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-08 19:14:51 +08:00