Commit Graph
65 Commits
Author SHA1 Message Date
Xichang(Seacen) ZhaoandJ da139a7c97 fix(wecom): name the two exits that leave a reply unaccounted for (MUL-7318) (#8348)
The WeCom outbound path had two silent exits: a completion whose delivery row
named another platform, and one with no delivery row at all. From outside they
were one quiet return, so an answer that reached the transcript but never the
chat could not be told apart from ordinary traffic on the shared bus.

Both now name themselves in the closed outcome set: not_wecom_turn (DEBUG),
no_delivery_row (WARN, the one skip worth an alert — in practice a task
enqueued before an upgrade), and route_unattributable (DEBUG) for a turn with
no route and no batch owner to attribute it to. The metrics Help and logs draw
the drop/skip line by obligation rather than by whether a frame was written,
and tests pin that wording.

The origin gate is one keyed read (GetTaskChannelOrigin) in place of
GetAgentTask plus TaskInputIsChannelIngested. It also reports whether the
batch owner is known, so an owner-less row is delivered when a route exists
and stays quiet when none does — which keeps the auto-retry of a legacy
channel turn answering its room.

Co-authored-by: J <[email protected]>
2026-09-22 13:23:25 +08:00
361edd90a1 MUL-7342: index chat session delete lookups (#8375)
* fix(db): index chat session delete lookups

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

* fix(db): address migration review

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-14 14:59:56 +08:00
c784d539b0 MUL-7247: bound cancelled chat pointer lookup (#8259)
* perf(db): index cancelled chat session guard

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

* test(db): remove planner-coupled migration test

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-10 18:35:39 +08:00
YYClaw 9e2d9b6d1c MUL-6661 feat(channels): add /new and /clear conversation controls (#7468)
* feat(channels): add new and clear conversation controls

* fix(dingtalk): resolve bot identity before dispatch

* feat(chat): expose channel-created chats consistently

* refactor(channels): snapshot task delivery routes
2026-08-25 18:28:01 +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
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
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
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
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
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
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
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
47f6e970f6 feat(onboarding): write Mika's opening on the server instead of running an agent (MUL-5827) (#6520)
* refactor(onboarding): drop the cross-workspace returning-member branch (MUL-5827)

Mika's onboarding kickoff carried a `returning` flag, set by the frontend
whenever the member was creating an additional workspace. It told the model
"this member has onboarded elsewhere; keep the introduction to one line".

Every workspace onboards from scratch. A member's second workspace may carry
entirely different work, different collaborators, and a different reason for
existing — compressing its opening on the strength of an unrelated workspace
only makes this one worse. The flag was also the single place where one
workspace's state reached into another's first conversation.

Removed end to end: the request field, the prompt note, the API client type,
the bootstrap input, and the `returning: isNewWorkspace` call site. Older
desktop builds that still send `returning` are unaffected — encoding/json
ignores the unknown field, which is exactly the new behavior.

The built-in skill never branched on this, so SKILL.md needs no change.

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

* feat(onboarding): write Mika's opening on the server instead of running an agent (MUL-5827)

The first message a new member ever read cost a runtime cold start plus two
model round trips: the opening was produced by a full chat task, so they
watched a spinner for tens of seconds, and a run that failed introduced Mika
as a red error bubble.

Nothing in that reply needed an agent. The multica-onboarding skill already
pinned it to four beats under a length budget, it reads no workspace state and
calls no tool, and every input it personalizes on — language, workspace name,
agent display name — is already in the request. It also never generated quick
actions (starter cards replace them), so the run bought nothing.

The endpoint now writes two rows in one transaction and enqueues nothing:

  - the opening the member reads, final on arrival, kind onboarding_opening so
    the starter cards still render under it;
  - the hidden kickoff, deliberately WITHOUT a task.

The member's first real message adopts that kickoff into its input batch. That
is what carries the onboarding skill instruction and the profile block into the
run that does the first real work, and — because the kickoff quotes the opening
verbatim — what stops Mika introducing herself a second time. She has no memory
of an opening the server wrote.

The cold start does not disappear; it moves to the member's first real request,
where they are waiting on something they asked for with content and three
one-click starter cards already in front of them.

Three shared invariants that a two-row input batch newly exposes:

  - Both rows are written in one transaction, so DEFAULT now() gives them an
    identical timestamp. The session-list LATERAL picks the last message with
    no tiebreaker and ids are random UUIDs, so a tie could select the kickoff —
    whose kind makes buildChatLastMessage return nil, reporting no last message
    and bringing back the "Start with Mika" card after a perfect onboarding.
    The opening is written one microsecond after the kickoff.
  - DeleteUserChatMessageByTask deleted every user row of a cancelled turn, so
    cancelling the first message would destroy the only copy of the onboarding
    context and could hand the member the product's internal prompt as their
    restored draft. It now excludes the kickoff, and callers release it back to
    unowned so the next send re-adopts it.
  - Both reanchor queries would move the kickoff to dispatch time, putting the
    product's context AFTER the member's message inside one batch. Both now
    exclude it. The regression test fails without that guard.

Also drops the completion-path rule that stamped a kickoff-input turn's reply
as the opening: with the kickoff riding into the first real turn, it would
render the starter cards a second time under a reply that is not an opening.

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

* fix(onboarding): keep stamping the opening for a pre-deploy kickoff turn (MUL-5827)

The server writes the opening now, so no new task produces one — but a kickoff
task enqueued by the previous server can still be claimed by this one during a
rolling deploy, and its reply IS that member's opening. Dropping the stamp
outright left it a plain 'message', and message_kind is persisted with nothing
to recompute it: that member's onboarding session would render without the
starter cards forever.

Gated on "the input batch is a kickoff and nothing else", which is exactly the
old shape. The new kickoff always rides in alongside a real member message, so
the member's first answer still cannot be mistaken for an opening.

Deletable once no pre-deploy kickoff task can still be in flight.

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

* fix(onboarding): release the kickoff when the first turn fails terminally (MUL-5827)

Caught in a live environment, not by a test. The member's first turn died in
preparation, the adopted kickoff stayed bound to that dead task, and their next
message reached Mika with no onboarding context and no record that she had
already greeted them — so she introduced herself a second time, in a
conversation she had already opened. That is the exact failure this design
exists to prevent.

The cancel path already released the kickoff. The failure path did not, and a
first-turn failure is entirely ordinary: runtime offline, agent CLI missing,
timeout, prepare error.

Released in FailTask under the existing retried == nil branch, which is the
terminal-failure condition. Deliberately not on the retry path: a retry child
inherits the root's chat_input_task_id and the kickoff stays bound to that
root, so the retry still reads it — releasing there would strip the context off
a turn that is about to run.

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

* fix(onboarding): hand the kickoff to a queued successor, not to nobody (MUL-5827)

Review catch. Adoption happens inside a send's transaction, so a message the
member queues WHILE the kickoff's turn is still running finds nothing to adopt
and never gets another chance. Releasing the kickoff to NULL when that turn
died therefore left the already-sealed successor to execute with no onboarding
skill, no profile block, and no record that Mika had already greeted them —
the double introduction this design exists to prevent. Worse, a message sent
later could adopt the orphan instead, delivering the context to a turn that had
nothing to do with the opening.

ReleaseOnboardingKickoffFromTask now re-targets the session's next un-started
turn, falling back to NULL only when there is none. Three restrictions on the
target, each load-bearing:

  - status = 'queued' only. A dispatched/running successor has already built
    its prompt from its input batch, so joining it now would consume the
    kickoff without ever delivering it; it waits unowned for the next send.
  - chat_input_task_id = id selects roots that own their input batch. A retry
    child names its root, and the kickoff is already reachable through that
    root, so retries must not be re-targeted.
  - regenerate_quick_actions_for IS NULL skips background suggestion passes,
    which carry no user input.

Ordering matches the shared visible-head selector, so the kickoff lands on the
turn the member will actually see run next.

Covers both directions: A-owns/B-queued/A-fails hands off in the right order,
and an already-dispatched successor is skipped. The first test fails against
the previous NULL-only release.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-07 18:48:07 +08:00
YYClaw 318d51377f MUL-5870: fix(chat): hide channel issue commands from Chat
Hide shared channel /issue commands from public Chat projections while preserving issue creation, ordinary conversations, and cleanup paths.\n\nCloses #6571.
2026-08-07 17:22:45 +08:00
YYClaw 7a809ddf80 feat(channel): materialize inline media references (MUL-5835) (#6529)
Replace adapter-generated inline media placeholders in channel message bodies with authenticated attachment references, preserving original image positions.

Closes #6528
2026-08-07 11:12:45 +08:00
3c9b861de3 MUL-5765 feat(chat): starter cards under Mika's onboarding opening (#6505)
* MUL-5765 feat(chat): starter cards under Mika's onboarding opening

Replace the LLM quick-action chips on Mika's onboarding opening with three
product-fixed starter cards (board / delegate / digest), so a new member
never starts from a blank canvas. Clicking a card sends its fixed prompt as
a visible member message through the existing quick-action path; cards
follow the chips' disabled rule while a task runs and stay clickable in
history. Older clients keep the LLM chips untouched.

The multica-onboarding skill gains matching starter plays (one-question
budget each) and a digest-only exception to the no-autopilot rule.

Co-authored-by: multica-agent <[email protected]>
(cherry picked from commit 32364cea89)

* MUL-5765 refactor(chat): drop pre-release compatibility around starter cards

The onboarding flow has not shipped, so no old client exists to protect
(per review): the server now skips the quick-actions pass for the opening
turn (new TaskHasOnboardingKickoffInput gate) instead of generating chips
the new client would hide, and the multica-onboarding opening points at the
cards directly — beat 4 is the mock's bridge line again.

Co-authored-by: multica-agent <[email protected]>
(cherry picked from commit 26e5242d0b)

* MUL-5765 fix(chat): stamp the onboarding opening so starter cards actually render

The cards never showed in the field: the client detected the opening by
finding the hidden kickoff row, but visibleChatMessages strips that row from
list responses — and the client zod schema coerced unknown message kinds to
'message' anyway, so the signal died twice before reaching the detector.

The opening now self-describes: writeChatCompletionOutcome stamps the reply
to a kickoff-input task with message_kind 'onboarding_opening', the schema
and type unions carry the new kind, and the client keys the cards off it
directly — pagination-proof, and the hidden kickoff stays hidden. The
explicit quick-actions eligibility gate is gone: a non-'message' kind
already skips the pass at both call sites. The kickoff prompt also forbids
pre-reply narration ('I'll load the onboarding skill first.' leaked into the
field opening).

Co-authored-by: multica-agent <[email protected]>
(cherry picked from commit ffacd3d02c)

* MUL-5765 fix(chat): stamp the opening on the input owner, not the retry clone

The kickoff check asked TaskHasOnboardingKickoffInput about task.ID. An
auto-retry clone gets a fresh id while inheriting the root's
chat_input_task_id (MUL-4351), and the kickoff user row stays bound to the
root -- so the child answered false.

An opening that only succeeded after a retriable failure (provider_network,
runtime recovery) therefore persisted as a plain 'message': no starter cards,
and a chips pass generated for a turn whose copy invites the member to pick
one of the cards that never rendered.

Keyed on chatInputOwnerID now, which exists for exactly this provenance
question. The query doc states that its argument is the input-owning id, so
the next caller does not reintroduce task.ID.

The regression test drives the real completion write with
child.ID != child.ChatInputTaskID; it fails on the old call site with
kind "message".

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

* MUL-5765 fix(onboarding): give the digest schedule a timezone to name

The digest starter play proposed "every morning at 09:00" while the kickoff
profile carried only workspace name, role and use case. `autopilot trigger-add`
defaults an absent --timezone to UTC (DefaultAutopilotTriggerTimezone, and the
flag's own help), so a member outside UTC could confirm a morning summary and
receive an afternoon one -- a wrong assumption that recurs daily.

The member's IANA timezone now travels in the profile block, stated as a value
either way and emitted before the skipped-questionnaire early return: the
digest card is clickable whether or not the questionnaire was answered, and
"unknown" is the case the skill has to handle rather than paper over. What to
do with it lives in the skill, since that block declares itself data and never
a command.

The digest play must now quote the whole time ("09:00 Asia/Shanghai") and pass
--timezone; when the zone is unknown it spends the one allowed question there
instead of falling back to UTC silently.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 16:54:45 +08:00
5a618e0925 fix(agents): stop resuming a session that can't resolve its provider auth (MUL-5803) (#6482)
* fix(agents): stop resuming a session that can't resolve its provider auth

A Hermes agent on a self-hosted install can get permanently stuck on a single
issue while every other issue on the same agent keeps working. The task
terminates with:

  hermes provider error: "Could not resolve authentication method. Expected
  either api_key or auth_token to be set. Or for one of the X-Api-Key or
  Authorization headers to be explicitly omitted"

and no amount of retry / Rerun recovers it.

Root cause (resume-pointer poisoning): every daemon version classifies this
text as agent_error.unknown, which is resume-safe. So GetLastTaskSession /
GetLastChatTaskSession keep returning the same failed session to every retry
and Rerun on that (issue, agent) pair, deterministically reproducing the auth
error forever. Other issues use fresh sessions, so they're unaffected — the
codebase's repeated "(agent, issue) permanently stuck" pattern (GH #6066 /
#5760 / #6360, MUL-5722), with this error falling through every prior defense.

The fix rests entirely on text guards; the classifier is deliberately left
untouched. Reclassifying this under missing_config would flip freshSessionMayHelp
to false and silently disable the in-turn fresh-session retry on the five
ResumeRejectionUndetectable backends — contradicting the (correct) diagnosis
that a fresh session cures it:

- service/task.go ResumeUnsafeFailure: text guard so manual Rerun and the
  fallback claim path start fresh rather than replaying the dead session.
- GetLastTaskSession / GetLastChatTaskSession SQL: ILIKE exclusion so
  already-wedged issues recover on their next trigger without a daemon upgrade.

Tests pin both halves: a freshSessionMayHelp regression (must stay true for
this error), the Go ResumeUnsafeFailure cases, and SQL exclusion + narrowness
regressions for both query families.

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

* chore: address PR review nits

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

---------

Co-authored-by: qiushihao279-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 14:39:24 +08:00
b99b04bb86 feat(onboarding): Mika issue-first onboarding (#6378)
* feat(onboarding): Mika issue-first onboarding

Replaces the starter-agent welcome with one conversation: onboarding creates
Mika, the workspace's built-in Chief of Staff, and opens a real chat whose
first turn is a product-authored kickoff hidden from the transcript. Every
workspace — first and subsequent — is created through this flow.

Mika is a system agent, not an agent-template instance. Her product prompt is
//go:embed-ed and composed at claim time, so a release updates it without
touching any workspace's row; the row holds only the workspace's own notes.
Creation is server-owned and idempotent under a per-workspace advisory lock,
and archiving a system agent is rejected.

This is the pre-merge half of the branch, squashed while rebasing onto main.
Replaying its fifteen commits individually meant re-deriving each one against
a main they were never written for; the net change reconciles against today's
main in four files, so it is reconciled once, here.

Three of those four are main moving under the branch: MUL-5573 took
quick-actions generation server-side and dropped QuickActionsDisabled /
RegenerateQuickActionsFor from the task payload and the SendDirectChatMessage
signature, so this takes main's shape and keeps only the onboarding entry
point. The fourth keeps main's OnboardingLogoutButton wrapper around the
flow's new mode/onCancel props.

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

* feat(onboarding): let quick-action chips carry the opening's examples

Chat now renders agent-suggested follow-up actions as buttons under a reply
(MUL-5149), and the onboarding kickoff qualifies for that suggestion pass
with no extra wiring — it is a direct chat turn on a web session with a
non-empty reply.

That made the opening's fourth beat redundant: Mika wrote a three-to-five
line menu of example tasks, then three chips appeared underneath offering
the same thing. The prose menu is the worse half — a member has to retype a
line they read, but can send a button — so the beat is gone and the opening
budget drops with it. Measured on a real local run: the first reply went
from 307 to 208 characters, and the chips still arrived 21s after the
kickoff.

The questionnaire profile stays in the kickoff. The suggestion pass resumes
the same provider session, so the profile now steers the chips as well as
the reply; only the sentence naming its purpose changes.

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

* refactor(onboarding): cut the step rails down to what the screen can't already show

The right rail carried more than twice the words of the column members were
actually working in — 42 vs 36 on the workspace step, 91 vs 34 on the runtime
step — and it got heavier the further in the flow went, which is backwards.

Step 3's rail was the clearest case. Its "Good to know" section promised the
runtime was swappable and that more could be added later; the step's own lede
already said both in nine words rather than forty-two, on the same screen. Its
60-word definition of "agent runtime" sat beside a list of named, online
runtimes under a headline reading "This computer is connected" — by then it
answers a question the member has stopped asking. What survives is the one
thing the screen does not show: what that background process is.

Step 2's rail keeps the workspace preview card, which does show something not
otherwise visible, and drops the bullet lists — promises the product is about
to keep on its own.

The freed words did not move to the rail; one moved into the main column. On
the create path "Mika" was never introduced before Step 3 used the name twice,
once on the primary button, so the lede now names the role in an appositive
right above that button. Mika stays ungendered, as everywhere else in the
product.

Net across the three regions: 167 words to 89.

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

* refactor(onboarding): drop the right rail from the workspace and runtime steps

Every remaining rail item was either something the screen already showed or
something the product was about to do anyway, so the column was costing a
member's attention without answering a question they had. Removing it leaves
each step a single full-width column — the shape the questionnaire step has
always had, and the only step nobody has complained reads as sparse.

Gone with it: RuntimeAsidePanel, the workspace preview card and its entity
rows, and 27 copy keys per locale. The two runtime paths (desktop
runtime-connect, web platform-fork) shared that panel, so both lose it in one
move and stay identical.

The welcome step keeps its column — it holds an illustration, not prose.

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

* fix(onboarding): put every step's header and content on one measured axis

Removing the right rail left the four steps geometrically inconsistent in
four ways, all of which the member sees as things moving while they advance.

The header carried the horizontal padding itself, so it ran flush to the
window edge while the content column stayed centred. A 480px rail had been
absorbing that difference; without it the two were 267px apart on a 1283px
window, and since StepHeader is justify-between the step indicator floated
off at the far right, ~270px from anything it labelled.

The rest compounded it: the measure changed between steps (920px on the
questionnaire, 620px after), the header bar and content block used different
vertical padding per step, and padding living inside a max-w box made the
reading width jump from 508px to 620px at the lg breakpoint.

All four now come from step-shell.tsx. Padding belongs to the gutter, never
to a measured box, so the reading width is constant from ~700px up. The
header measures on STEP_FRAME on every step, so the one element that survives
each transition never moves; content picks STEP_FRAME or STEP_COLUMN by what
it holds, and both centre, so a step that needs the width still sits on the
header's centreline. Vertical rhythm is one value.

The header was near-identical in four files, which is how it drifted in the
first place, so it is now one component. Its test pins the invariant that
broke — padding out of the measured box, header measured on the frame — and
fails if either is put back.

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

* fix(onboarding): give the runtime step the frame measure and cut its copy

Nine runtimes in a 620px column meant truncated names, five rows, and
scrolling to reach the last four, with ~330px of dead space on each side.
This is the case step-shell's wider measure exists for, so the step takes
STEP_FRAME — which also puts it on exactly the header's measure — and the
card grid gains a third column at lg. Nine runtimes now land in three rows.

Copy went with it. The headline was two sentences over two lines; the first
one, "This computer is connected", is already said louder by the "9 agent
runtimes · all online" row directly beneath it, so only the instruction
remains. The lede was 44 words and five lines — I had grown it myself adding
Mika's introduction — and is 20 now, still naming the role. The
remote-computer note drops from 25 words to 15.

Prose stays capped at 620px inside the wider frame; a 920px measure is for
the card grid, not for reading.

The found-phase test keyed on the headline copy, so a copy edit read as a
behaviour regression. It now asserts the runtime count row, which is the
signal the test is actually about.

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

* refactor(onboarding): put the workspace step on the shared frame

It was the only step still measuring at STEP_COLUMN, so its eyebrow, headline
and footer CTA started and ended ~150px inside where every other step's did —
the page margins visibly moved when you advanced from step 1 to step 2.

The step now sits on STEP_FRAME like the other two, with a new STEP_MEASURE
capping the prose and the form inside it. Matching the frame is not the same
as widening the field: a workspace name does not want an 800px input, so the
form keeps its reading measure and left-aligns to the frame instead. The
footer row spans the frame, which is what puts the CTA in the same place on
all three screens.

STEP_MEASURE deliberately does not centre — centring would pull the content
off the frame's left edge, undoing the alignment. Its test pins that.

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

* fix(onboarding): close the gap above the step CTA and put Log out back on one row

Two defects that read as one thing on screen: a large empty block sitting
directly above the primary button.

The card list was capped at STEP_MEASURE along with the form. That cap exists
so a workspace name does not get an 800px input — a good reason for a text
field and a bad one for selection cards, which are a list like the runtime
grid. Capped, they stopped 299px short of the CTA, leaving a void above the
button. The cards take the frame now; only the form keeps the reading measure.

Log out came in from main as `fixed right-8 top-8`, pinned to the window
corner. Its own comment says the fixed position exists to survive the flow's
full-bleed layouts — which is what the measured frame replaced, so it landed
outside the measure and above Back / Step N of N as a second header row. It
now rides the header row on the frame.

StepShellHeader takes it as a `trailing` slot rather than rendering it:
calling useLogout inside the shared header forced a QueryClient into five
step test files just to render a header bar. The flow injects it, matching
how runtimeInstructions is already threaded, and the header stays
presentational.

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

* feat(onboarding): make step 3 about Mika, with the runtime as a sub-decision

The step was titled after its dependency — "Pick an agent runtime" — while
the thing actually being created was named only in the grey lede. A member
reached a button reading "Start with Mika" without having been told who that
is, and said so: being confused there is not a failure to read carefully, it
is the page putting the lead role in a footnote.

So the subject changes rather than the step count. The headline names the
outcome, a card carries the introduction with a mark on it, and the runtime
list drops to a labelled sub-section under "Where should Mika run?". Reading
order becomes: who you are getting, she needs a machine, pick one, start.

MikaIntro sits above the phase switch so the subject holds still while the
runtime block below cycles through scanning / found / empty, and each phase's
own heading drops from h1 to h2 now that the page has a real h1.

Mika does not exist yet at this point — she is created on commit — so the
card cannot render her stored avatar. It reuses the mark the Runtimes page
already uses for "Start with Mika", so the two entry points read as the same
thing.

No fourth screen: the introduction and the only decision on this screen are
one beat, and splitting them would add a step between finishing setup and the
payoff. The defect was never a missing screen, it was an invisible
introduction.

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

* feat(onboarding): pick the runtime and the model from dropdowns

Nine runtimes as cards took three rows and left nowhere to put a second
decision. Two dropdowns fit both on one screen, and the model is a real
choice the card grid had no room for.

Both controls already existed on the agents surface — RuntimePicker and
ModelDropdown, the same pair create-agent-dialog uses — so this reuses them
rather than growing a parallel picker. ModelDropdown owns its own discovery,
grouping and unsupported-runtime states, and selecting a different runtime
clears the model because models are per-runtime.

The model now reaches the agent: POST /api/agents/mika takes an optional
model, CreateSystemUserAgent writes it to the column the agent table already
had, and empty still means "whatever the runtime defaults to" — which is what
every deployment without per-agent model support gets anyway.

Also drops the "No local runtime, or prefer a remote computer?" note. It said
in twenty-five words what the Skip button next to it says by existing, and it
appeared on all three phases.

currentUserId comes in as a prop rather than from the auth store: reading the
store inside the step broke six tests that render it without one, the same
coupling the header slot avoided.

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

* fix(onboarding): stop gendering Mika in the intro card

Mika is ungendered everywhere else in the product — the agent instructions,
the onboarding skill and every other locale string avoid a pronoun. The intro
card I added last round reintroduced one in two languages ("she turns it into
an issue" / "她会把它变成一个 issue"), which the Chinese screenshot made
obvious. All four locales now read around it.

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

* feat(ui): port the ReUI stepper primitive

Groundwork for rebuilding onboarding on the @reui/onboarding-3 interaction
model: a persistent vertical stepper with named steps and click-to-return
navigation, which our horizontal "Step N of 3" dots cannot express.

Routed to components/ui rather than the vendor's components/reui namespace —
it is our code now — and rewritten to the role-named type scale (text-xs ->
text-caption, text-sm -> text-label / text-caption, dropping the leading-none
the token already supplies). No other convention fixes were needed: shadcn had
already rewritten the imports, "use client" survived because main's
components.json now sets rsc: true, and it pulls no npm dependency we do not
already have (@base-ui/react is declared).

The block's other twelve registry dependencies are primitives we already own,
so only this one was installed.

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

* feat(onboarding): rebuild the step shell as a named progress rail

Onboarding's chrome was a row of dots and a "Step 2 of 3" counter. It told
a member how much was left but never what was coming, so every step arrived
unannounced -- the same reason the runtime step read as a surprise even after
it was retitled "Meet Mika". Naming all three steps up front makes onboarding
legible as a whole before the first field is filled in.

StepShellHeader becomes StepShell, and it owns the window rather than a strip
at the top: rail on the left, the step's own content scrolling on the right.
That lets the four steps drop an identical wrapper/DragStrip/header/<main>
preamble, including the scroll-fade wiring that was duplicated verbatim in
all four and is now set up once.

The rail is built on the ported ReUI stepper, but deliberately not as a
tablist: that component defaults to role=tablist with each trigger owning
aria-controls on a panel id, which is right for a stepper that renders its
own panels and wrong here, where the panel is a routed step and those ids
would dangle. It uses the presentational slots and marks position with
aria-current instead.

Only completed steps are clickable -- moving forward has to run the current
step's validation and submit, so the rail would skip it. New-workspace mode
gets no rail navigation at all: it enters at the workspace step and, once
that workspace exists, every step behind it is gone. Same invariant
runtimeStepBack already enforced for the Back button.

step_header.step_of is replaced by step_nav across all four locales; Mika
stays ungendered in each.

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

* feat(onboarding): standardise the steps on the shared UI primitives

Follow-up to the rail, fixing what the rail exposed.

The workspace form was three hand-rolled `flex flex-col gap-1.5` stacks with
their own label sizing and a bare <p> for the slug error -- exactly what
Field/FieldLabel/FieldError/FieldDescription standardise. The manual version
had already drifted: its labels were caption-sized and muted while every
other form in the product labels at body weight.

The platform fork wrapped on STEP_COLUMN while the steps before it wrapped
on STEP_FRAME. Both measures centre, so 620px and 920px put their left edges
~150px apart and the headline visibly jumped right on arrival. It now sits on
the frame and caps its own content, which is the pattern step-shell already
documents.

The About you eyebrow read "About you" -- now the rail's label for that very
step, so the page said its own name twice, once in grey caps and once in the
headline under it. Dropped, along with the locale key. The other steps keep
theirs because they say something the rail doesn't ("Connect a computer",
"Workspace creation is disabled").

Log out is `inline` on every step, and inline now means "on the rail", which
is an inverted surface -- the muted/destructive pair it used unqualified is
mixed from the light palette, so it was rendering as near-invisible grey on
black.

e2e: the smoke spec asserted "Step 1 of 3", text the rail replaced. It now
asserts the rail's named steps and which one is aria-current, and carries on
into the runtime step so all three get captured. Two pre-existing bugs in
that spec surfaced while fixing it: the zh-Hans case pinned its locale cookie
to a hardcoded port, and it advanced with getByRole("button").first(), which
is the pinned Log out button -- so that case had been signing the user out
and asserting against a login redirect.

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

* feat(onboarding): rebuild the rail as the ReUI inset panel

The first pass kept the block's idea -- a named vertical rail -- and dropped
most of its structure. Side by side with onboarding-3 the gap was obvious:
no brand lockup, no inset panel, no texture, steps jammed against the top
edge, numbered chips instead of the ring/check/dot progression, stub
separators instead of one continuous track, and a bare Log out where the
block has a footer row.

This ports the structure properly:

- Inset panel -- the aside carries the padding and the panel is rounded with
  a hairline ring, instead of a dark rectangle bleeding to the window edge.
- Brand lockup top-left (the existing MulticaIcon plus a wordmark), Back
  demoted to an icon button top-right, which is where the block puts it.
- Step list centred in the remaining height rather than stacked under Back.
- Indicators follow the block: filled + check when done, ring + dot when
  current, faint ring when upcoming. Numbers move to sr-only text, since the
  ring already encodes position and the digit was redundant next to a label.
- One continuous hairline behind each row instead of a stub between rows.
- DotSphere ported to packages/ui as the panel's texture. It is decorative,
  reads no product state, and already honours prefers-reduced-motion.

Two fixes fell out of doing it properly:

`.dark` is a plain class selector in our token sheet, so scoping it to the
panel redefines the custom properties for that subtree and `bg-background` /
`text-muted-foreground` mean the right thing inside it. That replaces the
hand-mixed `text-background/60` shades of the first pass -- which is what had
made Log out near-invisible -- and it is why the button is back on ordinary
tokens here.

StepperSeparator hardcodes a 3rem height for vertical navs, so an absolutely
positioned track overshot its row and drew the line straight through the next
indicator. Overridden with the same variant rather than !important, so
tailwind-merge drops theirs.

DotSphere cycles three constant arrays by `index % length`. Under
noUncheckedIndexedAccess that is `T | undefined`, so they are typed as
non-empty tuples and index 0 is the fallback -- no non-null assertions.

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

* feat(onboarding): put the content pane on the block's type and layout

The rail matched onboarding-3 and the pane it sat next to did not, so the
two halves read as different products: serif display headlines and a grey
uppercase eyebrow on the right, the block's sans hierarchy on the left.

Typography now maps onto the block exactly, and it lands on our scale
without rounding -- ReUI's `text-xl/7` heading is our `text-title-lg`
(20/28) and its `text-sm/5` supporting line is our `text-body` (14/20).
StepHeading owns both, so no step hand-rolls a headline again.

The eyebrows are gone. The block has no such slot, and with the rail naming
every step, a grey uppercase label above a headline saying the same thing
was the third name for one screen. The workspace step's disabled-state
wording was the only eyebrow carrying information the headline lacked, and
that variant already exists as its own headline copy.

Geometry collapses from three competing measures -- a 920px frame, a 620px
column, an in-frame cap -- to one 28rem column. Three measures is what let
the platform fork sit ~150px right of every other step. STEP_MEASURE stays
for capping a single control inside the column.

Actions move into StepFooter: full-width, stacked, pinned to the bottom of
the column. In a 28rem column the old right-aligned inline bar left the
primary action floating mid-screen instead of where the eye finishes the
form. The column is `min-h-full` rather than centred by the pane, because
`items-center` on a scroll container clips the top of anything taller than
the viewport and these steps do overflow on short windows.

Questionnaire options become the block's wrapping chips. They were
full-width cards in a 4-column grid; inside a 28rem column that grid had
nowhere to go, and stacking all 18 as rows turned one screen into a long
scroll. Chips are the block's own answer for a many-option question. This
also reaches the workspace source-backfill prompt, which shares the
component -- intentionally, it is the same question in the same style.

MikaIntro moves onto StepHeading + Item for the same reason.

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

* fix(onboarding): drop the rail clear of the macOS traffic lights

The rail is a dark inset panel and it started at the window's top-left
corner, which is exactly where macOS draws the traffic lights -- so the
close/minimise/zoom buttons sat on top of the dark surface.

The underlying mistake was structural. The desktop shell rule asks for a
single DragStrip as the first flex child of a full-window view; this had two
hand-rolled strips instead, one inside each pane, and neither was first. The
sidebar's was 28px of internal padding trying to duck under the traffic
lights from inside a panel that had already begun above them, which cannot
work -- the panel's own background was the thing being overlapped.

One DragStrip now spans the window above both panes. The panel starts below
it (48px strip + the aside's inset, measured at 64px on a wide window against
traffic lights that end around 32px), the whole band stays draggable, and the
two ad-hoc strips are gone.

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

* fix(onboarding): make every block in the column share one width

Reported on the workspace step: the name field ended short of the
description above it. Measured rather than eyeballed -- the heading,
description and footer all render at 448px, the form at 384px, because the
FieldGroup still carried STEP_MEASURE.

STEP_MEASURE made sense against the old 920px frame, where a full-width
input would have been absurd. Against a 28rem column it does nothing but
misalign, so both remaining uses are gone -- the workspace form and the
runtime picker -- and the constant with them. A single measure that no step
can locally narrow is the whole point of the column; leaving the knob
exported invites the same drift back.

Two stale measures went with it. The runtime phase views still capped their
ledes at max-w-[620px], inert inside a 448px column, and sized them
text-body-lg against StepHeading's text-body; their h2 was text-title-lg,
the same size as the h1 above it. Both now match the shared scale.

Guarded in e2e rather than a unit test. The shell test renders a stub child,
so asserting "no narrow cap inside the column" there would pass whatever the
real steps do. The new spec walks all three steps and compares rendered
geometry -- it fails on the reported bug and passes after the fix, which is
what makes it worth keeping.

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

* fix(onboarding): stop the whole window re-fading on every step change

Every step rendered its own <StepShell>. Because each step is a different
component type, React tore the shell down and built a new one on each
transition: the "persistent" rail remounted, its canvas restarted, and the
shell replayed `animate-onboarding-enter` -- a 0.4s fade from opacity 0
across the entire window. That full-window re-fade is the flash.

Measured before changing anything: tagging the live <aside> and switching
steps showed the attribute gone, and the shell root still reporting
animation-name `onboarding-enter` after the switch.

The upstream block has no step-change motion at all -- no animate-*, no
transitions, no framer-motion, no AnimatePresence. Its sidebar and section
live in one component and only the step body swaps. This does the same: the
flow owns a single StepShell and the steps render content. The shell's
entrance fade now runs once, on entering onboarding, which is what it was
for.

`backDisabled` was the one thing blocking the hoist -- the shell needs it but
only the workspace step knows the create request is in flight. It reports
upward through `onBusyChange`, with an unmount cleanup: a successful create
advances immediately, so without clearing the flag the next step would open
with Back and the rail dead.

Guarded in e2e by tagging the rail and content nodes and asserting they
survive a step change, plus a single DotSphere canvas rather than one per
visited step.

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

* chore: drop a stray commit-message file

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

* i18n(zh): retranslate onboarding and fix cross-file inconsistencies

Reported as "生硬" on the onboarding Chinese. Reviewed all 217 onboarding
strings against the Chinese voice guide in conventions.mdx, then swept the
other 24 locale files for the same classes of problem.

Three were mistranslations, not stiffness. The worst: an agent runtime was
described as the AI coding tool "我们接管的" — take over — where the English
says "we connect to". Also a "推荐" that exists in no source string, and
"12 calls" (user interviews) rendered as "12 通电话".

Punctuation, decided from the repo's own zh docs rather than guessed:
「」 is forbidden by the guide and appeared 16 times; 破折号 spacing was
split 26 spaced vs the rest unspaced, and the docs run 152 unspaced to 35,
so unspaced wins. Four strings had a stray space inside Chinese text
("正在跳转到 工作区").

67 English strings had two or more Chinese translations. Most are
legitimate — weekday pickers use single characters where labels spell them
out, 飞书/Lark are deliberate regional variants, and "Name" is 姓名 for a
person and 名称 for an object. 42 were arbitrary and are now unified.

ja/ko got the same consistency pass on the clear-cut cases only, kept
parallel with the zh choices. Their punctuation was deliberately left alone:
「」 is standard Japanese quoting, so the zh rule does not transfer.

Edits are applied to the raw file text rather than through a JSON
round-trip, which reformatted compact one-line objects and turned ~20 real
edits into a 97-line diff.

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

* chore: drop a stray commit-message file

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

* feat(mika): use the unicorn emoji as Mika's placeholder avatar

Mika shipped with a hand-rolled data-URI SVG — a sparkle glyph on a dark
rounded square — which only that one constant knew how to produce. Agents
already have an emoji avatar convention (`emoji:` marker in avatar_url,
owned by agentEmojiAvatarPrefix on the server and parseAvatarEmoji on the
client), so this reuses it instead: ActorAvatar renders the emoji as text and
no surface needs to special-case her.

The two cards that stand in for Mika before the agent row exists move with
it — the onboarding intro card and the Runtimes "Start with Mika" card. Both
were drawing the same sparkle mark, and leaving them would mean a member sees
one face during onboarding and a different one the moment Mika is created.
They now share MIKA_PLACEHOLDER_EMOJI, which carries a pointer to the server
constant so the two cannot drift apart silently.

The dark square went with the sparkle: it was built to frame a white line-art
glyph, and an emoji on it reads badly. These use bg-muted, matching how
ActorAvatar already frames an emoji avatar.

Placeholder until Mika has real artwork.

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

* i18n: stop the skip path promising things it does not do

Two claims in the runtime-skip copy that the code does not back, both found
while walking the flow end to end.

"Enter your workspace in read-only mode" — there is no workspace read-only
concept on the server. The only READ ONLY in handlers is Postgres transaction
isolation on the issue-table and search queries. What actually exists is
admission.go's ReasonAgentRuntimeRequired, which blocks dispatch when no
runtime is connected. So the sentence's second half was already true and
enforced; the first half promised a restriction that does not exist — a
member who skips can create issues, comment, edit fields and invite people
exactly as normal. Same over-claim in cloud_waitlist.intro_warning.

"We've added one task" — the skip path creates an *issue* (it lands on the
Issues board as NOR-1), and task/issue are distinct entities in this product.
Now says issue.

All four locales.

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

* i18n: reframe Mika as your first agent teammate

Requested copy change on the step 3 headline.

The rail's description for that step moves with it. It is the step's own
subtitle and sits on the same screen as the headline, so leaving it saying
"Your Chief of Staff" would have put two different framings of Mika side by
side on one page.

Three "Chief of Staff" references are deliberately untouched, because
dropping the title everywhere is a positioning call rather than a copy fix:
the role chip on the intro card (name + title reads fine under the new
headline), and the web fork's lede, which is a different screen.

Chinese follows the glossary (Agent -> 智能体) rather than the mixed-language
phrasing in the request.

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

* fix(runtimes): let the member pick the runtime before Mika is created

"Start with Mika" provisioned immediately on
`runtimes.find(online) ?? runtimes[0]`. One machine commonly registers every
agent CLI installed on it — nine on the box this was reported from — so "the
first online one" is an arbitrary pick, and Mika could end up bound to a CLI
the member never intended to run their Chief of Staff on. Rebinding after the
fact is more work than choosing up front.

The action now opens a dialog with the same two controls onboarding already
uses for this decision, RuntimePicker and ModelDropdown, so the same choice
reached from a different entry point is asked the same way. The old heuristic
survives only as the dialog's initial selection, which makes it visible and
changeable instead of silent.

Model resets when the runtime changes, because models are per-runtime and a
value picked for the previous one may not exist on the next.

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

* refactor(runtimes): one component for "which runtime should Mika use"

Three surfaces asked this same question and had drifted apart: desktop
onboarding and the Runtimes page offered a runtime plus a model, while the
web CLI dialog offered only a runtime. `step-platform-fork` called
`onNext(picker.selected)` with no second argument, so connecting through the
web terminal path silently created Mika on whatever model the runtime
defaulted to, while the desktop step let you choose. Two of the three also
re-implemented "changing the runtime clears the model"; the third simply
lacked it.

MikaRuntimeChoice now owns the pair and that reset rule, so no caller can
forget it and the three entry points cannot drift again. The web CLI path
gains model selection, which is the behaviour change here.

`layout` is a prop rather than a single unified presentation because the
difference is real: the CLI dialog lists machines because that is the moment
they appear one at a time after `multica setup`, and a collapsed dropdown
hides exactly the feedback that dialog exists to give. Everything below the
list is identical.

compact-runtime-row moves from onboarding/ to runtimes/ so imports only flow
onboarding -> runtimes rather than both ways.

Creation is deliberately left alone. A and B still funnel through
`handleRuntimeNext`, which also runs saveQuestionnaire, completeOnboarding
and onComplete; the Runtimes page must not do any of that, since that member
is already onboarded. Merging those would leak onboarding completion into a
non-onboarding surface.

The platform-fork test now needs a QueryClientProvider, because the dialog
renders a model dropdown that queries the runtime's model list, and its
onNext assertion moves to the two-argument signature.

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

* fix(onboarding): stop the download card claiming a result it cannot know

Clicking "Use this computer" set `downloaded` unconditionally, which flipped
the card to "Opening the download page..." / "Opened in a new tab." Neither
is knowable: `window.open` is called with `noopener`, and per spec that
returns null whether the tab opened or a popup blocker ate it, so a blocked
click still produced a card asserting a tab had opened.

The same flag was also write-once, so the transient "Opening..." became a
terminal state — come back to the tab later and it still says the page is
opening. And because the swapped title wraps to two lines, the card grew
10px and pushed the two cards under it down, which is what made the click
read as a page refresh in the first report.

The card now states its intent up front — "Opens in a new tab — pick your
platform there" — which is true before the click, after it, and when the
popup never appears. The state, both `_after` strings and `hint_downloaded`
are gone from all four locales.

Its test asserted the flip, so it now asserts the opposite: the mocked
window.open returns null (the blocked case) and the card must be unchanged.
Measured after the change: primary card 330px before and after, and the
cards below it do not move.

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

* fix(desktop): stop one worktree in a thousand booting into a blank window (#6436)

Worktree renderer ports are `5174 + cksum(path) % 1000`, a 5174-6173 window
that contains exactly one port Chromium refuses to navigate to: 6000, the X11
port on its restricted list. A worktree whose path hashes to offset 826 gets a
healthy Vite server on 6000 and an Electron window that fails the load with
ERR_UNSAFE_PORT -- so it reads as a renderer bug, not a port one, and the only
way out was setting DESKTOP_RENDERER_PORT by hand.

Restricted ports in the window are now remapped into the block immediately
above it (6000 -> 6174). Sending them past the end rather than shifting them by
one keeps the offset -> port mapping injective, so two worktrees still cannot
land on the same port and race for it.

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

* fix(onboarding): close the Mika multi-member and failure-recovery gaps

From Emacs's review of #6378. All seven findings reproduced.

The two blocking server bugs were the same shape: Mika is one agent per
workspace but sessions and ownership are per member.

- StartMikaOnboarding required the agent owner, so the member who lost
  the CreateMikaAgent race — the race that handler's advisory lock exists
  to survive — got a valid Mika, opened a valid session, and then a 403.
  Mika is created workspace-visible and workspace-invocable; the session
  gate and canInvokeAgent were already the checks that matter.
- The onboarding session was resolved client-side by listing sessions and
  creating one on a miss, matched on the localized title.
  LockWorkspaceForChatSessionCreate is FOR KEY SHARE precisely so
  concurrent creators do not block, so two tabs each opened their own
  conversation with its own kickoff, and switching language between a
  failed attempt and its retry opened another. It is now get-or-create
  server-side under a per-(workspace, member) advisory lock, keyed on
  (workspace, creator, agent), returned alongside the agent.

Also:

- The skipped-runtime welcome dismissed itself silently when provisioning
  the guide issue failed. The signal is not persisted and onboarding is
  already complete, so a blip was terminal. It now offers a retry.
- The Runtimes recovery card gated on `agents.length === 0`, so creating
  any ordinary agent hid the only surface that can mint a Mika — the
  generic endpoint accepts no system_key. Gated on Mika's absence.
- CompactRuntimeRow ignored `disabled`; the CLI dialog was already
  passing it, so the runtime could change mid-submit. It is a real
  <button> now, which also gets focus and Enter/Space for free.
- The rail never went below 15rem while the content pane kept its gutter,
  leaving ~87px of form at 375px. It is hidden under md, where a compact
  bar carries the step name and the Back button instead.
- Dropped a stray __pycache__ artifact I had committed by accident.

Each new test was checked against the bug it covers: reinstating the
owner gate, the title-keyed lookup, or the dropped disabled prop makes
the corresponding test fail.

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

* fix(onboarding): make the Mika entrypoint survive a partial bootstrap

From Emacs's second review of #6378.

Bootstrapping Mika is three server steps — provision the agent, open the
member's session, enqueue the opening turn — and the last two can fail
after the agent commits. Two things then conspired: the server reported
success with the session omitted, and the Runtimes card gated on the
agent existing. The agent's own `agent:created` broadcast invalidates the
agent list, so the card (and its open dialog) was torn down the instant
step one succeeded, and never came back on reload — the agent is durable,
the rest was not. The member was left holding a Mika they could not start.

- The endpoint now fails when the session cannot be resolved. Every step
  is idempotent, so a retry converges; handing back a half-built flow the
  caller cannot distinguish from a finished one does not.
- The entrypoint is gated on the member's own state — does this member
  have a Mika conversation that was actually kicked off — rather than on
  the workspace having an agent. That is the question the card answers,
  and it is true again for every partial state above.

Also restores the Log out escape hatch below `md`. Hiding the rail last
round took its footer with it, which stranded every step but Welcome with
no way out on a narrow screen; the compact bar now renders the same slot,
so `sidebarFooter` is `chromeFooter`.

Each new test was checked against its bug: the old agent-only gate fails
three of the memberNeedsMikaSetup cases, and dropping the footer prop
fails the chrome test.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 14:21:02 +08:00
Bohan Jiang c896f677d5 fix(agent): recover from a codex resume that overflows the reader (MUL-5722) (#6420)
Layer 1 (#6383) raised the shared scanner cap to 32 MiB, which moved the cliff
without removing it — a codex rollout is append-only, so a thread that outgrows
any fixed cap still fails its resume forever. This adds the recovery path.

Layer 2: an oversized thread/resume response is reported as
Result.ResumeRejected rather than a crash, which is the positive evidence
shouldRetryWithFreshSession needs and reconnects the recovery #5715 had
unintentionally cut off. Reaching it required one real fix — the reader wrapped
the scanner error with %v, so bufio.ErrTooLong never survived to a caller.

Layer 3: a new codex_resume_oversized reason marks the session resume-unsafe,
and both resume lookups block by TIME rather than by matching the failed row.
That shape is required, not stylistic: the failure happens before the turn
starts, so the row lands with session_id NULL and is dropped by
latest_per_session before any error-text filter runs. The block expires once a
thread terminates after the overflow, so an issue recovers instead of starting
cold forever.

Also splits the MUL-4424 continuity notice by what each surface can still read
— issue comments and Slack channel history can be re-read, web chat and Feishu
cannot — so only the last group tells the user a loss happened. The backend no
longer holds any of that wording; it receives ExecOptions.ResumeContinuityNotice
from the caller and stays silent when the prompt already carries it, which makes
the duplicate injection on the retry path structurally impossible.

Known gap, tracked not claimed: for chat, the claim handler reads
chat_session.session_id before the fallback query, so a daemon predating this
PR leaves that pointer naming the oversized thread. Clearing it needs the
attempted session recorded at claim time, which is a schema change.
2026-08-05 16:55:29 +08:00
521a63671c fix(chat): preserve visible queue-head ordering (MUL-5751) (#6431)
* fix(chat): keep visible queue heads in transcript order

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

* test(chat): cover cancelled follow-up ordering

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-05 16:26:07 +08:00
b3fa17f0e0 fix(chat): preserve follow-up transcript order (#6419)
Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-05 15:39:55 +08:00
73283556d7 MUL-5750: fix(chat): keep idle sends out of follow-up queue (#6418)
* fix(chat): keep idle sends out of follow-up queue

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

* fix(chat): preserve the positional queue head

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

* fix(chat): polish deferred queue states

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

* fix(migrations): resolve pending index prefix collision

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-05 15:06:42 +08:00
c1be369f6d [MUL-5736] fix(channel): make issue commands terminal outcomes (#6398)
* fix(channel): make issue commands terminal outcomes

* fix(channel): stop handled command media from gating later chat tasks

A `/issue` turn is now answered synchronously and excluded from every later
input batch, but it still persisted a media deadline, and both session-scoped
media gates counted it. The next, unrelated chat message was therefore deferred
until the command's attachments bound — or for the full fallback budget on the
create-failure path, where no binder ever runs to clear the marker.

Give GetChannelMediaPendingUntil and PromoteChannelChatTasksIfMediaReady the
same population as the batch seal, so only a turn that can join a batch can gate
one. DeferChatTaskForSealedPendingMedia already scopes by task id and needs no
change.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-05 14:33:30 +08:00
TeAmoandTeAmo a4dc54f2ed MUL-5570 / MUL-5493: feat(chat): add a managed follow-up queue (#6211)
* feat(chat): queue follow-up messages during active runs

* fix(chat): preserve queued message compatibility

* fix(chat): harden queued task consistency

* fix(chat): protect legacy and mobile queue state

* fix(mobile): refresh stop handler on task promotion

* fix(mobile): preserve queued chat state

* fix(chat): hide queued prompts from legacy clients

* test(chat): read queued channel input from paged transcript

* fix(chat): protect legacy and mobile queue state

* fix(mobile): refresh stop handler on task promotion

* fix(mobile): preserve queued chat state

* fix(chat): hide queued prompts from legacy clients

* test(chat): read queued channel input from paged transcript

* fix(chat): hide queued inputs from paged transcripts

* test: prove queued messages do not consume cursor pages

---------

Co-authored-by: TeAmo <[email protected]>
2026-08-05 13:15:57 +08:00
Naiyuan Qingandmultica-agent 9fb46adc43 fix(agent-builder): serialise draft autosave against session delete (MUL-5642) (#6376)
SaveAgentBuilderDraft read the chat session, then upserted agent_builder_draft
as a separate statement, with no lock between them. DeleteChatSession takes
LockChatSessionForDelete for its whole transaction, so a save could pass its
checks, block on nothing, and land its INSERT after the delete committed.
agent_builder_draft carries no chat_session FK (repo rule), so nothing rejected
that write.

The surviving row held the configuration the user had just confirmed discarding.
It is invisible to the UI — the drafts list joins through chat_session — and no
prune can reach it: DeleteAgentBuilderDraft and the runtime teardown both key off
a session that no longer exists, leaving only the workspace teardown. The client
autosaves on an 800ms debounce and the conversation is addressable by URL, so a
second tab can autosave at any moment while this one discards.

Add LockChatSessionForDraftWrite, the same row and lock mode the delete and
runtime-bind paths take, and run the upsert in a transaction that acquires it
first and re-reads the session under it. Existence and status are the only two
things a concurrent writer can change, and both are now decided inside the lock;
workspace, creator and carrier are immutable for a session and stay on the
cheap unlocked read. Either ordering is now correct: the save commits first and
the delete prunes it, or the delete commits first and the save returns 404.

The same lock closes the archive variant, where the last autosave after
"create agent" could write a draft onto an already read-only session.

Both regression tests drive the interleaving deterministically — hold the
session row, prove the save blocks, then commit — and fail on the pre-fix
handler with a 204 that writes the orphan.

Co-authored-by: multica-agent <[email protected]>
2026-08-04 17:52:33 +08:00
Bohan Jiangandmultica-agent eea687d461 MUL-5686: fix(chat): resume the cancelled turn's session instead of starting cold (#6352)
* fix(chat): resume the cancelled turn's session instead of starting cold

Stopping a chat turn the agent had already begun answering, then sending
the next message, produced a reply with no memory of the conversation.

The cancelled turn's provider session is real and recorded — the daemon
pins it onto the task row mid-flight and cancellation keeps it there —
but nothing could hand it back:

  - GetLastChatTaskSession / GetLastTaskSession only considered
    'completed' and 'failed' rows, so a cancelled row's session was
    invisible to resume resolution;
  - chat_session.session_id is written only by CompleteTask / FailTask,
    and a cancelled task reaches neither: the daemon discards its result
    and sends a cancel-ack. On a chat whose first turn was cancelled the
    pointer therefore stayed NULL and the next turn started cold, since
    buildChatPrompt injects only the current user message.

Let cancelled rows into both resume lookups, and advance the chat-level
pointer at cancel time so a provider that mints a new session id per
resume does not rewind past the cancelled exchange. The mid-flight pin
may now also fill an EMPTY session slot on a just-cancelled row, which
is what a Codex pin waiting on its rollout needs when the cancel wins
the race; occupied slots and completed/failed rows stay untouchable.

Retired sessions, poisoned-failure filters and the rollout-present guard
are unchanged. A transcript killed mid-tool-call that the provider later
refuses is still caught by taskfailure.UnresumableHistory, which retires
the session and starts the next turn fresh.

Fixes #6340

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

* fix(chat): close the two cancel/pin races on the chat resume pointer

Review found the pointer advance had the same shape of hole it was meant
to fix, on both sides of the cancel.

1. The status flip and the pointer advance were separate statements. In
   the gap the row is already `cancelled` while the pointer still names
   the PREVIOUS turn, so a follow-up the user had queued could be claimed
   there and resume the older session — and a failed pointer write only
   logged, still reporting the cancel as successful. Both now commit in
   one transaction and the error propagates.

2. The mid-flight pin may land AFTER the cancel (for Codex it waits for
   the rollout), so the cancel transaction sees no session to publish and
   the pin only fills the task row. On a chat that already had history
   the stale pointer kept shadowing it, which is precisely the case the
   pin change was added for.

Both paths now run one guarded statement,
AdvanceCancelledChatSessionPointer. It reads the task row itself rather
than trusting an in-memory copy, ignores anything that is not a cancelled
chat task, and refuses to move the pointer when a NEWER task on the chat
already recorded a session — so a straggler pin cannot drag the
conversation back onto the interrupted turn.

Regression tests, both failing before this commit: a cancel whose pointer
write is blocked must not expose `cancelled` to another connection, and a
late pin on an already-cancelled row must reach the next real claim. The
newer-turn guard is covered too.

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

* fix(chat): make the pin atomic and restore the chat->task lock order

Second review round found the remaining half of the same class of bug.

1. PinTaskSession still committed the session onto the task row and
   advanced the chat pointer as two statements, and the pointer failure
   only logged while the endpoint still answered 204. A follow-up claimed
   in that window resumes the previous turn — the exact case the pin
   change was added for. Both writes now share one transaction and every
   failure is reported.

2. Adding the pointer write gave the cancel transaction two rows to hold,
   and it took them in the wrong order: agent_task_queue first, then
   chat_session. DeleteChatSession takes them the other way round, so the
   two could deadlock (40P01, and runInTx has no deadlock retry). The
   repo's documented order is chat_session -> agent_task_queue; both the
   cancel and the pin now open with LockChatSessionForTask, the same
   helper FinalizeDeferredCancelledChat uses. ErrNoRows there means a
   non-chat task or an already-deleted session — nothing to lock and
   nothing to advance.

Regression tests, all three failing before this commit (the concurrency
one with a real `deadlock detected`): the pin must not expose a session
on the task row while its pointer write is blocked; a cancel waiting on
the chat session must hold no lock on the task row (FOR UPDATE NOWAIT
probe); and cancel racing a chat delete must never come back with 40P01.

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

* fix(chat): put every chat-task terminal write behind the same lock order

The cancel and pin paths took chat_session before agent_task_queue; the
terminal reports still took them the other way round, so the two could
deadlock (40P01) — reproduced deterministically as cancel-vs-complete.
Choosing per-path was never going to work: either every writer that holds
both rows agrees on an order or none of them are safe.

CompleteTask, FailTask and the cancelled-chat finalize now open with the
same LockChatSessionForTask the cancel, pin, DeleteChatSession and
FinalizeDeferredCancelledChat paths use, via a shared helper that
documents the invariant in one place.

Also de-flakes the concurrency tests this PR added. They held a row lock
and then read other rows on a FRESH pooled connection; the suite shares
one database, so a sibling package's DDL could queue an ACCESS EXCLUSIVE
request in between, park our later ACCESS SHARE request behind it, and
wedge the package until the 10-minute timeout (observed in a parallel
`go test ./internal/...` run). Those transactions now take their table
locks up front and read on the connection that already holds them, and
every racing call is bounded so a stall fails loudly instead of hanging.

Regression tests, all failing before this commit: complete and fail must
hold no lock on the task row while waiting for the chat session (FOR
UPDATE NOWAIT probe), and cancel-vs-complete plus pin-vs-fail must never
come back with 40P01.

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

* test(chat): fail the race tests on any unexpected error, bound every call

Review nits on the concurrency tests.

Matching only *pgconn.PgError(40P01) let the pin side through: a pin that
loses a deadlock is reported by the HTTP handler as a plain 500 with no
PgError to unwrap, so the check skipped exactly the failure the test
exists for. Both racers now fail on anything except the one benign
outcome — whoever finalized the task first leaves the loser matching no
row (pgx.ErrNoRows).

The remaining racing calls still used an unbounded context.Background(),
which this PR had already claimed were bounded. They now share the same
raceTimeout as the rest.

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-04 16:47:17 +08:00
Naiyuan QingandClaude Opus 5 a5c1d44701 MUL-5642: fix(agents): stop the creation studio polling, and stop it losing work (#6307)
* feat(agents): make AI agent creation resumable (#6246)

Leaving the Agent Creation Studio destroyed the conversation. The unmount
cleanup called deleteChatSession, so a sidebar click, a tab close or a route
change deleted the builder session and every message in it — the bug external
users reported. Archiving instead (PR #6247) would have stopped the deletion
without giving anyone a way back in: builder sessions hang off a hidden
`kind = 'system'` carrier agent, which the `kind = 'user'` filter keeps out of
every chat list, so an archived one is unreachable rather than recoverable.

A creation conversation is now a durable object with its own address.

Server:
  - GET /api/agent-builder/sessions lists the caller's unfinished creations.
    Creator-scoped like every other chat read. It reports the CARRIER's
    runtime, not chat_session.runtime_id — the latter is the daemon's resume
    pointer and is deliberately left stale after a switch, so resuming from it
    would put the picker on a runtime that executes nothing (MUL-5163).
  - PUT /api/agent-builder/sessions/{id}/draft stores the configuration,
    including the edits the user typed but never sent. Migration 251 adds
    agent_builder_draft (no FK per repo rule; pruned explicitly by
    DeleteChatSession, the runtime teardown and the workspace teardown, and
    registered in the workspace-deletion manifest).
  - The payload is opaque to the server: its shape is the studio's AgentDraft,
    validated client-side. Teaching Postgres and the handler about it would
    create a second definition to keep in sync for no gain.

Client:
  - The session id lives in `?session=`, so a refresh, a back/forward and a
    reopened tab land back in the same conversation.
  - Leaving no longer deletes anything. The only destructive path is an
    explicit "discard", confirmed in a dialog, next to the create button.
  - Creating the agent archives the conversation instead of deleting it: it is
    the record of how that agent was designed, and an idle carrier costs
    nothing since usage is booked per task.
  - The configuration autosaves (debounced) and restores on arrival, with the
    applied-assistant-message marker stored alongside it so a restore cannot
    re-apply the last reply over edits made after it.
  - The 1.5s polling of messages and pending-task is gone. The global realtime
    sync already invalidates both per session id, exactly as it does for the
    main chat window, which has never polled.
  - The `<agent_draft>` block collapses to one "configuration updated" line.
    The regex now also swallows an unterminated block, which is what streaming
    produces — the raw payload used to scroll past on every turn.

The 2185-line agent-creation-studio.tsx is split into three routes
(`/agents/new`, `/agents/new/manual`, `/agents/new/ai`), its pure logic moves
to packages/core/agents/ with its tests, and the unreachable template flow —
`setMode("templates")` had no caller — is removed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(agents): let the builder panes resize

The conversation / configuration split was not draggable. Two structural
reasons, both fixed by giving the group the same shape the chat page uses:

  - The panels reached the group through BuilderWorkspace's fragment, so they
    were not children the group could measure.
  - The group's children alternated between one panel (runtime setup) and two
    (conversation), under one persisted layout id.

The group now lives inside BuilderWorkspace with its two panels as its only
children, and the setup screen renders no group at all — it has nothing to
split.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(ui): give the resize handle a cursor on hover

The separator had no cursor of its own, so the only signal that a split was
draggable arrived after the drag started — the library writes a global
`cursor: ... !important` while dragging, and nothing before it.

Fixed on the shared handle rather than at one call site: every split surface
(chat, inbox, issue detail, project detail, the agent builder) was missing the
same affordance. The library's drag-time rule still outranks this one, so the
cursor keeps narrowing to `e-resize` / `w-resize` once a panel hits its bound.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* feat(agents): render the builder's draft block as an inspectable row

Every builder reply ends in an <agent_draft> block that rewrites the form on
the right. Flattening it to a line of prose said that something changed but not
what, and the payload — the only record of what the builder actually claimed —
was unreachable.

A settled reply now carries a full-width row saying the configuration was
updated, which opens the exact payload. A streaming one keeps a text line
instead: the block is still being written, so there is nothing complete to
open, and without the line the half-finished JSON scrolls past.

ChatMessageList gains an optional `renderAssistantAddon`. It is opt-in per
surface and undefined everywhere but this one, because no other chat speaks
this protocol — the alternative was to keep pushing an embedded protocol
through `transformContent`, which can only ever produce prose.

`extractBuilderDraftBlock` returns an unparseable payload verbatim rather than
withholding it: a malformed block is exactly when someone wants to read it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(agents): address review blockers on the resumable builder

1. Migration prefix collision. `251_agent_runtime_unbind` landed on main after
   this branch cut, so the backend's prefix-uniqueness guard failed. Renumbered
   to 252.

2. #6287 — the manual form still lost everything. The route split moved where
   you land, not what survives: the draft was `useState`, so a tab switch (the
   desktop shell mounts only the active tab) remounted it empty, and the
   beforeunload guard covered a hard reload and nothing else. It now persists
   through the repo's draft-store factory, scoped by what is being created — a
   blank agent and a copy of agent X are different work, and a copy of X is not
   a copy of Y — cleared once the agent is committed, and registered for logout
   / workspace-delete cleanup.

3. A saved draft with no messages was unreachable. The configuration form is
   editable from the moment a builder session exists and autosaves, so someone
   could open it, type a name and leave before the first turn; the list keyed
   "is this a draft" on messages alone, so that row existed and nothing could
   reach it. A session now qualifies on a message OR a stored draft, and sorts
   by whichever it has.

4. The debounce dropped the last edits. Its timer died with the component, so
   navigating away inside the 800ms window lost exactly the keystrokes the user
   had just made. The pending payload is now flushed on unmount.

`useUnsavedDraftWarning` is gone with its last caller: both routes persist, so
the browser prompt would have been warning about work that is already saved.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(ui): stop the resize cursor flipping mid-drag

The library narrows its cursor the moment a panel hits a bound — col-resize
while both directions are open, a one-way arrow once only one is. Truthful, but
it reads as a glitch: the icon changes under your hand halfway through a drag
you never stopped making.

`disableCursor` turns that global rule off; the handle's own `cursor-col-resize`
is now the only source. A drag captures the pointer and walks it across the
panels, away from the 8px handle, so the group carries the same cursor for as
long as a separator is active — otherwise it would fall back to a text caret the
instant the pointer left the handle.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(agents): key manual drafts by owner, drop the draft-block rendering

Two changes.

One slot destroyed the other flow's work. The manual draft was stored under a
single key: opening a blank form, or a copy of a different agent, refused to
adopt the stored draft and then immediately wrote its own empty form over it —
so a half-finished copy of agent A died the moment the user opened anything
else, before typing a character. Drafts are now keyed by what is being created,
the same shape the chat composer uses for its per-session drafts, and a slot is
dropped when its content is gone rather than parked blank (which also stops the
map growing a dead key per agent ever opened for duplication). Committing an
agent clears that flow's slot only.

The `<agent_draft>` block goes back to being hidden outright. Labelling it and
opening its payload dressed up machinery as content: the block drives the
configuration form, and the form is where its effect is already visible.
`renderAssistantAddon` goes with it — ChatMessageList is back to what it was,
since no surface needs the slot. The two-pattern strip stays: an unterminated
block is what streaming produces, and without matching it the raw JSON scrolled
past the reader on every turn.

Also removed six barrel exports nothing imported through, and unexported five
types only their own file used.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(agents): keep a manual draft whose only edit is a picker

The "is this worth storing" predicate listed six fields by name, and the draft
serializes eleven. A form whose only change was the model, the thinking level,
the service tier, the access scope or a team grant read as untouched, so the
next save deleted its slot — picking a model before typing a name and switching
tabs lost the model.

Enumerating was the mistake, not the specific omissions: the predicate stops
covering every field added after it is written, and the failure is invisible
because each field saves correctly as long as some *other* field is also set.
It now compares the whole draft against a fresh one. The runtime stays outside
that comparison, on the entry rather than in the draft, because the form seeds
it on every visit and counting it would store a draft for a form nobody
touched.

Covered field by field, one edit at a time, so a future field cannot quietly
fall out.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-03 19:00:00 +08:00
b06af2ae17 feat(runtime): unbind agents on runtime delete instead of destroying them (#6220)
* feat(runtime): unbind agents on runtime delete instead of destroying them

Deleting a runtime archived its agents and then hard-deleted the rows, so the
agents and every conversation with them disappeared — while the confirmation
dialog said "archive", which a user reasonably reads as recoverable. Retiring a
laptop is an ordinary action; losing the agents configured on it is not an
ordinary consequence.

An agent is now a persistent business object and a runtime is replaceable
execution capacity: deleting a runtime unbinds its agents. `runtime_id IS NULL`
means unbound — orthogonal to archived — and the agent keeps its instructions,
skills, chats, labels, channel installations, autopilots and task history.
service.AgentReadiness already refused an agent with no runtime, so the
scheduling safety gate needed no change.

Two columns become nullable, not one. Without `agent_task_queue.runtime_id`,
deleting the runtime still cascades the task history away (and task_message /
task_usage / task_token with it), so the agents would survive with no record of
anything they did — the same class of loss. A NOT VALID CHECK keeps NULL confined
to history: an active task must always have a runtime, so claim / dispatch /
delivery-CAS paths can never observe one without. It is written against
completed_at rather than a status list so a future non-terminal status fails
closed instead of slipping through.

Two prerequisites this depends on:

- 'deferred' (migration 128) was missing from CancelAgentTasksByRuntimeOrAgent.
  It went unnoticed because the delete used to cascade those rows away; with the
  new CHECK it would abort the delete and make the runtime undeletable.
- The channel-installation / label / chat-pin / invocation-target / draft-restore
  cleanups were scoped to "archived agents on this runtime". Archived user agents
  now survive, so that scope is narrowed to kind='system' — otherwise the fix
  would produce a subtler loss: agent alive, configuration wiped.

Also removes the squad guard that refused (409) when an active squad's leader was
an archived agent on the runtime, plus the archived-squad delete that existed
only to get past squad.leader_id's RESTRICT FK. The leader is no longer deleted,
so nothing needs to be given up to retire a machine. Autopilots are no longer
paused either: their assignee survives, and a rebind restores them without the
owner having to remember to re-enable.

Reason codes: an unbound agent reports agent_runtime_required, not
runtime_offline. The copy for runtime_offline tells users to reconnect a machine;
an unbound agent has no machine to reconnect, and the fix is to bind a runtime.
Chat's bare 409 string gains the same code so the composer can offer that action.

API: agents gain runtime_bound. runtime_id stays a string (empty when unbound) so
installed clients keep parsing and no gated two-release rollout is needed. The
confirmed-delete endpoint is /unbind-agents-and-delete; /archive-agents-and-delete
still routes to it, and the compared expected_active_agent_ids set is unchanged —
widening it would 409 every older client forever.

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

* fix: make runtime unbinding recoverable

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

* fix: address runtime unbind review nits

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

* fix: resolve runtime unbind review blockers

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

* fix(migrations): renumber runtime unbind after main merge

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

* test(daemon): avoid late-request lease flake

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

* test(autopilots): bind validation fixture runtime

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-03 12:39:27 +08:00
f13969b996 refactor(chat): generate quick actions server-side via the LLM layer (MUL-5573) (#6214)
* refactor(chat): generate quick actions server-side via the LLM layer (MUL-5573)

Follow-up suggestions were produced by a second, full provider CLI invocation
per chat turn: the daemon resumed the just-finished session and ran a
suggestion-only pass. That pass inherited the main turn's exec options, so its
20s budget had to cover process spawn, every MCP handshake, session replay, and
model reasoning at the agent's own thinking level — typically 8-15s of visible
skeleton, and every turn paid two provider cold starts.

Generate them here instead, through the same pkg/llm layer that backs chat
auto-titling. Suggestions need no tools, workdir, or agent identity — only the
tail of the conversation — so a bounded 8s call on the deployment's small model
replaces the whole resumed turn.

Quality changes that came with the move:

  - The prompt now states the frame explicitly ("you write FOR THE USER"). The
    old pass ran inside the agent's session and inherited the runtime brief's
    identity, which drifted suggestions toward agent-operations actions.
  - Previously-offered labels are replayed as ALREADY SUGGESTED. The old
    architecture had the opposite effect: on providers that append on resume,
    each pass saw its predecessor's JSON and anchored on it.
  - A failed generation broadcasts failed=true. Before, a timeout delivered an
    empty array — indistinguishable from "nothing worth suggesting", so every
    slow pass read as a quality problem.
  - The in-band footer is still stripped from replies but its actions are now
    discarded, so a pre-upgrade session is not pinned to the retired
    suggestions with the replacing pass suppressed.

The refresh path no longer enqueues an agent task: it validates the target and
calls the same generator, which also drops the not-resumable refusal — a session
whose runtime was rebound can now be refreshed. Client contract is unchanged
(chat:done pending flag, chat:quick_actions supplement); the only frontend
change is the pending window, resized from 30s to 12s to match the new budget.

Also removes the daemon's TMPDIR-after-cleanup hazard by construction: the old
pass started after runTask's defers had already deleted the temp dir it was
still pointed at.

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

* refactor(chat): drop the quick-actions opt-out setting (MUL-5573)

Suggestions are always on. The Settings → Chat toggle is removed along with
the whole per-turn opt-out path it fed: the persisted client preference, the
quick_actions_enabled send field, the quick_actions_disabled task stamp, and
the eligibility gate that read it.

The toggle predates server-side generation, when it could only hide chips a
provider pass had already paid for. Now that generation is a bounded call the
server decides on, an off switch buys nothing a user would miss, and it was
the last piece of UI implying the feature might be unavailable.

agent_task_queue.quick_actions_disabled is no longer written (dropped from
CreateChatTask's INSERT; the column keeps its false default). Left in place
alongside regenerate_quick_actions_for for a later drop migration — removing
columns an already-running binary still inserts would break mid-deploy.

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

* fix(chat): address quick-actions review findings (MUL-5573)

Four defects from review of the server-side generation change.

1. Automatic failures were reported as refresh failures. The generator
   broadcast failed=true on any LLM error, but the client turns every
   failed=true into a "couldn't refresh" toast — so an automatic timeout
   popped a toast for an action the user never took. This also contradicted
   ChatQuickActionsPayload.Failed, which documents false for the automatic
   pass. The caller now passes its origin; only an explicit refresh reports.

2. Generation context was not bound to the target turn. The pass re-read the
   session's newest messages while always writing to the task it was handed,
   so a turn landing between the completion callback and the detached read
   supplied the context for a reply it did not belong to. Worse, a user
   typing a follow-up in the second after a reply left the window ending on
   a user row, which the old code treated as "nothing to build on" — that
   turn silently never got pills. The window is now anchored on the target
   assistant message and queried strictly before it.

3. No concurrency or idempotency bound on generation. Refresh stopped
   creating a task, so the busy check could not see a pass already running:
   two refreshes both returned 202, spent two upstream calls, and raced to
   write one row. Nothing bounded generation process-wide either. Adds a
   per-session in-flight guard (refresh now 409s on a duplicate) and a
   process-wide ceiling; a shed pass still resolves the client placeholder
   so no skeleton hangs on work that never started.

4. A new daemon could not safely talk to an older server. The refresh task
   discriminator was deleted, so a regenerate task from such a server fell
   through to the ordinary chat path: no user message, but the agent would
   answer anyway and the server would persist it as a real reply. The field
   is restored as a refusal marker only — the task completes empty, which is
   the shape the retired pass produced and which that server writes no row
   for. Not a restored execution path.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-31 15:59:50 +08:00
0fdc38704e MUL-5149: add agent-generated Chat quick actions (#5766)
* feat(chat): add agent-generated quick actions

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

* fix(chat): preserve mid-response quick-action fences

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

* fix(chat): drop quick actions on empty reply to keep no_response fallback

An actions-only completion — a quick-actions footer with no visible text —
wrote an empty-content assistant message (message_kind=message). Older
Desktop/mobile clients ignore the quick_actions field and render that as an
empty bubble, breaking the MUL-4351 contract that an empty turn always gives
old clients a visible no_response fallback.

Drop the quick actions when the visible body is empty so an actions-only turn
falls through to the visible no_response outcome, and revert the completion
switch to gate the message row on visible text only. Update the completion
test to pin the corrected behavior.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(chat): generate quick actions via daemon suggestion pass

Replace the in-band runtime-brief instruction with a dedicated post-completion
provider turn: after a direct chat reply finishes, the daemon resumes the same
session with a JSON-only suggest prompt and forwards the raw output on the
complete callback. The server parses it leniently and reuses the existing
sanitize/redact/store/broadcast pipeline; the stripped in-band footer stays as
a fallback for older daemons and pre-upgrade sessions. The footer strip now
covers every chat completion, fixing the intro-turn protocol leak. Adds a
Settings → Chat toggle (client-persisted, default on) that hides the chips.

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

* feat(chat): deliver quick actions async with skeleton placeholders

Decouple suggestion generation from the turn: the daemon reports completion
immediately (chat:done carries quick_actions_pending as a per-turn capability
signal) and runs the suggestion pass in the background, delivering results
through a new supplement endpoint + chat:quick_actions broadcast. A new turn
on the same session cancels the stale pass. Clients render pill skeletons
under the finished reply until the supplement resolves them (entrance
animation on arrival, 30s safety timeout); older daemons never raise the flag
so no skeleton dangles. Suggest usage re-reports merged totals because
task_usage upserts replace per (task, provider, model). Prompt now asks for
exactly 3 actions.

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

* feat(chat): make the quick-actions toggle stop generation, not hide pills

The Settings → Chat toggle previously only hid rendered pills while the
daemon kept burning a suggestion call every turn. It now travels with each
send (quick_actions_enabled, absent = enabled for older clients), is stamped
on the chat task (migration 213), forwarded on the claim, and gates the
daemon's suggestion pass at the source — no call, no pending flag, no
skeleton. Existing suggestions stay visible; settings copy now says
'generate' instead of 'show'.

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

* fix(migrations): renumber quick-action migrations onto current main

Merging current origin/main brought the vcs migrations to their canonical
216-221 prefixes, which collided with the quick-action migrations that were
sitting at 219/220 (backend CI red in
TestMigrationNumericPrefixesStayUniqueAfterLegacySet). Renumber them to the
next unused prefixes:

- 219_chat_message_quick_actions        -> 222_chat_message_quick_actions
- 220_agent_task_quick_actions_disabled -> 223_agent_task_quick_actions_disabled

Contents are unchanged; sqlc regeneration produces no drift since the added
columns are independent of the vcs tables.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(mobile): render async chat quick actions via chat:quick_actions

The daemon generates quick actions in a background pass after the turn
finishes, delivering them on a separate chat:quick_actions event. Mobile
only handled chat:done (which invalidates + refetches an actions-less
message list) and keeps the messages query at staleTime: Infinity, so an
active mobile session never rendered async-generated quick actions until a
manual pull-to-refresh or refocus.

Add applyChatQuickActionsToCache — mirroring web's patcher — which patches
the supplement onto the targeted assistant message in the flat messages
cache, and subscribe to chat:quick_actions in use-chat-session-realtime.
Patch-only (no invalidate), matching web and mobile's cellular
patch-over-invalidate rule; an empty supplement is a terminal no-op. Covered
by chat-ws-updaters.test.ts.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): cancel in-flight messages refetch before quick-actions patch

The chat:done invalidate can leave a messages refetch in flight that read the
assistant row before the daemon persisted the quick actions. If that refetch
resolves after the chat:quick_actions setQueryData patch, it overwrites the
freshly-patched actions with an actions-less row. Both message caches are
staleTime: Infinity, so the overwrite never self-heals and the actions vanish
permanently (MUL-5149, Howard review).

applyChatQuickActionsToCache now awaits cancelQueries for the affected caches
(web: flat messages + messagesPage, mobile: flat messages) before patching, so
a stale in-flight refetch is cancelled and cannot land after the patch. Cancel
must precede setQueryData because cancelQueries reverts to the pre-fetch state.
WS handlers call it via `void` (fire-and-forget).

Adds an active-query race regression test on both web and mobile that holds a
refetch open across the supplement and asserts the patched actions survive;
verified to fail without the cancel.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(chat): quick-actions refresh/regenerate + review hardening (MUL-5149)

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

* fix(chat): address quick-actions re-review (MUL-5149)

- Ack alignment: refresh request carries the target message_id; server
  atomically confirms it is still the session's latest turn (409 stale
  otherwise), so the client marker always matches the resolving
  chat:quick_actions — no response reconciliation. Adds a regression test.
- Converge the pending marker on every terminal path: HandleFailedTasks
  (sweeper/orphan) now resolves it, and the daemon reports a failed supplement
  so FailTask resolves it instead of leaving a completed-but-unresolved task.
- Timeout fallback now clears the real query state (useQuickActionsPendingTimeout)
  instead of a component-local flag that only masked the UI; drop the skeleton's
  and pill row's local timers.
- frontend-test type-scale: text-xs -> text-caption. Strip EOF blank line.

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

* fix(chat): close quick-actions refresh races and failure feedback (MUL-5149)

Third-round review of the refresh button surfaced three issues; all three
are addressed here.

§1/§2 Session-busy race + concurrent-refresh double-spend: a newer reply
that is queued/running but whose assistant row hasn't landed leaves the old
turn as latest-persisted, so the stale check passes and the regen resumes
the newer provider state — attaching suggestions to the wrong turn. And two
concurrent refreshes each enqueue a quota-spending pass. Add
HasActiveChatTaskForSession and refuse a refresh (ErrChatQuickActionsBusy →
409) whenever the session has any task in flight, checked under the same
session lock as the enqueue so no sibling insert slips past.

§3a Timeout re-arm on surface switch: the pending marker now carries an
absolute expires_at deadline instead of a per-mount timer, so switching
between the floating window and the chat tab resumes the same deadline
rather than restarting a fresh 30s window each remount.

§3b Generation failure masked as success: runChatSuggestPass now returns ok
so an explicit refresh distinguishes a failed pass (didn't start / didn't
complete / timed out) from a completed-but-empty one. On failure the regen
task reports failure, resolveFailedRegenerateQuickActions broadcasts a
FAILED chat:quick_actions, and the client resolves the spinner AND toasts
"couldn't refresh" instead of silently stopping on unchanged pills.

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

* fix(chat): count deferred tasks in refresh busy check; solid refresh icon tone (MUL-5149)

Two re-review blockers on 2dc9404d.

§1 (deferred window): HasActiveChatTaskForSession only treated
queued/dispatched/running/waiting_local_directory as in-flight, so a chat
auto-retry armed with a backoff fire_at — inserted 'deferred' by
CreateRetryTask, as provider_network's ~5s final attempt is — slipped past
the busy check. In that window the failed turn has no assistant row yet, so
the old turn is still latest-persisted and refreshable; the regen would then
resume a session the retry is about to advance and pin the new turn's
suggestions onto the old one. Add 'deferred' so the set matches the
canonical in-flight status list the rest of the queries already use
(agent.sql has-active-task checks). New regression test covers a deferred
active turn.

CI (text-contrast gate): the refresh icon button used
text-muted-foreground/70 (transparency standing in for a text tone), which
the frontend-test contrast gate rejects. Switch to the solid
text-faint-foreground token — the tone the gate recommends for icons/glyphs,
already used repo-wide and clearing WCAG 1.4.11.

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

* test(chat): assert quick-actions pending marker carries expires_at (MUL-5149)

The chat:done supplement-flow test still expected the 2-field marker from
before the absolute-deadline change; applyChatDoneToCache now stamps
expires_at, so the deep-equal failed on frontend-test. Assert the deadline is
present (expect.any(Number)) rather than a wall-clock-dependent value — its
timing semantics are covered by the pending-timeout hook.

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

* fix(db): renumber regenerate-quick-actions migration 237 -> 240 (MUL-5149)

main merged Issue Quick Actions (MUL-5465) taking migrations 237/238/239
(quick_action, quick_action_workspace_index, comment_quick_action). This
branch independently took 237 for agent_task_queue.regenerate_quick_actions_for.
The two 237s do not textually conflict (different filenames) so the PR reads
mergeable, but the merged tree would carry two migration 237s. Renumber this
one to 240 so it applies after main's chain. The migration is a standalone
`ALTER TABLE agent_task_queue ADD COLUMN IF NOT EXISTS` — order-independent,
touches a column none of main's migrations reference.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Walt <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Naiyuan Qing <[email protected]>
Co-authored-by: NevilleQingNY <[email protected]>
2026-07-30 21:25:03 +08:00
9072cef12c Revert "MUL-5493: feat(chat): add a visible follow-up queue (#6133)" (#6171)
This reverts commit b13657be71.

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-30 15:37:13 +08:00
TeAmoandTeAmo b13657be71 MUL-5493: feat(chat): add a visible follow-up queue (#6133)
* feat(chat): add a visible follow-up queue

Add a visible, manageable FIFO follow-up queue for Web and Desktop chat while preserving the existing per-session scheduler and backward-compatible pending-task response.

* fix(chat): preserve queue after deferred cancellation

---------

Co-authored-by: TeAmo <[email protected]>
2026-07-30 15:14:23 +08:00
30318b79bc MUL-5426: fix(daemon): retire sessions whose history the provider refuses to replay (#6083)
* fix(daemon): retire sessions whose history the provider refuses to replay

A run killed mid-reply (machine shutdown, force-quit, SIGKILL) can leave an
empty assistant message in the agent CLI's transcript. Every later resume
replays it, the provider rejects the request, and the (agent, issue) pair is
bricked with no self-healing and no user-facing recovery.

Multica already has the mechanism for this — poisoned-session classification —
but its detector paired "400" with "invalid_request_error", which is the
Anthropic wire shape. The same defect reported by any other provider carried
neither token, so it classified as agent_error.unknown: resume-safe by
omission. GetLastTaskSession kept handing back the dead session on every
follow-up, manual Rerun resolved it through the same predicate, and the
in-turn fresh-session retry never fired because ResumeRejected is false here
(nothing rejected the resume — the transcript loaded and the provider refused
to replay it).

Add taskfailure.UnresumableHistory, which recognises the defect by what the
provider says is wrong — some content is empty, and here is which message in
the history — rather than by status code or provider name. Both signals are
required, so a tool reporting "field must not be empty" does not match.

Wire it into the four places that decide whether a session survives:

- classifyPoisonedError, so the task is written as api_invalid_request
- shouldRetryWithFreshSession, so the turn recovers on all 17 backends
  instead of the subset whose adapter learned to detect it; the tools == 0
  gate is unchanged, so a run that already used a tool is never re-run
- ResumeUnsafeFailure, covering the manual-Rerun path
- both resume queries, as defense-in-depth for hosts whose daemon predates
  this (self-host daemons upgrade on their own cadence)

Fixes #6066. Also covers the daemon half of #5760.

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

* fix(session): close the Chat and fresh-retry paths that resurrect a poisoned session

Review found the previous commit stopped short in two places, both of which
put the dead transcript back in play.

Chat never consulted the guarded query. The claim handler reads
chat_session.session_id first and only falls back to GetLastChatTaskSession
when it is empty, so a poisoned pointer there bypasses every filter that query
applies. The fail path merely declined to OVERWRITE the pointer, leaving it in
place. It now clears it in the same transaction, matched on session and
runtime so a concurrent turn's newer pointer survives. The promote guard moves
to ResumeUnsafeFailure as well — the reason-only check passed an un-upgraded
daemon's agent_error.unknown row and re-pinned what the clear had just removed.

GetLastChatTaskSession also kept the row-level filter the issue query dropped
in GH #5975: it discarded the newest poisoned row and fell back to an older
completed row carrying the same dead session. It now judges each session by
its latest terminal state, matching GetLastTaskSession.

A recovered turn could not retire anything. A terminal report carried one
session_id, and an empty one meant both "nothing to report" and "forget the
old session", so a fresh-session retry that SUCCEEDED left the id it retried
away from selectable — through an older completed row on the issue, or through
the chat pointer. agent_task_queue.retired_session_id records the abandonment
itself, reported on every terminal path including completed, and both resume
lookups exclude it. This is the contract gap the previous PR deferred; the
fresh-retry path now runs on all backends, so deferring it is not safe.

Also narrows what the cross-backend test claims: it pins the shared decision,
not that all 17 adapters surface the error into Result.Error (#5760 is the
counter-example), and says so.

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

* test(session): require pgx.ErrNoRows in the resume-exclusion assertions

The `if err == nil && prior.SessionID.Valid` form these tests shared is
false-green: any real fault — undefined column, syntax error, dead connection
— makes err non-nil, so the condition is false and the test passes. Run
against a database missing this branch's new column, the exclusion tests
reported PASS on a SQLSTATE 42703, meaning they could not have caught a broken
query.

requireSessionExcluded demands pgx.ErrNoRows specifically and fails loudly on
anything else, so a green run now means the filter worked rather than the
query never ran.

Applied to all nine sites, not just the four this branch added: the other five
guard the same GetLastTaskSession exclusion behaviour that this branch
changes, so leaving them false-green would leave the change under-tested. All
nine pass on a correctly migrated database.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-29 15:54:51 +08:00
60048172a7 fix(lark): ingest inbound images and videos as chat attachments (MUL-4934) (#5580)
* fix: ingest feishu media as chat attachments

* fix: ingest feishu post embedded media

* fix(lark): make inbound media retries safe

* fix lark media resource limit

* fix(lark): move inbound media off ack path

* fix(channel): make inbound media runs durable

* fix(channel): close enqueue-vs-append race on media deferral

EnqueueChatTask read the session-wide media deadline in one statement and
sealed the input batch in a later one. Under READ COMMITTED a media message
committing between the two got sealed into a task the deadline read had
already decided was 'queued', so the daemon could claim it before its
attachment bound — the agent received the bare placeholder, and the later
media-ready promotion was a no-op against a non-deferred task.

After the seal, re-derive the deferral from the sealed batch itself in the
same transaction (DeferChatTaskForSealedPendingMedia): if any sealed message
still carries an unexpired media marker, flip the task to deferred with
fire_at aligned to the latest marker. The existing post-commit promote fence
already covers the opposite direction (marker cleared mid-transaction).

Adds a deterministic regression test that injects the media append between
the deadline read and the seal via a wrapped pgx.Tx.

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

* fix(channel): keep committed chat task out of enqueue error path

The post-commit media-ready fence returned its error from EnqueueChatTask
even though the deferred task was already durably committed. The router
flush treats any enqueue error as "no task exists": it clears the typing
indicator and logs an enqueue failure while the run still happens at its
fire_at deadline. Log the fence failure instead — the claim-path deferred
promoter re-queues the task regardless.

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

* fix(channel): cap global media resolution concurrency

Media jobs were serialized per session but unbounded across sessions: a
burst could open arbitrarily many concurrent 45s Lark downloads, and each
unknown-length upload may buffer up to the 100 MiB resource cap in memory.
Gate resolveAndBindMedia behind a global slot semaphore (default 8,
RouterConfig.MediaConcurrency). Per-session ordering is unchanged; on
shutdown a job cancelled while waiting for a slot proceeds straight to the
bounded DB finalize so marker clearing stays prompt. Also document that the
per-message media budget spans queue/slot waits (it must match the
persisted fire_at) and why timed-out uploads cannot leak unbounded orphans.

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

* fix(chat): keep channel-sealed user messages on task cancel

Sealing the channel input batch stamps task_id onto channel user
messages, which exposed them to the cancel draft-restore path: an
empty-transcript cancel would DeleteUserChatMessageByTask the sealed
Feishu/Slack messages and detach their attachments. Those messages are
the durable record of what the platform sender wrote — the sender has
no Multica composer to restore a draft into.

Gate the restore-delete on ChatSessionHasChannelBinding in both the
synchronous finalize and the deferred finalize (the latter covers
markers left by an older replica during a rolling deploy); a bound
session now settles as "Stopped." instead.

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

* fix(channel): skip the media pipeline for messages without media

Every inbound message on a Media-enabled platform persisted a 45s
media deadline and queued a resolution job, so a plain text message
could wait behind the global media semaphore (its task deferred while
other sessions download 100 MiB videos) and a crash between append and
clear delayed a pure-text run to the full 45s fallback.

Add MediaResolver.HasMedia — a pure in-memory probe the Router calls
on the ACK path — and only persist the deadline / enqueue the job when
the message actually references platform media. The Feishu resolver
decodes the already-received payload and reports standalone image or
video keys and post-embedded img/media spans.

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

* fix(chat): gate cancel restore on immutable channel provenance

The previous guard keyed the cancel restore-delete off
ChatSessionHasChannelBinding, but a binding only proves routing exists
right now: archiving a session and rebinding an installation both
delete the binding while preserving chat history, so a still-cancellable
sealed task could again restore-delete the original inbound messages.

Persist provenance on the message instead: migration 203 adds
chat_message.channel_ingested, stamped inside the channel append
transaction and never mutated, and both cancel finalize paths now gate
on TaskHasChannelIngestedMessages over the task's sealed batch. The
binding-existence query is removed. Regression tests cover ingest ->
archive/unbind -> cancel for a queued and a started task.

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

* fix(channel): reclaim media uploads that never gain an attachment row

Deadline expiry dropped already-resolved refs and a BindMedia failure
was log-only, leaving uploaded objects with no attachment row and no
reclaim path — the dedup mark commits with the message before media
runs, so a redelivery is dropped as a duplicate and never re-resolves
(and thus never overwrites) those keys, and workspace/session deletion
only enumerates the attachment table.

Add MediaResolver.DiscardMedia — a best-effort delete by StorageKey —
and call it from both failure paths in resolveAndBindMedia. The Feishu
resolver forwards to the storage backend's Delete. Tests cover a
partial upload discarded at the deadline, discard on bind failure, and
key-level deletion in the resolver.

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

* docs(server): refresh comments stale after detached media ingestion

Channel tasks now seal a self-owned input batch, media ingestion is no
longer out of scope for the flattener, and MediaRefs are filled by the
detached resolver after append rather than by feishuChannel pre-engine.

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

* fix(chat): stop keying channel empty-completion silence off chat_input_task_id

Sealing gave channel tasks a self input-owner, which broke
writeChatCompletionOutcome's discriminator: it treated any owned task
as direct, so an empty channel completion wrote the no_response
fallback row and the outbound patcher — which forwards any non-empty
chat:done content verbatim — pushed the English fallback body to
Feishu/Slack, violating the MUL-4351 contract.

Silence is now decided by the immutable channel_ingested provenance of
the task's input batch, looked up by the batch OWNER id
(chat_input_task_id): auto-retry clones inherit the owner while their
sealed messages stay tagged with the parent's id, so keying off the
task's own id would misread a channel retry as direct. The cancel-path
provenance gates switch to the same owner key via chatInputOwnerID.
chat_input_task_id is back to meaning only "input batch owner".

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

* chore(migrations): renumber to 203/204 after upstream took 202

Upstream main merged 202_runtime_profile_add_qwen while this branch
held 202/203, tripping TestMigrationNumericPrefixesStayUniqueAfterLegacySet
on the CI merge tree. channel_media_pending becomes 203 and
channel_ingested becomes 204; no content changes.

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

* fix(channels): gate outbound delivery on channel provenance, not owner

Merging main brought #5645 (keep direct chat replies in Multica),
whose outbound gate assumed channel tasks leave chat_input_task_id
NULL. Sealed channel tasks own an input batch too, so on the merge
tree every channel reply and failure notice was classified as direct
and silently dropped — agents stopped replying in Feishu/Slack.

Both outbound gates now call engine.TaskInputIsChannelIngested: a NULL
owner keeps #5645's deliver-by-default for pre-sealing tasks, an owned
batch delivers only when it carries the immutable channel_ingested
stamp (keyed by the owner id, so auto-retry clones inherit the
verdict). Direct replies stay in Multica; sealed channel replies reach
the platform. Tests cover both directions on both platforms.

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

* fix(channel): discard media orphans on a fresh context, after finalize

DiscardMedia shared finalizeCtx with BindMedia, so a bind that failed
because the finalize deadline expired handed the storage deletes an
already-dead context — the compensation silently no-opped and the
orphans leaked anyway. The deadline path also ran S3 deletes before
the marker clear, eating the same 5s budget the user-facing
bind/promotion needed.

Collect the refs from both failure paths, run bind + promotion on the
finalize budget first, then delete on a fresh discard context. The
bind-failure test now pins that discard receives a live context.

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

* fix(channel): compensate result-uncertain media uploads and commits

The compensation protocol treated "the call returned an error" as "the
side effect did not happen", which is wrong in both directions across
the result-uncertain windows:

- An upload error can follow a server-side write (lost response,
  deadline mid-write). The attempted key never reached the router, so
  nothing could reclaim it and dedup guarantees no re-resolve. The
  resolver now idempotently deletes the deterministic key on a fresh
  budget right at the failure site.

- A commit error is not a rollback guarantee: a lost ack can report
  failure after Postgres durably committed the attachment rows, and
  the router's discard would then delete objects those rows reference.
  BindMediaRefs now converges the ambiguity on a fresh budget — any of
  the batch's URLs present proves the atomic commit landed (bind
  reports success); none proves the rollback (discard stays safe); a
  failed verification returns ErrMediaBindResultUnknown and the router
  keeps the uploads, preferring a rare orphan over a broken attachment.

Fault-injection coverage: an upload error deletes the attempted key; a
lost-ack commit keeps the bound attachment and reports success; a
verified rollback stays a discardable error; the router keeps uploads
on the unknown-outcome sentinel.

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

* chore(migrations): renumber to 207/208 after upstream took 203-206

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

* docs(channel): note DiscardMedia self-invocation and the unknown-outcome skip

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

* chore(migrations): renumber to 212/213 after upstream took 207-211

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

* feat(channel): replace inline media compensation with an intent ledger and reconciler

Inline best-effort compensation cannot answer "did my side effect
happen?" at the moment it needs the answer — the DELETE/PUT reordering
and the empty-read-vs-in-flight-COMMIT gaps were both instances of the
same two-system atomicity problem. Persist the intent instead and let
an asynchronous reconciler settle it:

- channel_media_pending_object (migration 214; claim index 215 as its
  own single-statement CONCURRENTLY migration): a state machine row
  ('pending' -> 'deleting') with lease, attempt, and backoff columns.
- The resolver upserts the row BEFORE each PUT, state-guarded so a key
  the reconciler owns is never resurrected (the resource is skipped).
  ObjectURL is a pure function of configuration, so the row carries the
  attachment URL pre-upload.
- BindMediaRefs deletes the batch's rows INSIDE the attachment-insert
  transaction: commit landed <=> intents gone, atomically, so an
  ambiguous COMMIT never needs adjudication. A key already claimed to
  'deleting' is skipped (placeholder stays).
- Nothing is ever deleted inline. The reconciler — an independent
  worker so storage latency cannot starve other sweepers — claims due
  rows ('pending' past the settle delay, or expired leases) under a
  fresh lease, checks for a durable attachment reference only AFTER the
  claim (race-free: bind can no longer succeed on the key), deletes
  unreferenced objects outside any transaction, and backs off failed
  deletes with attempt-based retry. Crash windows converge for free.
- The settle delay is a fixed constant carrying NO correctness weight;
  invariant tests pin it at >=10x every pipeline budget. Metrics cover
  deletes, referenced clears, delete failures, and ledger backlog.

Removed: MediaResolver.DiscardMedia, ErrMediaBindResultUnknown, the
post-commit verification, and both router discard branches.

Tests: intent-before-upload ordering; upload error leaves the row and
deletes nothing; bind-wins vs reconciler-wins on the same key; lost-ack
and rolled-back commit injections (intent cleared iff the attachment
landed); reconciler three-state settle; expired-lease reclaim; delete
failure backoff and retry; settle invariants.

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

* fix(channel): never build or sweep the media reconciler without storage

store is nil when S3 is unconfigured AND the local upload dir fails to
initialize, but the reconciler was constructed unconditionally and
main only gates the goroutine on the reconciler pointer — the first
unreferenced ledger row (rows can pre-exist from a boot where storage
worked) would nil-pointer panic a bare goroutine and take down the
process.

Construct the reconciler only when a storage backend exists, and guard
RunOnce defensively: with no deleter it skips the sweep without
claiming, so rows are not stranded in 'deleting' until lease expiry.
Test covers the pre-existing-row + missing-storage boot.

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

* chore(migrations): renumber to 213-216 after upstream took 212

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

* refactor(channel): remove the dead pre-resolved MediaRefs ingress path

lark.InboundMessage.MediaRefs and the resolver's early-returns for
pre-populated refs were vestiges of the pre-detached synchronous design
— no producer fills them before the router anymore. Worse, the intent
ledger made the path actively misleading: refs arriving without ledger
rows would be silently skipped at bind (with a log blaming the
reconciler), contradicting the field's "already persisted" contract.

Delete the field, its channelMessageFromLark mapping, and both
early-returns; channel.InboundMessage.MediaRefs is now documented as
what it actually is — ResolveMedia's output channel, always empty on
ingress, attachable only through a claimed ledger intent.

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

* fix(channel): enforce workspace tenancy on every ledger query

The intent-ledger upsert's conflict branch guarded only on state, so a
cross-workspace storage_key collision could rewrite the row's
workspace/message/url ownership; release and delete keyed on
(storage_key, lease_token) alone. The derived key embeds the workspace
UUID so none of this is reachable today — but tenancy must be enforced
by the workspace column in every query, never derived from the key
string (MUL-3515 rule, restated in this PR's review).

The upsert now updates only within the same workspace (a cross-tenant
conflict updates nothing, returns no row, and the resolver skips the
upload — the fail-safe direction), and release/delete take
(workspace_id, storage_key, lease_token). Tests pin that a foreign
workspace can neither steal, release, nor delete a row.

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

* fix(migrations): build the ledger primary key via a concurrent index

storage_key TEXT PRIMARY KEY created its unique index implicitly at
CREATE TABLE, against the repo convention that every migration index —
including a new table's unique index — is built CONCURRENTLY in its
own single-statement migration (the exact three-step pattern
client_usage_daily shipped in 207-209). The table now declares
storage_key NOT NULL, 216 builds the unique index concurrently, and
217 attaches the primary key USING INDEX; the claim index moves to
218. ON CONFLICT (storage_key) still resolves against the constraint,
and the full down/up round-trip is verified.

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

* fix(channel): bound each reconciler object delete with its own timeout

DeleteObject ran on the worker-lifetime context and the SDK's default
HTTP client has no overall request timeout, so one black-holed
connection would wedge the sequential sweep loop — and with it every
later batch and the backlog gauge — forever; a single-replica
deployment has no other worker to reclaim the lease. Each delete now
gets a 30s timeout (well under the 2min lease), and a timed-out delete
takes the existing release/backoff path. Covered by a blocking-deleter
test with an injectable timeout.

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

* fix(channel): anchor the media deadline to the DB clock and bound queue waits by it

Two deadline gaps from review:

- The persisted marker was an application-clock timestamp compared
  against SQL now() everywhere it is read, so a skewed app node could
  shrink the fallback window and hand the agent a placeholder before
  the resolver's local budget ended. The append transaction now anchors
  a relative budget (MediaPendingSeconds) with now() + make_interval,
  writer and readers sharing one clock; the local resolve budget stays
  monotonic app-side. A DB test pins that the remaining budget measured
  by the DB clock equals the requested one.

- enqueueMedia's waits (per-session order, global slot) only watched
  shutdown, so in a burst an already-expired job kept its goroutine and
  payload until it reached the front. Both waits now also watch the
  message's deadline; on expiry the job skips the resolver entirely and
  runs only the empty finalize (marker clear + promotion), which also
  unblocks the session's later messages. Covered by a queued-expiry
  test that finalizes while the only slot is deterministically held.

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

* chore(migrations): renumber to 216-221 after upstream took 213-215

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

* fix(channel): start the local media budget before the append transaction

The DB anchors the durable fallback at insert-time now(), but the
local monotonic budget started only after AppendMessage returned — so
the resolver outlived the fallback by the append/commit latency, a
window where the deferred task is already claimable while the resolver
still runs and the agent reads a placeholder that binds moments later.
Capture the local deadline before calling AppendMessage, restoring the
ordering local-gives-up <= durable-fallback-fires. A slow-append test
pins that the resolver's context deadline is measured from the
pre-append instant.

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

* chore(migrations): renumber to 224-229 after upstream took 216-223

Verified against the merged tree: the numeric-prefix uniqueness test
passes and the full migration set applies cleanly from scratch.

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

* fix(channel): heartbeat the reconciler lease per row

One claim covers up to 50 rows under a single 2-minute lease, but the
batch is processed sequentially and each delete may run its full 30s
timeout — a few stalled deletes could outlive the lease mid-batch,
letting another replica reclaim the tail: duplicate concurrent
deletes, inflated attempt/backoff on rows whose owner was alive, and
skewed metrics.

The lease is now renewed before EACH row's settle work, so it only
ever needs to cover one row's worst case (invariant-tested: lease >=
2x the per-delete timeout). A renewal that matches no row means
another worker reclaimed it after a genuine expiry — the row is
skipped, leaving the new owner's state untouched. Test simulates a
mid-batch reclaim and pins the skip.

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

* fix(channel): dedup post media resources and make local writes atomic

A rich post may reference the same image_key/file_key in several spans.
The object key derives from (message, type, key), so duplicates uploaded
to the SAME key twice: LocalStorage.UploadStream truncated the
destination up front and removed it outright on a copy error, so a
second failing attempt destroyed the object the first success had
produced — leaving an attachment row pointing at nothing. A second
succeeding attempt instead produced two attachment rows for one object.

Collapse duplicate spans by (fetch type, platform key) before the
upload loop, and write local uploads through a temp file renamed into
place so a failed write can only discard its own temp file. Tests cover
a duplicated span uploading once and a failed re-upload leaving the
previous object intact.

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

* fix(channel): fence late-materializing PUTs with a tombstone schedule

A DELETE cannot be ordered against a PUT the client already abandoned:
the store may materialize the object after the delete completes. The
reconciler cleared the ledger row right after deleting, so such an
object had no row and nothing to reclaim it — which made the settle
delay the de-facto correctness barrier for the PUT/DELETE race, exactly
what the design says it must not be.

The row is now kept as a tombstone ('tombstoned' state, migration 226's
CHECK) and re-deleted on a widening schedule (15m, 1h, 6h, 24h, the
pass index carried in last_error), so a late materialization is
reclaimed by a later pass; only after the schedule is exhausted is the
row dropped. Claim, heartbeat, lease, and tenancy predicates are
unchanged — a tombstone is claimed exactly like any other due row. A
separate gauge reports tombstones so they cannot be mistaken for a
backlog of objects awaiting reclaim, and the header comment now states
precisely what state fences (bind/commit) versus what the schedule
fences (late PUTs).

Tests: the reviewer's interleaving — DELETE completes, the abandoned PUT
materializes right after, and the object is gone by the end of the
schedule — plus a full schedule walk asserting the object is counted
once and the row clears at the end.

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

* fix(channel): tombstones must re-delete, not re-ask the reference question

A tombstone revisit ran the same reference check as a first settle, so
an attachment carrying the same URL — a re-ingested copy of the object —
sent the row down the "referenced, keep it" branch: the object was kept
and the row cleared, abandoning the re-delete schedule that fences the
ORIGINAL object against an abandoned PUT. A tombstone has already been
judged unreferenced and deleted; it exists only to re-delete whatever
materializes later, so it now goes straight to the delete + schedule
tail (extracted as settleDeletedObject, shared with the first settle).

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

* fix(channel): keep the tombstone schedule position in its own column

The re-delete pass index was encoded into last_error, which the failure
path also writes: one failed re-delete erased the position and restarted
the walk. A store failing intermittently could therefore keep a tombstone
alive indefinitely — every recovery would resume at pass 1 and the row
would never reach the end of the schedule to be dropped.

tombstone_pass is now its own column (the table is introduced in this PR,
so migration 226 carries it), advanced only by a successful delete, and
the tombstone write clears the now-stale last_error. Test walks the
schedule across a failed re-delete and asserts it resumes rather than
restarts, and that the row still terminates.

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

* fix(lark): derive media object keys per chat message

The object key was derived from the platform message alone, so a second
ingest of the same Feishu message reused the first ingest's ledger row.
That row can be a tombstone (up to ~31h while the re-delete schedule
runs), and the intent upsert refuses anything that has left 'pending', so
the second ingest skipped the upload and silently produced a placeholder
with no attachment. A re-ingest is reachable: the inbound dedup claim is
reclaimable once 60s stale and the dedup row is only vacuumed after 24h.

Keying on the chat message the object will attach to keeps the two
ingests independent, and nothing leaks: each one's objects are covered by
its own ledger row.

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

* refactor(storage): route both local upload paths through one atomic write

UploadStream wrote through a temp file and renamed into place, but the
buffered Upload path still truncated the destination up front — the
destructive shape the stream path exists to avoid, one caller away from
coming back. Both now share writeAtomic, which also restores the 0644 the
direct write used (CreateTemp makes files 0600).

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

* chore(channel): gofmt the media-pending append fields

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

* fix(storage): keep the local upload chmod best-effort

The rename-into-place rewrite made a failed chmod fail the whole upload.
CreateTemp's 0600 has to be widened to the 0644 the direct write used, but
an upload dir on a mount that ignores chmod (SMB/NFS/FUSE) accepted the
old direct write fine — turning those deployments' uploads into hard
errors would be a regression for a cosmetic property. Log and continue.

Tests pin 0644 on both upload paths, and that a failed buffered upload
leaves no temp litter and no damage to a previous object.

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

* fix(channel): never re-delete an object an attachment references

The tombstone pass skipped the reference check and deleted unconditionally,
so a durable attachment carrying that URL lost the only object it can read
— the dangling attachment the intent ledger exists to prevent, and the
opposite of the posture every other path here takes ("a reclaimable orphan
beats a broken attachment").

The check now runs on every pass. A positive result on a tombstone is
unreachable by design — keys are per (chat message, resource) and a bind
cannot attach a key that has left 'pending' — so reaching it means an
invariant broke: keep the object, clear the row, log it, and count it on
a dedicated reconciler_tombstone_referenced_total counter. The test's
contract is flipped to assert the referenced object survives.

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

* fix(storage): make the local staging file reclaimable after a crash

os.CreateTemp's random suffix meant a crash between the staging write and
the rename left a file nothing could name: the ledger records only the
final storage key, and DeleteObject removed only the object and its
sidecar. Each leftover can approach the 100 MiB resource cap and they
accumulate without bound.

The staging path is now derived from the object key, so DeleteObject
removes it alongside the object — which makes the media reconciler reclaim
it too, since the intent row is written before the upload. Opening it 0644
directly also drops the chmod the previous commit had to make best-effort.
Both read paths refuse the staging name (keys come from the request URL,
and a half-written body should not be readable); a user-supplied ".tmp"
extension is unaffected, since object keys are generated and never
dot-prefixed.

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

* chore(channel): renumber the media migrations after merging main

main took 224 (agent_task_session_rollout_missing), so the ledger group
moves to 225-230 and the cross-references inside the table migration follow.
main's CompleteTask also grew a sessionRolloutMissing parameter; the three
call sites this PR added to chat_input_ownership_test.go pass false.

Verified the way the numbering is meant to be verified: full migration set
applied from scratch on the merged tree, and the whole server suite run
against that database.

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

* fix(channel): let Postgres compute every reconciler deadline

The reconciler built settle cutoffs, lease expiry, backoff and re-delete
times from the process clock and compared them against the database's
now(). A replica whose clock had drifted would therefore settle rows whose
upload was still in flight (the object is deleted and the bind then refuses
to attach — media silently lost), hand out leases that are born expired
(rows churn between workers, attempt/backoff inflate), or compress the
tombstone schedule that fences a late-materializing PUT.

The four settle queries now take durations and derive their timestamps from
now(), so every replica reads one clock. The parameter types are the guard:
an app-side timestamp can no longer be passed. Test asserts the persisted
lease, backoff and re-delete deadlines all track the database's now().

The generated code also picks up main's new agent_task_queue column in the
two RETURNING task.* queries this PR adds.

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

* chore(lark): drop unrelated gofmt-only churn from this PR

Six files carried whitespace/comment-reformatting with no functional
change, unrelated to the inbound media pipeline. Reverting them to the
base revision keeps the diff focused on the feature (75 -> 69 files):

  server/internal/service/empty_claim_cache.go
  server/internal/integrations/lark/markdown_detect.go
  server/internal/integrations/lark/ws_chunk_assembler.go
  server/internal/integrations/lark/ws_chunk_assembler_test.go
  server/internal/integrations/lark/ws_frame_test.go
  server/internal/integrations/lark/registration_test.go

Verified: `git diff -w` against these files was already empty, so no
behavior is affected. go vet clean; tests covering these files pass.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-27 15:44:39 +08:00
a3fe6d91dd MUL-5150: add project context to Chat (#5765)
* feat(chat): add project context

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

* fix(chat): resolve MUL-5150 review blockers

- Renumber project-context migrations to unique prefixes after current main:
  206_chat_session_project -> 212 (column), 207_chat_session_project_index ->
  213 (concurrent index). 206/207 collided with 206_agent_disabled_runtime_skills
  and main's 207-211 client_usage_daily set.
- Add the 4 missing chat input.project_context keys to ja/ko locales so the
  locale parity test passes (en/zh-Hans already had them).
- Lock the project-context control while a send is in flight (isSubmitting),
  not just while the agent is running. A brand-new chat creates its session
  lazily during send bound to the project at click time; switching project
  mid-send would create the session against the stale project and clear the
  editor as if the send landed on the new selection. Add a regression test.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): complete project context handling

* fix(chat): pin fresh chat to open session's agent on project switch

Switching an existing session to a different project opens a fresh chat but
only cleared the active session, dropping selection back to the stored
`selectedAgentId`. When that preference was stale (open session belongs to
agent B while the persisted pick is still agent A), the lazily-created session
and its first send bound to the wrong agent (agent A).

Extract the project-switch decision into a shared `planProjectContextChange`
pure helper in use-chat-controller.ts and route both chat surfaces (the chat
tab controller and the floating ChatWindow) through it, so the fresh chat is
pinned to the open session's agent and the rule cannot drift between the two
copies. Add a dual-entry regression test (pure-fn guard + controller
integration) covering the stale selectedAgentId case.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* chore(ci): re-trigger required checks on latest head

The prior push updated the branch ref but GitHub did not emit a pull_request
synchronize for it (PR head-sync lag), so CI/Mobile Verify never ran on the
commit carrying the stale-agent project-switch fix. Empty commit to force a
fresh synchronize on a head that includes it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): renumber project migrations to 213/214 after main added 212

Current main added 212_agent_service_tier; the PR's 212/213 chat migrations
collided with it on the merge ref, failing TestMigrationNumericPrefixesStay
UniqueAfterLegacySet. Merge current main and move the chat column migration to
213 and the concurrent index migration to 214 (column before index preserved).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): lock ProjectPicker clear control during send (keyboard path)

The send-pending lock only put pointer-events-none on the wrapper, which
blocks the mouse but leaves ProjectPicker's inline clear button in the tab
order — a keyboard user could Tab to "Remove from project" and press Enter
mid-send, detaching the project after the lazily-created session already went
out with the old one (reopens the mid-send retarget path via keyboard).

Add an explicit `disabled` capability to the shared ProjectPicker that locks
the trigger, the menu (forced closed), and the inline clear button (disabled +
out of the tab order). Defaults to false, so issue/create/autopilot callers
keep their hover/keyboard clear. ChatInput passes disabled while the project
selection is locked.

Tests: real-ProjectPicker regression (keyboard activation of the clear control
is inert when disabled; still works when enabled) + ChatInput wiring assertion
that the picker is disabled mid-send.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Walt <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Naiyuan Qing <[email protected]>
Co-authored-by: NevilleQingNY <[email protected]>
2026-07-24 11:30:27 +08:00
Bohan Jiang 168620fc15 MUL-5163 fix(agents): rebind the Agent Builder carrier when the runtime is switched (#5780)
Switching the runtime mid-conversation in Build with AI only updated local React state, so the picker could show runtime B while every subsequent message still executed on the runtime frozen at session create time.

- Add PATCH /api/agent-builder/sessions/{id}/runtime to rebind the hidden builder carrier (runtime_id/runtime_mode, model cleared since model ids are per-runtime). Creator-only, builder carriers only, target must be in-workspace, usable by the member, and online; a reply in flight returns 409.
- Serialise rebind against send: both take LockChatSessionForRuntimeBind on the chat_session row and SendDirectChatMessage re-reads the agent inside that transaction, so a send blocked behind a rebind cannot resume and stamp its task with the runtime the switch moved away from.
- Leave chat_session.runtime_id stale on purpose so the daemon starts a fresh provider session on the new runtime while Multica-side history and the draft survive.
- Frontend updates the draft only after the server reports the bound runtime, blocks sending during a rebind, disables the Mine/All filter alongside the trigger, and explains why the picker is locked during a pending reply.

Closes #5773
2026-07-23 10:56:53 +08:00
ae68799c64 feat(task): reuse workdir on manual retry, gate session on error class (MUL-4869) (#5525)
* feat(task): reuse workdir on manual retry, gate session on error class (MUL-4869)

A manual retry (execution-log "retry" button -> POST /api/issues/{id}/rerun)
previously set force_fresh_session=true unconditionally, so the daemon threw
away the failed run's workdir and started from an empty one. For a transient
failure (network blip, provider 5xx / rate-limit, runtime_offline, timeout)
that discards work the agent already did.

New contract: a retry ALWAYS reuses the source task's workdir when it still
exists on disk; only the agent SESSION is gated, on whether the source task's
failure poisoned the conversation.

- RerunIssue derives force_fresh_session from the source task's failure_reason
  via resumeUnsafeFailureReason instead of hardcoding true. Transient/cancelled
  failures resume the session; conversation-poisoning failures start fresh.
- The daemon claim handler resolves the retry's session/workdir precisely from
  rerun_of_task_id, not the most-recent (agent,issue) row that a parallel task
  could hijack. PriorWorkDir is returned whenever present; PriorSessionID only
  when resume-safe AND on the same runtime.
- force_fresh_session now gates only the session, never the workdir.
- Add agent_error.context_overflow to the resume-unsafe set (Go helper plus the
  GetLastTaskSession / GetLastChatTaskSession blacklists): resuming an overflowed
  context would immediately overflow again.

Objectively-unreusable cases (workdir GC'd, different runtime, failed before a
workdir was recorded) fall back to a fresh Prepare via the daemon's existing
execenv.Reuse / gateResumeToReusedWorkdir path, so no daemon change is needed.

Tests: RerunIssue force_fresh classification by failure_reason; claim-layer
workdir-always-reused plus session gating (same/different runtime, poisoned);
context_overflow classifier.

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

* fix(task): make manual-retry workdir reuse rollback-safe and error-text aware (MUL-4869)

Addresses code review on #5525.

- Rollback safety: RerunIssue no longer writes force_fresh_session based on the
  source failure. Rerun rows are pinned to force_fresh_session=true again, so an
  OLD claim handler picked up mid rolling-deploy degrades to a clean start
  instead of falling back to the (agent, issue) most-recent lookup and resuming
  a *different* execution. The new claim handler ignores the flag for reruns and
  computes session reuse from the exact source task (rerun_of_task_id).

- Legacy poison defense: add shared service.ResumeUnsafeFailure(failureReason,
  errorText), which combines the failure_reason poison set with the same
  400/invalid_request_error raw-error-text guard GetLastTaskSession applies. The
  claim handler's exact-source path uses it, so legacy 'agent_error' /
  deploy-window rows carrying a 400 marker are no longer resumed.

- CI: TestRerunIssueTargetsSourceTaskAgent passes again (rerun rows stay
  force_fresh_session=true). Service test now asserts that rollback-safe
  invariant across failure classes; the claim test drives session gating from
  the source task and adds a legacy-400 case.

- Docs: tasks.{mdx,zh,ja,ko}.mdx and the GetLastTaskSession SQL comment now
  distinguish execution-log per-row retry (task_id: reuse workdir, conditional
  session) from CLI/API rerun (no task_id: fresh session + fresh workdir).

- Clarify cross-runtime workdir is best-effort: the daemon offers the source
  workdir regardless of runtime (a shared mount may resolve it) and only the
  per-cwd session is runtime-gated.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-16 16:38:18 +08:00
4fac8d772f feat(attribution): Human Attribution Phase 1 (MUL-4302) (#5150)
* feat(attribution): Phase 1 foundation — provenance schema + resolver (MUL-4302)

Human Attribution, Phase 1 (地基) first increment. Every agent run must be
traceable to exactly one accountable human AND record at which waterfall level
that human was resolved, so a NULL originator can be told apart from a genuine
'no human in the chain'.

- migration 149: add originator_source (waterfall label) + delegation/retry/
  rerun/rule-version lineage + kind-tagged trigger evidence to agent_task_queue.
  No FK, no cascade, no CHECK on the source enum (MUL-4302 §7); nullable ADD
  COLUMNs = fast metadata-only change on the hot queue table.
- internal/attribution: the accountable-human vocabulary (Source, EvidenceKind,
  TriggerKind) + pure, unit-tested classification rules (ClassifyComment/
  ClassifyDirect). No DB, no authorization — provenance labeling only.
- service: attributionFor{IssueTask,TriggerComment} gather facts and delegate to
  the pure classifier; the legacy originator resolvers now delegate here so
  there is one source of truth. originator_user_id's VALUE is unchanged, so the
  Composio-overlay and canInvokeAgent A2A authorization boundaries are
  byte-for-byte preserved (MUL-4302 §1.3).
- enqueueIssueTask / enqueueMentionTask stamp originator_source + evidence;
  CreateRetryTask carries the parent attribution forward and records
  retry_of_task_id so retry and manual rerun stay separable (MUL-4302 §5).

Verified: go build ./..., go vet, gofmt clean; new attribution unit tests +
enqueue stamping integration test green; existing resolve_originator tests
unchanged.

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

* feat(attribution): split accountable_user_id from originator, close enqueue bypasses (MUL-4302)

Phase 1, per Bohan's decision on the MUL-4302 thread: audit and authorization
answer different questions and get different columns.

- Migration 151 adds agent_task_queue.accountable_user_id (no FK, no cascade).
  Authorization keeps reading ONLY originator_user_id (canInvokeAgent A2A gate,
  Composio overlay); audit/UI/usage read accountable_user_id + source + evidence.
- Invariant (finalizeAttribution, single chokepoint + §11 tests): originator
  non-null ⟹ accountable equals it. The two diverge only when originator is null
  (autopilot / degraded fallback), which is the deferred rule_owner/owner_fallback
  increment; this lands the column + mirror-write so that split has a home.

- Close the NULL-source enqueue bypasses Elon flagged: chat, quick-create,
  deferred-fallback and run_only-autopilot now stamp originator_source + evidence
  (+ accountable where a human exists). Autopilot stays unattributed until the
  rule-version snapshot table lands, but is no longer a silent NULL-source row.
  Retry inherits accountable_user_id like the rest of the attribution lineage.

- Fix assign/promote attribution (§4): a member who assigns/promotes an existing
  issue is now the accountable human (and, by the invariant, originator) ahead of
  the issue creator. Threaded as an OPTIONAL actor override, so comment/rerun/
  autopilot paths keep today's resolution and create-with-assignee (creator ==
  actor) is unchanged. The squad leader gate already judged the same member.

Also merges origin/main: renumbers the attribution migration 149→150 (main took
149 for issue_origin_agent_create) and folds agent_create into ClassifyDirect's
origin inheritance.

go build/vet/gofmt clean; attribution unit tests + service stamp/actor tests +
handler suite pass on a fresh DB migrated through 151.

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

* docs(attribution): fix accountable NULL semantics + close chat/quick-create evidence boundary (MUL-4302)

Addresses Elon's 2nd-round review on PR #5150 (pre-merge doc/evidence items):

- Migration 151 no longer overclaims NULL. accountable_user_id is NULL not only
  on pre-migration rows but on NEW rows whose audit source resolved no human yet
  (run_only autopilot writes originator_source='unattributed' with NULL
  accountable until rule_owner lands). Header + COMMENT ON COLUMN reworded so a
  schema reader does not misjudge the invariant.

- Chat now uses the UNIFORM evidence pair (kind=chat, ref=chat_session_id), like
  autopilot_run/issue_assignment, instead of relying only on the dedicated
  chat_session_id column — new EvidenceChat kind. Added a service test asserting
  chat stamps direct_human + chat evidence.

- Quick-create is documented as the ONE intentional no-antecedent-row path: no
  comment/issue/session/run exists at enqueue time (the run creates the issue), so
  trigger_evidence_kind/ref stay NULL while the human rides originator/accountable
  and source is direct_human — not a NULL-source bypass.

No authorization behavior change. attribution + service + handler suites pass on a
DB migrated through 151.

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

* chore(attribution): renumber migrations 150/151 → 157/158 after merging main (MUL-4302)

main's #5162 ("unblock release migrations") renumbered the chat migrations and
took 150 (agent_task_coalesced_comments) and 151 (chat_read_cursor), colliding
with this branch's attribution migrations. Renumber them above main's new highest
(156) so TestMigrationNumericPrefixesStayUniqueAfterLegacySet passes:

- 150_agent_task_attribution      → 157_agent_task_attribution
- 151_agent_task_accountable_user → 158_agent_task_accountable_user

Fixed the internal "migration 150" references in 158's header to 157. Migrations
apply cleanly through 158 on a fresh DB; migration lint green.

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

* feat(attribution): autopilot rule_owner — accountable = rule version publisher (MUL-4302)

Implements rule_owner (MUL-4302 §3.4), the first attribution source where the
accountable human diverges from the (NULL) authorization originator.

- Migration 159 adds the append-only autopilot_rule_version snapshot table (no FK,
  no cascade); migration 160 adds its CONCURRENTLY lookup index.
- Write-on-publish: CreateAutopilot appends v1 (publisher = creator); UpdateAutopilot
  appends a new version when a SUBSTANTIVE autopilot-row field changes (assignee /
  status / execution_mode) — cosmetic edits (title/description/template) write none.
  Both run inside the existing handler tx (atomic with the autopilot write).
- Dispatch resolution: both autopilot execution modes now resolve the active rule
  version and stamp originator_source='rule_owner', accountable_user_id=publisher,
  rule_version_id=<snapshot>, with originator_user_id left NULL (authorization
  unchanged). run_only stamps CreateAutopilotTask directly; create_issue resolves in
  attributionForIssueTask so both modes attribute identically. A missing version /
  non-member publisher degrades to unattributed — never fabricates a human.
- finalizeAttribution now enforces the invariant ONE-WAY: it mirrors originator onto
  accountable only when originator is valid, leaving an explicitly-set accountable
  (rule_owner / future owner_fallback) intact when originator is NULL. Added
  rule_version_id to CreateAgentTask so the create_issue path persists it too.

Also merges origin/main and renumbers this branch's attribution migrations
150/151 → 157/158 (main's #5162 took 150/151); rule_version table is 159/160.

Tests: attribution unit RuleOwner + one-way invariant table; service integration
tests proving an autopilot-origin issue stamps rule_owner + rule_version_id (and
degrades to unattributed with no version). Full service/attribution/handler/
migration suites pass on a DB migrated through 160; build/vet/gofmt clean.

Deferred (same PR): trigger-table republish (cron/webhook/event_filters) and
system-pause/archive versioning; owner_fallback + fail-closed; manual rerun.

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

* fix(attribution): manual autopilot trigger → direct_human to the triggering member (MUL-4302)

Elon's blocking finding: a member manually triggering an autopilot was attributed
rule_owner (accountable = rule publisher, originator NULL) like a schedule/webhook
run, so member B triggering member A's autopilot landed accountable=A and carried
no originator authorization context for the run. Per MUL-4302 §4 a manual "run now"
is a direct human action and must attribute direct_human to the triggering member.

- Thread the triggering member from TriggerAutopilot into dispatch: new
  DispatchAutopilotManual carries actorUserID (resolved via resolveActor +
  memberActorUserID, so only a member actor is a human; an A2A agent actor falls
  back to rule_owner). DispatchAutopilot / DispatchAutopilotForPlan keep their
  public signatures (pass an invalid actor); only the internal dispatchAutopilot /
  dispatchCreateIssue / dispatchRunOnly gained the param, so the many existing
  callers are untouched.
- run_only: dispatchRunOnly stamps direct_human (originator == accountable ==
  actor, no rule_version) for a manual actor, else rule_owner. CreateAutopilotTask
  gains an originator_user_id param for the manual case.
- create_issue: dispatchCreateIssue enqueues a manual trigger via the actor-carrying
  *WithHandoff entry points; attributionForIssueTask's autopilot-origin rule_owner
  branch is now guarded on !actorUserID.Valid, so a valid actor falls through to the
  direct_human override. Both execution modes attribute identically.
- schedule / webhook keep rule_owner (no actor). Trigger-table + system-pause/archive
  versioning remain the pre-merge follow-ups.

Tests: the run_only row assertion Elon asked for (schedule → rule_owner row on
CreateAutopilotTask), plus manual direct_human on BOTH modes (run_only and
create_issue), including a manual actor distinct from the rule publisher. Full
service/attribution/handler/migration/scheduler/cmd suites pass on a DB migrated
through 160; build/vet/gofmt clean.

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

* feat(attribution): owner_fallback + fail-closed policy, and manual-rerun direct_human (MUL-4302)

Two of the three remaining Phase 1 items (trigger-table / system-pause versioning
is deferred — see PR description).

owner_fallback + fail-closed (§1/§3.5) — the never-null accountable guarantee:
- attribution.OwnerFallback degrades an UNATTRIBUTED result to owner_fallback:
  accountable = agent owner, originator stays NULL (audit-only, authz untouched),
  Source.Precise()==false. finalizeAttribution's one-way invariant already allows
  accountable-set / originator-NULL divergence, so nothing else changes.
- Migration 161 adds workspace.attribution_fail_closed (default FALSE) + a lean
  GetWorkspaceAttributionFailClosed read. (Also added the column to ListWorkspaces'
  explicit column list so its row type stays db.Workspace.)
- applyAttributionFallback is applied at every enqueue boundary (issue, mention,
  chat, quick-create, deferred-fallback, autopilot run_only): unattributed →
  owner_fallback (agent owner) by default, or ErrAttributionFailClosed when the
  workspace is fail-closed, which the caller surfaces to refuse the enqueue (the
  run does not start). So no run is left without an accountable human, and a
  compliance workspace can block unattributable runs instead.

manual rerun (§5) — a rerun is a NEW direct_human trigger to the rerunning member:
- RerunIssue threads the acting member (resolved in the handler via resolveActor)
  down to enqueueRerunTask, and attributionForIssueTask is now actor-first so the
  actor wins over an INHERITED trigger comment (a rerun keeps the comment for the
  daemon's prompt context but must attribute to whoever clicked rerun, not the
  original comment's human).
- rerun_of_task_id lineage is recorded via a targeted SetAgentTaskRerunOf update on
  the rerun path only (keeping the shared CreateAgentTask insert untouched), so
  system retry (retry_of_task_id) and human rerun stay separable in reporting.

Tests: OwnerFallback unit test; owner_fallback + fail-closed-refusal + manual-rerun
(direct_human + rerun_of_task_id) service tests; the prior "degrades to unattributed"
test updated to owner_fallback. Full service/attribution/handler/migration/scheduler/
cmd suites pass on a DB migrated through 161; build/vet/gofmt clean.

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

* fix(attribution): close fail-open holes + move rerun_of_task_id into creation snapshot (MUL-4302)

Addresses Elon's two must-fixes on PR #5150.

1. accountable-never-null / fail-closed had fail-open holes. applyAttributionFallback
   now, for an UNATTRIBUTED run, refuses the enqueue (ErrAttributionFailClosed) in
   THREE cases instead of silently degrading to a runnable NULL-accountable task:
   - workspace policy read fails (or no workspace) → fail closed; we cannot confirm
     fallback is permitted, so we don't run an unattributable task on a DB hiccup.
     (Only the rare unattributed path pays this; precise runs never read the policy.)
   - workspace is fail-closed → refuse (unchanged).
   - owner_fallback has no valid agent owner → refuse rather than enqueue a task with
     a NULL accountable_user_id.
   ErrAttributionFailClosed's doc now covers all three "cannot guarantee an
   accountable human" refusals. Added missing-owner / policy-read-failure /
   precise-passthrough tests.

2. manual rerun rerun_of_task_id was a post-notify UPDATE (race: the queued event /
   daemon claim could see rerun_of_task_id = NULL, and a failed update degraded the
   run to a plain direct_human). It now rides the CreateAgentTask insert — threaded
   through enqueueIssueTask / enqueueMentionTask as a creation param (like
   retry_of_task_id) so it is written in the same statement before the daemon is
   notified. Removed the SetAgentTaskRerunOf follow-up query.

Also merges origin/main (unrelated CLI fix #5167, no conflict). Full service /
attribution / handler / migration / scheduler / cmd suites pass on a DB migrated
through 161; build / vet / gofmt clean.

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

* feat(attribution): rule_owner versioning on trigger edits + system-pause/archive (MUL-4302)

The final remaining Phase 1 item: substantive publishes beyond the autopilot row now
republish the rule version, so a run's rule_owner accountable follows whoever last
changed what the rule does.

- Extracted the config-summary + insert into service.RecordAutopilotRuleVersion so
  the handler and the (different-package) failure monitor share one writer; the
  handler's recordAutopilotRuleVersion is now a thin wrapper.
- Trigger edits: UpdateAutopilotTrigger and DeleteAutopilotTrigger republish the rule
  version with the acting member as publisher, ATOMICALLY (tx-wrapped mutation +
  version write, mirroring CreateAutopilot/UpdateAutopilot). CreateAutopilotTrigger
  republishes best-effort — the webhook path mints its token with a retry loop that
  cannot share one tx, and a create is usually initial setup already covered by v1;
  a failed write there is benign (active version stays the current publisher, the new
  trigger fires under it, no immediate daemon claim rides it).
- Archive (DeleteAutopilot) republishes (member, status=archived), tx-wrapped.
- System auto-pause (failure monitor) republishes with a 'system' publisher,
  best-effort — a background sweep to a non-dispatching state (a paused autopilot
  never dispatches; a later member resume supersedes).
- RotateWebhookToken / SetSigningSecret deliberately do NOT version: they rotate
  credentials, not the rule's behavior (not §3.4 substantive).

Semantics: a system-published (no-member) active version degrades dispatch to
unattributed → owner_fallback, never fabricating a human.

Tests: republish-reattributes (member A → member B supersedes → dispatch resolves to
B; system publisher → unattributed). Full service/attribution/handler/migration/
scheduler/cmd suites pass on a DB migrated through 161; build/vet/gofmt clean.
Also merges origin/main (unrelated frontend feature #5074).

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

* fix(attribution): make trigger-create rule-version republish atomic (MUL-4302)

Addresses Elon's final Phase 1 blocking finding: CreateAutopilotTrigger recorded the
rule-version republish best-effort AFTER the trigger insert. If member B added a
schedule/webhook trigger to member A's autopilot and the version write failed, future
schedule/webhook dispatches would keep attributing to A — violating the rule_owner
invariant that the last member to substantively change the rule owns future runs
("no immediate daemon claim" doesn't save it, since the miss surfaces at the LATER
trigger firing).

Both create paths now write the version in the SAME tx as the trigger INSERT:
- schedule create: wrap CreateAutopilotTrigger + recordAutopilotRuleVersion in one tx.
- webhook create: each mint-with-retry attempt runs in its own tx (insert + version
  commit together; a token collision rolls that attempt back and retries with a fresh
  token; a version-write failure rolls the trigger back). Passes ap + the acting
  member id into the helper.
- removed the best-effort recordTriggerRuleVersionBestEffort helper (and the now-unused
  slog import).

Test: TestCreateTrigger_RepublishesRuleVersionAtomically drives both create paths
through the handler and asserts a rule version is published by the acting member.
Existing webhook/trigger/archive handler tests still pass. Also merges origin/main
(unrelated avatar feature #5074). Full service/attribution/handler/migration/
scheduler/cmd suites pass on a DB migrated through 161; build/vet/gofmt clean.

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

* feat(attribution): Phase 2.1 — surface run attribution on the task API (MUL-4302 §9)

First Phase 2 (visibility) increment: the agent-task API now returns the resolved
accountable-human provenance so the UI can render an "on behalf of" badge.

- AgentTaskResponse gains an `attribution` object: source label (never blank —
  pre-migration NULL renders "unattributed") + `precise` flag (false for the degraded
  owner_fallback / backfill / unattributed sources), the initiator (accountable) and
  originator (authorization) user refs, the evidence {kind, ref_id} pointer, and the
  rule_version / delegated / retry / rerun lineage ids.
- The label + evidence + raw ids are built in the PURE taskToResponse (no DB), so
  every task response carries them. Names are hydrated separately, only on the
  user-facing surfaces (ListAgentTasks, ListWorkspaceAgentTaskSnapshot, RerunIssue,
  CancelTaskByUser) — daemon-claim paths stay lean.
- Hydration resolves initiator/originator from the GLOBAL user table (departed-member
  safe) via a new batch GetUsersByIDs query (no N+1); best-effort, so a lookup hiccup
  leaves the raw ids intact.

Tests: pure taskAttributionBase (direct_human / rule_owner NULL-originator /
owner_fallback degraded / pre-migration→unattributed) + DB hydration (fills known
ref, leaves unknown id un-filled, skips nil). Full handler/service/attribution/
migration/scheduler/cmd suites pass on a DB migrated through 161; build/vet/gofmt
clean. The field is additive — the frontend's parseWithFallback ignores unknown keys,
so nothing breaks until the UI increment consumes it.

Also merges origin/main (unrelated editor feature #5090).

Remaining Phase 2 (next increments, same PR): frontend zod schema + "on behalf of"
badge + evidence-chain jump; append-only correction events (write + display).

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

* feat(attribution): Phase 2.2 — on-behalf-of badge in the execution log (MUL-4302 §9)

Surface the accountable human on every agent run row:
- AttributionBadge composes Badge + ActorAvatar, shows "on behalf of <member>"
  with the resolution source as a tooltip; degraded (non-precise) attribution
  gets a warning tone, and an unresolved initiator renders an explicit
  "no responsible member" chip.
- Wire the badge into both active and past rows of the execution log.
- Mirror the attribution shape into AgentTaskResponseSchema (defensive, .loose())
  so the cancel-task path carries it through zod; add parse tests.
- Export TaskAttribution/AttributionUser/TaskEvidence from @multica/core/types
  and add the attribution block to all four issues.json locales.

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

* fix(attribution): hydrate initiator names on issue-facing task endpoints + bound the badge (MUL-4302 §9)

Address Elon's PR #5150 review:
- ListTasksByIssue (the execution-log data source), GetActiveTaskForIssue and the
  issue-scoped CancelTask now call hydrateTaskAttributions, so the "on behalf of
  <member>" badge shows the real member name on issue detail instead of falling
  back to "someone". Mirrors the existing ListAgentTasks / snapshot behavior.
- AttributionBadge: cap width (max-w-40, min-w-0) and truncate the name span so a
  long name / narrow right column can't squeeze out trigger/status/actions; keep
  the avatar shrink-0.
- Add a handler test asserting the issue task list returns a hydrated
  attribution.initiator.name.

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

* fix(attribution): use semantic AvatarSize 'xs' for the badge avatar

main refactored ActorAvatar.size from a raw pixel number to the semantic
AvatarSize union (packages/ui/lib/avatar-size). Switch the on-behalf-of badge
avatar from size={14} to size="xs" (16px) after merging main.

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

* fix(attribution): stage-cascade falls back to parent-issue provenance, not agent owner (MUL-4302)

When closing the last sub-issue in a Stage wakes the parent's assignee agent, the
run was enqueued via a system-authored child-done comment with no actor, which the
resolver classified as unattributed and then degraded to owner_fallback (the agent's
own owner). That is the wrong accountable human: the woken run should be accountable
to whoever caused the parent issue to exist.

attributionForIssueTask now detects a system-authored trigger comment and falls
through to the parent issue's own provenance — the same creator / agent_create-origin
/ autopilot-origin chain a direct enqueue resolves (so an agent-decomposed parent
attributes via delegation to the human who drove it; a member-created parent to that
member; an autopilot parent to the rule publisher). owner_fallback is now only the
last resort when the parent provenance itself has no human.

- Extract attributionFromComment so attributionForIssueTask can inspect author_type
  without a second GetComment; authorization resolution stays byte-identical.
- Add a DB-backed test asserting a system child-done comment resolves to the parent
  issue's origin human (delegation), not owner_fallback.

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

* feat(attribution): autopilot runs attribute to the firing trigger's creator (MUL-4302)

Per Bohan: an autopilot schedule/webhook run should be accountable to the human
who created the SPECIFIC trigger that fired it, not the rule publisher. (Manual
triggers already attribute to the invoking member via direct_human — unchanged.)

- Migration 162: add autopilot_trigger.created_by_type/created_by_id (nullable, no
  FK/cascade). Capture the creating member at both trigger-create sites (schedule +
  webhook).
- New precise source trigger_owner: originator stays NULL (an autonomous fire
  carries no human authorization — same authz-safe divergence as rule_owner),
  accountable = the trigger's member creator.
- triggerOwnerAttribution resolves run.trigger_id → creator; wired into run_only
  dispatch and the create_issue path (bridging issue → active run → trigger_id).
  Legacy triggers with no recorded creator, and agent-created triggers, degrade to
  rule_owner then owner_fallback — nothing regresses.
- Frontend: trigger_owner source label in all four locales + badge switch case.
- Tests: attribution TriggerOwner unit + Precise/invariant; DB-backed resolver
  tests (member creator → trigger_owner; creatorless → rule_owner fallback).

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

* chore(attribution): re-trigger CI (dropped synchronize event on 249090260)

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

* fix(attribution): mirror accountable_user_id on comment-coalescing merge (MUL-4302)

The one-way invariant is 'originator_user_id IS NOT NULL ⟹ accountable_user_id =
originator_user_id', enforced at finalizeAttribution for enqueues and preserved by
the retry-clone (copies both columns). But MergeCommentIntoPendingTask (main #5192)
re-stamps originator_user_id to the newly-coalesced comment's human WITHOUT touching
accountable_user_id — so folding member B's comment into member A's queued task left
originator=B / accountable=A, violating the invariant. Re-stamp accountable to mirror
the new originator (same thing finalizeAttribution does). Add a DB-backed regression
test.

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

* fix(attribution): Elon's 3 must-fixes + DB cross-column invariant CHECK (MUL-4302)

Bohan approved Elon's plan; this closes the three attribution boundaries he flagged
and locks the one-way invariant at the DB.

1. Delegation now inherits the parent's ACCOUNTABLE, not just its originator. An
   autopilot-rooted chain (parent originator NULL, accountable = trigger creator)
   @mentioning an agent / creating a sub-issue used to drop to unattributed →
   owner_fallback and fail-closed workspaces wrongly rejected the fan-out. Added
   ParentAccountable/OriginAccountable to CommentFacts/DirectFacts; ClassifyComment/
   ClassifyDirect copy accountable down (source=delegation, precise, originator NULL)
   so the chain root stays stable at any depth (§3.2).
2. The direct-chat send path (SendDirectChatMessage, MUL-4351) wrote only
   originator_user_id — no accountable/source/evidence, a NULL-source bypass. It now
   stamps the full direct_human attribution like EnqueueChatTask.
3. Comment-coalescing merge re-attribution is now ATOMIC: MergeCommentIntoPendingTask
   re-stamps the whole snapshot (person columns + source + delegation lineage + rule
   version + evidence) of the new comment, not just the two person fields, so a merged
   run never shows B accountable while pointing at A's stale source/evidence.
4. Migration 169: NOT VALID CHECK (originator_user_id IS NULL OR (accountable_user_id
   IS NOT NULL AND accountable_user_id = originator_user_id)). Enforces the invariant
   on every new write (the class of bug #5192 introduced); historical rows not blocked,
   VALIDATE after Phase 3 backfill. Updated test fixtures that seeded originator-only
   rows to also set accountable.

Merged latest main (renumbered attribution migrations 163–168 after main took 161/162;
merged the retry-clone chat_input_task_id + attribution columns). Verified: go build/
vet, attribution/service/handler/cmd-server tests on a migrated DB, frontend
typecheck/lint/tests.

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

* fix(attribution): trigger responsibility transfers to effective publisher; fail-closed comment merge

Elon final-review must-fix 1 (MUL-4302): trigger_owner now resolves to the
member CURRENTLY responsible for the firing trigger's effective config, not the
fixed creator. Per-trigger published_by on autopilot_trigger, seeded to the
creator and re-stamped to the editor on a substantive edit — a trigger-scoped
edit bumps only that row (UpdateAutopilotTrigger), an autopilot-level edit bumps
all its triggers (UpdateAutopilot). Editing one trigger never reassigns another.
Adds real dispatchRunOnly transfer test + resolver-level isolation test.

Must-fix 3: AttributionForMergedComment reuses applyAttributionFallback and
returns ErrAttributionFailClosed; the merge caller refuses on fail-closed,
keeping the queued task's original precise snapshot instead of degrading it to
owner_fallback. Adds regression test.

Renumbered attribution migrations to 166-172 after merging main.

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

* fix(attribution): make migration 172 invariant CHECK upgrade-safe (Option A)

Elon final-review must-fix 2 (MUL-4302), rollout chosen by Bohan. The NOT VALID
CHECK still checks a pre-existing row on any later UPDATE (even one not touching
attribution columns), so cross-deployment stale queued/running tasks (originator
set, accountable NULL from before migration 167) would fail on their next
claim/complete/cancel. Exempt exactly those legacy rows via 'originator_source
IS NULL' — that column was added in 166 with no backfill, so it is NULL only on
pre-migration rows and non-NULL on every attribution-aware write. New writes
stay fully enforced; the #5192 bypass class (source always set) is unaffected.
Phase 3 backfills legacy rows then drops+re-adds the strict form + VALIDATE.

Adds TestAttributionInvariantCheck_ExemptsLegacyRows (legacy row survives a
status UPDATE) and updates the reject-bypass test to the enforced regime.

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

* fix(attribution): pin substantive/cosmetic edit boundary for trigger transfer

Elon re-review must-fix 1: an autopilot-level edit only transfers trigger_owner
responsibility when it changes what the automation instructs or who/whether it
runs. autopilotRuleSubstantiveChange now includes description (the run Prompt)
and issue_title_template; title and project_id stay cosmetic/routing.
UpdateAutopilotTrigger no longer transfers on every PATCH — it compares the
persisted before/after and transfers only on a real cron/timezone/enabled/
event_filters change, not a label-only or no-op PATCH. Adds real handler tests
(prompt->all, title->none, cron->one+isolation, label->none, no-op->none).

Must-fix 3: real merge-path regression (TestMergeCommentIntoPendingTask_
FailClosedKeepsOriginalSnapshot) drives mergeCommentIntoPendingTask and asserts
a fail-closed workspace preserves the queued task's full snapshot; fail-open
control completes the owner_fallback merge.

Docs: migration 172 comment reworded to legacy-writer/unbackfilled-lineage
semantics (source NULL is not strictly pre-migration); PR description synced.

Migration renumber vs latest main (must-fix 2) deferred to pre-launch per Bohan.

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

* chore(attribution): renumber migrations to 167-173 after syncing main

Merged latest main (which took 166_project_dates) and shifted the attribution
migration series off the 166 collision: 166->167 agent_task_attribution,
167->168 accountable_user, 168->169 rule_version, 169->170 rule_version_index,
170->171 fail_closed, 171->172 trigger_publisher, 172->173 invariant_check.
Updated the internal cross-references in the migration comments accordingly.
Fixes TestMigrationNumericPrefixesStayUniqueAfterLegacySet on the merge tree.

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

* feat(admission): unify dispatch outcome + close rerun/chat/autopilot invoke holes (MUL-4525)

P0 first increment toward a platform-wide execution-admission contract so a
user who names an execution target always gets a definite result and never a
silent no-op, and so blocked targets are reported without leaking private-agent
details.

Backend:
- New shared contract (handler/admission.go): DispatchOutcome / DispatchStatus
  (queued/coalesced/deferred/blocked) + stable, enumeration-safe
  DispatchReasonCode set, plus writeDispatchBlocked() whose legacy `error`
  string never reveals target existence.
- Rerun (task.go / task_lifecycle.go): re-validate the operator can invoke the
  RESOLVED target agent (historical agent for a task_id rerun) before any
  cancel/enqueue; blocked returns a structured 403 and mutates nothing
  (ErrRerunInvokeNotAllowed).
- Chat send (chat.go): re-run canInvokeAgent on every send, not just the softer
  canAccessPrivateAgent view gate; a revoked permission blocks before the
  message/attachments/task persist.
- Autopilot manual "run now" (service/autopilot.go): admission now keys on the
  current CLICKER, not the autopilot creator — clicker admission and clicker
  attribution no longer fork. Automation (schedule/webhook) still falls back to
  the creator gate. Added reason_code to the run response for the UI.

Frontend:
- triggerAutopilot response is schema-parsed; handleRunNow branches on run
  status and shows a localized, reason_code-based warning for skipped/failed
  instead of a false-success toast.
- Chat send and rerun surface the structured 403 reason_code as localized
  toasts (dispatchReasonCode helper) instead of a generic failure.
- Additive fields only; older clients keep working. i18n added to all 4 locales.

Tests: rerun fail-before-mutation gate, autopilot clicker-vs-creator fork
(service + handler), reason-code classification, and a malformed-response
schema test. Backend handler+service suites and FE typecheck/lint green.

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

* test(cmd/server): thread nil invoke gate through RerunIssue call sites (MUL-4525)

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

* fix(admission): typed reason codes + run-now whitelist + real security tests (MUL-4525)

Addresses Elon's review of the P0 first increment (must-fix 1–3).

1. Run now no longer has a false-success branch. handleRunNow now classifies on
   a whitelist via a pure runNowToastKind(): only issue_created/running →
   success; skipped → warning; failed and any unknown/future status → error. Add
   run-now-toast unit test over all five status classes plus reason-code → key
   mapping.

2. reason_code is a typed value decided at the admission source, not
   reverse-engineered from English failure text. New leaf package
   internal/dispatch holds the canonical ReasonCode enum (shared by handler +
   service so they can never drift). shouldSkipDispatch / errDispatchSkipped /
   the fail path now carry a typed code through DispatchAutopilotManual straight
   into the response; the substring classifier is deleted. Fixes the two missed
   branches: attribution fail-closed → attribution_blocked (typed errors.Is),
   "agent has no runtime bound" → runtime_offline. Regression tests for both.

3. Security acceptance tests exercise the REAL handlers, not injected callbacks:
   - Chat: create session while invokable → revoke invoke (flip to private, keep
     owner-view) → send returns 403 + reason_code with zero chat_message / task
     writes.
   - Rerun: private historical agent through RerunIssue + canInvokeAgent — a
     non-invoking workspace owner is refused 403 + reason_code and mutates
     nothing (fail-before-mutation); the agent owner is allowed 202.

No migration; reason_code is a decision-time value only the manual "run now"
response carries. Additive on the wire. Backend build/vet/handler+service
suites and FE typecheck/lint/vitest green. (Migration-prefix collision with main
remains the deferred pre-merge renumber.)

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

* test(admission): make must-fix 3 acceptance tests prove the invariant (MUL-4525)

Addresses Elon's round-3 narrow review — the two security tests were not yet
falsifiable against the bugs they must catch.

- Rerun: after enqueuing the historical task, reassign the issue to a SECOND
  agent that the denied user CAN invoke. Now the current assignee and the
  task_id agent differ, so a rerun that wrongly validated the current assignee
  would let the denied user through — the 403 proves the gate is keyed on the
  historical private agent. The allow case now asserts the reran task's agent_id
  is the historical agent, not the current assignee.

- Chat: the blocked send now carries a valid, still-unbound attachment. After
  the 403 the test asserts the attachment's chat_session_id and chat_message_id
  are both still NULL, guarding against anyone moving attachment binding ahead
  of the invoke gate.

Test-only. Full internal/handler Go suite green.

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

* feat(comments): surface blocked @mention trigger_outcomes instead of silent no-op (MUL-4525 §2)

A comment that @mentions an agent/squad the author cannot invoke used to save
with zero feedback — the user assumed a bug. Now the explicit-mention path
reports a per-target outcome on both preview and create/edit.

Backend (server/internal/handler/comment.go):
- resolveMentionedAgentCommentTriggers collects blocked outcomes instead of a
  silent `continue`. The invoke gate is evaluated BEFORE any archived/runtime
  state is read, so a caller who cannot invoke a private target only ever sees
  the generic invocation_not_allowed and can never enumerate its existence.
- enqueueCommentAgentTriggers returns queued/coalesced/deferred/blocked per
  explicit mention; enqueue errors are typed (attribution_blocked via
  errors.Is), not swallowed. Implicit routing (assignee/thread/conversation)
  carries no outcome — the user never named those targets.
- trigger-preview returns `blocked[]`; create/edit return additive
  `trigger_outcomes[]`. One blocked mention never fails the comment.

Frontend:
- Composer shows a warning chip for blocked mentions before sending; after
  sending, a "posted, but N not triggered" toast (blocked-only; coalesced/
  deferred are success-shaped). Additive schema + defensive parse; i18n ×4.

Tests: handler partial-success + enumeration-safety acceptance tests; core
outcome-parse + preview-schema tests; hook/parity updated. Backend
handler+service suites and FE typecheck/lint/vitest green.

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

* i18n(admission): clearer, consistent blocked-trigger copy (MUL-4525)

Reword the awkward "you are not allowed to run this autopilot's assignee" and
polish all MUL-4525 blocked/partial copy across en/zh-Hans/ja/ko:

- Unify on "you don't have permission to use this <agent|target>" (zh: 没有…的
  使用权限) for autopilot Run now, comment mentions, rerun, and chat send —
  replacing the various "not allowed to run/trigger" phrasings.
- Align zh to the glossary term 智能体 (was mixed "Agent"), matching the
  surrounding UI voice (e.g. agent_link_no_access).
- Drop jargon: "blocked by an admission policy"/"被准入策略拦截" → "the run was
  blocked"/"本次运行已被拦截"; "attributed"/"归因" → plainer wording.
- Comment copy counts "mentions" (was "targets") for consistency with the chip.

Backend dispatchBlockedFallbackMessage (old-client English fallback) reworded to
match; its enumeration-safety test assertion updated. Copy-only — no key/logic
changes; parity + typecheck green.

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

* fix(comments): one outcome per explicit mention + FE success whitelist (MUL-4525)

Addresses Elon's round-2 review of the §2 comment trigger_outcomes.

1. Separate execution dedup from per-target outcomes. resolveMentionedAgent-
   CommentTriggers now returns triggers (deduped by executing agent) AND one
   commentMentionTarget per EXPLICIT mention. enqueueCommentAgentTriggers returns
   a per-executing-agent result map; commentTriggerOutcomes fans each agent's
   status to every target that resolved to it. So @Agent A + @Squad S(leader=A)
   coalesces to ONE task but yields TWO outcomes. The squad-leader self-suppress
   branch now returns a definite `deferred` outcome instead of no result. New
   CreateComment test asserts 1 task, 2 outcomes.

2. Frontend no longer treats an unknown status as success. unhandledComment-
   TriggerOutcomes whitelists queued/coalesced/deferred as handled; blocked and
   any unknown/future/empty status warn (mirrors the Run now whitelist). The
   preview schema's `blocked` now drops malformed entries INDIVIDUALLY instead of
   z.array(...).catch([]) discarding the whole set. Regression tests for the
   unknown status and the per-item drop.

Backend handler suite + go vet, FE typecheck/lint/vitest green.

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

* fix(comments): honest role + status in trigger_outcomes fan-out (MUL-4525)

Addresses Elon's round-3 review of the §2 fan-out.

1. Execution merge preserves the squad-leader role instead of first-mention-
   wins. The dedup now UPGRADES an already-added plain @agent trigger to a
   @squad-leader trigger for the same agent, so @Agent A + @Squad S(leader=A)
   always runs as a leader task (is_leader_task + squad_id=S) regardless of
   mention order — the daemon still injects S's briefing. When two DIFFERENT
   squads share one leader, the single run carries one squad's context and the
   other squad is reported `coalesced` (folded), never a second `queued`. The
   enqueue result now records the executed squad so the fan-out can tell them
   apart. Tests assert the task role in both orders and the two-squad split.

2. Squad-leader self-suppression no longer fakes success. The self-trigger
   guard keys on the latest task ROLE with no status filter, so a long-completed
   task also suppresses; reporting `deferred/already_active` when nothing is
   active was a false success. The branch now reports `deferred` only when a
   real non-terminal task is active (its reconcile covers the comment), else a
   non-success `blocked` + new `already_handled` reason. Fixed the reversed
   helper-semantics comment. New handler test covers the completed-task branch.

Backend handler+service suites and go vet green.

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

* fix(comments): fail-closed active-task check, never fake deferred (MUL-4525)

Elon round-4 must-fix: hasActiveTaskForIssueAndAgent swallowed DB errors by
returning true, so both call sites could turn a query failure into a
success-shaped `deferred/already_active` — a silent false success, exactly what
this issue forbids for admission-query failures.

- hasActiveTaskForIssueAndAgent now returns (bool, error).
- Two pure decision helpers govern the branches, fail closed on error:
  - decidePostMergeMiss: on query error, do NOT enqueue a fresh task (duplicate
    concurrent-run risk) AND report non-success blocked/internal_error; a
    confirmed active task defers; a confirmed-none enqueues fresh.
  - decideSuppressedLeaderOutcome: on query error, blocked/internal_error; a
    confirmed active run defers; else self_trigger_suppressed. Never a fabricated
    deferred.
- Renamed reason already_handled -> self_trigger_suppressed (Elon non-blocking
  note): the old name implied the new comment was already processed, but the
  real meaning is a suppressed self-trigger.

Deterministic unit tests cover the query-failure branch at both call sites
(no fresh enqueue, non-success outcome) — a real DB fault can't be forced
through valid handler inputs, and the decision is what governs the behavior.
Backend handler+service suites and go vet green.

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

* fix(comments): honest merge outcome — refused merge is blocked, not fake coalesced (MUL-4525)

A pending-task merge previously reported success (coalesced) even when it
was refused or failed: attribution fail-closed and unknown DB errors both
returned handled=true, so the caller recorded coalesced for a merge that
never happened. mergeCommentIntoPendingTask now returns a distinguishable
commentMergeResult; commentMergeTerminalOutcome maps a real merge to
coalesced, a fail-closed refusal to blocked/attribution_blocked, and any
other failure to blocked/internal_error. Only "no queued task to fold"
falls through to the active-task decision. Adds pure coverage of the
mapping plus a fail-closed regression asserting the non-success outcome
and unchanged task count.

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

* fix(comments): name blocked @mentions in preview + toast instead of a vague count (MUL-4525)

The blocked-trigger preview showed a red "1 mention won't trigger" with no
name, and the post-send toast said "but 1 mention wasn't triggered" — the
user can't tell which target or why. Now each blocked mention renders its own
chip named from the mention markup the user typed ("Go · No permission"),
with an error indicator and a short reason; the toast names the single target
too. The wire outcome still omits the target name (enumeration-safety) — the
label comes from the user's own draft, so nothing new is disclosed.

Shares a blocked-trigger-copy module (long + short reason labels) between the
chip and the toast, and a pure mentionLabelsByTarget/parseMentions helper in
core (fresh regex per call — a shared global leaked lastIndex). Adds core +
chip tests; drops the now-unused trigger_blocked_count keys across locales.

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

* feat(attribution): accountable-member avatar on agent task rows + transcript header (MUL-4302)

Surface who each agent run is on behalf of where runs are actually
browsed:
- Agent detail activity tab: an avatar-only AttributionBadge on every
  task row's meta line (Now + Recent work), tooltip carries the name +
  resolution source.
- Execution-record (transcript) dialog header: the full on-behalf-of
  badge next to the status pill.

Adds a compact variant="avatar" mode to AttributionBadge, reusing its
source-label mapping and degraded-attribution tone. Renders nothing when
a run has no resolved accountable member.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-15 03:09:56 +08:00
YYClaw 9eddcaff10 fix(chat): defer cancellation-time finalization until the task transcript is stable (#5246)
A quick Stop before the agent's first token no longer races a late reply. Started-but-empty cancellations defer the empty/non-empty judgment until the daemon acks its transcript flush (or a grace-period sweeper fires), then settle to a single outcome. Empty outcomes persist a durable, creator-authorized draft restore (fetched/consumed via a dedicated endpoint, reconnect-safe and at-most-once) instead of broadcasting the prompt over the workspace bus.

Closes #5219
2026-07-15 00:52:27 +08:00
47f9c5813f fix(chat): reland archived-session unread + mount-race auto-read (MUL-4360) (#5333)
* fix(chat): correct unread — archived sessions + mount-race auto-read (MUL-4360)

Reland of #5315, which was reverted by #5332 as collateral in an unrelated
release-wide revert (to unwind 162/163 migration BLOCK from other PRs), not for
any defect in this code — Howard/Preflight assessed these changes WARN /
non-blocking. Restores all three fixes verbatim off current main:

- backend: ListAllChatSessionsByCreator derives unread_count=0 / has_unread=false
  for status='archived' rows via a CASE gate. last_read_at is untouched, so
  unarchiving restores the true unread state. Single source of truth for every
  unread surface (quick-chat FAB, sidebar Chat tab, chat-window header, mobile);
  installed desktop clients benefit with no app update.
- frontend: the archive mutation onMutate optimistically zeroes the row's unread
  so no badge counts a just-archived session in the frame before the refetch
  lands. Unarchive does not fabricate a count — the true state returns from the
  server refetch.
- frontend: auto-mark-read is deferred a tick and cancelled on cleanup, so a
  session that is only momentarily active on mount (persisted activeSessionId
  restored for one frame, then cleared by the URL->store effect) is not marked
  read; only a session that stays active past the tick is.

Verification: sqlc regenerate produces no drift; go test ./internal/handler
-run 'TestListChatSessions_ArchivedSessionReportsZeroUnread|TestSetChatSessionArchived_ClearsChannelBinding'
passes against a real Postgres; vitest mutations.test.tsx (3) and
use-chat-controller.test.tsx (8) pass; core + views typecheck clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): converge archived unread on chat:session_updated realtime event (MUL-4360)

Howard's #5333 review found a real cross-tab gap: the chat:session_updated
handler patched status but never unread, and chatSessionsOptions is
staleTime: Infinity, so a session archived in one tab kept its unread badge lit
in another tab/device forever — the same stuck-badge bug, one surface over.

Extract the inline handler into applyChatSessionUpdatedToCache and force
unread_count=0 / has_unread=false when payload.status === "archived", mirroring
the archive mutation's optimistic patch and the backend deriving unread=0 for
archived rows. Unarchive does NOT fabricate a count — the true unread returns
from the server refetch (last_read_at untouched). No sessions-list
invalidation; minimal field patch as reviewed.

Adds use-realtime-sync.test.ts coverage: an archived event zeroes a cached
unread row; an active event does not resurrect unread; a rename-only event
leaves unread untouched.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-14 09:37:02 +08:00
Naiyuan Qing 9a071b827f Revert "fix(chat): correct unread — archived sessions + mount-race auto-read …" (#5332)
This reverts commit cec071dc06.
2026-07-13 16:22:44 +08:00
cec071dc06 fix(chat): correct unread — archived sessions + mount-race auto-read (MUL-4360) (#5315)
* feat(skills): search runtime local skills

* feat(skills): highlight matched substrings in runtime local skill search

Reuse the shared HighlightText (the same component the global search
command uses) to highlight matched substrings in a result's name,
provider, description, and path, so styling stays consistent across the
app. Narrow the search to the fields the row actually renders and drop
`key`, so every match maps to something visible. While a query is active,
lift the description's 2-line clamp so a match past the first two lines
stays on screen instead of being clipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(chat): zero unread for archived chat sessions across all badges (MUL-4360)

Archiving a chat session flips status but deliberately does not advance
last_read_at, and ListAllChatSessionsByCreator counted unread
unconditionally. So an archived session that had unread replies kept
reporting has_unread=true / unread_count>0 — a stuck badge the user can
never clear (archived sessions are read-only and hidden from history, so
there is no mark-as-read entry). MUL-4372 fixed only the quick-chat FAB
surface; the sidebar Chat tab badge and the chat-window "other unread"
header still counted it.

Fix at the source: derive unread_count = 0 for status='archived' rows in
ListAllChatSessionsByCreator. Because has_unread is server-derived as
unread_count > 0, and all surfaces (FAB, sidebar via
countUnreadChatMessages, chat-window header, and mobile) read this one
payload, every badge drops archived sessions with no per-surface filter.
last_read_at is left untouched so unarchiving restores the true unread
state. Installed desktop clients benefit without an app update.

Also zero unread optimistically in the archive mutation so no badge
counts a just-archived session in the frame before the refetch lands
(FAB already filtered archived; this keeps sidebar/header consistent).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(chat): stop auto-mark-read from clearing a transiently-active session on mount (MUL-4360)

The chat page persists `activeSessionId`, so on a bare `/chat` navigation it
restores the last-open session as active for one frame before its URL→store
effect (which runs AFTER useChatController's effects, since the hook is called
first) clears it back to null. The auto-mark-read effect fired in that gap and
marked the restored-but-never-opened session read — its unread badge vanished
though the right pane still showed "select a chat" and the user never opened it.
This is why the sidebar count dropped (e.g. 2 → 1) just by entering the tab.

Defer the read by a tick and cancel it on cleanup: a session that is only
momentarily active (restored on mount, then cleared) has its pending read
cancelled when activeSessionId changes; only a session that stays active past
the tick — a real select, deep link, or refresh — is marked read. A live-store
re-check in the timer is a belt-and-suspenders guard.

Adds the previously-missing auto-mark-read coverage: a stable-active session is
read after the tick; a momentarily-active one is not.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: coderbaozi <[email protected]>
Co-authored-by: abun <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-13 15:59:20 +08:00
cb87dd106b feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351) (#5195)
* feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351)

Direct (web/mobile) chat no longer uses the last-assistant-row as an implicit
input cursor. Each direct send now owns an immutable input batch:

- agent_task_queue.chat_input_task_id makes a task the owner of the user
  messages it must consume; the send path creates the task + user message +
  attachment bindings + session touch in one transaction, and the daemon is
  notified only after commit. A claim reads exactly that batch, so a message
  that arrives mid-run belongs to the next task and is never absorbed.
- Auto-retry inherits the root input owner and is queued at a bumped priority,
  created inside FailTask's transaction so no newer chat task can jump ahead.
- CompleteTask writes exactly one assistant outcome inside the completion
  transaction: a normal message, or a visible no_response outcome (with a
  non-empty English fallback) when the final output is empty. The write failing
  rolls the completion back and the handler returns 5xx so the daemon retries;
  the status CAS keeps it idempotent. chat:done carries message_kind.
- Web/desktop/mobile render no_response as a localized 'no text reply' state
  (keeping the tool timeline), suppress Copy, keep it unread, and keep the
  session-list preview non-blank.
- Legacy/channel tasks (chat_input_task_id NULL) keep the trailing-message
  selector, so a rolling deploy never replays Slack/Lark history.

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

* fix(chat): scope no_response to direct tasks; don't cancel task on input read error (MUL-4351)

Addresses PR review (Niko):
- writeChatCompletionOutcome only writes a no_response row for task-owned
  direct tasks (chat_input_task_id set). Legacy/channel (Slack/Lark) tasks keep
  the prior behavior: empty output writes no assistant row, so chat:done carries
  empty content and the channel outbound silently drops it — the no_response
  fallback body never reaches an external channel.
- The daemon claim distinguishes a genuine zero-input batch from a failed
  input read: on ListChatInputMessages / ListChatMessages error it returns 5xx
  and preserves the dispatched task for redelivery instead of cancelling a valid
  task on a transient DB error.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-10 16:22:42 +08:00
LinYushenandmultica-agent e6e63e6a13 feat(chat): LLM-generated chat session titles with silent fallback (MUL-4295) (#5141)
* feat(chat): LLM-generated chat session titles with silent fallback (MUL-4295)

Generate a concise, language-matched title for a chat session after the
first user message, replacing the raw first-message-derived title. The
work is best-effort and fully non-blocking:

- Triggered on the first user message in SendChatMessage (detected via
  ChatSessionHasUserMessage before insert), run in a detached goroutine
  so it never delays the send or first response.
- Reuses pkg/llm GenerateText on the configured default model
  (MULTICA_LLM_DEFAULT_MODEL, else gpt-4o-mini); no model from the client.
- Self-hosted with no LLM key (h.LLM.Enabled()==false): silent no-op,
  the original title stands. Same on timeout / upstream error.
- CAS write (UpdateChatSessionTitleIfCurrent) so a manual rename during
  generation is never clobbered and titling runs at most once.
- Pushes chat:session_updated so the frontend refreshes in place.
- sanitizeChatTitle strips quotes/brackets, 'Title:'/'标题:' prefixes,
  trailing punctuation, and caps at chatSessionTitleMaxLen.

Tests cover all six cases: configured→semantic title, disabled→fallback,
upstream error→fallback, manual rename→no clobber, empty output→fallback,
idempotent second run, plus sanitize rules and the realtime push.

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

* fix(chat): panic-contain title goroutine + loop sanitizer to a fixed point (MUL-4295)

Address PR #5141 review (张大彪 / multica-eve, Phase B):

1. The detached title-generation goroutine now has a defer recover() at the
   top of its body. It runs outside chi's Recoverer, so an unhandled panic
   in GenerateText / sanitize / the DB write / publish would crash the
   server process. Best-effort path: log and keep the original title.

2. sanitizeChatTitle now alternates prefix-stripping and wrapper-stripping
   in a loop until the string is stable, so a forbidden label hidden inside
   a wrapper ("Title: Fix login", 「标题:修复登录问题」) is fully cleaned
   regardless of nesting order. Added both cases to the sanitize test table.

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

* fix(chat): fold trailing-punctuation trim into sanitizer fixed-point loop (MUL-4295)

Address PR #5141 follow-up review: the trailing-punctuation trim ran once
AFTER the prefix/wrapper loop, so a trailing '.' / '。' left the closing
wrapper unrecognized and the forbidden prefix untouched for inputs like
"Title: Fix login". and 「标题:修复登录问题」。. Trailing trim now runs inside the
same loop, so removing the trailing punctuation re-exposes the wrapper (and
the prefix it hid) on the next pass. Added both cases to TestSanitizeChatTitle.

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-07-09 13:49:07 +08:00
a51ab4d551 feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171) (#5076)
* feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171)

Replace the floating chat FAB/window with a first-class Chat tab under
Inbox, laid out as an IM-style two-pane surface (thread list + conversation).

Highlights:
- New Chat page (packages/views/chat/chat-page.tsx) with URL-addressable
  session selection; web + desktop routing wired up. Removes the old
  chat-fab / chat-window / resize-handles / context-items paths.
- IM thread list: agent avatar + last-message preview + IM timestamp, red
  unread *count* badge (read-cursor model), presence-gated typing vs waiting.
  Rename lives only in the conversation header ⋯ menu (not the list hover).
- Per-session conversation header (rename / view agent / delete), agent-aware
  empty state (avatar + name + description + starter prompts), and a
  deterministic clean-title derivation from the first message.
- Server: read-cursor unread model (migration 145) and per-user pinned agents
  (migration 146, dedicated chat_pinned_agent table + handler/queries).
  New-agent welcome chat auto-enqueues a real agent run (LLM intro, no
  static template).
- Design: fade the global --border token; borderless list headers on
  Chat/Inbox, kept (faded) on the conversation header.

Verified: pnpm typecheck (all packages), go build ./..., go vet, gofmt.
Co-authored-by: multica-agent <[email protected]>

* feat(chat): make new-agent welcome read as an agent-initiated intro (MUL-4230)

The "meet your new agent" chat used to insert a fake user message
("👋 Hi! Please introduce yourself …") and have the agent reply to it, so
the thread looked like the creator prompting the agent.

Drop the persisted user message. Flag the auto-created session
is_agent_intro (migration 147) and drive the intro run server-side: the
daemon builds a proactive self-introduction prompt for such sessions
(buildChatPrompt) instead of a "reply to their message" prompt. The intro
stays LLM-generated; the thread now opens with the agent's own message, as
if it reached out first.

- migration 147: chat_session.is_agent_intro
- CreateChatSession carries the flag; sendAgentWelcomeChat no longer
  persists/publishes a user message
- daemon: ChatIntro threaded from session flag → intro prompt

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

* feat(chat): Settings toggle for the floating chat window (MUL-4235) (#5080)

* feat(chat): Settings toggle for the floating chat window (MUL-4235)

Re-introduce the floating chat overlay on top of Chat V2 as an optional,
Settings-gated surface instead of deleting it outright.

- Settings → Preferences → Chat: a switch (floatingChatEnabled, persisted
  client preference, default ON) to show/hide the floating window.
- FloatingChat wrapper owns the two gates: the preference, and the /chat
  route (hidden on the tab so the same activeSessionId isn't shown twice).
- ChatFab + a compact ChatWindow that reuse the shared useChatController and
  conversation components, so activeSessionId stays in lockstep with the tab.
- Restore use-chat-context-items so the overlay's @ surfaces the current
  issue/project (the 'current context' affordance) — the tab stays manual.
- i18n (en/zh-Hans/ja/ko), store unit tests.

typecheck: core/views/web/desktop green. tests: chat store 9, settings 82,
chat 39 pass.

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

* feat(chat): dedicated Chat settings tab, floating window opt-in (MUL-4235)

Address review: give Chat its own Settings tab instead of a section inside
Preferences, and default the floating window OFF (opt-in).

- New Settings → Chat tab (chat-tab.tsx) under My Account; moves the
  floating-window toggle out of the Preferences tab.
- floatingChatEnabled now defaults OFF — only an explicit enable from the
  Chat tab mounts the FAB/overlay.
- i18n: page.tabs.chat + a top-level chat block (en/zh-Hans/ja/ko);
  revert the Preferences chat section and its test mock; store tests updated
  for the opt-in default.

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

---------

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

* refactor(chat): drop starter prompts from chat empty state (MUL-4237) (#5081)

The three starter prompts (List my open tasks by priority / Summarize what
I did today / Plan what to work on next) read as filler more than help, so
remove them along with the now-unused returning_subtitle ("Try asking").

The empty state keeps its agent-aware header — avatar + "Chat with {name}"
+ optional description — and the composer stays the entry point. Locale
keys dropped across en/zh-Hans/ja/ko (parity preserved).

Based on the Chat V2 branch (parent MUL-4171, #5076), not main.

Co-authored-by: Lambda <[email protected]>

* feat(chat): pin a chat to the top of the Chat list (MUL-4240) (#5082)

Builds on Chat V2 (#5076). Adds a per-conversation pin so a user can
keep important chats at the top of the IM-style thread list, above the
activity-sorted rest.

Backend:
- migration 148: chat_session.pinned_at (nullable) + partial index; the
  timestamp doubles as the pinned-group sort key and the boolean flag.
- list queries order pinned-first, then by most-recent activity.
- SetChatSessionPinned query + PATCH /api/chat/sessions/{id}/pin handler;
  pinning never bumps updated_at, so an unpinned chat won't jump the list.
- ChatSessionResponse.pinned + chat:session_updated carries the new state.

Frontend:
- ChatSession.pinned; setChatSessionPinned API + useSetChatSessionPinned
  with optimistic re-sort; shared sortChatSessions comparator.
- thread list: pin indicator on pinned rows + pin/unpin hover action;
  list sorted pinned-first so it stays ordered after cache patches.
- realtime patch re-sorts on pin change; en/ja/ko/zh-Hans strings.

Tests: SetChatSessionPinned handler test, sortChatSessions unit tests.

* feat(chat): round send button, move file upload into a + menu (MUL-4250) (#5088)

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

* fix(inbox): match chat list selected-item style (inset padding + rounded) (#5093)

Wrap the inbox list in p-1 and give each row rounded-md/px-3 so the
selected bg-accent reads as an inset rounded card — same treatment the
chat thread list already uses — instead of a full-bleed, sharp-cornered
highlight. Content stays 16px-inset (p-1 + px-3 == old px-4).

MUL-4253

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

* fix(chat): rename + menu upload item to "Image or files" (MUL-4250) (#5092)

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

* fix(chat): stop welcome intro session repeating the same introduction (MUL-4259)

The is_agent_intro flag on chat_session is persistent, so every follow-up turn on a welcome session re-selected the self-introduction prompt in buildChatPrompt and the agent kept replying with the same intro instead of answering the user.

Gate resp.ChatIntro at claim time on the session still having zero human (role='user') messages via a new ChatSessionHasUserMessage query: the first, message-less server-driven turn introduces the agent; once the creator replies, later turns fall back to the normal reply prompt.

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

* fix(chat): address review findings + unbreak CI (MUL-4171)

- task:failed now refreshes the sessions list (invalidateSessionLists), so
  the thread-list preview / unread / sort stays correct after an agent
  failure — FailTask persists a failure chat_message but only broadcasts
  task:failed, mirroring the chat:done success path.
- Self-heal stale chat deep links: once the sessions list has loaded and a
  ?session= id isn't in it (deleted / no access / never existed) with nothing
  in flight, clear the selection instead of rendering an editable empty chat
  that would POST into a nonexistent session. Freshly-created sessions are
  exempt (they carry optimistic messages + a pending task).
- CI: add the new parameterless `chat` route to link-handler's
  WORKSPACE_ROUTE_SEGMENTS and to paths/consistency.test.ts (route set +
  expectedSegments) — keeps the two in sync, fixes the failing @multica/core
  test.
- Fix a MUL-4235/MUL-4237 merge collision that broke @multica/views
  typecheck: chat-window.tsx still passed the removed `onPickPrompt` prop to
  EmptyState.

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

* fix(chat): self-heal dangling session in shared controller, not just ChatPage (MUL-4171)

Re-review follow-up: the stale-session self-heal only lived in ChatPage, so
the floating ChatWindow still entered from a persisted activeSessionId and
would render an editable empty chat (then POST into a nonexistent session)
when the selected session was deleted / lost access off the /chat route.

- Move the self-heal into the shared useChatController so every surface (tab
  and floating window) drops a dangling activeSessionId once the sessions list
  has loaded and doesn't contain it.
- Harden ensureSession: trust the current id only when it's in the loaded list
  or is a just-created session still awaiting the refetch; a dangling id falls
  through to create a fresh session instead of POSTing into a 404.
- Exempt just-created sessions via an OPTIMISTIC-write signal
  (hasOptimisticInFlight: pending task or optimistic- message), not hasMessages
  — a session deleted elsewhere with real cached history stays eligible for
  self-heal. Add a unit test for the discriminator.

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

* test(views): fix app-sidebar useWorkspacePaths mock for the new chat nav (MUL-4171)

The AppSidebar personal nav gained a `chat` item, so it calls
`useWorkspacePaths().chat()` at render. The app-sidebar.test.tsx mock hadn't
been updated, so `p.chat` was undefined and every render threw
`TypeError: p[item.key] is not a function`, failing @multica/views#test in CI.

- Add `chat: () => "/acme/chat"` to the mocked useWorkspacePaths.
- Route the chat-sessions query key through a mutable `chatSessions` fixture.
- Add coverage for the Chat nav: renders the link, badges the summed
  unread_count, and hides the badge when all sessions are read — so this drift
  is caught next time.

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

* fix(chat): use the original floating window, not the rewritten one (MUL-4235) (#5102)

Follow-up to the merged #5080, which shipped a hand-written, simplified
ChatWindow and lost the original's animations / drag-resize / expand-minimize.
The floating window is just a quick entry point — it should be the original
UI, not a rewrite.

- Restore chat-window.tsx, chat-fab.tsx, chat-resize-handles.tsx and
  use-chat-resize.ts verbatim from main (0-diff): motion animations, drag
  resize, expand/minimize and the session dropdown are back.
- Restore the empty_state.returning_subtitle + starter_prompts i18n keys the
  original window renders (V2 had dropped them); drop the now-unused
  window.open_full_tooltip key the rewrite added.
- Settings gating is unchanged: FloatingChat still wraps the original FAB +
  window, gated by floatingChatEnabled (default off) and hidden on /chat.

typecheck: core/views/web/desktop green. tests: chat + settings views 126 pass.

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

* feat(chat): archive chats from the list, delete only from Archived (MUL-4263) (#5098)

Restore an archive flow as the reversible sibling of delete:
- Chat list hover now offers Archive (not Delete); pin/stop unchanged.
- A footer entry ('Archived · N') opens an Archived view listing archived
  chats; hard delete lives only there (hover -> unarchive + delete, with
  the existing inline confirm).
- Conversation header ⋯ menu mirrors this: active chats archive, archived
  chats unarchive/delete.

Backend: PATCH /api/chat/sessions/{id}/archive flips status active<->archived
(SetChatSessionArchived), broadcasts status on chat:session_updated so other
tabs re-sort into the right list. SendChatMessage already refuses archived
sessions, so archived chats stay read-only until unarchived.

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

* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265) (#5100)

* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265)

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

* refactor(chat): drop chat-list archive marker, keep conversation read-only (MUL-4265)

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

* feat(chat): apply archived-agent read-only to the floating chat window (MUL-4265)

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

---------

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

* fix(chat): address floating-window + archived-agent review blockers (MUL-4171)

Re-review follow-up on the restored floating ChatWindow + archive flow:

1. Floating stale-session self-heal. The restored ChatWindow doesn't use the
   shared controller, so its ensureSession trusted any non-empty
   activeSessionId and there was no dangling-session cleanup — a deleted /
   no-access persisted session could send into a nonexistent session. Ported
   the same guard used for the tab: a self-heal effect that clears a dangling
   activeSessionId once the sessions list has loaded, and ensureSession only
   trusts an id that's in the list or has an in-flight optimistic write
   (hasOptimisticInFlight, reused from use-chat-controller). handleSend seeds
   the optimistic message + pending task before setActiveSession, so a
   freshly-created session is never mis-cleared.

2. Floating dropdown bypassed archive-first safety. Its active rows offered a
   hard-delete, letting the floating window destroy active chats and skip the
   "archive first, delete only from Archived" model. Active rows now ARCHIVE
   (reversible, one-click) like ChatThreadList; the floating window offers no
   hard-delete — unarchive/delete live only in the full Chat page's Archived
   view (reachable via expand). Removed the now-dead delete-confirm machinery.

3. Orphan user message on archived-agent send. SendChatMessage created the
   chat_message before EnqueueChatTask, which rejects an archived / runtime-less
   agent — a stale client would land a user message then get a 500, orphaning
   it. Added a preflight that checks the session agent's archived / runtime
   state and returns 409 before any mutation, plus a handler test asserting the
   send is rejected with no message persisted.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-08 21:58:16 +08:00
LinYushenandmultica-agent 566d51f1c0 perf(chat): fix pending-task index mismatch + cut aggregate request storm (MUL-4159) (#5018)
* perf(chat): fix pending-task index mismatch + cut aggregate request storm (MUL-4159)

ListPendingChatTasksByCreator was a top DB hotspot. Root cause: the partial
index idx_agent_task_queue_chat_pending (migration 040) only covers
status IN (queued, dispatched, running), but migration 109 added a fourth
in-flight status (waiting_local_directory) that both pending chat queries now
filter on. Postgres can only use a partial index when the query predicate is a
subset of the index predicate, so the 4-status query stopped using it and
degraded to a Seq Scan over the whole agent_task_queue.

Implements the reviewed P0-P3 plan in one PR:

P0 index fix (split single-statement CONCURRENTLY migrations per repo convention)
- 143: CREATE INDEX CONCURRENTLY idx_agent_task_queue_chat_pending_v2 covering
  all four in-flight statuses (same column list, so GetPendingChatTask still
  benefits).
- 144: DROP the superseded 3-status index, in its own migration.

P1 SQL + handler hot path
- ListPendingChatTasksByCreator now returns cs.agent_id and states
  chat_session_id IS NOT NULL so the planner can prove the partial-index subset.
- ListPendingChatTasks filters private-agent access against the already-loaded
  accessible-agent set using the returned agent_id, dropping the extra
  ListAllChatSessionsByCreator scan on the hot path.
- Regenerated sqlc.

P2 frontend request amplification
- FAB uses the new boolean has-any query gated on enabled:!isOpen, so the
  minimised button never holds the full aggregate.
- use-realtime-sync maintains the pending aggregate (list + has-any) in place
  from task lifecycle events (queued/dispatch/running/waiting_local_directory
  -> upsert; completed/failed/cancelled -> remove) instead of invalidating on
  every chat:message/chat:done, with a debounced fallback invalidate for
  reconnect / unknown payloads.

P3 boolean endpoint
- GET /api/chat/pending-tasks/has-any backed by HasPendingChatTasksByCreator
  (EXISTS). Permission filtering is baked in via agent_id = ANY($3); an empty
  accessible-agent set short-circuits to false. The detailed list stays for the
  ChatWindow history / stop-task flows.

Tests: new handler tests cover the private-agent gate on both endpoints
(hidden from a creator who lost access, visible to the agent owner) plus the
boolean status/terminal semantics.

EXPLAIN (ANALYZE, BUFFERS) on a 300k-row reproduction:
- before (3-status index): Parallel Seq Scan, ~300k rows filtered,
  shared hit=3012, 12.1 ms.
- after (v2 index): Index Scan on idx_agent_task_queue_chat_pending_v2,
  shared hit=131, 0.07 ms.

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

* fix(chat): stop optimistic cross-session pending aggregate writes from workspace-fanout task events (MUL-4159)

Review on PR #5018 flagged a real privilege-escalation bug in the P2 change:
use-realtime-sync optimistically upserted the cross-session pending aggregate
(pendingTasks / pendingTasksHasAny) from chat task:* events. Those events are a
workspace fanout delivered to every member (server still BroadcastToWorkspace,
see cmd/server/listeners.go), and the payload carries no creator / agent
visibility. So member B starting a chat task could flip member A's FAB to
has_pending=true, bypassing the server-side permission filter on
/api/chat/pending-tasks[/has-any].

Fix (option 1 from the review — the self-contained one): never optimistically
write the aggregate from task:* events. On every task lifecycle transition,
debounced-invalidate the aggregate so it is refetched through the
permission-filtering endpoint, which only returns the caller's own
creator-owned, accessible-agent tasks. The per-session pendingTask cache is
still written directly — it is keyed by chat_session_id and only rendered for
sessions the user can open (server-gated), so it is not a cross-user leak.
chat:message is still excluded from aggregate refresh, so the MUL-4159 request
storm stays fixed; task transitions are per-task and coalesced by the debounce.

- Removed upsertPendingAggregate / removePendingAggregate.
- Added exported refetchPendingChatAggregate(qc, wsId) — an invalidate, never a
  setQueryData — used by the debounced handler.
- Regression tests: refetchPendingChatAggregate leaves the cached
  has_pending/list untouched (no optimistic write) and only invalidates for an
  authoritative server-filtered refetch; no-ops without a workspace id.

Verified: @multica/core + @multica/views typecheck; full core vitest suite
(752 tests) green including the 2 new guard tests.

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

* chore(chat): address review nits on pending-tasks endpoints (MUL-4159)

- Restore the GetPendingChatTask godoc first line that was clipped when the
  has-any handler was inserted (nit#1).
- ListPendingChatTasks short-circuits to an empty list when the caller has no
  accessible agents, mirroring HasPendingChatTasks — skips the DB round-trip
  (nit#2).
- Add a cross-creator negative test: user A's in-flight task on a
  workspace-visible agent returns has_pending=false / empty list for user B,
  locking the cs.creator_id tenant gate that the agent-visibility filter does
  not cover (nit#3).

Verified: go build ./... and go test ./internal/handler -run PendingChatTasks
(7 tests) green against live Postgres.

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-07-07 13:20:30 +08:00
cb68669c73 feat(composio): gate MCP apps behind feature flag (#4876)
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720) (#4608)

* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720)

Compose the merged server/pkg/composio SDK into a user-facing connection
manager: signed-state connect handshake, local user_composio_connection
mirror, idempotent disconnect, and a per-user MCP session helper (not yet
wired into task dispatch).

- migration 127_user_composio_connection (no FK/cascade, per DB rules)
- sqlc queries: upsert (idempotent on user_id+connected_account_id), list
  active, owner-scoped get, mark revoked
- internal/integrations/composio: signed HMAC-SHA256 state, BeginConnect,
  CompleteCallback (idempotent upsert), ListConnections, Disconnect
  (upstream 404 = idempotent success), CreateMCPSession (no-op when empty,
  pins connected_accounts per toolkit), CallbackRedirect
- REST handlers under /api/integrations/composio (user-scoped, 503 when
  COMPOSIO_API_KEY unset): connect/init, callback (302), connections list,
  delete
- router wiring gated by COMPOSIO_API_KEY; COMPOSIO_AUTH_CONFIGS_JSON maps
  toolkit->auth_config (MVP: notion); state secret from COMPOSIO_STATE_SECRET
  or derived from JWT_SECRET; callback base from COMPOSIO_CALLBACK_BASE_URL
  or MULTICA_PUBLIC_URL
- tests: state (expire/tamper/wrong-secret), service (mapping, callback
  idempotency, non-success, disconnect owner/404 idempotency, MCP pin),
  handlers (httptest), redact regression for Bearer mcp_ tokens

MVP scope: Notion only; no task-dispatch overlay, sharing, or webhook
event handling (later stages).

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

* fix(composio): bind callback account to user + idempotent revoked disconnect (MUL-3720)

Address PR 4608 review (CHANGES_REQUESTED):

- callback: verify connected_account_id with Composio before mirroring it.
  The signed state only proved user/toolkit/exp, so a valid state paired with
  a tampered connected_account_id would be written verbatim. CompleteCallback
  now calls ListConnectedAccounts and fails closed (ErrAccountVerification)
  unless the account belongs to the state's user (composio_user_id == multica
  user id) and was created under the toolkit's auth config. No row is written
  on mismatch / unknown account / upstream error.

- disconnect: short-circuit to a no-op when the local row is already revoked,
  before touching upstream. Previously a second DELETE re-hit Composio and a
  non-404 upstream error surfaced as a 502, breaking the 204-idempotent
  contract.

- CreateMCPSession: document the v1 single-active-connection-per-(user,toolkit)
  constraint and make duplicate selection deterministic (newest-wins, rows are
  connected_at DESC) instead of order-dependent map overwrite. Stage 3 owns the
  real single-account-enforcement vs multi-account-shape decision.

Tests: tampered/wrong-auth-config/unknown-account callback rejection, revoked-row
disconnect no-op (asserts upstream not re-hit). composio pkg 85% coverage; all
green.

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

* feat(composio): list all toolkits + dynamic auth-config resolution (MUL-3720)

Yushen's follow-up to the Notion MVP: surface the full Composio toolkit
catalog, render it in Settings, and drop the static env mapping in favor of
dynamic auth-config discovery.

Config correctness (per Composio docs):
- Remove COMPOSIO_AUTH_CONFIGS_JSON entirely. The toolkit→auth_config mapping
  is now resolved at request time from the project's /auth_configs (cached,
  5-min TTL), so enabling a toolkit is a dashboard action, not a redeploy.
- Do NOT add COMPOSIO_PROJECT_ID. The project API key (x-api-key) authenticates
  to exactly one project; the project is resolved from the key. Only org-level
  endpoints use x-org-api-key, which this integration never calls.

Backend:
- SDK: server/pkg/composio/auth_configs.go — ListAuthConfigs (toolkit_slug,
  is_composio_managed, show_disabled, limit, cursor).
- service: dynamic resolver (authConfigMap cache; betterAuthConfig prefers a
  custom/white-label config over Composio-managed, newest wins); BeginConnect
  and CompleteCallback resolve via it; ListToolkits fetches the full catalog
  (paginated, capped) annotated with connectable = has an enabled auth config,
  connectable-first ordering.
- handler + route: GET /api/integrations/composio/toolkits (user-scoped, 503
  when COMPOSIO_API_KEY unset) returning slug/name/logo/category/connectable.

Frontend:
- core: ComposioToolkit/ComposioConnection types, api client methods, and
  composio query options (@multica/core/composio).
- views: Settings → Integrations now has a Composio section rendering every
  toolkit as a card with search. Connect is gated on `connectable`;
  non-connectable toolkits show a muted "not configured" hint instead of a
  dead button. Connected toolkits show a badge + Disconnect (with confirm).
- i18n: composio block added to en/zh-Hans/ja/ko settings.

Tests: SDK + service (dynamic resolution, custom-over-managed preference,
connectable flag, resolver-error soft-degrade) and handler toolkits endpoint;
composio pkg 85.7% coverage. go build/vet/gofmt clean; core+views typecheck,
core+views lint, and core tests (691) all green.

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

* fix(composio): close cross-toolkit callback fail-open by signing auth_config_id into state (MUL-3720)

Re-review blocker: CompleteCallback resolved the toolkit's auth config at
callback time and ignored a resolve error/empty result, while
verifyAccountOwnership skipped the auth-config comparison when the expected
value was empty. A user could then pass another toolkit's connected_account_id
into this toolkit's callback — the owner check passed and it was written under
the wrong toolkit_slug/account binding.

Fix: the auth_config_id is already resolved in BeginConnect (before the state
is signed), so sign it into the state and compare it exactly at callback. No
re-resolve, no fail-open. verifyAccountOwnership now fails closed when the
expected auth config is empty (rejects instead of skipping) and requires an
exact match — closing the cross-toolkit binding gap.

Tests: state round-trips auth_config_id; BeginConnect signs it; callback
rejects wrong/cross-toolkit auth config and an empty (no-mapping) auth config
fails closed. composio pkg 85.2% coverage, all green.

Frontend (non-blocking): the Composio settings tab now surfaces an error when
the connections query fails instead of silently rendering everything as
unconnected.

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

* fix(composio): hide Settings section entirely when integration unconfigured (MUL-3720)

Decision (option 2, hide-then-merge): don't show a card that leaks the internal
COMPOSIO_API_KEY env-var name to every end user. IntegrationsTab now gates the
whole Composio section (heading + body) on the toolkits query — a 503 means the
key is unset, so the section is withheld instead of rendering the not-configured
card. Admin-only setup guidance is a later, role-gated affordance.

Removed the notConfigured card (and now-unused ApiError import) from
ComposioTab; it only mounts when configured. views typecheck + lint clean.

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

---------

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

* feat(composio): Stage 2 frontend polish — callback toast, last_used & expired UI, e2e (MUL-3718) (#4688)

* feat(composio): callback toast + refresh, last_used & expired UI, e2e (MUL-3718)

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

* fix(composio): real callback redirect route + StrictMode-safe toast dedup (MUL-3718 review)

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

---------

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

* fix(composio): callback endpoint should not require Multica auth (MUL-3843) (#4709)

* fix(composio): move OAuth callback out of the Auth group (MUL-3843)

Composio 302-redirects the browser to /api/integrations/composio/callback
at the end of the OAuth flow, but PR #4608 mounted it inside the cookie-auth
middleware group. When the session cookie is absent (expired session,
SameSite=Strict / Safari ITP, private window, self-hosted callback subdomain)
the Auth middleware returned a hard 401 and a JSON blob instead of the
settings redirect, breaking the flow.

Identity never came from the cookie anyway: it is carried by the HMAC-signed
state param that CompleteCallback verifies (signature, expiry, replay) and
cross-checked by verifyAccountOwnership; h.Composio == nil still 503s. So the
callback is registered alongside the other public OAuth/webhook routes; the
other four composio endpoints stay session-gated.

Refs MUL-3843, MUL-3715.

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

* fix(composio): correct stale callback routing comments (MUL-3843)

The package header and ComposioCallback doc comments still described the
callback as sitting under the Auth middleware group. After the route was
moved out (this PR), update both to state it is a public route whose identity
comes from the signed state — addressing review nit from 张大彪.

Refs MUL-3843.

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

---------

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

* feat(composio): inject MCP overlay into agent runtime at task dispatch (MUL-3721) (#4704)

Stage 3 of the Composio epic. Wires the per-user Composio MCP session into
every agent task so the agent process sees the initiator's connected tools
without any prompt-time plumbing.

Server side
  - Migration 128 adds agent_task_queue.runtime_mcp_overlay JSONB plus a
    BEFORE-UPDATE trigger that wipes the column on any transition into a
    terminal status (completed / failed / cancelled). A trigger is the single
    source of truth — future queries that flip status cannot bypass it.
  - composio.Service.BuildTaskOverlay(userID) reuses CreateMCPSession and
    emits the Claude-style { mcpServers: { composio: { type: http, url,
    headers } } } shape the daemon's existing sidecar generators consume.
    Returns (nil, nil) on zero active connections so we never burn a
    Composio session for a user with nothing to call.
  - TaskService grows a Composio ComposioOverlayBuilder seam, wired in
    router.go after composiointeg.NewService succeeds. Five enqueue paths
    (issue / mention / quick-create / chat / auto-retry) attach the overlay
    after CreateAgentTask returns and before the daemon is notified — so
    every claim reads a settled row, with no second daemon hop. Best-effort:
    a builder failure logs and proceeds with no overlay.
  - resolveInitiatorFromTriggerComment derives the initiator user from the
    trigger comment when it was authored by a member. Agent-authored
    triggers are not treated as initiators (their connected-apps view is
    empty by construction).

Daemon side
  - handler/daemon.go claim path merges task.runtime_mcp_overlay onto
    agent.mcp_config via mergeMCPOverlay before populating
    TaskAgentData.McpConfig. Overlay wins on server-name collisions
    because it carries the live user-scoped session URL. Errors fall back
    to the agent config unchanged — a bad overlay must not surprise-disable
    saved MCP tools. The existing execenv sidecar generators (cursor /
    codex / openclaw / opencode / hermes-kiro) need no changes: they keep
    consuming the merged result through TaskAgentData.McpConfig.

Tests
  - 9 merge cases (mcp_overlay_test): both-nil short-circuit, agent-only
    pass-through, overlay-only canonicalization, two-side merge, name
    collision (overlay wins), top-level key preservation, malformed agent
    fallback, malformed overlay fallback, non-object server rejection.
  - 4 dispatch cases (composio): zero-connections returns nil without
    CreateSession, happy-path emits the right shape with the right user
    id, empty-URL defensive branch, SDK error surfacing.
  - 4 TaskService helper cases: nil Composio is a no-op (Queries-safe),
    invalid initiator does not call the builder, nil overlay skips the
    UPDATE, builder error swallowed without panic.
  - Migration 128 verified to roll up + down + up cleanly against the test
    database.

Out of scope (deferred): assignment-triggered enqueue paths with no
trigger comment get no overlay attached today (no initiator UUID flows
through enqueueIssueTask in that case). Retry paths recompute the overlay
fresh from the parent's initiator_user_id instead of inheriting the bearer
from the parent row, so a stale token can never resurface on a retry.

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

* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869) (#4736)

* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869)

Stage 3.1 of the Composio epic (MUL-3721 parent). PR #4704 wired in the
runtime_mcp_overlay column and a per-task dispatch hook; this change
inverts the default from "all-on" to opt-in and locks the overlay to the
agent owner's own connected apps:

- Agents carry composio_toolkit_allowlist TEXT[]. NULL or [] => no MCP.
  Owner-only read/write; non-owner GET/PUT silently redacts/drops the
  field (same shape as mcp_config).
- agent_task_queue carries originator_user_id UUID. Set from the
  top-of-chain HUMAN at every enqueue path:
    * issue/mention comment by member  -> author_id
    * issue/mention comment by agent   -> inherit via comment.source_task_id
                                          -> parent task originator_user_id
    * quick-create                     -> requester_id
    * chat                             -> initiator_user_id
    * retry                            -> SQL-inherited from parent row
    * autopilot                        -> NULL (system-driven)
- BuildTaskOverlay (composio dispatch) now takes (ctx, originatorUserID,
  agent) and short-circuits on five gates: invalid originator,
  originator != agent.owner_id, empty allowlist, empty intersection of
  allowlist ∩ active connections, defensive empty session URL. Composio
  CreateSession is called with BOTH `toolkits.slugs` (the intersection)
  AND `connected_accounts` (the pinned account ids), narrowing the
  tool-router twice.
- The originator-vs-owner gate closes the agent-fanout privacy hole: any
  workspace member who can @-mention a public agent used to project the
  owner's connected apps into their run. Now the overlay only mounts
  when the human at the top of the chain IS the agent owner.

Tests:
- dispatch_test.go covers all 5 gates plus uppercase/whitespace slug
  normalisation.
- task_runtime_mcp_overlay_test.go covers the no-op gates of the new
  applyRuntimeMCPOverlay signature.
- agent_composio_allowlist_test.go (handler): owner roundtrip
  (list/empty/null), workspace-admin silent-drop, owner-only GET
  visibility, pure normaliseComposioToolkitAllowlist.
- resolve_originator_test.go (service, DB-backed): member-authored,
  agent-authored inherits via comment.source_task_id, invalid id.

Migration 129 up/down/up verified against docker postgres.

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

* chore(composio): gofmt + regenerate sqlc with v1.31.1 (MUL-3869 review nits)

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

---------

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

* fix(composio): accept nested connected account auth config

* feat(views): creator-only MCP tab for per-agent Composio allowlist (MUL-3870) (#4743)

Stage 3.2 frontend on top of the Stage 3.1 backend (MUL-3869, 4708dba97).
Adds an agent-detail tab that lets the agent owner pick which of their own
active Composio connections this agent may mount as MCP servers, writing the
selection to agent.composio_toolkit_allowlist via the existing PUT /api/agents.

- core/types: composio_toolkit_allowlist (+ _redacted) on Agent; tri-state
  composio_toolkit_allowlist on UpdateAgentRequest (omit/no-change, null/clear,
  array/replace), matching the backend contract.
- core/agents: useUpdateAgentAllowlist - optimistic mutation hook (patches the
  cached workspace agent list, rolls back on error, invalidates on settle).
- views: AgentMcpTab renders the owner's active connections as checkboxes;
  empty state links to Settings -> Integrations; defensive redacted state.
- views: wired into AgentOverviewPane as tab "composio_mcp", labeled "MCP Apps"
  to disambiguate from the existing raw-JSON "MCP" (mcp_config) tab. The entry
  is gated to the creator (currentUserId === agent.owner_id), matching the
  backend's owner-only read/write of the allowlist.
- i18n: tabs.composio_mcp + tab_body.composio_mcp.* in en/ja/ko/zh-Hans.
- tests: agent-mcp-tab.test.tsx (gating, toggle->allowlist body, active-only,
  empty, redacted); e2e/agent-mcp.spec.ts (creator sees tab + PUT body,
  non-creator hidden) with Composio + agent endpoints mocked at the boundary.

Note: the product spec says "creator"; the schema has no creator_id - the
backend gate and redaction are keyed on owner_id, so the tab uses owner_id.

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

* fix(composio): mount remote MCP for codex

* feat(agents): agent invocation permission system (MUL-3963) (#4844)

* feat(agents): agent invocation permission system (permission_mode + invocation targets)

MUL-3963: split who may INVOKE an agent out of the overloaded visibility
column into an explicit, extensible model on feature/composio-integration.

- DB: agent.permission_mode (private|public_to) + agent_invocation_target
  table (workspace/member/team targets) + lossless backfill from visibility
  (migration 130).
- canInvokeAgent: owner-only for private (NO admin bypass, NO A2A bypass);
  public_to honours the allow-list; A2A judged by the top-of-chain originator.
- All trigger paths rewired: issue assign, comment @agent/@squad, chat,
  quick-create, autopilot, squad leader, child-done.
- Agent API: permission_mode + invocation_targets on responses and
  create/update (owner-only writes); legacy visibility kept as a derived field
  so old clients never see a permission widening.
- Composio: BuildTaskOverlay now FOLLOWS invocation permission and uses the
  agent OWNER connection (removed the originator==owner gate); front-end warns
  when a shared agent enables Composio apps.
- CLI: --permission-mode / --public-to-workspace / --public-to-member (legacy
  --visibility still mapped).
- Frontend: AccessPicker (Private / workspace / specific people / team soon),
  permission rules mirror canInvokeAgent, Composio warning banner.
- Tests: migration backfill, admin cannot invoke others private, public_to
  workspace/member whitelist, A2A by originator, Composio overlay uses owner
  connection.

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

* feat(agents): stackable, mixed public_to invocation targets (MUL-3963)

Follow-up on PR #4844: public_to now supports selecting MULTIPLE, MIXED
targets on one agent (e.g. Public to workspace + specific people + team),
with canInvokeAgent admitting on ANY matching target (OR).

- Frontend AccessPicker: reworked from a single exclusive kind into a
  stackable multi-select — an "Everyone in workspace" toggle, a member
  multi-select checklist, and a (disabled, v1) team placeholder can be
  combined freely. Emits the full union of selected targets; empty union
  collapses to Private. Existing team targets are preserved across saves.
  Added the access.public_group locale string (en/zh-Hans/ja/ko).
- Backend already supported this (agent_invocation_target is multi-row per
  agent; create/update take a target ARRAY and batch-replace the whole
  allow-list; canInvokeAgent OR-matches). Added tests to lock it in:
  mixed member+team targets, overlapping-member batch replace, and
  workspace+member stacking then narrowing.

Refs MUL-3963.

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

* fix(agents): address review on invocation permission (MUL-3963)

张大彪 review on PR #4844 — three blockers + product ruling + nits:

1. Migration 130: drop the FK/cascade on agent_invocation_target
   (agent_id, created_by) per the Multica no-FK rule; relationships are now
   maintained in the app layer (matching MUL-3515 §4). Added
   DeleteAgentInvocationTargetsByArchivedRuntimeAgents and call it before
   DeleteArchivedAgentsByRuntime in all three runtime-delete paths
   (runtime.go x2, runtime_profile.go) so hard-deleting agents can't orphan
   target rows.
2. revokeAndRemoveMember: prune the leaving member's member-target grants
   (DeleteAgentInvocationTargetsByMember) in the same tx as the member-row
   delete, so a re-invited user can't reclaim a stale invocation grant.
3. Empty public_to is a phantom — parsePermissionInput now normalises a
   public_to with no resolvable targets to a single workspace target, so
   `--permission-mode public_to` alone (and any empty target array) means
   "public to workspace" instead of "shared but nobody can run it".

Product ruling: the system/no-human-originator → workspace-target path in
canInvokeAgent is a deliberate, documented exception (webhook/system/
workspace-wide automation); member/team targets still fail closed without a
resolved originator. Documented in code + locked with a test.

Nits: refreshed the stale "originator must be owner" comments — models.go
(via migration 130 COMMENT ON COLUMN + sqlc regen for composio_toolkit_allowlist
and originator_user_id) and agent-mcp-tab.tsx — to the owner-connection +
invocation-permission rules.

Tests: member remove/re-add regression, system workspace exception + member
fail-closed, empty public_to → workspace (plus the earlier mixed/overlap/
batch-replace suite). Migration 130 applied to the test DB; Go handler/service/
composio suites green; views typecheck clean.

Refs MUL-3963.

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

* fix(agents): scope member invocation-target cleanup to one workspace (MUL-3963)

张大彪 3rd review — cross-workspace permission bug + comment nits:

- DeleteAgentInvocationTargetsByMember was a GLOBAL delete by user id, so
  removing a user from workspace A also wiped their member-target grants on
  agents in workspace B. Scoped it to a single workspace by joining through
  agent.workspace_id; revokeAndRemoveMember now passes (workspaceID, userID).
- Regression test TestRevokeMember_InvocationTargetCleanupIsWorkspaceScoped:
  same user allow-listed by agents in two workspaces; removal from one leaves
  the other workspace's target intact.
- Nits: refreshed the remaining stale "originator == agent.owner_id" /
  "owner-vs-originator" comments — CreateRetryTask (agent.sql, regenerated),
  and the AgentResponse allowlist doc + ListAgents/UpdateAgent redaction
  rationale in agent.go — to the owner-connection + invocation-permission rule.

Migration 130 applied to the test DB; Go handler/service/composio suites green;
go vet clean.

Refs MUL-3963.

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

---------

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

* fix(agents): agent access owner-only editable, read-only for others (MUL-3963) (#4853)

* fix(agents): make agent access owner-only editable, read-only for others (MUL-3963)

Interaction bug: a non-owner (incl. workspace admin) could open the AccessPicker
and set an agent public — the backend silently ignored it and the UI bounced
back to private. Access is owner-only, so non-owners must see a read-only state
and the backend must reject real changes explicitly.

Frontend:
- AccessPicker renders a static, non-interactive read-only state when the
  viewer is not the owner: the current access value + a lock affordance + a
  tooltip "Only the agent owner can change who can run this agent." No clickable
  trigger is rendered, so a non-owner can never open a control the backend would
  reject (the GitHub/Notion pattern for permission settings you can see but not
  edit). The editable multi-select picker is unchanged for the owner.
- agent-detail-inspector gates the picker on ownership specifically
  (currentUserId === agent.owner_id), NOT the general canEdit (which also admits
  admins, who may edit other fields but not access).
- New locale key access.owner_only_readonly (en/zh-Hans/ja/ko).

Backend:
- UpdateAgent now returns an explicit 403 when a non-owner submits a REAL
  permission change (permissionInputChangesAgent compares requested mode +
  target set against the persisted state); a no-op resubmit (admin PATCH-as-PUT
  echoing unchanged permission) is still tolerated so admin edits of other
  fields keep working. Replaces the previous silent-drop that caused the bounce.

Tests:
- access-picker.test.tsx: non-owner gets a non-interactive read-only display
  with the owner-only tooltip; owner gets an interactive picker; owner can pick
  a member and stack workspace + member.
- TestUpdateAgent_AccessChangeIsOwnerOnly: admin real change → 403; admin no-op
  resubmit → 200; admin editing other fields → 200; owner change → 200.

Incidental: fixed a pre-existing base typecheck break in
slash-command-suggestion.test.tsx (stray `signal` arg not in the suggestion
items type) that otherwise fails the whole @multica/views typecheck.

Refs MUL-3963.

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

* fix(agents): compare legacy visibility, not expanded permission, for no-op detection (MUL-3963)

PR #4853 review: permissionInputChangesAgent expanded a legacy-only
visibility:"private" into a real private permission and compared it against the
agent's actual permission. A member-only public_to agent derives legacy
visibility "private", so an admin PATCH-as-PUT echoing visibility:"private"
while editing another field was misread as a public_to→private downgrade and
rejected with 403 — contradicting the "unchanged permission no-op is allowed"
contract.

Fix (per review): when a request carries ONLY legacy `visibility` (no
permission_mode / invocation_targets), derive the agent's CURRENT legacy
visibility from its real targets and compare the legacy string values. Equal =
no-op (allowed); a real legacy change (e.g. "workspace") still returns 403.
Requests that carry permission_mode / invocation_targets keep the precise
mode+target comparison.

Regression test TestUpdateAgent_LegacyVisibilityNoOpForMemberOnlyPublicTo:
member-only public_to agent — admin submitting visibility:"private" + a
non-permission field → 200 with targets unchanged; admin submitting
visibility:"workspace" → 403.

Go handler/composio suites green; migration 130 applied; go vet clean.

Refs MUL-3963.

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

---------

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

* feat(composio): brief agents on connected apps

* feat(composio): gate MCP apps behind feature flag

* fix(mobile): parse agent invocation permissions

* fix(tests): update agent fixtures for access fields

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Multica Eve <[email protected]>
Co-authored-by: Eve <[email protected]>
Co-authored-by: Eve <[email protected]>
2026-07-03 14:18:43 +08:00