mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
8ade96e671bca7d84fd2d764ffca305e2fdc358d
894
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]> |
||
|
|
a4e1ea7140 |
MUL-6581: expose globally versioned Plugin Public API (#7446)
* feat(plugins): expose globally versioned public API Co-authored-by: multica-agent <[email protected]> * fix(plugins): proxy same-origin public API 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]> |
||
|
|
bedad9e222 |
MUL-6485: isolate hosted plugin surfaces (#7329)
* 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]> * feat(plugins): isolate hosted surfaces (MUL-6485) Co-authored-by: multica-agent <[email protected]> * 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 * fix(plugins): refresh issue-scoped surface launches Co-authored-by: multica-agent <[email protected]> * feat(plugins): isolate hosted surfaces (MUL-6485) Co-authored-by: multica-agent <[email protected]> * fix(plugins): refresh issue-scoped surface launches Co-authored-by: multica-agent <[email protected]> * fix(plugins): make surface termination event-driven Co-authored-by: multica-agent <[email protected]> * feat(plugins): isolate hosted surfaces (MUL-6485) Co-authored-by: multica-agent <[email protected]> * fix(plugins): refresh issue-scoped surface launches Co-authored-by: multica-agent <[email protected]> * fix(plugins): make surface termination event-driven Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[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]> |
||
|
|
6431076012 |
MUL-6552 docs(self-hosting): correct the PostgreSQL extension requirements (#7421)
* docs(self-hosting): correct the PostgreSQL extension requirements The docs claimed Multica requires pgvector and told operators to run `CREATE EXTENSION IF NOT EXISTS vector`. Nothing in the codebase uses it: across all 429 migrations there is no `vector` column and no migration creating the extension, and no Go package imports a pgvector driver. On a stock PostgreSQL 17 that `CREATE EXTENSION` fails outright, so the instruction actively blocked the "use your own PostgreSQL" path it appeared in. There are exactly four `CREATE EXTENSION` statements in the migration set. `pgcrypto` (001) and `pg_trgm` (137) are bare statements that fail the migration when missing; both ship with every standard PostgreSQL 17 build. `pg_bigm` (032) and `pg_cron` (076) are wrapped in `DO ... EXCEPTION` and skip gracefully. Document all four with their real status, and note that a `schema_migrations` row does not prove a conditionally skipped object exists. `pg_bigm` is described as what it actually is — a CJK search-quality improvement, not a correctness requirement. Migrations 138-142 install portable `pg_trgm` fallback indexes covering every column the search handlers touch, so a deployment without `pg_bigm` still searches on an index. References to the bundled image name are left alone: those describe which image the compose file pulls, which is still accurate. The Helm chart description and values comment are updated because they present pgvector as part of the stack rather than as an image name. Co-authored-by: sail-cn <[email protected]> Co-authored-by: multica-agent <[email protected]> * docs(self-hosting): document the local disk attachment storage variables The File Storage section only covered S3 and CloudFront, but local disk is what the backend uses whenever `S3_BUCKET` is unset — every default self-host install. `LOCAL_UPLOAD_DIR` and `LOCAL_UPLOAD_BASE_URL` were absent from the self-hosting docs. The default `./data/uploads` resolves against the backend's working directory. Inside the bundled image that is `/app`, where the compose file mounts the `backend_uploads` volume, so it happens to be correct. Running the binary manually resolves it against whatever directory the process was launched from, which silently scatters attachments as that directory changes and leaves `attachment` rows pointing at files the server no longer looks for. Co-authored-by: sail-cn <[email protected]> Co-authored-by: multica-agent <[email protected]> * docs(search): fix the migration numbers in the statement-timeout comment The comment on `searchStatementTimeout` cited migration 134 as the one that installed the extensions the search fast path needs. 134 is `134_runtime_profile_add_qoder`, which widens a `protocol_family` CHECK constraint and has nothing to do with search. The migration that runs `CREATE EXTENSION IF NOT EXISTS pg_trgm` is 137. The referenced index list is also extended to 137-142: 137 only installs the extension, while the trigram indexes themselves are created by 138-142. Co-authored-by: sail-cn <[email protected]> Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: sail-cn <[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]> |
||
|
|
8f9c766e40 |
MUL-6460: feat(brief): inject the workspace status catalog into the agent brief (#7306)
Agents could not see custom issue statuses (MUL-6243): the brief's status command line hardcoded the seven built-in keys, so a workspace's Human Review / Rework statuses were invisible even when instructions asked for them, and the CLI offers no catalog read an agent could fall back on. Ship the catalog on the claim payload, the same path WorkspaceContext already rides: buildClaimedTaskResponse reads the workspace's ACTIVE CUSTOM statuses (built-ins stay off the wire — their keys, names and semantics are locked) and the daemon copies them into TaskContextForEnv. The brief's status bullet then renders the workspace's statuses grouped by category, with each custom status's name and description — category anchors the inherited platform behavior, name is what users call the status in instructions, and description is the admin's disambiguator when a category holds more than one status. A bridge bullet in the issue workflow connects the existing category-level status rules (which stay untouched) to the concrete key choice. With no custom statuses — every old-server pairing included — both renders are byte-identical to before, keeping the prompt-cache prefix stable for existing deployments (MUL-5377). The catalog is durable workspace configuration like WorkspaceContext, not per-turn state, so it belongs in the brief rather than the per-turn prompt. The claim read is not gated on the custom_issue_statuses flag: the flag only guards creation, and a catalog written before a flag flip must stay visible as long as issues can sit on it. Name/description are user-authored and pass through sanitizeNameForBriefMarkdown; a key failing the code-token guard drops its entry. A 30-entry cap with an explicit omitted count keeps a scripted catalog from flooding the brief while disclosing the truncation. Also update the built-in skills that document status semantics (multica-working-on-issues, multica-squads) and their source maps: statuses beyond the built-ins inherit their category's behavior in full, with the two literal-key exceptions (failed-task rollback writes todo, merged close-intent PR writes done) called out. Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
13f9f8e5ca |
MUL-6529: let admins add workspace seats (#7388)
* feat(billing): let admins add workspace seats (MUL-6529) Co-authored-by: multica-agent <[email protected]> * fix(billing): recover seat purchase retries Co-authored-by: multica-agent <[email protected]> * fix(billing): reconcile seat purchase feedback Co-authored-by: multica-agent <[email protected]> * fix(billing): parse processing seat purchases Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[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]> |
||
|
|
18a85b8c7f |
fix(github): support single-character issue prefixes (#7385)
Co-authored-by: Rogee <[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 |
||
|
|
3adc0061a9 |
MUL-6490 fix(server): keep the A2A originator chain across cross-issue agent comments (#7381)
* fix(server): keep the originator chain across cross-issue agent comments An agent comment only recorded its authoring run in source_task_id when that run's task was on the SAME issue as the comment. The ordinary "agent creates issue Y, then coordinates there" flow therefore persisted a NULL lineage: the run that comment woke resolved as unattributed, and every @mention, assign and sub-issue inside it hit invocation_not_allowed — while the identical delegation on the originating issue succeeded. Admins recovered by widening public_to to workspace, which dismantles the minimum-privilege setup the gate exists for. Stamp the lineage unconditionally. The header it comes from is server-trusted (the task token forces X-Task-ID and X-Agent-ID from its own row; resolveActor rejects the JWT path unless the task belongs to the agent), and propagation is monotonic: a run can only carry the human it already acts for, and every hop re-runs canInvokeAgent against that same human. Substituting a human — the owner fallback the report proposed, or adopting the target issue's originator — stays rejected. The same-issue requirement moves to the consumers that actually need it: the reply-parent / no_action guards, and autopilotDelegationAuthority, which already verified task.issue_id itself (MUL-4857). The edit path re-stamps the same way, so the admission gate and the persisted chain can no longer disagree — the split that let the gate admit a delegation whose run then started with no authority. Also name the root cause in the logs when the gate denies an agent whose chain carries no human. The wire reason stays the deliberately generic invocation_not_allowed, so the one denial a workspace can fix was previously diagnosable only by querying agent_task_queue directly. Fixes #7328 Co-authored-by: multica-agent <[email protected]> * docs(skills): document that a delegation chain keeps its human across issues Co-authored-by: multica-agent <[email protected]> * test(server): build the cross-issue chain suite on the shared fixtures Rewrite the MUL-6490 regression suite against internal/testutil instead of hand-rolled setup, per the repo rule that DB-backed handler tests go through dbfx and testutil.Call: dbfx.Agent/Issue/Task/InsertNoID for rows (with explicit teardown for the grants and handler-written rows the fixture cannot know about), and testutil.WithHeaders/WithURLParams/Call for requests. Drive the assign and sub-issue delegations through the real UpdateIssue and CreateIssue handlers rather than calling validateAssigneePair directly. This fix's risk is that one entry point resolves the originator chain differently from another, and only the wired path can hold that: the suite now runs {mention, assign, sub-issue} x {an allow-listed human's chain, another member's chain, no chain} from inside a run woken across an issue boundary. Issue numbers come from the workspace counter here, because CreateIssue allocates from it and dbfx's MAX(number)+1 default would collide with the sub-issue the test asks the handler to create. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
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]> |
||
|
|
6450c7ad30 |
Revert "chore(server): log task message batch size (#7371)" (#7382)
This reverts commit
|
||
|
|
1dc135598e |
MUL-6343: Enforce recently created issue windows (#7305)
* MUL-6343: enforce recently created issue windows Co-authored-by: multica-agent <[email protected]> * fix(issues): address creation window review Co-authored-by: multica-agent <[email protected]> * fix(issues): bound table window ID cache Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
52a063d0df |
chore(server): log task message batch size (#7371)
Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
3622db21cf |
MUL-6491 fix(daemon): give each task an exclusively claimed env root (#7347)
* fix(daemon): derive task env roots and branches from the full task id Task ids are UUIDv7, whose leading 8 hex chars are the high 32 bits of a 48-bit millisecond timestamp: they only advance once every 2^16 ms (~65.5s). Deriving the env-root segment from that prefix meant every task created inside one such window landed on the same directory. Prepare removes an existing env root before recreating it, guarded only by the comment "task IDs are unique". That premise held for the id but not for the truncation, so the second task's Prepare ran os.RemoveAll over a live sibling's env root — taking its workdir (including a worktree with uncommitted work), output/, logs/ and the 0700 multica-config/ with it. Overwritten CLAUDE.md / AGENTS.md identity files were the visible symptom; the deletion was the actual damage. Use the whole id for the env root and for the agent/<name>/<task> branch names, which truncated the same way and collided for concurrent tasks of one agent. shortID stays for logs and display only. Prepare now derives its root through PredictRootDir so the two can no longer drift apart. Existing short-prefix directories need no migration: the GC enumerates task dirs with ReadDir and reads .gc_meta.json, so it still reclaims them. Fixes #7326 Co-authored-by: multica-agent <[email protected]> * fix(daemon): take the task path segment from the id's random tail The first attempt used the whole 32-char task id, which overflowed Windows MAX_PATH: a branch name becomes a path under .git/refs/heads/ inside an already-deep task checkout, and CI failed with cannot lock ref 'refs/heads/agent/windows-codex/<32 chars>': Filename too long Length was never the fix. The bug was which end the segment came from: a UUIDv7 leads with a 48-bit millisecond timestamp, so its first 8 hex chars are the high 32 bits and only advance once every ~65.5s. Read from the tail instead, where the id is random, and 12 chars are enough — 4 more than the original segment rather than 24. That trades a certainty for a probability, so make the destructive step prove itself instead of assuming: Prepare stamps a .task_owner marker into the env root and refuses to run os.RemoveAll over a root another task owns. A rerun of the same task still resets its own directory. A task that fails to start is recoverable; one that deletes a running sibling's uncommitted work is not. Two existing fixtures used synthetic ids differing only in the head and so shared a tail. The guard caught them; they now differ where a real UUIDv7 does. Fixes #7326 Co-authored-by: multica-agent <[email protected]> * fix(daemon): make the env-root claim atomic instead of check-then-delete Review caught that the ownership guard did not cover the case it was added for. It read .task_owner, then deleted, then wrote the marker — three steps, so two tasks sharing a taskKey could both observe "no owner", and the slower one would still os.RemoveAll the faster one's live directory. The guard only held when a foreign marker was already durably in place, i.e. serially. Replace it with a claim protocol built on two atomic filesystem primitives: os.Mkdir fails if the directory exists, and O_EXCL fails if the marker exists. Exactly one racing caller wins each, and a caller that wins neither never reaches the destructive path. The claim is then held for the rest of Prepare. A rerun clears the env root's CONTENTS and leaves the marker in place rather than removing and recreating the directory, so there is no window in which the root looks unowned to a racing task. A directory that holds files but names no owner now fails closed instead of being deleted as abandoned. The marker is written before any content, so the only unowned directory that is safe to adopt is an empty one — the residue of a crash between Mkdir and the marker write, which would otherwise wedge the task permanently. Tests: a barrier-synchronised concurrent Prepare asserting exactly one task starts and the winner's tree survives (fails against the previous sequence with "directory not empty" / "invalid argument" — the deletion racing the create), plus fail-closed and empty-adoption cases. repocache carried a third copy of the task-segment rule with no test; it now has the same contract test as the handler. Fixes #7326 Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]> |
||
|
|
ec6a7b1285 |
MUL-6356: fix(skills): skip binary supporting files in runtime-local skill sync (#7175)
* fix(skills): skip binary supporting files in runtime-local skill sync collectLocalSkillFiles read every supporting file into SkillFileData.Content as a Go string. That string crosses the daemon/server hop as JSON, and encoding/json rewrites each invalid UTF-8 byte to U+FFFD, so writeSkillFiles recreated a file whose bytes differ from the original. A .docx, .xlsx or .pdf carried in a runtime-local skill bundle therefore arrived in the prepared task environment corrupt and would not open. The archive/URL importer already refuses this class of file for the mirror-image reason: those bytes cannot go into a PG TEXT column. Its extension blacklist moves to internal/skill as IsLikelyBinaryFilePath so both ingestion paths share one definition, and runtime-local collection now skips the same files with a log breadcrumb, matching the importer's behaviour. The skip does not depend on includeContent: discovery reports the bundle with content omitted and sync uploads it with content included, so a skip in only one pass would leave the two disagreeing about which files make up the bundle. Fixes #7143 * fix(skills): catch binary supporting files by content, not just extension collectLocalSkillFiles only skipped files whose extension matched a fixed blacklist, so a binary file with an unlisted or missing extension (.safetensors, .parquet, a stray no-extension blob) still went through SkillFileData.Content and came back corrupted on the other side. Read the file and require utf8.Valid on both the includeContent=false (discovery) and includeContent=true (sync) passes so they keep agreeing on the bundle; IsLikelyBinaryFilePath stays as the cheap extension-based first pass. Adds regression cases for an unlisted binary extension, a no-extension binary file, and a valid-UTF-8 text file that must still pass through untouched. * fix(skills): require NUL-free content, check extension before reading utf8.Valid alone is not a sufficient round-trip guarantee: a NUL byte is legal UTF-8, but the server-side import path strips every 0x00 via sanitizeNullBytes (server/internal/handler/skill_create.go), so a file that is valid UTF-8 but contains an embedded NUL still comes back different from what went in. UTF-16LE text made of ASCII characters is a realistic example. Require content to be both valid UTF-8 and NUL-free, with embedded_nul as its own skip reason distinct from invalid_utf8. Also reorders the extension check ahead of the read: IsLikelyBinaryFilePath is genuinely the cheap first pass now, so a known-binary extension never pays for the os.ReadFile it doesn't need. Adds regression cases for an unlisted extension and a no-extension file, each containing valid UTF-8 with an embedded NUL. |
||
|
|
fa34f0e134 |
MUL-6243 test(gc): pin that gc-check reports a status's category, not its key (#7327)
* test(gc): pin that gc-check reports a status's category, not its key
The daemon GC decides reclaim eligibility with literal string comparisons
— `result.Status == "done" || "cancelled"`, and an `isKnownIssueStatus`
switch over the 7 built-ins. Neither knows custom statuses exist, and
that is correct: an installed daemon has no catalog, and daemons
predating MUL-6243 must keep deciding correctly against an upgraded
server.
So the normalization belongs to the server, and both gc-check endpoints
already do it via issuestatus.Effective. Nothing pinned that. Dropping
either call compiles, passes every existing test, and silently means:
- an issue on a `done`-category custom status is never terminal, so its
workdir is retained indefinitely, and
- isKnownIssueStatus rejects the raw key, disabling the
GCCompletedTaskTTL full-cleanup path for that issue.
Adds the regression test over both endpoints (batch and the legacy
per-issue fallback older daemons still call), and records the invariant
on the daemon side, where a reader of those literals is most likely to
conclude the function needs teaching about custom statuses.
No behavior change.
MUL-6243
Co-authored-by: multica-agent <[email protected]>
* fix(gc): resolve a gc-check batch with one catalog read, not one per row
Elon's review. BatchIssueGCCheck resolved each row through the
package-level issuestatus.Effective, which issues a
GetIssueStatusEntryByKey for every CUSTOM key it sees. With the batch
capped at 500 ids, one request could cost 500 catalog point lookups — on
the endpoint that exists specifically to replace per-issue requests, and
which every installed daemon runs on a timer. Enabling custom statuses
would have turned the GC loop into a query amplifier.
A request-scoped issuestatus.NewResolver reads the catalog lazily and at
most once, so cost is flat in the number of custom rows and still zero
when a batch holds only built-ins. The legacy per-issue endpoint keeps
the single-shot Effective — one issue, at most one lookup — but now goes
through the same injection point so its cost is observable too.
Pinned with the existing counting querier, extended to count per-key
lookups: that count IS the N+1, and nothing was watching it. Reverting to
the package-level call fails with "per-key catalog lookups = 4, want 0".
MUL-6243
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]> |
||
|
|
a12985ab01 |
MUL-6472: fix(autopilots): stop leaking dispatch error text through Run now (#7311)
* MUL-6472: fix(server): stop echoing dispatch errors from TriggerAutopilot The unclassified-failure branch appended err.Error() to its 500 body, so a member clicking "run now" could read the Go error chain behind it — pgx table and constraint names, SQLSTATE, internal ids. It was the only 5xx in this file that did; the other err.Error() sites are 400s written for the user. Return the fixed string and log the chain server-side instead, matching how autopilot_webhook.go reports its own internal failures. The quota branch above keeps its structured 429 + Retry-After body untouched. Co-authored-by: multica-agent <[email protected]> * MUL-6472: fix(views): keep server 5xx text out of the run-now toast The catch branch fell back to err.message, which is the server's error string — so the fixed 500 above would still have shown as English "failed to trigger autopilot" in a Chinese UI, and any other 5xx would show its raw body. clientErrorMessage() keeps a 4xx message (handlers write those for the user: "autopilot is not active") and withholds a 5xx one or a transport failure, so run-now degrades to its localized generic sentence. Typed reason_code outcomes are unaffected — they were already localized. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
cc21d19353 |
MUL-6463: confirm the promotion that starts an agent run (#7293)
* feat(issues): confirm the promotion that starts an agent run (MUL-6463) Assigning an agent to an issue opens a confirmation with a handoff note and "Don't start yet". Promoting a parked issue that ALREADY has an agent owner starts a run just as directly — the status change alone is what fires RunSourceStatus — but applied silently from a one-click picker, with no way to tell the agent anything before it woke up. The dialog now covers both writes. `RunConfirmModal` gains a `promote` mode that submits the status change (plus the same optional handoff note, or suppress_run for "Don't start yet") instead of an assignee change; the owner is already on the issue, so the promotion payload carries the status and nothing else. The gate is the run predicate, not a status allow-list: parked (backlog CATEGORY) + agent/squad owner + a target outside backlog/done/cancelled. So built-in `todo` and every custom Todo-category status behave identically, which is what the report asked for, and so do the other promotion targets that start a run the same way. Drag-and-drop and the batch toolbar keep applying directly — the existing split (MUL-4155), not a new one. Also fixes the assign gate next to it, which still compared the raw status KEY to "backlog": an issue parked in a CUSTOM backlog-category status popped a confirm promising a run the server would never start. Both gates now resolve the category the way the server does (issuestatus.Effective), preferring the payload's own status_category and falling back to the workspace catalog. Tests: promotion routing for built-in todo / custom todo / custom backlog origin / non-todo target, the five no-run status changes that stay direct, the custom-backlog assign regression, and the modal's promote payloads (note, suppress_run, no assignee fields) plus its status naming. Verified each fails without the change. Docs: the promotion dialog in assigning-issues (en/zh/ja/ko). Co-authored-by: multica-agent <[email protected]> * fix(issues): bold the status name in the promotion confirm "Move this issue to Rework." left the status looking like an ordinary word — worse for a custom status an admin named something like Later or Blocked-ish. The headline already had a fence-and-bold mechanism for the assignee name, so generalize it to bold every fenced span and fence the status too. CJK copy drops the 「」 it used for the same job: bold is now the one mechanism across all four locales. Co-authored-by: multica-agent <[email protected]> * fix(issues): close the three run-confirm gaps from review (MUL-6463) 1. Server and client disagreed about moving WITHIN the backlog category. `WillEnqueueRun`'s status branch only excluded `done`/`cancelled`, so `backlog` → a custom Backlog-category status (`Later`) changed the key, normalized both sides to `backlog`, and still enqueued a run — while the client, which reads categories, applied it silently. Before custom statuses the two conditions were the same; now leaving the category is its own requirement. Regression test covers both halves: no run inside the category, still exactly one on the way out. 2. The table's inline pickers bypassed the gate entirely — `meta.updateIssue` went straight to the surface action, so promoting an agent-owned backlog row from the table started a run with no confirmation. `meta.updateIssue` now takes the issue (the gate needs its category and owner) and routes through the same intent. 3. "Unresolvable category" was collapsing into a guess. `catalog.categoryOf` answers `todo` for a custom key it has not loaded, which made an unknown status read as not-parked and skipped the promote dialog. Category resolution is now explicitly three-state — carried, built-in, catalog, else null — and both writes that can start a run confirm on null. The gate itself moves to a pure `runConfirmIntent` next to a canonical matrix test, so the issue detail, the context menu and the table all route on one answer instead of three copies; the hook/table suites keep only their wiring. Docs nit from the same review: the new CJK paragraphs said issue / イシュー / 이슈 where the glossary requires 任务 / タスク / 태스크, and named statuses in capitalized prose instead of backticked keys. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
cb6b0c885f |
MUL-6396: remove unused task message clipping flag (#7292)
* refactor(realtime): remove unused task message clipping flag Co-authored-by: multica-agent <[email protected]> * refactor(realtime): remove task message clipping consumers Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
9900e96686 |
MUL-6458: feat(issue-statuses): sync the status catalog over the realtime channel (#7287)
* feat(issue-statuses): sync the status catalog over the realtime channel An admin editing the workspace status catalog was invisible to every other tab and device for up to the query's 5 minute staleTime: the protocol had no issue_status event at all. Other clients kept offering a status the server had archived, and kept painting a renamed one under its old name. Server: one `issue_status:changed` event, published after create, edit, archive and reorder. One event rather than a verb per write because every client answers all four the same way — re-read the catalog — and reorder has no single row to name. Archive and reorder publish after the commit, and the already-archived no-op publishes nothing. Client: the event invalidates the catalog and nothing else. Issue caches are deliberately not dragged along — a row stores the status KEY and resolves its name and colour from the catalog at render time, so refetching the catalog is what repaints the boards. The catalog mutations now install the row (or, for reorder, the list) the server returned instead of invalidating, because the event refreshes the writing tab too. That keeps a catalog edit at ONE catalog read per client rather than two for the admin who made it, and drops the workspace-wide board/list/table refetch a rename used to trigger. Reconnect recovery invalidates the catalog as well, so an edit missed while disconnected is not held behind the staleTime. Co-authored-by: multica-agent <[email protected]> * fix(issue-statuses): stop a slow mutation response from reverting a newer catalog Installing a mutation's response body into the cache is a last-write-wins merge by arrival order, and the HTTP response and the realtime frame are independent channels — the realtime refresh is debounced 100ms on top. So this ordering is legal: A renames, A's response stalls, B renames, A's refetch picks up B's version, A's response lands and rolls the row back to its own older snapshot. Nothing corrects it until the next event or the 5 minute staleTime, in exactly the concurrent-editing case this feature exists for. Reorder was the worst of them: its response is the whole catalog, so one late arrival reverted every concurrent edit at once. No mutation writes its response body any more. The realtime refetch is what converges the cache, and it is ordered correctly by construction — the event is published after the write commits, so the refetch can only read post-commit state. What each mutation writes locally is instead something that stays true whatever else landed in between: - create inserts only when the id is absent. Holding it already means a refetch read it back, so the cached copy is never the older one. - update and reorder keep their optimistic patches and add nothing on success. - archive patches only archived_at. It is monotonic — there is no unarchive, and the server refuses to edit an archived row — so it is safe on any row, where the full snapshot would revert a rename that raced it. Regression tests drive each case through a controlled promise: the newer catalog lands while the write is still in flight, and the assertion is that the newer one survives. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
4ff37edf91 |
fix(daemon): record in_progress when work on the issue's ask starts, not only at turn end (MUL-6417 follow-up) (#7283)
Post-merge report from Bohan: a todo issue assigned to an agent sat in todo for the whole first work turn — half an hour of active work the board attributed to nobody. The end-of-turn-only rule conflated when the agent can JUDGE with when the FACT changes: a turn that advances the issue's own ask makes "being worked" true the moment it begins. The activity indicator shows the run, but columns, filters, and sorting read status — the indicator alone is not the surface people watch. The status rule now has two write moments, same fact anchor: - start: the moment the turn is known to advance the issue's own ask → in_progress (only if it differs — rework on an in_review issue flips it back the same way); - end: the state the work reached (in_review / in_progress-continuing / blocked). An ancillary turn (research, answers, review, discussion) still writes nothing at either moment, so the research-stays-todo case and the concurrent-convergence property from the original MUL-6417 change are unchanged. This is the pre-MUL-6417 timing with the fact anchor's conditionality — not a revert to the unconditional lifecycle flip. Docs, skill reference, and source maps updated to the two-moment rule; guard tests pin the start moment and ban the end-only phrasing. Co-authored-by: Bohan-J <[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]>
|
||
|
|
4f56785b02 |
refactor(daemon): merge Reply/Ownership turn modes into one fact-driven workflow (MUL-6417) (#7245)
* refactor(daemon): merge Reply/Ownership turn modes into one fact-driven workflow (MUL-6417)
The Reply/Ownership split dated from PR #205 and had already been merged
in substance by MUL-5377 (one brief section + mode router) and MUL-6300
(reply turns own the same status arc). What remained was a per-turn
marker plus two mode blocks restating information the per-turn message
already carries as data. This finishes the merge:
- Status is judged once, at the END of the turn, from what the work
actually changed about the issue — not from the trigger type, not from
the run lifecycle, and not gated on being the assignee. No opening
in_progress write: the board's in-flight signal is the issue activity
indicator, and a todo issue the agent was only asked to research now
correctly stays todo (the old unconditional arc got that wrong twice).
Lifecycle writes oscillate under concurrent runs; fact writes converge,
which is what the retired assignee gate existed to protect.
- Delivery routes on data: a triggering comment in the per-turn message
(reply in that thread with THIS turn's --parent, one reply per thread)
or none (post a new top-level comment).
- The blocked rule and the squad-leader dispatch case become end states
of the same judgment instead of mode-block special cases; the leader
keeps one bullet (dispatching members is not delivery). The reply-mode
mention pointer is gone — the discipline lives in ## Mentions.
- The Turn mode markers are removed from the per-turn prompts, deleting
the marker/router-drift failure class (
|
||
|
|
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. |
||
|
|
67c6aa05c4 |
MUL-6166: feat(telegram): add Telegram channel integration (#6944)
* feat(telegram): add Telegram channel integration Co-authored-by: multica-agent <[email protected]> * chore(telegram): rebase onto main and renumber migrations to 366-367 349/350 collided with main's agent_task_queue_chat_terminal_resume_index and agent_task_queue_chat_retired_session_index. Renumber the Telegram origin pair to the next free prefixes, update the cross-references inside the SQL comments, and point telegram_origin_migration_test.go at the new filenames. Contributor-facing behavior is unchanged. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]> Co-authored-by: Bohan-J <[email protected]> |
||
|
|
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]> |
||
|
|
0c0baf0505 |
perf(realtime): stop the task:message fanout from flooding every client (MUL-6396) (#7227)
* perf(realtime): stop the task:message fanout from flooding every client (MUL-6396) `task:message` is broadcast to every client in the workspace for every run, and the client cached all of it: the handler's `(old = [])` default built a timeline entry on first sight, so each client accumulated the full transcript of runs its user would never open, held for the life of the run. Tool input is unbounded on the wire, so a single large Write shipped its whole body to every open client and was retained there. Three changes, each useful on its own: - Client drops frames for tasks no mounted view is rendering. Presence in the query cache is the signal, which registers when a view mounts and before its fetch resolves, so a frame landing mid-backfill is still kept. Rows are persisted before they are broadcast, so a dropped frame costs nothing — whoever opens the task next fetches it. - Server clips oversized `input` / `output` out of the broadcast copy and marks it `truncated`. Persisted rows and the REST list responses are untouched. A client seeing the flag repairs that task's timeline from the DB, once per flush window rather than once per frame. - Client coalesces surviving frames into one cache write per 100ms window, turning a burst of streamed messages into one merge and one render. The window is fixed, not a resetting debounce, so a continuous stream still flushes instead of being starved until it pauses. Mobile keeps its own per-session updater and was already scoped to mounted sessions, but shares the wire format, so it refetches on `truncated` too. The remaining half of the batching idea — one WS frame per reported batch instead of one per message — is deliberately not here. The client identifies itself on the upgrade URL but the server does not retain that version, so a shape change would silently break every older client. It belongs with the client scope-subscription work that `listeners.go` is already waiting on. Co-authored-by: multica-agent <[email protected]> * fix(realtime): keep broadcast clipping off the wire, and stop a late fetch dropping live seqs Addresses both must-fix findings from review. 1. Clipping the broadcast copy is not something an existing client can ignore: it changes what `input` / `output` mean, and every deployed client already consumes them. A build without the `truncated` reader caches the clipped copy under staleTime:Infinity and never refetches, so its execution log would stay incomplete until the window is reloaded — the PR even proposed shipping the server half first, which is exactly backwards. Clipping now sits behind MULTICA_CLIP_TASK_MESSAGE_BROADCAST, off by default, so the reader ships first and the flip is a later, revertible ops step. Capability routing would be the principled fix, but the hub does not retain the client_version it is handed at upgrade and frames cross nodes through the Redis relay as serialized bytes — the same protocol work per-task scope routing is already waiting on. 2. Not caching unopened tasks made first open of a live task fetch where it previously found a pre-seeded cache. That fetch replaced the array wholesale, so any frame written while it was in flight was dropped — and with staleTime:Infinity nothing would ever fetch it again. Every write to the timeline cache now folds through one rule, installed as the query's own structuralSharing: union by seq, server data winning on conflict, rows the response never mentioned kept rather than deleted. That subsumes the backfill's hand-rolled eviction, so the three write paths (fetch, backfill, realtime batch) can no longer disagree. Also documents why mobile's truncated branch is unreachable today: its task:message handler gates on chat_session_id, which the server's payload does not carry, so mobile appends nothing at all. Pre-existing, reported separately, and the branch stays so whichever side is fixed first is not left caching clipped rows. Co-authored-by: multica-agent <[email protected]> * fix(realtime): re-check the timeline is still held before writing a batch Holding was verified when a frame entered the batch, but the write happens up to a window later — and `setQueryData` does not postpone garbage collection. query-core arms that timer when the last observer leaves and never re-arms it on write, so closing a transcript while its run keeps streaming has the entry collected mid-window with a batch already in flight. Writing then rebuilt the entry holding only that batch. Under the app-wide `staleTime: Infinity` the next open reads the stub as fresh and never fetches, so everything before it is missing until the window is reloaded — silent, permanent, and not self-healing. Agent runs routinely outlive the 10 minute gcTime, so "opened a running task, closed it, it kept going" reaches this on an ordinary day rather than in a race. The flush now re-checks immediately before each write and drops the batch if the entry is gone, dropping its repair fetch with it. `backfillTaskMessages` guards its own post-await write for the same reason. Dropping costs nothing: the rows are persisted, so the next open fetches the whole timeline — which the regression test asserts rather than just checking the entry stays absent. Also corrects the doc comment on isTaskMessageTimelineHeld, which described the entry as a mounted view. It means "opened at some point and not yet collected", and the bounded tail is one gcTime. Co-authored-by: multica-agent <[email protected]> * docs(realtime): describe the task:message gate as it actually behaves The overview still said frames are dropped unless a mounted view holds the task, and that the old behaviour retained a transcript for the life of the run. Neither matches the code: the gate is timeline-entry presence (opened at some point, not yet collected), and writes do not postpone GC, so retention is bounded by gcTime rather than the run. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
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>
|
||
|
|
c2474e2fe1 |
fix(agents): align private-agent invoke surfaces with the owner-only contract (MUL-6380) (#7202)
* fix(mobile): gate the chat agent picker on the real invoke rule (MUL-6380) apps/mobile/lib/can-assign-agent.ts was a hand-copied mirror of the pre-MUL-3963 permission model and had drifted from the server in both directions: - it granted workspace owner/admin a bypass on private agents, so an admin could pick a teammate's personal agent in the chat picker and only learn it was refused when the send hit canInvokeAgent (403); - it judged on the derived `visibility` field, which collapses "public_to scoped to specific people" into "private" — so a member the owner had explicitly granted access to never saw that agent on mobile at all. Delete the mirror and call `canAssignAgentToIssue` from @multica/core/permissions directly, the same rule the issue mention bar on this app already uses (mention-suggestion-bar.tsx). The root CLAUDE.md sharing rule allows mobile to import pure functions from @multica/core, so the mirror bought nothing but a second copy to drift. No new tests: the access matrix stays canonical in packages/core/permissions/rules.test.ts, which already covers the admin, owner, plain-member and member-target cases. Co-authored-by: multica-agent <[email protected]> * fix(agents): stop telling users workspace admins can run a personal agent (MUL-6380) Since MUL-3963 the invoke gate is owner-only for a private agent — admins keep management and view access, not the ability to trigger a run. Several strings still describe the old model, and they sit next to controls that enforce the new one: the "Personal" badge tooltip on the agent detail page, overview summary and hover card says "only you and workspace admins can use this agent", while the assignee picker beside it is disabled and reads "only the owner can assign work". - packages/core/agents/visibility-label.ts: fix VISIBILITY_DESCRIPTION and VISIBILITY_TOOLTIP (shown in the create-agent dialog, the visibility picker and the agents-list lock tooltip). The description covers a CHOICE, which submits permission_mode "private" — owner-only, so "Only you can assign". The tooltip covers an EXISTING agent, where `visibility: "private"` is the lossy projection that also covers "shared with specific people" — so it names the owner's grants instead of promising either extreme. The doc comments asserted the opposite contract and cited a long-stale line ref. - packages/views/locales/{en,zh-Hans,ja,ko}/agents.json: same fix for the VisibilityBadge tooltip, which is also its aria-label. Co-authored-by: multica-agent <[email protected]> * fix(issues): stop naming the target's permission mode when an assign is denied (MUL-6380) validateAssigneePair answered a refused assignment with "cannot assign to private agent" / "cannot assign to squad with private leader". Both are wrong twice over: - they disclose the target's permission mode to a caller who was just told they may not use it, which is exactly what ReasonInvocationNotAllowed (dispatch/reason.go) deliberately avoids — it does not distinguish "not permitted" from "does not exist"; - "private agent" is simply inaccurate for a `public_to` agent scoped to specific people, which is refused through the same branch. Report the missing permission instead of the target's configuration. Co-authored-by: multica-agent <[email protected]> * fix(issues): correct the authorization contract stated at the assignment gate (MUL-6380) Review found the drift this PR set out to fix still present in the comments and test docs closest to the gate itself: - validateAssigneePair's doc comment claimed assignment runs canAccessPrivateAgent and that agent callers bypass the gate. It runs canInvokeAgent, and an agent caller is judged by the top-of-chain human originator like anyone else — the code twenty lines below has said so since MUL-3963. - TestShouldEnqueueOnComment_PrivateAgentGate's doc listed "allow workspace owners/admins" and "allow agent-to-agent traffic regardless of visibility"; its own cases assert both are DENIED. - TestCreateIssue_SquadPrivateLeader_OwnerAllowed's doc said a workspace owner can assign; the body uses the leader agent's owner, and an inline comment already corrected the mismatch. - The comment added earlier in this PR overclaimed: it read as if the 403 were enumeration-safe, but the not-in-workspace branch still answers 400. Narrowed to the guarantee actually made — the reason no longer names the permission mode. Also pins the denial BODY, which no test covered: status-only assertions let the reason sentence drift back into describing the target. New cases cover the workspace owner, a plain member, an agent actor with no originator, a `public_to` agent scoped to someone else (the case the old wording called "private", which was simply false), and a private-leader squad. Co-authored-by: multica-agent <[email protected]> * fix(chat): make a session read-only when its agent can no longer be run (MUL-6380) Filtering the agent PICKER was only half the path. A chat session outlives the permission that created it: the agent can be flipped to personal, change owner, or drop the member from its allow-list. The server already treats that as a defined state — the transcript stays readable through the view gate while every send is refused by the invoke gate (MUL-4525, admission_security_mul4525_test.go) — but the clients kept resolving the open session's agent from the full list and never re-checked whether it could still be run. The composer looked live, and the user found out from a 403. Judge the SESSION's agent, not just the picker list, on every surface: - packages/views: `isAgentAccessRevoked` in use-chat-controller.ts and chat-window.tsx, wired into the composer disable, upload affordance, quick actions, queued send-now, and a new AgentAccessRevokedBanner. handleSend keeps a belt-and-braces guard so it never spends a roundtrip on a certain refusal. - apps/mobile: same derivation in the chat tab, plus the failure path — a thrown send only restored the draft, so the 403 read as a silent no-op. It now names the cause, via a mobile-local `dispatchReasonCode` (core's narrows on core's ApiError class, which mobile never throws). Two reason-precedence fixes fall out of this. "No agents in this workspace" was shown when the workspace's ONLY agent was the revoked one — an agent IS bound, it just cannot be run — so revoked access now wins in the placeholder, the banner and mobile's disabled reason. And the queue's Steer button said "Steer is available after the current reply starts", telling the user to wait for something waiting cannot fix; it now states the permission reason. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
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]> |
||
|
|
eb2a94e860 |
MUL-6368: Prevent cross-agent task and workdir contamination (#7205)
* fix(daemon): isolate task identity and workdir reuse Co-authored-by: multica-agent <[email protected]> * test(llm): use validated retry override in contract Co-authored-by: multica-agent <[email protected]> * fix(daemon): address task isolation review Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
24d8c521a6 |
MUL-6364: make the LLM client retry budget explicit, wired, and validated (#7201)
* feat(llm): make the LLM client retry budget explicit, wired, and validated Config.MaxRetries was a bare int that nothing outside tests ever set. The production wiring in handler.New never passed it and no MULTICA_LLM_* env var fed it, so the field was configurable in name only and every deployment ran on the SDK default. Its semantics were also inverted. Because the zero value and an explicit 0 are the same int, "0" fell into the unset branch and silently produced 2 retries, while a negative — clamped to 0 to dodge the panic in option.WithMaxRetries — was the only value that actually disabled retries. - MaxRetries becomes *int so unset, 0 (disabled) and N (exact) stay distinct. New always passes the budget to the SDK explicitly, including the default, so the enforced policy and the reported one cannot drift apart. - Add MULTICA_LLM_MAX_RETRIES as the single configuration source. Non-numeric, negative and above-ceiling values fail the boot instead of being corrected into something that looks configured. The ceiling of 5 is a latency budget: backoff reaches ~21s at 6 retries while the internal callers of this layer time out after 8s and 20s. - Report the effective policy at startup via Client.RetryBudget, whose fields are counts and a fixed enum so the line cannot carry a key or gateway URL. - Document the four states in .env.example and the environment-variable docs, and record what the budget does not cover: the GenerateJSON parameter negotiation and a stream that breaks after ChatStream returns. Tests pin every state by upstream request count, so the unset-versus-zero collapse cannot come back unnoticed. Refs MUL-6364, closes #7154 Co-authored-by: multica-agent <[email protected]> * fix(llm): reject invalid retry budgets at the client boundary and wire self-host Review found the retry budget still reachable in two invalid shapes, and never reachable at all on the main self-hosted deployment path. - docker-compose.selfhost.yml declares the backend environment as an explicit allowlist and mapped none of the MULTICA_LLM_* variables, so every value .env.example and the docs told a self-hoster to set was dropped before the container: the LLM layer read as unconfigured no matter what they wrote. Map all four, and assert propagation in scripts/selfhost-config.test.sh, plus a drift guard so a future MULTICA_LLM_* documented without a mapping fails the script instead of silently going nowhere. - Config.MaxRetries took a *int, so a negative still reached llm.New, which warned and used the default instead. Warning is not silence, but substituting a different budget is still the implicit correction #7154 asked to remove. It now takes *RetryOverride, whose field is unexported and whose only constructor rejects negatives — the invalid state is unrepresentable, so New has no correction branch left to take. - The budget is a ceiling, not a quota: only retryable failures consume it, and a success or the caller's deadline can end a call sooner. Say "at most N" rather than "exactly N" in the field docs, .env.example and all four environment-variable docs. The negative-value test now asserts rejection instead of pinning the fallback as contract. Refs MUL-6364, closes #7154 Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]> |
||
|
|
ec8027cbc6 |
fix(runtime): support restricted Redis request stores (#7179)
MUL-6379 |
||
|
|
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]> |
||
|
|
877a71568f |
test(server): add internal/testutil and migrate the eight heaviest handler suites (MUL-6361) (#7160)
internal/handler held roughly 1000 hand-written `INSERT ... RETURNING id` blocks with a matching `t.Cleanup(DELETE ...)`, and roughly 1200 copies of the recorder / call / status-check / decode quartet. Each copy was an independent statement of the same contract, so changing a shared one meant editing every copy. internal/testutil holds the two halves of that contract once: Fixture builds rows for a test workspace and deletes them again when the test ends; Call runs one handler and records what it wrote, with Want asserting the status and JSON decoding the payload. The eight heaviest suites move onto it: 19,622 lines -> 16,984 (-13.4%). Assertions whose message says something the shared one cannot stay where the test wrote them. Verified by comparing what actually ran: the package's test inventory is identical before and after, 2822 PASS / 44 SKIP / 0 FAIL over the same test names, and go test -race is green for both packages. |
||
|
|
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]> |