mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 02:16:45 +08:00
8ade96e671bca7d84fd2d764ffca305e2fdc358d
421
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8ade96e671 |
MUL-6584: establish Public API v1 foundation (#7450)
* feat(api): establish public v1 contract foundation Co-authored-by: multica-agent <[email protected]> * fix(plugins): close public API contract gaps Co-authored-by: multica-agent <[email protected]> * fix(issues): enforce revision guard during update Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]> |
||
|
|
5bf10d485c |
MUL-6582: add durable scheduled Plugin hooks (#7448)
* MUL-6582 feat(plugins): add durable scheduled hooks Co-authored-by: multica-agent <[email protected]> * MUL-6582: add schedule-pulse example plugin Ship a real scheduled plugin that records each unique delivery_id in workspace storage, so a host retry does not look like a second pulse. The cmd/server test installs this example manifest and drives two scheduler replicas through a dropped first attempt. Co-authored-by: multica-agent <[email protected]> * MUL-6582 fix(migrations): make schedule indexes retry-safe Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]> |
||
|
|
9622dd55ac |
MUL-6583: fix archived inbox group counts (#7447)
* fix(inbox): align archive operations with issue groups Co-authored-by: multica-agent <[email protected]> * perf(inbox): narrow archived group scan Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]> |
||
|
|
aa4c504c78 |
fix(issues): re-rank an issue when its status moves it to a new column (#7422)
position ranks an issue inside its own (workspace, status) column, but a status change carried the old column's value over unchanged. The value that put an issue on top of Todo is roughly zero, and lands it below every hand-dragged issue in Done — so agent-completed work sank into the middle of a long Done column and read as "the task disappeared". Re-rank to the top of the destination column on both status-write paths, reusing the MIN(position) - 1 policy new issues already get. An explicit position still wins, so cross-column drag-and-drop keeps the slot it dropped on, and a same-status write leaves position alone. The re-rank reads MIN(position) for the destination (workspace, status), which idx_issue_status could only serve with a heap fetch per issue in the column — paid on every completion against the column that grows without bound. Add (workspace_id, status, position) to make it an index-only min. Closes #7410 Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
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]> |
||
|
|
fa797d0063 |
MUL-6547 fix(daemon): resolve claim project context once, workspace-scoped (#7405)
Collapses the project-context resolution that had been copied across the issue, chat and quick-create claim paths (and would have been copied a fourth time) into one workspace-scoped, fail-closed resolver, and hydrates run_only autopilot project context on top of it. - Project and resource reads are both workspace-scoped, so a dangling or cross-tenant project reference can no longer lift another workspace's repository URL, local directory path, title or description into a claim. - A failed read is no longer treated as "there is no project". Transient failures reject the claim and preserve the task for the stale-dispatched reclaim rather than silently falling back to workspace repos. - Source rows (issue, chat session, autopilot run, autopilot) fail closed the same way. Previously a read error skipped the branch entirely and, because taskToResponse seeds the runtime workspace, sailed through the backstop isolation check — dispatching an agent with no issue context or, for chat, no input at all, bypassing the empty-input guard. - Workspace isolation is resolved authoritatively per branch and checked before any context is hydrated. For run_only autopilots that check had been a tautology, comparing the runtime workspace against itself. - run_only autopilots now receive their configured project's repositories and local_directory, the same contract issue-bound and quick-create tasks already had. Absorbs #7396. Its inline implementation is replaced by the shared resolver; the author's regression test is carried over unchanged. Co-authored-by: RAVN <[email protected]> |
||
|
|
f45b591623 |
MUL-6469: host plugin artifacts, bind installs to immutable versions (#7321)
* feat(plugins): host plugin artifacts, bind installs to immutable versions A plugin used to be a URL. The manifest was frozen at install, but the surface script was fetched from the author's server every time a panel opened, with no integrity check — so an administrator consented to a manifest while the browser ran whatever that host served that day, inside the scopes already granted. The author's uptime was our uptime, every panel open leaked the reader's IP and "who read which issue" to the author, and publishing at all required a public HTTPS domain, which is why the repo's own deploy-sentinel example never ran. The author now uploads an artifact bundle and Multica stores it: - plugin_package / plugin_package_version / plugin_package_file. A version is insert-only, and the (package_id, version) unique index is what makes immutability a database rule rather than a convention. - An installation names one version. Publishing another changes nothing for a workspace until an administrator upgrades, which is a second consent. - The bundle is validated once, at publish: the manifest parses, every file it declares is present and loadable, and a surface entry with a top-level import is refused with the line named. That failure used to surface months later in a reader's browser. - The host inlines the surface script into the document it generates, so the CSP names no remote script origin at all. `connect-src 'none'` now means it: a surface with no net: scope cannot reach anywhere, including its author. - Install-by-URL is gone rather than kept alongside. Two paths would make "is this code frozen?" depend on how the plugin was installed. - MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary immutable version instead of being a second render path; re-publishing an unchanged version lands as `+dev.N`. Hook endpoints and MCP servers remain on the author's own infrastructure. Migration 376 drops existing installations: they name a source URL that is no longer a concept, and the code they would run was never published here. plugins_v1 has never left its feature flag and holds no production data — the same call migration 344 made. Closes MUL-6469 Co-authored-by: multica-agent <[email protected]> * fix(plugins): serialize publish/install/delete, correct the isolation claim Review of #7321 found five problems. Four are fixed here; the fifth is real, needs its own design pass, and is now tracked and described honestly in the code instead of claimed away. Migration prefix collision. main took 376 for agent_task_durable_work_dir while this branch was open, so `migration prefix 376 is reused` failed backend-tests. Rebased and renumbered the six migrations to 377-382. TOCTOU between delete and install/publish. Relationships are application-owned by repository policy, so nothing made "the version this installation names still exists" true across statements: `delete counts zero installs` -> `install reads the version` -> `delete commits` -> `install commits` left an installation whose panel 404s forever, and a publish racing a delete left a version whose package row was gone. Publish, install and delete now hold a (workspace, plugin key) advisory lock for their whole transaction, and delete counts installations inside it. Both interleavings have regression tests that reproduce the bug with the lock removed. Failed publishes moved package state. upsertPackage ran before the version transaction, so a republish that lost the unique index had already renamed the package, and a first publish that failed afterwards left a plugin with zero versions. It now runs inside the same transaction. The top-level import check was wrong in BOTH directions. It read line prefixes, so it missed ` import x` and `/* c */ import x` — and it refused a valid file whose template literal contained the word at a line start, which is ordinary in a surface that renders code samples. A refusal blocks a publish with no way around it, so that false positive was the worse half. Replaced with a scanner that skips comments and string/template literals; where it is imprecise it is imprecise safely (`/` always reads as division), so a mistake can only cost a detection, never invent one. Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self` navigation by design, no shipped CSP directive covers it, and contentWindow is unchanged across it — so a hostile artifact can still reach its author once and hand the replacement document the bridge port. Closing it needs an embedder frame-src policy or a handshake a navigated document cannot complete; both are decisions of their own. What lands here is damage control, labelled as such: the generated document beacons on pagehide from a listener the plugin cannot detach, and the embedder drops the bridge and unmounts the frame. The "a surface cannot reach its author" wording is removed from the SDK README and the tests. Closes MUL-6469 Refs MUL-6485 Co-authored-by: multica-agent <[email protected]> * fix(plugins): harden surface validation and error reporting * fix(migrations): renumber plugin package migrations * fix(plugins): validate classic surfaces and reset failures --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
115bfab759 |
MUL-6523: persist task message batches in one statement (#7377)
* perf(server): persist task message batches in one statement
/api/daemon/tasks/{id}/messages is the hottest write path in the system:
every running task POSTs its buffered transcript every 500ms. The handler
was paying 3 + N database round trips per batch — GetAgentTask, the
workspace lookup inside requireDaemonTaskAccess, a SECOND identical
GetIssue/GetChatSession in the handler body, and then one
`INSERT ... RETURNING` per message, each in its own implicit transaction.
Two changes, both on that path:
- Take the workspace id from requireDaemonTaskAccessWithWorkspace, which
already resolved it to authorize the daemon, instead of re-querying it.
Broadcast reach is deliberately unchanged: the id is still only used for
issue- and chat-backed tasks, so autopilot / quick-create tasks keep
their current (non-)streaming behaviour.
- Add CreateTaskMessages, which expands the whole batch from one jsonb
document via jsonb_to_recordset and inserts it with a single statement.
A jsonb document rather than parallel arrays because `input` is itself
jsonb and a text[]/uuid[] column cannot express a per-row NULL from Go;
it also stays clear of the ~64k bind-parameter ceiling a multi-row
VALUES list would hit.
Per batch this takes 3 + N round trips down to 3, and collapses N commits
into 1. Measured locally against Postgres 17 (200 iterations per shape,
same rows): 2.1ms -> 0.7ms at 3 messages, 7.0ms -> 0.8ms at 10, 80ms ->
1.1ms at 50.
Atomicity is the other half of the fix, not a side effect: the daemon does
not retry this endpoint, so a loop that failed halfway left a permanent
hole in the transcript. One statement makes a batch all-or-nothing.
The NUL sanitizer calls stay exactly where they were and matter more now —
jsonb rejects \u0000 for the whole document, so an unsanitized byte would
fail the entire batch rather than one row (GH #7098).
Co-authored-by: multica-agent <[email protected]>
* fix(server): guarantee task message publish order, test the real query
Review follow-ups on the batch insert.
Blocking:
- Realtime ordering had no contract. `INSERT ... RETURNING` defines no row
order, but the publish loop walked the result as-is, so a batch could
reach subscribers scrambled where the per-message loop always published
in request order. CreateTaskMessages now returns through an ordered CTE
(`WITH inserted AS (INSERT ... RETURNING *) SELECT * FROM inserted ORDER
BY seq`), putting the ordering in the query rather than asking every
client to re-sort. Verified it is not a no-op: with the ORDER BY removed,
the new test reports the published order as [3 1 2] for a request listing
seq 3, 1, 2 — i.e. the bug was real, not theoretical.
- The atomicity test copied the batch SQL by hand, so it could have passed
while CreateTaskMessages was broken. It now calls the generated query.
Both tests were also rewritten onto the repo's shared fixtures — dbfx for
rows, testutil.JSONRequest / WithURLParams / Call(...).Want(...) for the
handler — instead of open-coded INSERT/cleanup pairs and recorder
quartets.
- New TestReportTaskMessagesPublishesInSeqOrder subscribes to the event bus
and asserts the published seq order, with the request deliberately listing
messages out of seq order so array order cannot pass for seq order.
Non-blocking:
- A failed `json.Marshal` of a message's input is no longer swallowed. A
tool_use row that silently loses its arguments is worse than a 500 the
daemon logs, because nothing anywhere records the loss.
- Corrected the atomicity claim in the query and test comments: one
statement buys CONSISTENCY, not completeness. Without a retry a failed
batch is now lost whole rather than leaving a prefix; actually closing the
gap needs a retry plus a (task_id, seq) uniqueness rule, which is out of
scope here.
The CTE makes sqlc name the result CreateTaskMessagesRow rather than reusing
the table model, so the publish site converts explicitly — a compiler-checked
cast that fails loudly if the query ever stops returning the whole row.
Co-authored-by: multica-agent <[email protected]>
* perf(server): pass task message batches as arrays, not a JSON envelope
Second review round. The jsonb-document parameter was a regression on large
messages: content and output had just been decoded out of the request, and
wrapping them back into JSON made the server escape every byte a second time
and Postgres parse the envelope out again. Measured against the per-row
insert it replaced, at one message: ~1.0x at 8KB, 1.46-1.48x at 128KB,
~3.0x at 1MB — worst exactly where the old path was cheapest, and those
messages are the least likely to be batched.
CreateTaskMessages now takes parallel arrays. NULLIF(value, '') is what makes
a per-row NULL expressible through the non-nullable []string sqlc gives a
text[] parameter, and it reproduces the single-row query's
pgtype.Text{Valid: x != ""} mapping exactly. input stays a text parameter
cast to jsonb in SQL — it is the one column that genuinely has to be parsed.
The arrays are zipped by several single-argument unnest calls in one SELECT
list, which expand in lockstep (PG10+ SRF semantics). The tidier
unnest(a, b, ...) form is equivalent but sqlc's analyzer only knows the
single-argument signature and fails to generate for it.
Measured after the change, same harness, two runs:
one message, 8KB 0.98-0.90x of the per-row insert
one message, 128KB 0.99-1.04x (was 1.46-1.48x)
one message, 1MB 1.59-1.67x (was 2.99-3.02x)
3 messages, 512B 2.7-3.2x faster
10 messages, 512B 7.7-8.2x faster
128KB is now parity. 1MB is still above parity because any batch shape makes
Postgres materialize the array and unnest it before the insert, which a
direct single-row parameter skips; closing that needs a len == 1 fast path,
which review explicitly preferred not to have for the extra code path. A real
1MB single message is also unlikely: tool_result output is truncated to 8KB
in the daemon (internal/daemon/daemon.go). The durable answer for oversized
payloads is a body/batch cap on this endpoint, which is still a follow-up.
Also from review:
- The conflicting row in the atomicity test is built with dbfx.Insert instead
of CreateTaskMessage, so test setup no longer shares code with the code
under test.
- The ordering test runs on its own event bus and restores the shared one, so
it stops leaving a listener behind on a bus that has no unsubscribe.
- The per-batch probe is Debug, not Info. At Info it was ~2 lines/second/task
in production; it was added at Info in #7371 only to count batch sizes,
which is answered.
Re-verified the ordering test still fails without the ORDER BY (reports
[3 1 2]) against the new query shape.
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
|
||
|
|
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 |
||
|
|
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]> |
||
|
|
8c9b7503a1 |
MUL-6146 fix(rerun): stop cancelling an in-flight agent run on manual rerun (#7282)
* fix(rerun): stop cancelling an in-flight agent run on manual rerun (MUL-6146) RerunIssue cancelled every active task for the (issue, agent) pair before enqueueing the replacement, including 'running' and 'waiting_local_directory'. That made a manual rerun destructive: asking an agent for another pass silently killed the pass it was still working on. Neither status appears in idx_one_pending_task_per_issue_agent_v2, so a fresh queued row can be inserted alongside an executing one, and ClaimAgentTask's per-(issue, agent) serialization already holds it there until the active run reaches a terminal state. Narrow the cancel to rows that have not begun executing and let the queue do the rest. Rows that have not started are still cancelled and replaced rather than reused, which keeps the new run attributed to the rerunning member (MUL-4302 §5) instead of inheriting the attribution of whoever created the pending row. Co-authored-by: multica-agent <[email protected]> * fix(rerun): skip auto-retry when a manual rerun already holds the pending slot (MUL-6146) Letting a manual rerun queue behind a running task opened a collision the previous commit did not account for. If that running task then failed for a reason whose retry child is created immediately (timeout, runtime_recovery, codex_semantic_inactivity, skill_bundle_unavailable, or provider_network's first attempt), CreateRetryTask inserted a SECOND queued row for the same (issue, agent) and violated idx_one_pending_task_per_issue_agent_v2. That insert shares FailTask's transaction, so the unique violation rolled the parent's failed status back with it: the daemon's terminal callback saw a 500 and the task was left stuck in 'running'. Guard both auto-retry paths with hasRunnableSuccessor. When something is already pending for the pair, skip the retry — a runnable successor is exactly what the retry was for, and the rerun is it. The FailTask check runs inside the failure transaction so a concurrently committing rerun cannot take the slot between check and insert. runtime_offline and provider_network's final attempt were never affected: retryDelayForAttempt defers those, and a deferred child is outside the index. Co-authored-by: multica-agent <[email protected]> * fix(rerun): make the retry insert yield the pending slot instead of raising (MUL-6146) The previous commit guarded both auto-retry paths with a successor check and claimed the in-transaction check prevented a concurrent rerun from taking the slot. It does not: hasRunnableSuccessor is a plain count at READ COMMITTED and takes no lock, and rerun's cancel and enqueue are separate commits, so a rerun can always commit between the check and the insert. The unique violation then aborts FailTask's transaction, which also carries the parent's failed status, leaving the task stuck in 'running' — the very bug the guard was meant to fix. Move correctness into the insert: CreateRetryTask now yields via ON CONFLICT DO NOTHING, so it can never abort its caller's transaction. That makes the policy explicit — whoever already holds the slot keeps it, so a deliberate human rerun wins over an automatic retry. Both callers read the resulting zero rows as 'no retry created' and still commit their own work; this also stops the pre-existing workspace-teardown fence from aborting a failure the same way. For the reverse order, RerunIssue re-clears and enqueues once when a retry beat it into the slot, so the human action still wins. Bounded to one extra attempt. The successor checks stay as an optimisation, with comments corrected to say so rather than claiming a mutual exclusion they never provided. Co-authored-by: multica-agent <[email protected]> * fix(rerun): fence deferred promotion and recognise the normalized slot-taken error (MUL-6146) Two gaps left by the previous commit, both reachable now that a manual rerun can sit in the pending slot while a task is still running. Deferred retries escaped the conflict fence entirely. runtime_offline and provider_network's final attempt arm the retry as a 'deferred' row, which is not covered by idx_one_pending_task_per_issue_agent_v2, so CreateRetryTask's ON CONFLICT never fires and the row is created alongside the rerun. Promotion then flipped it to 'queued' unconditionally and hit the index — and because the claim loop promotes before it claims, that error blocked every claim on the runtime, starving the rerun the operator was waiting for. Both promote queries now skip a row whose (issue, agent) slot is occupied, and promote at most one row per pair so a single statement cannot collide with itself. Skipped rows stay deferred and promote on a later tick once the slot frees, so no retry is lost. RerunIssue's reclaim only matched the raw pgconn violation, but enqueueMentionTaskWithCommentPlan normalizes it to the bare ErrDuplicatePendingTask sentinel — the path taken by every rerun whose target is not the issue's current agent assignee (squad leader, an agent re-fired by task_id after being displaced, a mentioned agent). Those never entered the reclaim branch, so losing the race surfaced as a hard error. pendingSlotTakenErr now matches both shapes. Co-authored-by: multica-agent <[email protected]> * fix(rerun): cancel superseded deferred retries and tolerate promotion slot conflicts (MUL-6146) Keeps the one product invariant this change exists for: one rerun click means exactly one more run. The previous commit only declined to promote a deferred retry while the slot was occupied, and a test froze that as correct. It is not. Once the manual rerun starts running it stops occupying the queued slot, so the stale retry promotes and runs the agent a SECOND time — duplicate comments, duplicate side effects, duplicate cost, and nothing anywhere to signal it. That also contradicted this file's own 'a manual rerun replaces the pending plan' and FailTask's 'a runnable successor already exists, so no retry is needed'. A superseded deferred retry is now cancelled before promotion instead. Scope is deliberately tight — retry clones only (retry_of_task_id set), never assignee-fallback escalations or channel-media pending rows, which are supposed to coexist with an active primary. Promotion also tolerates the unique violation now. The in-query NOT EXISTS fence cannot see an enqueue that has not committed yet, so promotion can still collide; that is self-healing (the next tick sees the committed row) but the error used to fail the claim outright, and for the batch path it failed every runtime in the set. One contended row now skips promotion for a tick instead. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]> |
||
|
|
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]>
|
||
|
|
4bf3c73a15 |
feat(plugins): agent integration — skill resources, agent trigger, mcp transport (MUL-6350 PR 4) (#7242)
* feat(plugins): skill resources into the existing skill table (MUL-6350 PR 4, 1/3) A plugin's skill resource becomes an ordinary row in the skill table. Not a plugin-owned copy, not a bundle, not an artifact with a digest: the previous plugin system built all of that across fourteen tables and, at the end of it, delivered one SKILL.md that this table could already hold. The only thing the platform has to remember is which installation contributed which skill, so uninstall removes exactly those and nothing a person wrote. That is one nullable column. The upsert is scoped to rows this installation owns, so a plugin claiming a name somebody already used fails the install instead of silently replacing their work — and the test asserts the human's content survives. Upgrade prunes what the new manifest no longer declares, or a renamed skill would leave its predecessor behind with nothing to attribute it to. Fresh install is now a transaction too. It was a single INSERT; skills have to land in the same commit, because an installation whose skills half-arrived is worse than one that failed — the missing half is invisible. Skill resources flip on here rather than with the rest of PR 4: nothing executes, so it does not wait for the daemon-side MCP server that the agent trigger and mcp transport still need. Co-authored-by: multica-agent <[email protected]> * feat(plugins): agent trigger — hooks as MCP tools (MUL-6350 PR 4, 2/3) The fourth call site, and the only one that is not the host deciding to call something: an agent reads a tool description and chooses. That choice is why hooks may be reached from an agent at all — the alternative, a hook that must run before or after every turn, is a third party holding the product's main loop open, and no such position exists anywhere in this design. The daemon renders the tools but does not call the plugin. A tool call goes back to Multica, which makes the signed request. Two reasons, and the second matters more: the signing secret is derived from the deployment key and putting it on every laptop running an agent would hand out a credential that can impersonate the server to every plugin backend; and routing through the server keeps the rate limit, circuit breaker, `net:` destination check and invocation record on one code path for all four triggers instead of a daemon-side copy that would drift. A failing hook is a TOOL error, not a transport error. The agent reads it, works around it, and finishes the issue — an unreachable plugin endpoint must not fail somebody's task. The server returns 200 with a status for the same reason: a non-2xx would look to the daemon like the broker itself is broken. TestPluginToolNameSeparatorCannotBeForged caught a real flaw in my first naming scheme. Sanitizing both halves and joining with an underscore is not injective — a plugin key uses `.` and `-`, both of which have to become `_`, so `a.b` and `a-b` collapse together and `a.b_` + `c` collides with `a.b` + `c`. Two plugins would have been offering the agent one tool name. The prefix now carries a short digest of the full key rather than a lossy transliteration. Co-authored-by: multica-agent <[email protected]> * feat(plugins): mcp transport with pinned tool approval (MUL-6350 PR 4, 3/3) An `mcp` hook adopts tools from an MCP server the plugin author already runs. The difference from an `http` hook is who decides the shape: an http hook declares one endpoint in a manifest an administrator read, while an MCP server decides its own tool list at runtime. So installing the plugin is not the grant — an administrator discovers the tools and pins a specific set by name and schema digest, and a tool that appears later is not adopted until approved. Approved hooks reach the daemon as ordinary remotemcp.Connection values, so the existing broker proxies them and validatePinnedRemoteMCPTools already refuses to start when an approved tool went missing or its schema drifted. No new enforcement path. Plugin connections carry a `plugin:` contribution-id prefix. They share the broker and the resolver with a workspace's own Remote MCP connections but keep their credential in the Plugin's secret storage, which a different route serves, and the contribution id is all the broker hands back at dial time. Flips TransportMCP on in HostCapabilities, completing PR 4's capability set. Co-authored-by: multica-agent <[email protected]> * test(plugins): cover mcp connection startup via the dev-origin seam The `http` transport has had MULTICA_PLUGIN_DEV_ORIGINS since the hook engine landed: an operator names exact origins, and endpoints there skip the public-internet requirement so a plugin author can develop against a server on their own machine. The `mcp` transport never got it. Two symptoms, one cause. A plugin author could not point an mcp hook at a local MCP server, and — for the same reason — the daemon's connection startup could not be reached by a test, because it validates the endpoint as public HTTPS and the repo's MCP fixture serves plain HTTP on loopback. That left the layer carrying the entire approval guarantee at 0% coverage: startTaskRemoteMCPBrokers 0.0% -> 59.5% validatePinnedRemoteMCPTools 0.0% -> 100.0% The seam is off unless the variable is set, and a test asserts that: the same loopback connection is refused when it is empty. It skips only the public-internet requirement — the net: scope still applies, and the dial-time host pin still refuses a redirect that changes host. Tests cover what the approval is for: an approved tool the server dropped, an approved tool whose schema drifted, and an optional connection that fails without failing the task. Each was verified by breaking the guard it covers. Co-authored-by: multica-agent <[email protected]> * feat(plugins): a real example plugin, and the two bugs it found examples/plugins/deploy-sentinel is an incident-response plugin: it correlates an issue with recent deploys, files rollback requests under the team's own rules (refusing deploys past a window, and reasons too short to be evidence), pages on-call on issue events, and adopts an external metrics MCP server. Four triggers, both transports, and a skill that tells an agent when to use any of it. The other examples each demonstrate one mechanism. This one was written to find out whether the mechanisms compose, and it found two things that no unit test was ever going to: 1. mcp discovery could not succeed against ANY server. DiscoverMCPHookTools passed a nil protocol-version list, and Discover checks the server's answer against that same slice — empty means nothing is acceptable. Discover now falls back to SupportedProtocolVersions() when the caller passes none. 2. A hook handler had no way to read its own configuration. The manifest's config block drives a host-rendered form, the Action API has no config endpoint, and the hook body did not carry the values — so a plugin would have had to keep a second copy on its own server. The body now carries non-secret config. Secret-typed fields are excluded; they live in a separate table and are the plugin's credentials for its own services. Also lets an mcp dev origin trust MULTICA_PLUGIN_DEV_CA, mirroring the http transport. The manifest validator requires HTTPS, so without this a local MCP server could not be developed against at all. plugin_example_test.go installs the real manifest from examples/, not a copy, so a broken example fails the build rather than rotting quietly. Migrations renumbered to 368/369 after main advanced. 362 is added to the legacy-duplicate allowlist: two PRs took that prefix within the same hour and both merged, and the ledger keys on the full stem, so renaming one would re-run it everywhere it has already applied. main is red on this today. Co-authored-by: multica-agent <[email protected]> * fix(plugins): the deploy-sentinel example servers must serve HTTPS Both .mjs servers used node:http. A hook's transport URL has to be an https:// URL or the manifest fails validation, so the example as shipped could not actually be installed against a running Multica — the README told you to set MULTICA_PLUGIN_DEV_ORIGINS to https:// origins that nothing was listening on. Found by running it: install, config, skill contribution and live MCP tool discovery all work against the real servers once they speak TLS. They read TLS_CERT / TLS_KEY and the README carries the openssl one-liner. MULTICA_PLUGIN_DEV_CA changes which certificate is trusted; it never disables verification, which is why a self-signed certificate is needed rather than plain HTTP. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
7fdc854c26 |
fix(issues): copy durable local path for worktree tasks (MUL-6378) (#7176)
A worktree-mode local_directory task runs in a disposable git worktree that Finalize removes on every exit path, so the task's `work_dir` pointed at a directory that no longer existed. "Copy local workdir path" handed the user a path they could not `cd` into. The daemon now reports a `durable_work_dir` handoff instead of having clients infer one: - `removeLocalWorktreeDir` returns an error and confirms removal with `Lstat`, so a broken symlink cannot be mistaken for an absent directory. Finalize fails when removal cannot be confirmed. - `durable_work_dir` is written only after Finalize succeeds and the worktree is verifiably gone. A preserved worktree, an in-place run, a running task, a squad-leader task, and any older daemon all leave it unset, keeping the real `work_dir` authoritative. - The field travels on the complete, fail, and cancel-ack callbacks and is persisted on the task row (migration 362, additive nullable column). Writes use COALESCE for replay idempotency; the cancel-ack path CASes on `status = 'cancelled'` so a late ack cannot touch a completed or failed row. - The server derives `relative_durable_work_dir` through the same home-stripping helper as `relative_work_dir`; the absolute value is reserved for explicit clipboard actions and is never rendered. - Clients select the durable path only for a terminal task that carries the field. The issue actions menu and the transcript dialog share one resolver, and the label and toast say the task worktree was reclaimed, naming the delivery branch when one exists. The stored path is a point-in-time delivery snapshot: later renames or detachments of the project resource do not rewrite historical tasks. |
||
|
|
659bb41abd |
MUL-6350: rebuild the plugin system (3/4) — hook engine (#7182)
* feat(plugins): hook engine — signed outbound calls, trigger-decided identity (MUL-6350) The direction reverses here. PR 1 and 2 only ever ran inbound: a sandboxed surface asked the host, and the host acted on the signed-in user's session, so nothing left our infrastructure on a plugin's say-so. A hook leaves, and every new check exists because of that. Destination: the target host must be inside the granted `net:` set, resolve to a public address, and be re-checked at dial. Body: HMAC-SHA256 over timestamp.body, so a receiver can tell our call from anyone else's and a captured one expires. Identity follows the trigger, not the plugin. A ui/manual hook runs because a person pressed something, so its writes stay theirs; an event hook has nobody behind it and writes as the installation — hence the fourth author_type. Two credentials, moving opposite ways, which is what decides how each is stored. The install token goes plugin -> host and is only ever verified, so it is hashed. The signing secret goes host -> plugin and must be reproduced to sign with, so it is derived from the deployment key and stored nowhere. Event hooks never block the host: Bus.Publish runs listeners inline on the publishing request's goroutine, so the dispatcher hands off to a worker pool immediately. Three attempts, then a circuit breaker, then silence. Co-authored-by: multica-agent <[email protected]> * feat(plugins): manual hook actions, ui trigger, and a worked hook example (MUL-6350) The host owns the manual menu entry rather than letting a surface draw one, because the trigger decides identity: a manual call acts as the person who picked it, so the entry has to live where the host can prove somebody did. The ui trigger goes through the bridge for the same reason a surface cannot fetch its own backend directly — routing through the host is what makes the call signed, rate limited, bounded to the granted net: domains and recorded. buildActorNameResolver gains a plugin branch. Without it an event-written comment rendered as "System", which is the one attribution that is actively misleading: it reads as the platform having said something. examples/plugins/triage-notify carries the half that does NOT run in Multica — a reference handler showing the four things a receiver must do, including the replay guard the host cannot do for it. TestShippedExamplesParseAndInstallOnThisHost closes the gap that let an example drift: nothing checked that shipped documentation still parses, or that it declares only what this build can run. TestCheckCapabilitiesReportsEveryUnavailableContribution rewritten the way the other two were in PR 2 — it named today's unshipped set, so flipping ui/manual/ event on turned it red with a message about the flip rather than about the gate. It now derives its expectation from HostCapabilities and survives a flip while still failing if the gate stops checking a category. Co-authored-by: multica-agent <[email protected]> * feat(plugins): modal surface, invocation cleanup, honest capability guards (MUL-6350) TestShippedHostCapabilitiesRunTheSurfacesTheHostMounts caught me flipping the modal surface on without a renderer — an install that succeeds and then never appears, which is the exact failure that guard exists to prevent. Built the mount point rather than weakening the guard, and rewrote it to name the host code behind each line so the next flip has to change both together. plugin_invocation was missing from workspace teardown and from uninstall. TestWorkspaceDeletionManifestCoversPublicSchema caught the first; the second would have left rows naming an installation that no longer exists. Deleted by workspace_id rather than through installation ids, so a record whose plugin was already uninstalled does not outlive the workspace it describes. A modal opens because a person picked it from the issue menu, never on the plugin's own initiative — same sandbox as a panel, differing only in where it appears. Co-authored-by: multica-agent <[email protected]> * test(plugins): prove the event path never blocks, and contain worker panics (MUL-6350) Writing the never-blocks test found a real bug rather than confirming one. Bus.Publish recovers panics in its listeners so one bad handler cannot kill the request that published; moving delivery onto a worker goroutine stepped outside that protection, where a panic is not a failed hook but a dead process. runGuarded restores the guarantee the hand-off gave up. The bridge now takes an EventSink rather than the concrete dispatcher, so the vocabulary mapping — which internal event becomes which published one — is asserted directly instead of through a live endpoint. That mapping is a published contract; the internal names are not. The backpressure test stops the workers rather than racing them, so it reproduces a saturated pool without depending on timing, and asserts the exact overflow instead of "something was dropped". Co-authored-by: multica-agent <[email protected]> * fix(plugins): two hook-contract bugs a live run found (MUL-6350) Both were invisible to the tests because the tests made one call each, and a real handler does not. The callback token was single-use. The reference handler reads the issue, decides, then posts a comment — and the second call died on a spent token. Read-then-write is the floor for a handler that does anything with what it read, so a single-use grant made the callback unusable. The trade that decides it is not one call versus several. It is this token versus the installation's standing token: a handler that cannot finish with the callback gets written against the install token instead, which never expires and is not scoped to an invocation. A control that pushes authors toward the stronger credential is worse than the looser one they will actually use. The grant is now invocation-scoped — minutes, this installation's scopes, the actor fixed at dispatch — and revoked as soon as the call it was issued for returns. The hook body also omitted the issue. The host resolves and permission-checks it, then said nothing, so a handler had to read a client-supplied field — unvalidated for ui/manual, and absent entirely for event, where there is no client. issue_id now travels in the body. Also adds MULTICA_PLUGIN_DEV_CA: a hook URL must be HTTPS, so an author testing against localhost needs its certificate trusted by something. A named CA file rather than InsecureSkipVerify, which survives into production behind a config flag nobody remembers and cannot be spotted by reading the code. Co-authored-by: multica-agent <[email protected]> * fix(plugins): enforce the callback token's issue scope, sweep invocations (MUL-6350) Addresses Fabel 5's review. The first item is the one that mattered. The callback grant's issue narrowing existed only in two doc comments and the PR description — pluginTokenCaller never read grant.IssueID, so a ui/manual token was worth every issue in the workspace its actor could see, for the whole five minutes it lived, on read and write alike. Now carried into the caller and checked in pluginIssueForUser, after resolution so an identifier and a uuid for the same issue agree. 404 rather than 403: "you are scoped elsewhere" confirms the id exists. DeleteExpiredPluginInvocations was generated and never called, while the table comment and the PR both said TTL-swept. A sweeper now runs on the dispatcher's lifecycle — same concern, bounded resources for something a third party drives. Event subscriptions bypassed the read scopes. issue.* pushes the description and comment.created pushes the body — the same content the Action API refuses without issues:read / comments:read. Subscribing was a way to receive what reading was not granted. Enforced at manifest validation so the consent screen shows the read scope a subscription implies. Migration 349's ADD CONSTRAINT copied 107's shape onto a table that is no longer 107's size: it revalidated every comment row under ACCESS EXCLUSIVE. NOT VALID plus a 352 that VALIDATEs under SHARE UPDATE EXCLUSIVE costs nothing and stops nothing. Plus the half-built breaker UI (a plugin could be enabled and silently delivering nothing), redirect refusal on the dev hook client (a 302 would have replayed the signed body and the callback token onward), and omitting callback_url rather than sending a relative one. Co-authored-by: multica-agent <[email protected]> * fix(plugins): the invocation sweeper must not touch the database at construction (MUL-6350) CI panicked in cmd/server: the sweeper I added last round fired immediately on its own goroutine, and that test builds a router over a Queries whose pool was never opened. pgxpool dereferenced the nil pool and the process died. Two things were wrong, and only one of them is the obvious one. The sweep no longer runs at construction. It waited for nothing and gained one hour of retention on a cold start against a 7-day TTL; the cost was a goroutine reaching for the database before the process is known to have one. The nil check was also the wrong instrument. sqlc's Queries wraps an executor, so a non-nil Queries over an unopened pool passes every comparison available at that layer and only fails inside pgxpool — no amount of nil-checking here can see it. The guard is a recover, for the same reason runGuarded exists: on a bare goroutine a panic is not a failed sweep, it is a dead server. I had already fixed that class once for delivery and then reintroduced it in the sweeper. The regression test builds the exact shape CI hit — a dispatcher over db.New(nil) — and fails if either the construction-time sweep or the recover comes back. cmd/server cannot reproduce it locally, where DATABASE_URL supplies a real pool. Co-authored-by: multica-agent <[email protected]> * fix(plugins): gate the event dispatch path behind plugins_v1 (MUL-6350) Answering "is this still behind the flag" turned up the one place it was not. Every other plugin surface is refused by a handler that reads the flag off a request. An event hook has no request: the dispatcher is subscribed to the bus at startup and delivers on a worker. Nothing in it consulted the flag, so turning plugins off hid the UI and refused the Action API while the OUTBOUND calls kept going — fail-open on the one path that reaches a third party. It only bites after a plugin has been installed, because installation is flag-gated, so a deployment that never enabled plugins was never exposed. A workspace that enabled it, installed something, then turned it off was. The check reads the flag per delivery rather than once at subscription, so the flip takes effect immediately instead of at the next restart. It also removes the flag-off cost: every dispatched event used to run a ListWorkspacePluginInstallations query to discover there was nothing to call. Nil flags read as disabled — a deployment that never wired the flag service must not get outbound hooks by omission. The test lives in internal/handler because it needs a real installed plugin to mean anything: with the flag on the endpoint is called, with it off nothing leaves while the same installation stays enabled and still declares the hook. Removing the check makes it fail on the second half. Co-authored-by: multica-agent <[email protected]> * perf(plugins): stop parsing event payloads on the publishing goroutine (MUL-6350) Found while answering whether merging can affect existing users, and the answer was "in one measurable way it can". The bus listener passed issueIDFromPayload(e.Payload) as an argument, so a JSON marshal plus unmarshal of the full event payload — for issue.created that is the whole issue body, description included — ran inline on the goroutine of whatever request published. Seven event types, every workspace, and the feature flag was checked far downstream on the worker, which then threw the result away. A deployment with plugins switched off paid for all of it. The listener now hands the payload over unexamined and the id is extracted on the worker, past the flag check. It also fits the rule this file already claimed: the publishing request does the least it possibly can. Not moved: the status_changed lookup, which is a map read, not a parse. TestPublishingDoesNotParseThePayload counts MarshalJSON calls during Publish and fails if any happen — restoring the old argument makes it fail with the count. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
ea07a29138 |
perf(db): generate task message IDs as UUIDv7 (#7220)
Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
66af64b9a6 |
feat(issues): support filtering custom properties by "no value" (#7156)
Add a "No value" (__none__) option to the custom-property filter for
checkbox/select/multi_select/actor properties. Selecting it matches issues
where the property is unset — the inverse of picking a value — so teams can
express "everything that is NOT parked", e.g. exclude a `挂起` Hold checkbox.
Backend:
- parsePropertiesFilterParam compiles "__none__" into a
{"__none__": "<defId>"} marker; propertiesFilterPredicate renders it as a
key-absence disjunction (NOT (i.properties ? $n)) instead of jsonb
containment. The static ListOpenIssues unroll recognizes the same marker.
- Property table facets report an unset "__none__" bucket so the filter menu
shows a real count for the option.
Frontend:
- issueMatchesPropertyFilters matches unset issues when "__none__" is selected
(OR-ed with other values within the definition, AND-ed across definitions).
- PropertyFilterOptions offers a "No value" option for every property type;
filter chips render it via pickers.custom_property.no_value_label.
- i18n keys added for en/zh-Hans/ja/ko.
Co-authored-by: deo <deo@deo的macbook\032pro.tyaio>
|
||
|
|
5d5fe64672 |
MUL-6343: Add semantic issue activity timestamps (#7193)
* feat: add semantic issue activity timestamps Co-authored-by: multica-agent <[email protected]> * fix(migrations): register issue activity index cleanup Co-authored-by: multica-agent <[email protected]> * fix(issues): address last activity review Co-authored-by: multica-agent <[email protected]> * fix(issues): close last activity review gaps Co-authored-by: multica-agent <[email protected]> * fix(issues): harden activity rollout safeguards Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
3460f9aeaf |
MUL-6342: enforce entitlement-backed autopilot quotas (#7194)
* feat(autopilot): enforce entitlement run quotas Co-authored-by: multica-agent <[email protected]> * chore(deploy): expose entitlement policy config (#7195) Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(autopilot): address quota review findings Co-authored-by: multica-agent <[email protected]> * fix(autopilot): finish quota review nits Co-authored-by: multica-agent <[email protected]> * refactor(autopilot): simplify quota schema Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
4b8873df3a |
feat(issues): add revision-aware concurrency guards (#7038)
* feat(issues): add revision-aware concurrency guards * fix(concurrency): decouple text edits from aggregate revisions |
||
|
|
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]> |
||
|
|
7e92c58f53 |
fix(db): regenerate DingTalk sqlc output (#7177)
Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
6377dc2656 | feat(dingtalk): show linked identities to workspace admins (#7138) | ||
|
|
97195885fa |
MUL-6350: rebuild the plugin system (2/4) — Action API + Surface + SDK (#7157)
* MUL-6350 feat(plugins): Action API and via-plugin attribution (2/4, part 1)
The server half of PR 2. A surface can now read and write through Multica, but
nothing renders one yet — the iframe host, SDK and example plugin follow.
Every Action API call passes three checks in order:
1. the installation exists and is enabled — a surface left open in a stale
tab stops working the moment an admin disables the plugin, rather than
merely disappearing from the settings page;
2. the installation holds the scope the endpoint needs, and the refusal names
the missing scope so an admin can act on it;
3. the SIGNED-IN USER may touch the resource.
Three is what makes the model safe, and it is deliberately not implemented
here: the workspace comes from the installation row rather than a client
header, membership is checked against that workspace, and the issue lookup is
scoped to it. A plugin therefore inherits exactly the reach of the person using
it, with no second copy of the permission rules to drift.
There is no plugin credential anywhere in this path. The iframe holds no token;
it posts a message to the host page, and the host re-issues the call on the
user's own session with a header naming which installation is speaking.
comment.via_plugin_id is the other half of that identity story: permission-wise
a plugin-mediated write is the user's, so author_type/author_id are unchanged,
but audit-wise it stays attributable to the plugin that produced it. Modeled on
comment.quick_action_id — no request field sets it, so it cannot be forged by a
member posting normally.
Two deliberate narrowings, both additive to widen later:
- PATCH issues covers title and description only. Status, priority, assignee,
parent, project and stage carry dispatch, catalog or hierarchy semantics
that would need a second, drifting copy of those rules here.
- A plugin-posted comment does not run @mention trigger dispatch. A plugin
able to post a mention could start agent runs, and spend the workspace's
budget, from a surface the user only meant to click a button in.
issue_panel and sidebar_panel flip on in HostCapabilities so an install stops
failing on a surface the host is about to be able to render.
Co-authored-by: multica-agent <[email protected]>
* MUL-6350 feat(plugins): surface host, bridge and SDK (2/4, part 2)
The half a user can see. An installed plugin's issue_panel now renders on the
issue page and can read, write and store through the Action API landed in the
previous commit.
Isolation reuses the convention already in this repo for untrusted HTML
(packages/views/editor/code-block-iframe.tsx): sandbox="allow-scripts" and NOT
allow-same-origin, which the HTML spec calls out as the pairing that defeats the
sandbox. The frame gets an opaque origin — no cookies, no localStorage, no
access to the embedder, and no storage shared between two plugins. That is why
no separate plugin origin domain is needed.
The host generates the document rather than pointing the frame at the author's
HTML. That is what makes the net: scopes real: CSP is decided by whoever emits
the document, so an author-emitted page would carry its own server's policy and
the scope the admin approved would be a claim nobody checks. Here connect-src is
derived from the granted scopes, and a plugin with no net: scope gets
connect-src 'none' — it cannot phone home at all.
Identity on the bridge is bound by a MessagePort, not by event.origin: every
sandboxed frame reports the opaque origin "null", so comparing origins would
authenticate nothing. The host creates one channel per iframe, transfers one end
in, and only listens on the other.
Also here: @multica/plugin-sdk (zero runtime deps, no @multica/core or
@multica/ui import, since it ships to third parties), the hello-panel reference
plugin, and the bridge's own refusals — a path outside the Action API never
becomes a request, and a resize is clamped.
A local: install cannot render a surface: its files live on the server's
filesystem with no URL a browser could fetch, and the panel says so rather than
showing an empty frame.
Tests cover what a reviewer cannot see by reading the components: the CSP
derived from scopes, entry resolution refusing local and plaintext sources,
attribute escaping, the sandbox attribute never gaining allow-same-origin, the
bridge's path allowlist and status pass-through, and disabled installations not
mounting.
Co-authored-by: multica-agent <[email protected]>
* MUL-6350 fix(plugins): give the SDK package a vitest config and protocol tests
A new workspace package inherits the repo's `test` script, so `vitest run` ran
with no config and no test files and exited non-zero — frontend-test went red
on a package that had nothing wrong with it.
Rather than only adding `passWithNoTests`, the guards now have the coverage they
warrant: they are the only thing between a hostile message and the bridge
handlers on both sides, so anything that is not exactly the agreed shape has to
fall through instead of being coerced into something close enough. Includes the
response/event mix-up, which would otherwise resolve a pending call with a theme
payload or apply a response as design tokens.
Co-authored-by: multica-agent <[email protected]>
* MUL-6350 fix(plugins): make the surface flow actually work end to end
Running a real plugin against a live stack found five defects that every test in
this PR passed straight through. Each one produced the same symptom — a blank
panel — and none was visible from reading the code.
1. The handshake raced in both directions. The host connected on the iframe's
load event, which for a srcdoc frame can fire before React attaches the
handler; and a guest that announced once could announce before the host was
listening. The surface now announces until it is answered, and the host
matches event.source against the frame it created. Origin cannot be used for
this — every sandboxed frame reports "null".
2. The bridge was reused across a document change. close() is terminal, and the
theme effect changed srcDoc, so the second connect() returned immediately on
a permanently closed bridge. One bridge per rendered document.
3. The theme was read on first render, when the ref is still null, so every
surface was built with no tokens at all.
4. X-Multica-Plugin-Installation was missing from corsAllowedHeaders, so the
preflight failed and every Action API call from a browser client died as
"Failed to fetch". The comment above that list warns about exactly this.
5. activity_log kept a via_plugin_id column in generated code from an earlier
draft of migration 348 that no longer creates it — every issue timeline
returned 500. Regenerated.
Also from seeing it rendered: frame-ancestors is dropped from the meta CSP
(ignored there, and the browser warns), and string config fields can declare
multiline so a value that is a list of lines gets a textarea instead of a
single-line input the user cannot read.
MULTICA_PLUGIN_DEV_ORIGINS lets an operator name an origin a manifest may be
served from while a plugin is being built, and a surface entry may resolve over
plain HTTP from loopback — what browsers already treat as a secure context.
Without both, a surface cannot be developed at all: there is no way to serve one
over public HTTPS from a laptop.
examples/plugins/release-checklist is the plugin this was verified with: a
per-issue definition-of-done checklist with workspace-configured items,
per-issue state in plugin storage, strict-mode gating, and a sign-off comment
that lands as the user with via_plugin_id set.
Co-authored-by: multica-agent <[email protected]>
* MUL-6350 fix(plugins): close the bridge hijack, fix the capability gate, sync the comment path
Review fixes for #7157.
The security one first: the SDK accepted a bridge-init from any window and let a
later init replace the port. Sibling surfaces are mutually opaque, but
`parent.frames[i]` is an allowed cross-origin access — so another plugin on the
same issue page could deliver its own MessagePort, become a surface's "host",
feed it fabricated data and read everything it wrote, including whatever it put
in storage:user. The host already made exactly this check on the readiness
signal; the guest half was missing. Both examples carried the same hole, and
examples get copied.
The capability gate had flipped to enabling sidebar_panel with no mount point
anywhere — a plugin declaring one would install cleanly and then never appear,
which is the silent failure the gate exists to prevent. Turned back off, and a
test now asserts the shipped set matches what the host actually mounts, so the
next flip has to come with its renderer.
The two red tests were red for a real reason but were also written to break on
any capability flip. Rewritten to test the thing rather than today's set: the
message is asserted against an explicit empty Capabilities{}, and the live gate
against a kind that genuinely is not shipped (a hook). Added the other half —
a shipped surface must install against the real HostCapabilities.
A plugin-posted comment now publishes comment:created and re-opens a resolved
thread, like every other comment path. Skipping mention dispatch was deliberate;
skipping these two was not, and it is why the demo needed a page reload to see
the comment appear.
Corrected an overclaim rather than pretending to fix it: "no net: scope means it
physically cannot send data out" was wrong, because script-src necessarily
allows the author's own origin. net: bounds THIRD-PARTY destinations. img-src
and font-src are narrowed to inline data, which removes the two side channels
that need no scripting at all; the rest cannot close without re-hosting
third-party code, which this design rules out.
Smaller ones from the same review: the surface-error listener now checks window
identity too (any frame could light every panel's failure banner), the bridge
takes a method allowlist instead of any string, surfaces[].platforms is enforced
rather than decorative, and the iframe is keyed on the issue so a bridge created
for a new issue is not left waiting on a guest that already stopped announcing.
Co-authored-by: multica-agent <[email protected]>
* MUL-6350 fix(plugins): drop the SDK's jsdom dev dependency and stub the window
`pnpm add -D jsdom` wrote a partial lockfile update: it resolved against a warm
local store, so `--frozen-lockfile` passed here and failed in CI on a vitest
peer key that was never written.
Rather than re-resolve, the dependency is gone. The SDK touches exactly
`window.addEventListener` and `window.parent`, so the handshake tests run on a
hand-built EventTarget in the node environment. A package that ships to third
parties is better off carrying no test-only dependency at all, and the tests
still fail without either guard they cover.
Co-authored-by: multica-agent <[email protected]>
* MUL-6350 fix(plugins): keep a test runner out of the SDK package
A new importer that pulls vitest through the catalog resolved to a peer key CI
could not satisfy on a clean install, so every frontend job died in
`pnpm install --frozen-lockfile` before running anything. Two attempts to
re-resolve it locally produced a lockfile that looked correct here and stayed
broken there — my store is warm, CI's is not.
So the SDK stops carrying a test runner at all. It ships as TypeScript source to
third parties and now declares only typescript plus the two workspace configs;
its handshake and protocol tests move to packages/views, its only consumer in
this repo, where the toolchain already exists. The lockfile delta for this whole
PR is now four workspace links and a typescript pin — nothing that can resolve
differently on a cold install.
The tests themselves are unchanged and still fail without the guards they cover.
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
|
||
|
|
6a17c00144 |
MUL-6350: rebuild the plugin system (1/4) — cleanup and foundations (#7144)
* MUL-6350 refactor(plugins): reset the plugin system to Action / Hook / Resource foundations The V1 plugin system shipped 14 tables, an append-only revision log per relationship, a capability-snapshot compiler, a per-task execution manifest pinned by an INSERT trigger on agent_task_queue, release signing, trust tiers, and a bundled catalog — and never left its feature flag. It holds no production data, so this removes it outright instead of migrating it forward. What replaces it, per the design in MUL-6338: - A plugin relates to Multica in exactly three ways. Action (plugin calls Multica), Hook (Multica calls plugin), Resource (a static contribution with no call at all). "Who triggers" and "what capability is called" are orthogonal: a hook is declared once and lists which triggers may invoke it. - We never execute third-party code. A plugin runs only in a sandboxed iframe in the user's browser or on the author's own server. - Distribution is "install by URL, admin reads the scopes". No signature, no trust tier, no publisher verification — the scope list IS the trust decision. This change is the foundation, with no user-visible surface yet: - Decouples remote MCP first. RemoteMCPConnection/RemoteMCPTool/DigestBytes move into pkg/remotemcp, where they belonged; the protocol layer and daemon broker survive intact and dormant, ready to be rewired for the agent trigger. - Drops all 14 plugin_* tables, their triggers, the manifest-composition function, and agent_task_queue.plugin_execution_manifest_id. Adds three: plugin_installation (one row per workspace+plugin, holding the manifest snapshot the admin consented to), plugin_storage, plugin_secret. - Defines the manifest schema in full, once — surfaces, hooks with their triggers and events, resources, and a closed scope list. Parsing stays strict (DisallowUnknownFields, no trailing JSON). Contribution kinds this build does not ship yet fail installation loudly rather than installing half-working. - Two-step install: preview parses and returns the scopes without writing, then install requires consent matching the manifest exactly. Manifest fetches reuse the remote MCP SSRF guard; MULTICA_PLUGIN_DIR adds a local source for self-hosting and plugin authors. - Storage quotas are enforced on write and never by eviction. Secrets live in a separate encrypted table with no read path that returns a value. Co-authored-by: multica-agent <[email protected]> * MUL-6350 fix(migrate): register the plugin index builds for invalid-index cleanup Every migration that builds an index CONCURRENTLY must be registered in concurrentIndexCleanups, or an interrupted build leaves a permanently INVALID index that the `IF NOT EXISTS` retry records as success. TestEveryConcurrentUp\ BuildHasCleanup caught all three new plugin index migrations. Co-authored-by: multica-agent <[email protected]> * MUL-6350 fix(plugins): address review — byte quotas, version bound, secret pruning, script entry From the review on #7144, plus one contract change the surface design forced. - Storage quotas were accounted in characters and enforced in bytes. The usage query and the size_bytes it reports now use octet_length, and so do the plugin_storage column checks. A UTF-8 value could otherwise spend up to a quarter of the budget it appeared to. - manifest version had no length bound while plugin_installation.version caps at 64, so a legal-but-long semver failed at INSERT as an opaque install error instead of a parse error naming the field. - Upgrading pruned config values the new manifest dropped but left the matching plugin_secret rows. Ciphertext nothing can reach is exactly the residue pruneConfig exists to prevent; upgrade now prunes both in one transaction. - SetConfig wrote secrets and config across two tables with no transaction, so a failed config write could leave a saved form half-applied. - A duplicate first install mapped a unique-index violation to 502; it is 409. - net: is now an exact host match. It was a suffix match in the hook transport check while the same scope becomes an exact-host CSP connect-src in the surface runtime — one scope string cannot mean two things across the two places the consent screen claims to describe. - surfaces[].entry must be a .js/.mjs script, not an HTML document. The host generates the surface's document so it can attach the CSP derived from net:; a plugin-authored document would carry its own server's policy and net: would be a claim rather than a control. Tests: the three storage limits get a canonical unit matrix plus DB-backed coverage of the byte counting and the exclude-the-candidate-key replacement semantics; upgrade-time secret pruning is pinned end to end (verified failing without the fix); and the admin-only routes now have a real middleware-level 403 check, which every direct-handler test bypassed. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
e3a40b0b8d |
feat(MUL-6243): custom issue status UI — picker, board, filters, settings (#7103)
* refactor(views): widen IssueStatus to open key, resolve category at render sites Board columns, swimlane columns and hidden-column panels are CATEGORIES, not status keys, so adding a custom status never adds a column. Retyped those to IssueStatusCategory and made every presentation lookup (STATUS_CONFIG, i18n labels, status icons) resolve the key to its category first. Status filters stay in concrete keys and map back to columns via the catalog. MUL-6243 Co-authored-by: multica-agent <[email protected]> * feat(views): catalog-driven status picker and per-status labels The picker now offers the workspace catalog's active statuses grouped by category, instead of the 7 hardcoded built-ins, and falls back to the built-ins until the catalog lands so a cold render is unchanged. Built-in names always resolve through i18n, never through the catalog's server-seeded English name — otherwise every non-English workspace would see 'In Progress' where it used to see '进行中'. Custom names come from the catalog. That rule lives in one place: useStatusLabel. Applied it to every surface that shows an issue's OWN status (detail activity feed, table group headers and CSV export, hover card, inbox), which previously showed the category label and so could not tell two statuses in the same category apart. MUL-6243 Co-authored-by: multica-agent <[email protected]> * feat(views): show custom statuses on cards and in filters Board columns and list sections are categories, so two issues in the same column can sit on different statuses with nothing to tell them apart. CustomStatusChip is that signal, and it renders nothing for a built-in — a workspace with no custom statuses looks unchanged. The status filter now lists the workspace catalog instead of the 7 hardcoded built-ins. Without this an issue moved onto a custom status could not be narrowed to at all. Picker and filter share one useStatusOptions hook so the two lists cannot drift apart. MUL-6243 Co-authored-by: multica-agent <[email protected]> * feat(settings): issue status catalog management with drag reorder Owner/admin CRUD for custom statuses, organised by CATEGORY rather than as one flat list. Category is not decoration here — it is the behavior a status inherits, and picking it is the one moment an admin can, since it is immutable afterwards. Built-ins are shown but locked: each is its category's canonical definition. Drag reorder writes intra-category positions starting at 1, because the built-in is seeded at 0 and PATCH on it is a 403, so it always heads its category. The optimistic cache write re-SORTS rather than only writing positions, or the drag would visually undo itself until the refetch. custom_issue_statuses is now published to the frontend, so the tab can hide creation when the deployment has the rollout gate off instead of offering a button that 403s. MUL-6243 Co-authored-by: multica-agent <[email protected]> * fix(issues): close the category contract end to end Elon's review found the board only half-converted: the UI targeted categories while the server still paged by concrete status key, so a card on a custom status was dropped twice — it never came back from the server, and the row filter would have discarded it if it had. 1. New `status_category` group kind (and compound secondary axis). Grouping rewrites the column with a static CASE built from the catalog; paging expands the category to concrete keys and stays on `status = ANY(...)`, so (workspace_id, status) is still usable. A workspace with no custom statuses gets exactly `i.status` back. Row filter, facet totals and swimlane cells all fold by category now. 2. Column visibility split from the status filter. Hiding a column used to write the other six built-in keys into statusFilters, so hiding Backlog silently dropped every CUSTOM status in other categories. Display state is now its own field. The saved-view baseline also stopped deleting custom status filters on reopen. 3. The board/list right-click menu reads the shared catalog options, so every entry point that can set a status offers the same set. 4. Remaining presentation consumers resolve a category instead of guessing: gantt bars were keyed by IssueStatus and went colorless on any custom key; hover card, sidebar, search, tab strip, inbox row, create toast and the activity feed all resolve explicitly now. 5. Reorder is one atomic statement behind a new endpoint, and the client submits ACTIVE rows only. The per-row PATCH loop half-committed when an archived row rejected mid-sequence. Also: one catalog observer per card instead of three, and the stale tasks/todo.md draft is gone. MUL-6243 Co-authored-by: multica-agent <[email protected]> * fix(issues): atomic reorder, category behavior sweep, rollout-safe grouping Second review round. 1. Reorder is now one transaction holding the catalog's SHARED lock. The old shape validated ids, THEN ran the UPDATE — an archive committing in that window left the UPDATE silently skipping the archived row while the earlier rows were rewritten, and still returned 200. The authoritative active set is read under the lock and the payload must match it EXACTLY: a partial order would assign positions from the array index and collide with the rows it left out. Affected rows are checked against the payload before commit. 2. Swept the Web behavior consumers that still compared raw keys, so a custom category actually inherits behavior: gantt hide-completed, batch backlog parking, done/cancelled strikethrough, the mark-done button, sub-issue progress, search cancelled-demotion and mention demotion/dimming. issueBehavesAs is the one predicate they share, and it fails toward showing more and asking more. Two glyph sites (gantt row, swimlane lane) were still painting custom in_review as Todo. 3. Rollout safety. status_category is a contract this PR introduces, and the default-off creation flag does not guard the request path, so a new Web build would have 400'd against any un-upgraded pod — for every workspace. The client now sends it only once the catalog confirms the workspace HAS a custom status, which can only be true if the fleet already serves this version. Everyone else keeps the exact request they made before. That also uncovered a real defect: plain status grouping 500'd on a workspace holding custom statuses, because the descriptor rejected any non-built-in value and failed the whole response. 4. One catalog read per resolve instead of three, and the common path reads none at all. Pinned with observable scan-count tests rather than an assertion that the SQL string matches. 5. A custom-key filter no longer routes on a guess. categoryOf answers todo for anything unloaded, so a saved qa filter paged the todo column while the query still restricted status=qa — empty on every cold load, permanent if the catalog fetch failed. Unresolved custom keys now contribute no column until the catalog is authoritative; facet counts drop unknown keys rather than inflating todo. MUL-6243 Co-authored-by: multica-agent <[email protected]> * fix(issues): swimlane category axis, catalog tri-state, tests with teeth Third review round. 1. Swimlane was only half-converted. The compound spec sent secondary=status_category unconditionally — the same rolling-deploy hole the board/list branches had — and the client row matcher then compared the raw key, so a qa row the server correctly folded into the in_review cell was thrown away again. Both now follow the same hasCustomStatuses switch, and the matcher knows which axis it is on. 2. The cold-load fix did not actually make the surface wait. Returning an empty column set is indistinguishable from "narrow to nothing", so the surface fetched nothing and rendered an empty board with no spinner; on catalog failure that was permanent, with no retry. Resolution is now pending / resolved / error: pending and error suspend fetching AND hold loading, error surfaces isStatusCatalogError. Fetching is suspended rather than left to fall back to all columns, which would briefly show the UNFILTERED board to someone opening a saved qa view. The group contract also joins the branch cursor identity, so a status cursor cannot ride into a status_category request. 3. Two tests were false-green and are replaced: - The reorder race archived BEFORE calling reorder, so the active-set check caught it even with the lock deleted. It now reproduces the real interleaving (reorder parked on the lock, archive commits, then reorder proceeds) and fails without the shared lock — with a 200, which is the actual hole. - The catalog read count read pg_stat_user_tables, whose counters flush asynchronously, and asserted delta <= 1 — vacuous at zero. It now counts through an injected querier and asserts exactly 1 read and 0 category-expansion reads. Also: plain status grouping 500'd on a workspace holding custom statuses (the descriptor rejected non-built-in values and failed the whole response) — that is the path the Table view and every client fallback use. And the chat mention items dropped status_category, so a custom done status in Current/Recent rendered as an active Todo. MUL-6243 Co-authored-by: multica-agent <[email protected]> * fix(issues): give the status catalog failure a real user path Fourth review round. One group, both halves of it. A failed status catalog suspends row fetching, because a custom status filter cannot be routed to a column without it. That left isLoading and isEmpty both false, so IssueSurface fell straight through to the content branch and rendered an empty view with no explanation and no way out — the controller's isStatusCatalogError had no consumer. The catalog now carries its own `retry` (React Query's refetch) rather than just a failure flag, the controller re-exposes it, and IssueSurface renders an explicit error state ahead of loading/empty/content. Component test covers the full loop: catalog rejects, error appears with no row requests issued, clicking retry succeeds, fetching resumes. The blocking condition is also narrowed. A BACKGROUND refetch can fail while the last successful catalog is still cached, and error-before-loaded meant that perfectly serviceable snapshot got a retry screen in front of it. Only a failure with no data behind it blocks now; otherwise the last successful catalog keeps resolving. That rule lives in status-behavior.test.ts, where it can be asserted directly. The component test asserts the wiring — it was originally written to claim the rule too, but reverting the narrowing did not fail it, so the claim moved to the layer that actually holds it. MUL-6243 Co-authored-by: multica-agent <[email protected]> * fix(issues): accept custom status keys in table filters.statuses Filtering a board, list or table by a custom status 400'd the whole request: `filters.statuses` carries the exact keys the user picked, and the table query still validated that list against the 7 built-ins. Every surface errored out instead of narrowing — the one thing the filter work in this PR exists to do. The predicate is a parameterized `status = ANY(...)`, so an unknown key simply matches nothing; the check is now non-empty and length-bounded. Found while writing the post-deploy verification steps, which is where the gap became obvious: "filter by QA" was step one and it 400s. MUL-6243 Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
a1cbd7c2ef |
MUL-6354: tighten build and generation guardrails (#7150)
* chore(build): tighten repository hygiene guardrails Co-authored-by: multica-agent <[email protected]> * chore(ci): remove snapshot ignore check Co-authored-by: multica-agent <[email protected]> * chore(ci): address repository hygiene review nits Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
afd05ca221 |
MUL-6063: recover terminal delegated task failures (#6844)
* fix: recover terminal delegated task failures Co-authored-by: multica-agent <[email protected]> * fix(tasks): make delegated failure recovery durable Co-authored-by: multica-agent <[email protected]> * fix(tasks): bound delegated failure recovery Co-authored-by: multica-agent <[email protected]> * fix(tasks): address delegated recovery review nits Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Eve <[email protected]> Co-authored-by: multica-agent <[email protected]> Co-authored-by: Sol-Boy <[email protected]> |
||
|
|
22db0b0a2c |
MUL-5651: preserve tasks through runtime network partitions (#7129)
* fix(runtime): preserve tasks through network partitions Co-authored-by: multica-agent <[email protected]> * fix(runtime): bound reconnect retry waiting Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
14c2e4e831 |
MUL-6243 feat(issue-status): board fetches by category; archive retires without migration (#7084)
* feat(issue-status): board fetches by category; archive retires without migration (MUL-6243) Product decisions from the issue thread: 1. Board fetches by CATEGORY, not per status. PAGINATED_STATUSES becomes PAGINATED_CATEGORIES and the cache buckets by category, so the board's fan-out stays fixed at 7 parallel requests however many custom statuses a workspace defines — instead of one more request per status added. Adds status_category / status_categories filters to the issue list endpoints, backed by the existing issue_effective_status() SQL function. 3. Archiving retires a status from FUTURE use only. It no longer refuses while issues still carry it: those issues keep their status and keep resolving to their category's behavior (Effective ignores archived_at), while Resolve still rejects the status so nothing new can be assigned to it. The exclusive catalog lock stays — it is what makes "no NEW assignment after archive" exact rather than approximate. Also lands the frontend catalog foundation: packages/core/issue-statuses with useIssueStatuses(wsId) (React Query, keyed by wsId per the state rules) and a pure buildIssueStatusCatalog for non-React callers, plus IssueStatusCategory as a distinct type from the status key and status_category on issue payloads. NOT included, and deliberately: widening IssueStatus to string. That change forces every Record<IssueStatus, …> presentation lookup in packages/views to resolve a key to its category first, and it is the first task of the follow-up PR. Everything here is the groundwork that makes it mechanical. Co-authored-by: multica-agent <[email protected]> * fix(issue-status): restore the status index, make category authoritative, keep archived resolvable Review round 1 on #7084 (MUL-6243): 1. Category filtering wrapped issue.status in issue_effective_status(), which makes the (workspace_id, status) index unusable — and the board switched to it unconditionally, so every workspace paid for it immediately. Categories are now expanded to concrete status keys (issuestatus.ExpandCategories) and filtered with an indexed status = ANY(...). Measured on a 100k-row table with the production index: seq scan cost 27013 -> index-only scan cost 6.07. A workspace with no custom statuses expands each category to exactly its own key, reproducing the pre-feature query. Archived keys are included, since issues left on them still belong in their category's column. 2. status_category was only filled for built-ins, so a custom-status issue arriving over the websocket was silently dropped by addIssueToBuckets and a cross-category update was skipped by the cache coordinator, drifting off-window totals permanently. List, grouped, get, create, update and batch responses now carry an authoritative category via a per-request Resolver (archived included), and the client invalidates instead of no-oping when a category is unresolvable. 3. The catalog was fetched without archived entries, so after archiving a status the issues still on it lost their name and colour and had their category guessed as todo — contradicting the decision that archiving leaves existing issues untouched. Resolution now includes archived entries; activeStatuses / inCategory expose only assignable ones to pickers. Co-authored-by: multica-agent <[email protected]> * fix(issue-status): category on every payload exit, one resolver per request, no same-category duplication Review round 2 on #7084 (MUL-6243): 1. Category fill was incomplete and N+1. The create WEBSOCKET payload was unfilled — only the creator's HTTP response was, which is too late for the other tabs the event exists to serve. GetIssue, the open_only list, search, the duplicate-conflict response, the table rows, the GitHub PR update and the onboarding shim were also unfilled. Grouped and batch called the single-response helper per row, so each custom-status row hit the catalog separately. Every exit now goes through newStatusCategoryFiller, a request-scoped closure over ONE Resolver: built-in statuses still cost no query, and a page of custom-status rows costs one catalog read rather than one per row. 2. A status KEY change within one category duplicated the card and inflated the total. patchIssueInBuckets compared patch.status (a key) against loc.status (a bucket CATEGORY), so in_review -> a custom human_review took the cross-bucket path, then deleted from and re-inserted into the same bucket from a pre-delete snapshot. It now resolves the destination category first and compares category to category; same-category changes replace in place and leave the total alone. The destination category is resolved from the patch alone — the merged issue inherits the previous status_category, which would have kept a genuinely moved card in its old column. patchNeedsInvalidation lets the loaded branch of the cache coordinator invalidate instead of silently no-oping on a status it cannot resolve. Also drops the unrelated share_link.sql.go churn from an sqlc version difference, per the review nit. The four cache cases were verified to fail against the previous logic (card duplicated, total +1) before the fix was restored. Co-authored-by: multica-agent <[email protected]> * fix(issue-status): normalize status_category on every cache write; one filler per create Review round 3 on #7084 (MUL-6243): Must-fix — a cached entity kept the STALE status_category after a status change. The server now sends a category on every issue, so a cached entity is {status: "todo", status_category: "todo"}; an optimistic patch carries only {status: "done"}, and a bare {...issue, ...patch} moved the card to the done bucket while the entity still claimed todo. A single update self-heals from the server response, but the batch API returns only {updated: n} and does not refetch bucketed lists, so the inconsistency was permanent — and the next off-window count then decremented the wrong bucket, recreating total drift. normalizeStatusPatch now rewrites status_category to match status before any cache write, applied at applyIssueChange's entry (covering the flat, table and detail spreads), in patchIssueInBuckets, and in the children mutation. An unresolvable custom key drops the inherited category rather than keeping it; patchNeedsInvalidation still routes it to a refetch. Test fixtures now carry status_category for built-ins, matching what the server actually sends — without that the regression was invisible. Non-blocking items also addressed: - CreateIssue builds one filler and shares it between the broadcast payload and the HTTP response, so a custom-status create reads the catalog once. - Background events via IssueToMap now resolve an authoritative category (IssueToMapWithCategory) for the task, issue and autopilot publishers. The channel router keeps plain IssueToMap and says why: it always creates with the built-in "todo", where key IS category. The "one resolver serves a whole page" assertion was REMOVED rather than kept: pg_stat counters lag, so a delta around one request passes even with a per-row filler. Verified that directly before dropping it. The comment now states what is and is not pinned instead of implying coverage that does not exist. Co-authored-by: multica-agent <[email protected]> * fix(issue-status): address review nits - Normalize the single-update children optimistic patch too, matching the batch path. It self-healed on onSettled, but it broke the entity/bucket invariant for the duration of the request. - Rename TestListEndpointsCarryStatusCategory: the old name claimed it covered "without per row queries", which the test explicitly does not assert. - Drop the unrelated gofmt churn in empty_claim_cache.go. - Add a background-event assertion: IssueToMap leaves a custom status uncategorized, IssueToMapWithCategory resolves it — the reason the publishers use the latter. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
446080bd57 |
MUL-6243 feat(issue-status): per-workspace custom issue statuses over 7 canonical categories (#7065)
* feat(issue-status): per-workspace custom issue statuses over 7 canonical categories (MUL-6243) Co-authored-by: multica-agent <[email protected]> * fix(issue-status): address review — normalize all status consumers, close archive race, gate rollout, split PK index Review round 1 (MUL-6243 / PR #7065): 1. Behavior normalization was incomplete. Adds issue_effective_status() as a SQL mirror of issuestatus.Effective so the six SQL consumers the Go resolver cannot reach (duplicate guard, open-issue list, sub-issue progress, project stats, completed-inbox archive, open_only) resolve categories too. Wires the resolver into the task-failure sweeper and the stage-barrier helpers. 2. Archive TOCTOU. Archive now runs its in-use census and the archive itself in one transaction under an exclusive catalog lock; writes that target a custom status take the shared side. Built-in writes stay lock-free — a built-in can never be archived. 3. Rollout safety. Creating a custom status is gated behind the default-off CustomIssueStatuses flag, so a value old pods cannot interpret cannot exist until the fleet is updated. Readers ship ungated and are safe. 4. Primary key index. Follows the repo convention (327-329): table without a PK, unique index built CONCURRENTLY in its own migration, attached via PRIMARY KEY USING INDEX, and registered for invalid-index cleanup. 5. Status normalization vs stored value. Write paths now persist the canonical key from Resolve, so 'HUMAN_REVIEW' no longer validates and then fails the column format constraint as a 500. Also fixes the two CLI tests failing in CI: they asserted the pre-MUL-6243 contract that an unknown-but-well-formed status is rejected locally. Co-authored-by: multica-agent <[email protected]> * fix(issue-status): close the archive race in both orderings; resolve categories in batch barrier and CLI progress Review round 2 (MUL-6243 / PR #7065): 1. The archive race was only half closed. Handlers resolved the status BEFORE opening a transaction, so an archive could commit in that window and the write would still land on an archived key. assertIssueStatusStillActive now takes the shared catalog lock and RE-RESOLVES inside the caller's transaction, which is what actually makes the status provably active at write time. Applied to create, update, batch, and both description-merge branches, which previously bypassed the guard entirely. A losing writer gets 409 rather than a silent partial update. 2. The batch stage-barrier prefilter still compared literals, so a batch that moved the last child onto a custom done status left childDoneCompleted empty and skipped the parent notification. It now resolves categories first. 3. multica issue children --output json counted only literal done/cancelled. ListChildIssues now emits status_category and the CLI reads it, falling back to status for older backends. TestArchiveCommitsInsideTheWriteRaceWindow interleaves the two operations so the writer validates against an active status and the archive commits before the write. Verified it fails without the re-resolve (issue stranded on the archived status) and passes with it. Co-authored-by: multica-agent <[email protected]> * refactor(issue-status): address review nits — honest race-test naming, one catalog read per list Review round 3 nits (MUL-6243 / PR #7065): 1. TestArchiveRaceBothOrderings claimed more than it proved. Its 'archive wins' cases archived first and only then sent the request, which the pre-flight check alone rejects. Split into TestWritesRejectAnArchivedStatus (the sequential case, named for what it is) and TestArchiveRefusesAfterACommitted Write, and extended TestArchiveCommitsInsideTheWriteRaceWindow — the only test that truly interleaves archive between pre-flight and write — from create alone to all five write endpoints. Re-verified by deleting the in-transaction re-resolve: 4 of 5 fail with issues stranded on the archived status (create is guarded in the service layer and checked separately). 2. ListChildIssues / ListChildrenByParents issued one category lookup per custom-status child. issuestatus.Resolver now amortizes that to a single catalog read per request, lazily on the first custom key, so built-in statuses still cost zero queries however long the list is. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
8cf2acbaa7 |
feat(share-link): workspace share-link invite system (#6833)
Adds workspace share-link invites: owner/admin can create a link with a target role (member/admin) and expiry, share it, and revoke it; invitees see the workspace and inviter before joining. MUL-6070 Co-authored-by: patrickstar179 |
||
|
|
4d495056e8 |
[MUL-6139] Expand Plugin skills and hosted MCP connections (#6920)
* feat(plugins): add remote MCP runtime Co-authored-by: multica-agent <[email protected]> * chore(plugins): add private Exa example Co-authored-by: multica-agent <[email protected]> * docs(plugins): remove private plugin guide Co-authored-by: multica-agent <[email protected]> * fix(plugins): stabilize remote MCP tool digests Co-authored-by: multica-agent <[email protected]> * fix(plugins): preserve remote MCP notification status Co-authored-by: multica-agent <[email protected]> * fix(plugins): make Exa example runnable end to end Co-authored-by: multica-agent <[email protected]> * fix(codex): record MCP tool calls in agent log Co-authored-by: multica-agent <[email protected]> * chore(plugins): add community skills example Co-authored-by: multica-agent <[email protected]> * feat(plugins): support multi-file skill bundles Co-authored-by: multica-agent <[email protected]> * feat(plugins): add Mobbin MCP example Co-authored-by: multica-agent <[email protected]> * chore(plugins): bundle full Matt Pocock skill set Co-authored-by: multica-agent <[email protected]> * feat(plugins): add remote MCP OAuth connections Co-authored-by: multica-agent <[email protected]> * fix(plugins): gate enable on remote MCP readiness Co-authored-by: multica-agent <[email protected]> * fix(plugin): authenticate remote mcp credential broker Co-authored-by: multica-agent <[email protected]> * fix(plugins): harden remote mcp oauth flow Co-authored-by: multica-agent <[email protected]> * refactor(plugins): use a single feature flag Co-authored-by: multica-agent <[email protected]> * fix(plugins): clean up remote MCP OAuth state Co-authored-by: multica-agent <[email protected]> * chore(plugins): remove Matt Pocock example Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
2c0912b6ec |
MUL-5421: make workspace MCP servers an explicit per-agent assignment (#6976)
* feat: make workspace MCP servers an explicit per-agent assignment #6961 shipped workspace MCP as a shared document every agent inherited automatically. That is the wrong shape: a workspace MCP server should behave like a workspace skill — a library entry that reaches an agent only when someone gives it to that agent, with a per-agent on/off toggle. Creating one now gives it to nobody, so nothing propagates implicitly. Model (mirrors `skill` + `agent_skill`): - `workspace_mcp_server` — the library, keyed by id so renaming is safe - `agent_mcp_server` — the assignment, carrying its own `enabled` toggle - the agent's own `mcp_config` stays what it was: servers private to it At claim time the effective set is runtime servers < (assigned AND enabled workspace servers + the agent's own), with the agent's own entry winning a name collision. 318 drops `workspace.mcp_config`: leaving it would keep a second, silently inherited source alive. No data migration — converting each workspace document into library entries would ASSIGN them to every agent, which is the behaviour being removed, and the column shipped in the same unreleased cycle. The write-only secret contract, the legacy-container normalization, and the transport guards from the previous round are all preserved; the resolver is re-pointed at the assignment list rather than a workspace document. UI: workspace Settings → MCP is now a library (adding says so, and renaming is allowed because assignments key off the id); the agent's MCP tab replaces the auto "Inherited from workspace" list with the servers actually assigned to it, a picker to add more from the library, and a per-assignment toggle. Co-authored-by: multica-agent <[email protected]> * fix: fence the MCP writes, stop laundering unknown transports, gate on canEdit Three findings from review: 1. Neither new table carries a foreign key (repo rule), so nothing in the database catches a write landing after the row it depends on is gone. Create and assign now run in a transaction that joins the workspace teardown fence (#5219), and assign additionally holds the target server FOR SHARE while DeleteWorkspaceMcpServer holds it FOR UPDATE — so an assignment either lands before the delete and is swept, or waits and finds the server gone. Driven by two two-transaction race tests, verified to fail without the locks. 2. `mcpTransportOf` classified `{"type":"websocket","url":"wss://…"}` as http, because an unrecognised type fell through to the url check. That told the settings UI the guided form could edit the entry, and saving from it rewrites the protocol to `type: "http"` — the failure the client-side guard was added to prevent, reintroduced from the server. An explicit type is now returned verbatim; inference from command / url happens only when none was declared, which is lossless. 3. The agent MCP tab showed the picker, toggles, and remove button to members who cannot edit the agent, where every click 403s. It now takes `canEdit` like SkillsTab and hides the write affordances, keeping the inventory readable. 318 keeps dropping `workspace.mcp_config` with no data migration and no expand-contract window, now recorded as an explicit maintainer decision (Bohan, 2026-08-14): 314 merged in this same cycle and was never deployed, so there is no stored document to preserve and no old binary reading the column. The comment spells out what this file would have to become if that ever stops holding. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
b30a3ae55c |
MUL-6164 feat(runtime): take an unusable agent CLI offline and refuse its triggers with the fix (#6966)
* fix(daemon): take a runtime offline when its CLI cannot be executed (MUL-6164) A CLI that cannot exec was classified as a transient probe failure, so the runtime stayed online and every task it claimed died at fork/exec. The user in MUL-6164 hit the same error once per task with no signal that the runtime was the problem — an npm placeholder stub passes exec.LookPath, so nothing else the daemon checks can tell it apart from a working install. ENOEXEC (and its Windows spelling, ERROR_BAD_EXE_FORMAT) is a fact about the bytes on disk, not a hiccup: it will reproduce until someone reinstalls. Give it its own probe verdict and route it down the track below-minimum already uses — claim barrier, seq-stamped hold, deregistration — so the provider goes offline with its reason instead of collecting failed tasks. Recovery needs no new machinery: a demoted provider has no runtime, which puts it back in providersMissingRuntimes, and the first healthy probe clears the hold and lets converge register it again, with no restart. One thing the below-minimum track did not need: a confirmation window. The repair we tell users to run overwrites the bin entry in place, so a probe landing mid-copy sees a truncated file. Demotion therefore requires two sightings a window apart; a genuinely broken install pays one extra probe round, and a spurious verdict would in any case recover on its own. detectBuiltinRuntimes' second return value is now "providers condemned this round, with the evidence against them" rather than "below-minimum versions" — same shape, both kinds, and the value is the reason /health already shows. Co-authored-by: multica-agent <[email protected]> * feat(runtime): refuse triggers for an unusable agent CLI instead of queueing them silently (MUL-6164) Taking the runtime offline stopped the per-task failures, but it replaced them with silence: the next @mention or assignment queued as if the machine were merely asleep, and nothing ran for two hours until queued_expired. Waiting only makes sense when waiting is a plan — a sleeping laptop comes back on its own; a CLI that cannot be executed does not. dispatch/reason.go already draws exactly that line between runtime_offline ("bring the machine back, queued work waits") and agent_runtime_required ("nothing will ever claim this"), and warns that collapsing the two sends users to fix the wrong thing. This adds the third member of that family, runtime_unusable, and routes the new verdict to it end to end: - The daemon reports WHY on deregistration — a stable code plus the repair command read from the package's own manifest, since only that side knows the path. Stored on agent_runtime.metadata, which registration overwrites wholesale, so a repaired runtime drops the explanation structurally. - AgentReadiness now returns a verdict (available / waitable / blocked) instead of a bool plus prose, and the direct-agent trigger paths start consulting it — blocking ONLY on the blocked verdict, so an offline machine still queues. agentReadinessReasonCode, which re-derived the same code from agent state, is gone: one source, no drift. - Every path that refuses without a human watching the response leaves the reason on the issue: an assignment, or another agent's @mention. The interactive surfaces already carry the code back to the composer, which now localizes it in all four languages. Below-minimum keeps today's behaviour deliberately. It is equally futile to wait on, but changing when THAT refuses a trigger is a separate product decision. Co-authored-by: multica-agent <[email protected]> * fix(runtime): keep the unusable verdict through a late register, and repair Windows (MUL-6164 review) Two ways the refusal path failed to reach the user. The verdict could be erased by an older answer. A register whose request was still in flight across the demotion upserts the runtime row afterwards, and that upsert overwrites metadata wholesale — including the reason just stored. The local hold refuses the response correctly, but the cleanup that took the revived row offline again sent no reason, so the server ended up "offline, cause unknown". Every admission path reads that as "the machine will come back" and queues, which is exactly the behaviour this change exists to remove. The demotion record now keeps the structured cause and the revived-row cleanup re-attaches it per row. The regression test drives the real interleave — a register held inside the server handler across the verdict — and fails without the fix. The repair command was POSIX-only. Windows had just gained detection (ERROR_BAD_EXE_FORMAT), so a Windows user was correctly refused and then handed `cd 'C:\...' && node install.cjs`: cmd.exe treats the quotes literally and PowerShell 5.1 has no `&&`. The command is now rendered for the shell of the machine that reported it — Set-Location on two lines for PowerShell, with PowerShell's own quote escaping — and carries that shell so the notice fences it truthfully instead of labelling everything bash. Also corrected the notice's own docs: it is written for every author, not only unattended ones. The chip and toast carry the reason code but not the repair command, so a human needs the durable copy too. The behaviour was already this; the comment claimed otherwise. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
736838af86 |
MUL-5421: workspace-level MCP configuration (resolution contract, write-only API, Settings UI, CLI) (#6961)
* feat(server): resolve a workspace-level MCP config under each agent's Adds workspace.mcp_config and layers it beneath the agent's own config on every claim, so a workspace's shared MCP servers no longer have to be re-entered on each agent (GH #6062). Resolution contract: - agent config absent (NULL/null) -> inherit the workspace document - agent declares >=1 server -> union by server name, agent wins - agent declares no server ({}) -> opt out entirely The last rule is what keeps shared credentials from silently reaching an agent that was deliberately given none: "managed but empty" already means "this agent runs with zero MCP" under the three-state contract from #3545, and it keeps meaning exactly that. The merged document keeps the AGENT's top-level keys, so a legacy `mcp` container survives untouched, and a shared server is skipped when the agent already declares that name under either spelling. On malformed input the agent's config is returned unchanged: a bad shared document must never take away servers an agent runs with today. Read on every claim like agent.mcp_config, so an admin's edit lands on each agent's next task with nothing to restart. Co-authored-by: multica-agent <[email protected]> * feat(server): add the workspace MCP configuration API GET /api/workspaces/{id}/mcp-config is member-visible so an agent's settings view can tell what it inherits, but the document itself follows the same visibility rules mcp_config already has on agents: agent actors never read it, the workspace always-redact setting wins over any role, and otherwise only the owner/admin who may write it can read it back. The coarse server_count stays visible either way — it carries no credential material. PUT is owner/admin and replaces the whole document (so removing a shared server is expressible), with an explicit null to clear the layer. It is shape-validated at the edge, unlike the agent column: one bad document here would otherwise fail-soft on every agent's claim for the whole workspace. Agent actors are refused even when their PAT satisfies the role gate. Co-authored-by: multica-agent <[email protected]> * feat(cli): add `multica workspace mcp get|set` Reuses resolveMcpConfig so the workspace surface agrees with `agent update` on what a valid document is, on the three mutually exclusive input channels, and on 'null' meaning clear. Table output prints server names and transports only — never entry bodies — so a summary stays safe to paste into a ticket; the JSON default is how an admin reads the document itself. Documents the new inheritance in the agent-creation built-in skill (an agent with a private server no longer has to re-list the shared ones, and `{}` is now the only way to give an agent no MCP at all) and adds the command to the CLI reference. Co-authored-by: multica-agent <[email protected]> * fix(server): keep a legacy agent's own MCP servers when the workspace shares some The resolver wrote shared servers into `mcpServers` and left an agent's legacy top-level `mcp` entries beside them. The daemon's runtime merge only falls back to `mcp` when `mcpServers` is ABSENT, so as soon as a workspace shared anything, an OpenCode agent that stored its servers under the legacy container ran with the shared set and silently lost every private server of its own. Fold both containers into the canonical `mcpServers` when merging, and drop the legacy key so the daemon can never read a second, stale copy. Nothing changes for an agent whose workspace shares no servers: that path still passes the agent document through byte-for-byte. The regression guard runs the real chain — ResolveWorkspaceMcpConfig into the daemon's mergeRuntimeAndAgentMcpConfig — because the two halves only agree if the resolver emits the container the merge reads, which is exactly what broke. Verified it fails against the previous resolver. Co-authored-by: multica-agent <[email protected]> * feat(server): make the shared MCP configuration write-only Upstream #6062 asks for shared secrets to be write-only and never echoed back; the previous read path handed the whole document to owner/admin, which also tied "audit what is shared" to "read the credentials". GET now returns only a non-secret inventory — name, transport, enabled — for every role. Not a redaction: there is no document field and no `_redacted` flag, because nothing secret is in the payload to gate. A url counts as secret here; a Composio-style session url is a bearer credential on its own. That removes the client's ability to read-modify-write, so editing one server gets its own endpoints: PUT/DELETE /mcp-config/servers/{name} merge server-side under a row lock (two admins editing different servers can no longer lose one another's write). Deleting the last server clears the column rather than storing an empty document, so agents fall back to their own config instead of inheriting a managed-empty one. The whole-document PUT stays for the declarative CLI path. Co-authored-by: multica-agent <[email protected]> * feat(cli): add `workspace mcp add|remove`, and stop printing the document `workspace mcp get` now renders the server inventory in both output formats — there is no document to print, and the previous JSON default would have written whatever the endpoint returned straight to a terminal. Since the document can no longer be read back, `set` (full replace) is not enough to change one server: `add <name>` and `remove <name>` map to the per-server endpoints. `add` takes the entry through the same three mutually exclusive secret-safe channels as `--mcp-config`, generalized here so both surfaces agree on what a valid payload is; `null` is rejected for a single entry, where it is far likelier to be a mistake than an intent to clear. Adds the CLI test coverage this surface was missing: endpoint and body per command, `null` clear, mutual exclusion, name escaping in the path, and a guard that a regressed server returning the document cannot get it printed. Co-authored-by: multica-agent <[email protected]> * docs: correct the workspace MCP contract in the built-in skill and CLI reference - "`{}` is the only way to opt out" was wrong: any config declaring no servers opts out, which is what the implementation and tests do. - The source map pointed the API row at workspace_mcp.go; the handlers live in workspace_mcp_api.go. - Document the write-only read path, the legacy-container normalization, and the new per-server commands. Co-authored-by: multica-agent <[email protected]> * feat(web): manage workspace MCP servers from Settings The user journey for this feature is a settings screen, not the CLI. Adds a workspace Settings → MCP tab that lists the shared servers and adds, replaces, and removes them one at a time through the per-server endpoints, reusing the agent tab's McpServerDialog for the form/JSON editor. Because the stored configuration is write-only, an edit opens an empty form and REPLACES the entry rather than prefilling it. The screen says so instead of implying the blank form is the saved state. Members see the inventory (nothing in it is secret) but no write affordances. The agent's MCP tab gains the matching "Inherited from workspace" section, with an Overridden badge on a shared server the agent shadows by name, and an explicit note when the agent's managed-but-empty config opts it out of the workspace layer entirely — otherwise the effective server set is impossible to reason about from the agent's own screen. Co-authored-by: multica-agent <[email protected]> * fix(client): stop the write-only boundary leaking through the clients The server never returns the stored document, but neither client enforced that on its own side, so a regressed (or hostile) backend could still push secrets into the frontend and onto a terminal: - the two workspace-MCP zod schemas were `.loose()`, so unknown fields — a top-level `mcp_config`, or `url` / `headers` / `env` inside a server entry — survived parsing into the app object and the query cache. These are now the only schemas in that file that strip: forward-compatibility is the right default everywhere else, but here keeping unknown keys is the hole. - the CLI decoded into `map[string]any` and re-encoded it, so the DEFAULT JSON output printed whatever arrived. It now decodes into a named struct, which is what makes leaking impossible rather than merely unlikely. The old "never prints secrets" test forced `--output table` and so missed the default path entirely. Replaced with a poisoned-response fixture (secrets at the top level AND inside a server entry) asserted across both output formats and on the write path; verified it fails against the previous renderer. Co-authored-by: multica-agent <[email protected]> * fix(web): stop a workspace MCP edit from silently becoming an add The shared dialog lets the name be edited and this API has no atomic rename, so renaming `linear` to `linear-v2` upserted a second server, left the original in place, and still toasted "updated". Editing now pins the name (the dialog gained a `lockName` prop) and the upsert targets the entry that was opened. Also seed the edit form's transport from the safe summary: the entry cannot be read back, and `formFromConfig({})` defaults to HTTP, so editing a known stdio server opened the wrong form. Co-authored-by: multica-agent <[email protected]> * fix(web): show the agent's real effective MCP set Two ways the agent tab was stating things it did not know: - With `mcp_config_redacted`, the viewer cannot tell an explicit empty document (opted out) from one that overrides some names — but the tab listed every workspace server as inherited anyway. It now says inheritance cannot be determined instead of guessing. - The runtime list marked a server "overridden" using the agent's names only. The real precedence is runtime < workspace < agent, so a shared server hides a same-named runtime one too; that was under-reported. Overrides are now computed from workspace + agent names, and from the agent's alone when it opted out. Co-authored-by: multica-agent <[email protected]> * docs: correct the opt-out claim and apply the zh terminology conventions An agent whose config declares no servers opts out of the WORKSPACE layer; it does not run with zero MCP. The daemon still merges the runtime's own local servers under whatever the claim carries — which the daemon test on this branch already asserts, so only the prose was wrong (skill doc, CLI help, UI copy). The new zh-Hans strings also used `agent` / `url` / `header` raw; per conventions.zh.mdx those are 智能体 / URL / 请求头. Co-authored-by: multica-agent <[email protected]> * fix(i18n): restore the zh-Hans quick-actions strings this branch clobbered My earlier terminology pass ran a `re.sub` for `description` / `delete_description` with count=1, which matched the FIRST such keys in the file — `quick_actions`, not `mcp` — so Quick Actions ended up describing shared MCP servers and its delete confirmation talked about sharing. A user-visible regression in an unrelated feature, and one no key/type check can catch because both values stayed valid strings. Restored both to their origin/main text. Audited all eight locale files the branch touches the same way; these two were the only unintended changes. Co-authored-by: multica-agent <[email protected]> * fix(web): never edit a non-stdio/http MCP server through the rewriting form The guided form only expresses stdio and http, and saving from it REWRITES the entry — `configFromForm` emits `type: "http"` for anything non-stdio. The server classifies and returns `sse`, so editing an SSE server opened the Streamable HTTP form and saving silently changed its protocol, which can break the server outright. Entries the form cannot express now take the JSON path, which round-trips what is typed, and the Form tab is disabled rather than merely unselected — switching to it would rewrite them just the same. The rule keys off the saved config when there is one and off the safe summary's transport when there is not, so it covers both the workspace tab (write-only, summary only) and the agent tab, where the same rewrite was already reachable for a saved SSE entry. Also fixes the garbled `multica workspace mcp get|list-only` in the built-in skill, which reads as a shell pipe. Co-authored-by: multica-agent <[email protected]> * fix(web): judge form-editability by the entry's explicit type, not its display bucket The previous guard asked `mcpTransport(config)` whether the form could express a saved entry, but that is a lossy DISPLAY classifier: it buckets anything with a `url` as http regardless of `type`. So `{"type":"websocket","url":"wss://…"}` still looked form-editable and `configFromForm` rewrote it to `type: "http"` on save — the very rewrite the guard exists to prevent. SSE happened to be caught only because the classifier has a dedicated branch for it. Saved entries are now judged on the explicit `type`, allowlisting exactly what `configFromForm` writes back unchanged (local / stdio / remote / http / streamable-http), with the no-type case still inferred from command / url. The summary path keeps using the transport string, which the server emits from a closed set. Tests cover both unknown-type spellings on the agent side and pin the common shapes to the form — this is a guard against rewriting entries, not a general retreat to the JSON editor. Verified the websocket case fails against the previous check while sse passed, which is why the earlier test missed it. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
b5a48eca13 |
fix(agent tasks): prevent duplicate self-assignment work (MUL-6168) (#6956)
* fix(agent tasks): avoid duplicate self-assignment runs Co-authored-by: multica-agent <[email protected]> * fix(agent tasks): address duplicate-run review Co-authored-by: multica-agent <[email protected]> * fix(agent tasks): resolve sibling-run nits Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
31645cc510 |
[MUL-6125] Ship Private Skill Plugin developer loop (#6900)
* [MUL-6125] Ship private Skill Plugin developer loop Co-authored-by: multica-agent <[email protected]> * [MUL-6125] Address Private Plugin review findings Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
96bf122f2c |
MUL-6107: preserve task history during runtime GC (#6894)
* MUL-6107: preserve task history during runtime GC Co-authored-by: multica-agent <[email protected]> * fix(server): address runtime GC review Co-authored-by: multica-agent <[email protected]> * fix(server): finish runtime GC review nits Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
c9727e6b2b |
feat(daemon): worktree mode for local_directory resources (MUL-5707) (#6759)
* feat(daemon): worktree mode for local_directory resources (MUL-5707) Local directory resources serialise every task on a per-path mutex, so two tasks on one folder queue behind each other. This adds an opt-in worktree mode: each task gets its own git worktree of the user's repo inside the daemon's env root, so tasks run concurrently and deliver their work as a branch instead of editing the user's working copy. Opt in per resource with execution_mode=worktree (default stays in_place, so existing resources are unchanged). Requires a git repo with at least one commit; a non-git directory fails with an actionable message rather than silently falling back to serial execution. Design points that make the mode safe to use: - The agent starts from what the user sees, not HEAD. Tracked edits are captured with `git stash create` (which writes a commit object without touching the index, working tree, or stash list) and replayed into the worktree; untracked non-ignored files are copied, bounded at 2000 files / 200MB with the skipped count logged at warn level. - That replayed state is committed as a baseline, so "did this task change anything?" has an exact answer and `git diff <baseline>..<branch>` is precisely the agent's work. - Nothing is silently discarded: leftovers are committed to the branch before the worktree is removed, including when the task fails partway. - A task that changes nothing leaves no branch behind. - Sidecars stay out of the branch by running the existing CleanupRuntimeConfig + CleanupSidecars pass over the worktree before finalizing. The .git/info/exclude approach repocache uses is not usable here: a linked worktree resolves info/exclude to the user's own common git dir, so it would change what `git status` hides in their checkout. - Worktree-mode env roots do not get the local_directory GC exemption, so they age out normally instead of accumulating one env root per task. - `git worktree prune` on create self-heals registrations orphaned by a crashed daemon. Adds --execution-mode to `project resource add/update`, and updates the docs and the projects-and-resources built-in skill. Co-authored-by: multica-agent <[email protected]> * fix(daemon): close data-loss and isolation gaps in worktree mode (MUL-5707) Review follow-up on #6759. Six issues, all on failure or skew paths. 1. Preserve work when the final commit fails. Finalize logged a warning and then ran `git worktree remove --force`, destroying the agent's changes. commit.gpgSign with no key available to the daemon triggers this, and --no-verify does not disable signing. Finalize now returns an error and deliberately keeps the worktree — it stays registered, so `git worktree list` points at the work — and the daemon fails the task instead of reporting success with a branch that is missing the changes. 2. Fail closed on an unsupported execution_mode. Treating an unknown mode as in_place was wrong: execution_mode is how a user asks for ISOLATION, so the "safe" fallback would edit the working copy they asked us to stay out of. An unrecognised mode now fails the task naming the version skew. 3. Keep Codex conversations alive across turns. The per-issue session store was keyed on LocalWorkDir != "", so worktree tasks — which also never reuse an env root — got a fresh empty CODEX_HOME per turn and silently lost the thread. Covered by a two-turn regression test that fails without the fix. 4. Fail closed on an incomplete snapshot. A failed stash create/apply, a failed untracked copy, or hitting the copy bounds all degraded to "agent sees HEAD" with only a daemon log line. A half-replayed tree looks like a working checkout, so nothing downstream questions it while the agent reads different code than the user has. All of these now abort the prepare and tear the partial worktree back down. 5. Do not snapshot a directory mid-mutation. The same real path can be attached to another project as an in_place resource; snapshotting under a running in_place task captured a half-written tree plus that task's in-flight .agent_context/.multica sidecars, which then reached the branch and could feed the agent another issue's brief. Worktree prepare now takes the per-path mutex for the snapshot alone (released before the run, so tasks still overlap), and the untracked replay skips Multica's own sidecars. 6. Stop the test helper from masking regressions. gitRun skipped on any git failure, including post-finalize assertions, so a lost branch could report as a skip. Git availability is now checked once at repo setup; every later git failure is fatal. Also rebased onto main, keeping both MulticaConfigRoot and LocalWorktree in the Environment initialiser. Co-authored-by: multica-agent <[email protected]> * fix(daemon): close sidecar-commit and wait-visibility gaps in worktree mode; gate worktree saves on daemon version (MUL-5707) - runTask: register the sidecar-cleanup defer immediately after the Finalize defer. Previously two early returns (temp-dir setup, StartTask failure) sat between them, and on those paths Finalize auto-committed the sidecars Prepare had just written — delivering a branch whose only content is Multica's own runtime files (in_place: leaving them in the user's tree). - runTask: give the worktree snapshot wait the same plumbing as the in-place acquire — waiting_local_directory status, resourceWaitTasks accounting, and a cancellation poller. The holder can be an hours-long in_place task; the wait was invisible and a user cancel went unnoticed until the 5-minute prepare timeout. - isMulticaSidecarPath: match sidecar dirs as whole path segments at any depth, not only at the repo root. An in_place resource pointed at a SUBDIRECTORY leaves its sidecars below that subdirectory; replaying them committed another issue's brief into the delivered branch. - handler: refuse saving execution_mode=worktree while the owning daemon's registered cli_version is below MinLocalWorktreeCLIVersion (422, daemon_version_unsupported). An old daemon json-skips the field and runs tasks IN PLACE — the daemon-side unknown-mode refusal cannot protect a daemon that predates the field, so the server has to. Residual (documented): a daemon downgraded after the save needs a claim-time gate. - copyUntrackedFiles: read the -z listing raw instead of TrimSpace-ing it, which corrupted filenames beginning/ending with whitespace into paths that silently vanished from the replay. - docs + skill: describe the save-time version gate; drop the stale claim that old runtimes fail the task themselves. Co-authored-by: multica-agent <[email protected]> * feat(views): UI switch for local_directory execution mode (MUL-5707) Worktree mode shipped CLI/API-only, which for its target user — someone running several tasks at once from Desktop — means it does not exist. This adds the switch to the resources panel. The choice is framed by what the user gets back, not by the mode identifiers: "Edit this folder directly" (one task at a time, changes land in your working copy) vs "Run in parallel, isolated" (tasks run at the same time and each hands back an agent/… branch). `in_place` / `worktree` stay visible as a secondary hint for anyone cross-referencing the CLI or docs. Two entry points, matching where the decision is actually made: - The add-a-folder flow now asks for the mode after the folder is picked and validated, instead of creating the resource and leaving the mode to be discovered later. - Every local_directory row has a mode control. It is deliberately NOT gated on being the owning machine — switching the mode only rewrites a field, so it works from the web app too, unlike rename or the folder picker. Both blocked states are handled where the user chooses, not after they commit: - Non-git folder: the desktop validator now reports whether the path is inside a git working tree (walking up for `.git`, so a subdirectory of a repo counts), and the option is disabled with that reason. Unknown — an older desktop build, or an existing row — stays permissive; the daemon re-checks authoritatively. - Daemon too old: the version floor is mirrored from the server as MIN_LOCAL_WORKTREE_CLI_VERSION and checked against the runtime for the resource's OWN daemon, not the machine the browser happens to be on. A server rejection is rendered inline in the dialog rather than as a toast, so the dialog stays actionable. A "Parallel" badge on the row is always visible (not hover-only): without it there is no way to tell whether tasks on a folder edit it directly or hand back a branch, which is the first thing anyone asks when a task queues. Also adds `execution_mode` to LocalDirectoryResourceRef — the field existed server-side but had no TypeScript counterpart — and brings the zh/ja/ko project-resources docs in line with the English ones, which were the only locale updated when the mode landed. Co-authored-by: multica-agent <[email protected]> * fix(daemon,handler): close worktree isolation and delivery gaps (MUL-5707) Second review round. Five must-fix items, all on paths where the mode's promises could still break. 1. A failed sidecar cleanup no longer ships Multica's own files as the user's branch. The cleanup errors were logged and Finalize then ran `git add -A`, so a permission/disk/lock failure could deliver a branch whose entire content was the runtime brief and issue context. Worktree mode now escalates a cleanup failure to an abort: nothing is committed, the worktree is kept for inspection, and the task fails with the preserved path. In place it stays a warning — no commit, no branch, and the leftovers are visible in the user's own tree. 2. The untracked replay no longer drops files silently. `Lstat` failures and symlinks were `continue`d without counting, so the caller read a partial snapshot as complete. Both now count as skipped, which the existing fail-closed check turns into a refused prepare. Non-content entries (sockets, FIFOs, devices) stay uncounted deliberately. 3. Prepare rolls back a worktree it created. Any later failure — context files, provider home, MCP config — returned without an Environment, so nothing downstream knew a worktree existed, leaving a registration and a branch no task ever ran in. A deferred Discard now runs unless Prepare reached its successful return. 4. The daemon-version gate moved to where it can actually hold. Save-time validation cannot see a machine downgraded afterwards, and an old daemon json-skips execution_mode entirely — it would run the task in place, editing the working copy the user asked to isolate. Claims are now checked against the runtime that is actually claiming, and a blocked task is cancelled with a reason rather than left to be redelivered to the same too-old runtime forever. 5. branch_name is delivered end to end. The daemon sent it, but TaskCompleteRequest had no field so it vanished at decode, and the failure path never sent it at all. It now has a column, travels on both terminal callbacks, and renders as a copyable row in Run details. The failure path matters most: worktree mode commits the agent's leftovers before tearing the worktree down, so a run that died partway still has work worth finding. Docs (all four locales) and the projects-and-resources skill are updated for the claim-time gate and the branch surface. Co-authored-by: multica-agent <[email protected]> * fix(handler,daemon): migration renumber, cancel reasons, branch on every terminal path (MUL-5707) Third review round. 1. Rebased onto main and renumbered the migration to 285 — main had taken 273 and is now at 284. sqlc regenerated on top of the rebased queries; the conflicts were confined to generated files. 2. The claim gate now persists why it cancelled. It called CancelTask, which writes only status='cancelled', and returned the reason as a 4xx that reaches the daemon's log and nothing else — batch claim drops the task from its response entirely. A run refused for a durable reason the user has to act on cannot present as a bare "cancelled", so this adds CancelAgentTaskWithReason and records error + failure_reason on the row. 3. branch_name now survives every terminal path, not just the two direct callbacks. Three were dropping it: - the context-exhausted complete→fail reroute did not copy it; - the daemon's complete→fail fallback (server rejected the complete, agent had succeeded) did not set it; - both cancellation paths discarded the result wholesale, even though the worktree is finalized — and its work committed — before the daemon learns of the cancel. The branch now rides the cancel-ack and is recorded by SetAgentTaskBranchName, which never overwrites a name a terminal callback already wrote. 4. Rollout/rollback compatibility. Isolation is enforced by two server-side gates, so a daemon downgraded at any point after the resource was saved is caught. The gap they cannot close is rolling the SERVER back past this change while sub-floor daemons are still connected: an old server has no gate and an old daemon ignores execution_mode. That constraint is now documented where an operator will meet it (the migration and the docs), and the version floor is pinned across server and frontend by test so the two gates cannot silently drift apart. Co-authored-by: multica-agent <[email protected]> * fix(server,views): route claim-gate cancels through TaskService, make cancel reasons visible, harden cancel-ack branch delivery (MUL-5707) Closes the fourth-round review findings on worktree mode's cancellation chain: - Claim gate: cancel through a new TaskService.CancelTaskWithReason instead of a raw CancelAgentTaskWithReason query, so a gate-cancelled task gets the full cancellation side effects — audit capture, chat settle, agent status reconcile, task:cancelled broadcast, NotifyTaskFinished. Added REAL claim integration tests: singular claim (422 + row cancelled with reason), batch claim (task omitted + row cancelled with reason), and a current-runtime control. - Cancel reasons are now user-visible: execution log rows, agent activity rows, and the transcript dialog label a SERVER-cancelled task (persisted error/failure_reason) like a failed one, with the actionable text on hover and a Reason row in Run details. User-initiated cancels stay a plain "Cancelled". New cancelReasonLabel helper + local_directory_error label; details_reason locale key in en/zh-Hans/ja/ko; widened AgentTask.failure_reason to the open string the wire actually carries. - Cancel-ack is now a terminal delivery: the daemon retries it on transient errors (same schedule as complete/fail), the server fails LOUD (5xx) when persisting the branch/error fails instead of warn-and-200, and re-broadcasts task:cancelled after a successful write so clients that already refetched a branchless row pick up the delivery. Replays are idempotent (never overwrite). - Cancel + Finalize-abort no longer swallows the preserved-worktree pointer: the finalize error is wrapped in worktreePreservedError (joined onto an existing run error, never displacing it), extracted on the cancel path, and delivered via the ack into agent_task_queue.error through the new SetAgentTaskErrorIfEmpty query — never overwriting a reason recorded by a fail callback or the claim gate. Other run errors on a cancelled run stay discarded as before: they are expected noise. - Docs: the self-hosted rollback constraint callout now exists in zh/ja/ko, not just English. Co-authored-by: multica-agent <[email protected]> * fix(handler): guard cancel-ack writes to cancelled rows; requeue when the claim gate's cancel fails (MUL-5707) Fifth-round review fixes: - SetAgentTaskBranchName / SetAgentTaskErrorIfEmpty now carry a status='cancelled' CAS. The daemon acks on EVERY terminal status it observes, so a late or replayed ack from a stale run could previously backfill its branch or preserved-path error into a completed/failed row whose own callback — the authoritative channel — recorded nothing, without any event invalidating the change. A CAS-refused write is a deliberate no-op (still 200: nothing to retry), and the rebroadcast stays guarded by the same status check. Regression tests: a late ack against a completed row with empty fields and against a failed row leaves both untouched. - When the worktree claim gate's CancelTaskWithReason fails, the claimed row no longer strands in dispatched (daemon refused, nothing to release it but stale reclaim). The gate requeues via RequeueTaskAfterClaimFailure — the next claim re-runs the gate and retries the cancel — and reports 5xx so the singular daemon reads a transient server problem, not a claim refusal; the batch path omits the task as before, now with the row requeued. Fault injection tests (row-scoped trigger raising on the cancel UPDATE while letting the requeue through) cover singular and batch. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
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]> |
||
|
|
d467cc9069 |
MUL-6075: complete Plugin V1 execution chain (#6853)
* feat(server): add Plugin V1 lifecycle foundation Co-authored-by: multica-agent <[email protected]> * feat(server): complete Plugin V1 execution chain Co-authored-by: multica-agent <[email protected]> * feat(web): add temporary plugin settings demo Co-authored-by: multica-agent <[email protected]> * refactor(plugin): keep foundation free of demo content Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
71bd844955 |
MUL-6055 refactor(agents): remove legacy agent templates (#6820)
Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
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
|
||
|
|
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]> |
||
|
|
6bce42b84a |
MUL-5983 fix(workspace): stop workspace delete from hanging on advisory lock 4246 (#6710)
* fix(workspace): stop workspace delete from hanging on advisory lock 4246 (MUL-5983) Deleting a workspace from the latest client did nothing: the confirm dialog stayed on "Deleting…" forever and the workspace survived. The teardown transaction takes global advisory lock 4246 (added in #6230) so the task_usage_hourly rollup cannot write aggregates for a workspace being torn down, and it waited on that lock with no bound. Nothing above the handler has a deadline either — the API client's fetch has no timeout — so any holder of 4246 turns the delete into an infinite spinner. Two fixes: - Migration 272 makes the rollup's lock transaction-scoped. Migration 102 used the session-scoped pg_try_advisory_lock and released it from an `EXCEPTION WHEN OTHERS` handler, but plpgsql's OTHERS does not match query_canceled and a session-level advisory lock survives the rollback that follows. A cancelled tick therefore handed its pooled connection back to pgxpool still holding 4246 — permanently blocking every later tick (usage aggregation silently stops) and every workspace delete. - DeleteWorkspace sets SET LOCAL lock_timeout on its transaction and maps SQLSTATE 55P03 to 503. Lock 4246 is also held for whole runs by the backfill commands and for up to 25 min by a rollup tick, so contention has to surface as a retryable error instead of a request that never returns. The cap applies to lock waits only; the teardown work itself stays unbounded. Both paths are pinned by tests that fail without the fix: the scheduler test proves a cancelled tick leaks 4246 against the old function, and the handler test proves DeleteWorkspace blocks on a held 4246 without the lock timeout. Co-authored-by: multica-agent <[email protected]> * test(workspace): serialise the new 4246 tests with the rollup guard (MUL-5983) Review found that the two new handler tests take advisory lock 4246 by hand without joining the cross-binary guard the rollup family uses (42463980), so `go test` running internal/handler and internal/scheduler in parallel against one database made the scheduler's lock tests fail. Reproduced locally, then fixed by calling lockRollupSingleton in both. Stressing the combined run surfaced a second, older interference: every workspace teardown takes 4246 in production code (#6230), so any handler delete test can own the lock for a moment and leave the scheduler's rollup tests seeing "no work" — TestPgCronConcurrentNoDoubleWrite fails with winners=0, and this predates the guard fix above. Both rollup tests now treat a round lost to an outside holder as a retry rather than a verdict; the real invariants (no leaked lock, no double write) are unchanged. Also from review: correct the sqlc source comment that still described the rollup's lock as session-scoped (regenerated), and drop migration 272's claim that the TTL prune is bounded and only delays the next tick — it has no row cap and now also delays workspace deletes waiting on 4246. Co-authored-by: multica-agent <[email protected]> * test(workspace): make the advisory-lock holder release idempotent (MUL-5983) The 15 s deadline branch releases the 4246 holder early so the blocked handler can finish, and the defer then released it again. pgxpool's Release is idempotent but Exec on a returned connection dereferences a nil resource, so the second call panicked and buried the assertion failure it was supposed to report. sync.Once makes the cleanup safe from both paths. Verified by forcing the deadline branch (lock_timeout temporarily removed from DeleteWorkspace): the test now reports the assertion cleanly where it previously ended in a nil-pointer panic. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
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 |
||
|
|
984a2c2bff |
MUL-5954: feat: saved issue views V1 (MUL-4796) (#6516)
* feat(issues): server-backed saved issue views API (MUL-4796) issue_view table (migrations 262-264: table + two CONCURRENTLY partial indexes) stores named filter definitions: query jsonb is the shared identity, display jsonb only seeds a user's first open. Scope model is workspace / my (with scope_variant, forced private) / project (validated scope_id, deleted with the project in the same app transaction). issue_view_preference (migration 265) holds each user's view-bar layout (hidden + order doc) per surface container, last-write-wins. CRUD + GET/PUT preference endpoints are gated on the saved_issue_views_v1 feature flag (404 while off, key published to the frontend config). Authorization: private views 404 for everyone but the owner; shared views editable by owner or workspace admin; updates use expected_revision -> 409. Co-Authored-By: Claude Fable 5 <[email protected]> * feat(core): saved issue views client core (MUL-4796) - zod schemas + parseWithFallback for views and preferences (definition blobs stay loose records interpreted per definition_version), with malformed-response tests per the API-compat rules - issue-views module: list/create/update/delete + preference queries with wsId-scoped keys; preference upsert is optimistic (toggle-grade rollback) - active-view store (zustand client state, URL is the durable carrier) and useActiveIssueView with missing-view detection - baseline.ts: enum-sanitized FilterSnapshot of a view's query for value-level locking, delta chips, and baseline-aware resets - view-store: clearFilterDimension + resetFiltersTo actions; surface store seeding routes server blobs through mergeViewStatePersisted Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issues): three-bar priority icons with a distinct urgent badge low/medium/high share one three-bar frame (fill level = severity, ghost bars at 35%); urgent breaks out of the scale as a filled badge with an exclamation cutout — it is an interrupt, not a fourth bar. All glyphs share the same 2-14 optical frame. Board card: PickerWrapper's bare block div gave its inline-flex trigger a line box (line-height 24px > 20px button), floating icons above the text midline — the wrappers are flex containers now, and the priority trigger gets a constant size-5 box so the row's rhythm doesn't depend on the glyph. Co-Authored-By: Claude Fable 5 <[email protected]> * feat(views): saved views UI — view bar, save/edit dialog, locked view mode (MUL-4796) View bar replaces the built-in tab row on Issues and My Issues: built-in tabs and saved views render as one flat, per-user ordered row that wraps instead of overflowing, with a menu for new-view / manage. The manage dialog does drag reorder (translate-only transform, vertical axis lock), show/hide (anchor built-in unhidable), and edit/delete for owned views. Open-view semantics: the view's query is the baseline — its values render checked-and-disabled in the filter menu, never as chips; chips show only the user's additions and every reset path (chip x, clear, menu reset, filtered-empty CTA, board/swimlane hide-column) returns to the baseline, not to empty. Display seeds once per user (view:<id> surface key) and is free afterwards. Built-in tab click exits; deleted/revoked views fall back with a toast; ?view= deep links sync on web via a platform hook. Save dialog doubles as the edit dialog (PATCH with expected_revision -> 409 toast); manager edits seed from the view definition, in-view edits from the live panel. Filter chips bar, extracted IssueFilterMenu (virtual frozen anchor), and the reworked display panel round out the header. Also: filter menu consistency fixes (closeOnClick, calendar apply, TableColumnPicker checkbox items), SelectGroup padding, dropdown anchor passthrough, four-locale i18n. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(views): neutralize workspace tab filter while a saved view is open The Members/Agents tabs are a coarse assignee-type filter carried in the surface scope, outside the filter-menu dimensions a view can express. It kept applying underneath an open view, so the same shared view returned different rows depending on which tab the user stood on when opening it — with nothing on screen to explain the difference (tabs dimmed, no chip). While a view is active the workspace scope now resolves to actorKind "all": a view's results are defined by the view alone, matching what the dimmed tabs already imply. Deliberately NOT captured into saved queries — a view condition must be visible and lockable in the filter menu, and assignee-type has no menu representation (add the menu dimension first if that need ever materializes; the loose schema takes the field additively). Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issue-views): workspace scope_variant captures and applies the assignee-type tab A workspace view now saves which built-in tab it was created from (members/agents, NULL = all) as scope_variant, alongside the existing my-scope variants. The save dialog shows a scope selector so the variant can be switched later; scope_type stays immutable. While a view is open, its own variant drives the workspace scope's assignee-type axis instead of whichever tab the user stood on. - migration 266 widens the scope_variant CHECKs (members/agents on workspace, NULL = all; my keeps its four variants; project stays NULL) - UpdateIssueView persists scope_variant; handler validates the variant against the view's scope_type on create and update - issue-surface applies activeView.scope_variant to effectiveScope, replacing the blanket tab neutralization - issues-header seeds the dialog scope from the live tab Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issue-views): project pages get a real assignee-type axis and clearer variant copy The Members/Agents tabs on project pages were decorative: they wrote the global tab store but the project query never read it. The project scope now carries actorKind like the workspace scope (assignee_types composes with project_id server-side), so the tabs filter for real, and project views join the same optional scope_variant vocabulary — saved from the tab, switchable in the dialog, applied while the view is open. - migration 266 (unmerged) widened in place: project allows members/agents, NULL = all - handler treats workspace and project variants identically; new TestProjectIssueViewVariant covers persist / normalize / reject - project scope key gains a per-tab segment (members/agents) so each tab keeps its own display state; the unrestricted tab keeps the old key - save dialog shows the variant selector for project views; the surface now provides the dialog's default variant directly (header-side injection removed) - variant labels spell out the meaning (Assigned to members/agents, Any assignee, Any relation) and project hints are variant-aware, x4 locales Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-table): project table scope honors assignee_types The table/rows channel dropped the Members/Agents narrowing on project pages: the client's project scope never sent assignee_types and the server's project branch never compiled it, so switching tabs only changed the highlight while table rows stayed put (the board/list channel already filtered). Both sides now share the workspace scope's optional assignee-type narrowing, with a compile test covering the predicate and the invalid-type rejection. Co-Authored-By: Claude Fable 5 <[email protected]> * refactor(issues): single assignee-type mapping + per-page tab state One core helper pair (assigneeTypesForActorKind / actorKindForViewVariant) replaces the four inlined tab->assignee_types literal mappings across query-plan, the table spec compiler, and the view-variant application. The assignee-type tab store becomes page-keyed (issues / project:<id>, persisted with a v0 migration carrying the old global tab to the Issues page): switching tabs inside a project no longer drags the Issues page or other projects along. The save-view dialog's default variant now comes from the header's own page tab, restoring the /issues default that the surface-side injection had dropped. Co-Authored-By: Claude Fable 5 <[email protected]> * refactor(issues): converge all issue surfaces onto the table query channel The legacy list/grouped query layer duplicated the table channel's scope semantics behind enable-conditions that were provably always false: list and status-board ride server status branches, every non-status grouping and swimlane ride server group branches, and the assignee-board / per-status load-more / flat-list / flat-export / client-side my:any 3-request merge fallbacks could never execute. Delete the whole layer: - queries: myIssueListOptions, myIssueAssigneeGroupsOptions, issueAssigneeGroupsOptions, issueFlatListOptions/ExportOptions, fetchAllFlatPages / fetchAllMyFlatIssues / fetchAllMyFirstPages / fetchAllMyAssigneeGroups, compareIssuesForSort (server owns ordering) - mutations: useLoadMoreByStatus, useLoadMoreByAssigneeGroup - board/swimlane: the never-taken client-paginated columns, property pool loader, hidden-column client counts, legacy footer rows - surface data hook / controller: dead queries and passthrough fields - gantt keeps its scheduled-only endpoint and now compiles the assignee-type tab into it, restoring the change alongside the cleanup query-plan shrinks to the scope's non-Table residue: scopeKey, the gantt filter, and create defaults. issueKeys and cache invalidation prefixes are untouched; mobile's own data layer is unaffected. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issues): review fixes — active view's variant wins in save dialog, stable dialog scope, dead mocks Post-review pass on the convergence refactor: - The save dialog's default variant now prefers the OPEN view's scope_variant over the page tab: saving a copy while an agents-scoped view is open must describe the rows on screen, not the tab underneath. - The dialog scope prop is memoized on primitive projections — a fresh object per header render re-armed the dialog's draft-reset effect, so any background refetch wiped a half-typed name. - Tests: persist migration coverage for the page-keyed tab store; gantt assignee_types reaches the request and forks the cache key. - Dead legacy load-more mocks removed from three view test files; stale hidden-columns doc comment; unknown hidden-column totals render no count instead of a blank; my-relation plan branch is return-exhaustive; leftover blank lines. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issues): keep the grabbing cursor for the whole row drag in manage views The handle's cursor classes only apply while the pointer hovers the handle — the vertical-locked drag loses it immediately, and buttons the pointer crosses flip it to pointer. Mirror the sidebar-resize cursor contract: an html[data-dnd-dragging] rule forces cursor: grabbing (and no text selection) document-wide for the drag's duration, toggled by the DndContext lifecycle with an unmount safety net. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issues): my views apply their relation at the query layer, not by switching the tab Opening a my-view used to overwrite the user's relation tab with the view's variant (VARIANT_TO_TAB), so when an open view vanished (deleted / access revoked) the fallback landed on the view's tab instead of where the user actually was. Views are now applied the same way on all three surfaces: while a view is open, effectiveScope substitutes the view's variant — assignee-type for workspace/project, relation for my — and the user's own tab state is never touched. myRelationForViewVariant joins its sibling mapping in scope.ts with unit coverage. Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issues): ease the save-view dialog's expand and validation transitions The display-defaults section snapped open/closed and the name-required hint popped in. The panel now runs the Base UI height transition (--collapsible-panel-height + data-starting/ending-style — the tw-animate collapsible keyframes only know Radix's variable, so the accordion's animate-* classes cannot be reused here), and the hint eases in with the house animate-in vocabulary. Scoped to this dialog: the shared Collapsible primitive stays untouched because chat/transcript panels grow while open and a pinned panel height would clip them. Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issues): filter chips ease in Chips popped into the bar and the save-view dialog's embedded list with no transition. Each chip now enters with the house fade+zoom vocabulary, keyed per dimension so value edits inside an existing chip don't re-animate. No exit animation by design. Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issue-views): drag-reorder the view bar, tab context menu, pin views to sidebar The bar's tabs (built-ins and saved views alike) now reorder by drag, writing the same per-user preference document the manage dialog edits — one list, two editors. Saved-view tabs gain a context menu: - Edit: visible to everyone, disabled without manage permission (the greyed row is the signal); Delete: rendered only for the owner or a workspace admin on shared views — mirroring the server's canManageIssueView — and routed through the shared confirm dialog now extracted from the manage dialog. The manage dialog's own affordances follow the same widened rule (admins included). - Pin to sidebar: pinned_item grows a 'view' type (migration 267; create validates read access so foreign private views 404). Sidebar pin rows resolve the view's name via the new GET-by-id options, render the view-bar icon, activate the view in the store and navigate to its owning surface on click. Flag-off clients keep view pins dormant instead of auto-unpinning; deleted views follow the existing 404-auto-unpin pattern. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): review fixes — guard preference wipes, never auto-unpin views Two data-loss paths from review, plus hardening: - A drag landing before the views list loaded pruned the preference document against an empty list, silently erasing every saved-view entry from order and hidden. savePrefs now refuses to write until the list has actually loaded (viewsReady threads down from useActiveIssueView). - View pins are exempt from 404-auto-unpin: the detail endpoint also 404s when the feature flag is switched off server-side, and a client that booted with the flag on cannot tell the difference — a flag flip would have deleted every view pin permanently. Deleted views' rows hide instead. - POST /api/pins with item_type=view now sits behind the same feature gate as every other view endpoint. - SortableBarTab: drop dnd-kit's aria attributes (they nested a second focusable role=button around the tab), move the wasDragged ref into an effect, and clear it after a cancelled drag so the next real click isn't swallowed. - Sidebar view pins carry ?view= in their href so a web reload stays on the view; scope resolution collapsed to one branch driving both the path and the container key. - Malformed GET /api/issue-views/{id} responses pinned by test as null (hide the row), never an error (which alone may unpin). Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issue-views): single-row view bar with overflow popover The bar no longer wraps: tabs that don't fit collapse into a trailing popover, Linear-style, with two differences — built-ins are first-class orderable entries, and the whole list (hidden rows dimmed) lives in the pulled-down panel with drag reorder, per-row actions (edit/delete under the same permission rule, pin, hide/show) and a new-view entry. - useSingleRowFit (views/common): hidden mirror row measures natural tab widths; ResizeObserver + change-guarded per-commit remeasure computes the fitting prefix with trigger reservation. First reusable collapse-into-menu primitive in the repo. - The open view is always visible: when it overflows it takes the trigger slot, showing its name + chevron (wider reservation; the two-pass promotion settle is monotonic, no oscillation). - Every tab now shares one max width (max-w-40) and truncates with the full name in tooltips/rows. - ManageViewsDialog retired: the popover carries all of its duties (reorder, hide/show incl. built-ins, edit/delete); hiding stays impossible for the anchor built-in and still exits a hidden open view. DeleteViewConfirm and ViewBarItem move to view-bar-popover.tsx; orphaned i18n keys dropped, row action keys added x4 locales. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): restore [layers] menu + manage dialog; 'more' appears only on overflow; fix zero-width bar The single-row rework overreached: it replaced the existing new/manage entries and dialog with an all-in-one popover, and the bar's container collapsed to zero width (content-sized parent + measurement feedback), rendering an empty header. Back to the intended shape: - The [layers] menu (new view / manage views) and the manage dialog are restored exactly as before; DeleteViewConfirm and ViewBarItem stay shared from view-bar-popover. - The 'more' trigger exists ONLY while tabs overflow, showing just the overflowed entries (click to open; per-row edit/delete/pin/hide with the same permission rules — those tabs have no rendered surface to right-click). The open view still takes the trigger slot when it overflows. - Header view-bar slots gain flex-1 so the fit measures a real width; reserve tiers (menu / +more / +promoted) settle monotonically. Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issue-views): list panel shows ALL tabs with drag reorder; trigger always visible The pulled-down panel now lists every bar entry (built-ins included) in bar order with vertical drag reorder — since the bar renders the longest fitting prefix, reordering in the panel directly decides what sits on the bar versus tucks away, with a separator marking the current fold. The trigger no longer appears/disappears with overflow: it is a fixed part of the bar's right side (still replaced by the open view's own tab when that view overflows). Row actions (edit/delete/pin/hide) and the [layers] new/manage entries are unchanged. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): kill the post-drop bounce with a synchronous order mirror The preference mutation is optimistic but its onMutate is async: on drop, dnd-kit clears the drag transform in the same frame while the reordered list lands a beat later, so the dropped tab flashed back to its old slot and then jumped — the manage dialog never bounced because it mirrors the new order synchronously in the drag handler. Both bar drag and panel drag now route through applyMove: set a local order mirror in the same tick, persist, and clear the mirror once the preference data catches up. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): panel row menu is always visible, not hover-revealed Hover-reveal made the three-dot row menu unreachable on touch devices; it is a primary affordance in the list panel, so it renders always. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): long view names truncate/wrap everywhere The manage dialog's row strip is a grid item — min-width:auto let an 80-char name blow past the dialog frame, carrying the row controls with it. Pin the wrapper (min-w-0 + overflow-hidden) and the rows (min-w-0) so labels truncate as designed. The delete confirmation and the save dialog's scope hint interpolate names/titles into prose — break-words so an unbroken name wraps instead of overflowing. Audited every other name surface (bar tabs, promoted trigger, panel rows, sidebar pins, context menus): all already bounded. Co-Authored-By: Claude Fable 5 <[email protected]> * refactor(issue-views): remove the saved_issue_views_v1 feature flag The feature ships unconditionally. The flag never reached any released client, so — unlike the retired flags that live on as always-on compat keys — the key is removed outright on both sides: - server: the issueViewsEnabled 404 gate drops from every view endpoint, the preference endpoints and the view pin type; the key leaves the registry and the frontend-public list; gate tests and the per-test enable helper go with it - frontend: the === true checks, the pre-view-bar fallback tab rows in both headers, and useActiveIssueView's enabled parameter are deleted; the view bar is now the only rendering - compat holds by construction: a NEW client against an OLD backend gets 404s on the list/preference queries → empty views, viewsReady stays false (no preference writes), sidebar view pins hide without auto-unpinning (comment updated to name this as the reason); an OLD client against the NEW backend reads an absent flag as false and keeps the feature hidden Full-suite run also surfaced that the workspace deletion manifest never classified issue_view / issue_view_preference: both now fall with the other issue roots in DeleteWorkspaceIssueRoots (no-FK policy: explicit teardown), and the manifest records the decision. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): fit reserve applies even when every tab fits The all-fit fast path skipped the reserve — a leftover from when the overflow trigger only rendered on overflow. With the list trigger and the [layers] menu now permanent, a row whose tabs exactly filled the container declared everything fitting and overflow-hidden clipped the trailing chrome. The reserve is now subtracted unconditionally. Co-Authored-By: Claude Fable 5 <[email protected]> * feat(issue-views): a freshly created view opens itself Creating a view now activates it on its surface immediately — the view you just saved is the one you meant to be looking at. Skipped only when the create response fails to parse (the list refetch still shows it). Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): creating a view no longer bounces the surface to the default tab Activation raced the list invalidation: the fresh view's id was set active before the refetched list contained it, the stale list read as 'view deleted', and the missing-view fallback kicked the surface back to the anchor tab (with an 'unavailable' toast). Two-sided fix: the create mutation seeds the created view into its scope's list cache before invalidating, and the missing verdict now requires the list to not be mid-refetch. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): release-gate fixes — rollback safety, limits, old-client pin compat, revoke sweep Addresses the pre-ship review blockers: - CI styles: text-muted-foreground/70 -> text-faint-foreground (solid tone rule) in the header hint and the dialog my-note; the chip emoji stack's text-[9px] -> text-micro (type-scale rule). apps/web text-contrast + type-scale suites pass. - migration 269 down normalizes workspace/project members/agents variants to NULL before restoring the old constraints — verified on a scratch DB with real variant data (up -> write -> down). - view pins are withheld from the legacy /api/pins contract unless the client opts in (?include=view): old Desktop builds classified any non-issue pin as a project pin and permanently auto-unpinned it on 404. New clients opt in; regression test pins one and asserts the legacy list never leaks it. - write hardening: MaxBytesReader (128KB) on view create/update and preference PUT; isJSONObject rejects JSON null (was a DB-CHECK 500); per-owner view quota (100/workspace); list query LIMIT 200. Boundary tests for null blob, oversized body, and quota. - member revoke now sweeps the departed member's PRIVATE views and their view-bar preferences in the same transaction (shared views stay, same rule as private quick actions) — the migration comments promised this; test covers private-gone/shared-stays/prefs-gone. - sidebar comment no longer claims project ?view= reload support. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(issue-views): deleting a view sweeps its sidebar pins on every path View pins never auto-unpin client-side (old-backend safety), so a pin whose view is gone was invisible in the UI and unremovable forever — and could leave an empty-looking Pinned group. All three deletion paths now sweep matching pinned_item rows atomically in the same statement via CTEs: direct view delete, project deletion (its scoped views), and member revoke (their private views). Regression tests cover each path. Co-Authored-By: Claude Fable 5 <[email protected]> --------- Co-authored-by: Claude Fable 5 <[email protected]> |
||
|
|
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. |
||
|
|
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]>
|