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

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

* fix(plugins): close public API contract gaps

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

* fix(issues): enforce revision guard during update

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

---------

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

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

* MUL-6582: add schedule-pulse example plugin

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

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

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

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

---------

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

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

* perf(inbox): narrow archived group scan

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

---------

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

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

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

Closes #7410

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

* fix(channels): address context boundary review findings

* fix(channels): enforce causal new context boundaries

* fix(channels): scope outbound provenance to target

---------

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

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

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

Co-authored-by: RAVN <[email protected]>
2026-08-22 11:18:02 +08:00
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]>
2026-08-21 17:40:27 +08:00
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]>
2026-08-21 16:16:16 +08:00
f45b591623 MUL-6469: host plugin artifacts, bind installs to immutable versions (#7321)
* feat(plugins): host plugin artifacts, bind installs to immutable versions

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

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

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

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

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

Closes MUL-6469

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

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

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

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

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

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

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

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

Closes MUL-6469
Refs MUL-6485

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

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

* fix(migrations): renumber plugin package migrations

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 16:03:44 +08:00
18a85b8c7f fix(github): support single-character issue prefixes (#7385)
Co-authored-by: Rogee <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 15:36:33 +08:00
115bfab759 MUL-6523: persist task message batches in one statement (#7377)
* perf(server): persist task message batches in one statement

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

Two changes, both on that path:

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

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

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

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

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

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

Review follow-ups on the batch insert.

Blocking:

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

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

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

Non-blocking:

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

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

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

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

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

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

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

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

Measured after the change, same harness, two runs:

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

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

Also from review:

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

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

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

---------

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

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

Closes #7239
2026-08-21 14:26:48 +08:00
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]>
2026-08-21 14:12:36 +08:00
bae3a324fc MUL-6504 fix(daemon): stop chat tasks holding the local_directory mutex they never write to (#7378)
* fix(daemon): stop chat tasks holding the local_directory path mutex (MUL-6504)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 13:56:18 +08:00
LinYushen 6450c7ad30 Revert "chore(server): log task message batch size (#7371)" (#7382)
This reverts commit 52a063d0df.
2026-08-21 13:43:05 +08:00
1dc135598e MUL-6343: Enforce recently created issue windows (#7305)
* MUL-6343: enforce recently created issue windows

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

* fix(issues): address creation window review

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

* fix(issues): bound table window ID cache

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 12:55:57 +08:00
52a063d0df chore(server): log task message batch size (#7371)
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 12:55:51 +08:00
Bohan Jiangandmultica-agent 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]>
2026-08-21 00:36:59 +08:00
Danila Katalshov 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.
2026-08-20 23:57:52 +08:00
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]>
2026-08-20 23:53:49 +08:00
Bohan Jiangandmultica-agent 8c9b7503a1 MUL-6146 fix(rerun): stop cancelling an in-flight agent run on manual rerun (#7282)
* fix(rerun): stop cancelling an in-flight agent run on manual rerun (MUL-6146)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-20 18:12:30 +08:00
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]>
2026-08-20 16:48:03 +08:00
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]>
2026-08-20 16:03:51 +08:00
cb6b0c885f MUL-6396: remove unused task message clipping flag (#7292)
* refactor(realtime): remove unused task message clipping flag

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

* refactor(realtime): remove task message clipping consumers

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

* fix(db): address UUIDv7 review feedback

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

* fix(db): cover Telegram audit UUIDv7

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 13:42:44 +08:00
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 (1ea3cf1d7) outright.

squad_briefing.go's Squad Operating Protocol text is deliberately
untouched: it does not reference the retired modes, and its guest-leader
prohibition is a squad-protocol product decision, not workflow routing.

Guard tests now pin the fact anchor: conversational turns write nothing,
research on a todo issue leaves it todo, assigned deliverables land in
in_review (stage barriers and parent notifications key off that write),
and concurrent agents converge instead of flapping the board.

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

* test(handler): align squad parent-status contract test with the fact-judgment brief (MUL-6417)

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

* refactor(daemon): replace the prescriptive mention default with the facts behind it (MUL-6417)

"Default: NO mention" told agents what to do without why. The mention
syntax hides its semantics — it reads like a free social gesture but is
a spawn/notify operation — and every real incident was an agent acting
on a false need: "they should know" (comments already reach followers;
completion wakes are platform-owned) or courtesy (a thank-you run whose
only reply is another thank-you run). The section now states those facts
plus the cost asymmetry (a missed mention is one follow-up ask; a stray
one is a paid run), from which the old default is derivable.

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

* fix(daemon): scope the mention notify-fact to followers; sync docs to the new status contract; compress (MUL-6417)

Review fixes from #7245 (Elon):

- "A mention routes work — it is never how someone finds out" was false
  for non-followers — a mention is exactly how a human who does not
  follow the issue finds out, and the absolute wording could suppress
  necessary escalation. The notify-fact is now scoped to followers, and
  a guard pin bans the overreach alongside the old prescription.
- Every surface that still taught the old status contract is synced to
  end-of-turn fact judgment: issues.mdx/zh (agent flips in_progress on
  start), assigning-issues.mdx/zh (todo → in_progress → in_review as the
  default arc), and working-on-issues-source-map.md:177 (reply turns
  gated on assignee — the exact gate this PR removes).

Also a compression pass over the PR-touched brief text (−474 bytes):
drop the intro signpost sentence (steps and status block are
self-contained), tighten the delivery routing and delivery bullet, and
compress the concurrency rationale to one clause. Ordinary brief is now
13,181 bytes vs 13,345 on main (−164), leader 14,286 vs 15,131 (−845);
with the per-turn marker deletion (−139/−142 per turn) every prompt
surface is a net reduction from the first turn.

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

* docs(squads): retire the grant-routing ledger and status-first dispatch ordering (MUL-6417)

Review nit from #7245 (Elon): squad_briefing.go's design comment still
described the reply-mode grant routing and assignee-scoped arc this PR
removed. It now records the current mechanics — the workflow judges
status at end of turn, the leader's brief-side special case is dispatch
leaves the parent in_progress, and this section's job is the
owning/guest permission boundary at the Agent Identity layer. squads.mdx
(en/zh) stops describing a status-write-then-dispatch ordering: the
status point is now an end-of-dispatch-turn fact, consistent with the
assigning-issues docs.

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

Three changes, each useful on its own:

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

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

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

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

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

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

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

Addresses both must-fix findings from review.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

* fix(migrations): register issue activity index cleanup

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

* fix(issues): address last activity review

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

* fix(issues): close last activity review gaps

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

* fix(issues): harden activity rollout safeguards

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

---------

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

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

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

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

* fix(autopilot): address quota review findings

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

* fix(autopilot): finish quota review nits

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

* refactor(autopilot): simplify quota schema

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

---------

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

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

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

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

* fix(daemon): address task isolation review

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

---------

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

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

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

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

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

Refs MUL-6364, closes #7154

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

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

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

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

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

Refs MUL-6364, closes #7154

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 15:00:56 +08:00
Multica Eve ec8027cbc6 fix(runtime): support restricted Redis request stores (#7179)
MUL-6379
2026-08-19 14:37:08 +08:00
YYClaw 4b8873df3a feat(issues): add revision-aware concurrency guards (#7038)
* feat(issues): add revision-aware concurrency guards

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

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

* fix(server): address chat resume review

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 12:40:04 +08:00
Jiayuan Zhang 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.
2026-08-19 03:09:59 +08:00
YYClaw 6377dc2656 feat(dingtalk): show linked identities to workspace admins (#7138) 2026-08-19 01:37:51 +08:00
97195885fa MUL-6350: rebuild the plugin system (2/4) — Action API + Surface + SDK (#7157)
* MUL-6350 feat(plugins): Action API and via-plugin attribution (2/4, part 1)

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

Every Action API call passes three checks in order:

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

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

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

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

Two deliberate narrowings, both additive to widen later:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Review fixes for #7157.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 19:15:56 +08:00