Commit Graph
733 Commits
Author SHA1 Message Date
571e61128d MUL-7002: remove database reads from WebSocket heartbeats (#7976)
* fix(runtime): remove websocket heartbeat lookups

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

* fix(runtime): preserve explicit deregistration

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 16:12:34 +08:00
035abddfe7 perf(db): retire delegated failure pending index (#7977)
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 15:49:21 +08:00
731af7ccd6 MUL-7002: push runtime deletion invalidation to active daemons (#7969)
* fix(runtime): invalidate deleted active runtimes

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

* fix(runtime): separate invalidation delivery metrics

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

* fix(runtime): complete invalidation metric separation

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 14:50:14 +08:00
603519d158 MUL-6977: test: remove implementation-only coverage (#7932)
* test: remove implementation-only coverage

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

* test: remove remaining implementation-only coverage

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

* test: restore compatibility and fragment navigation coverage

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

* test: preserve behavioral cleanup contracts

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 13:23:25 +08:00
Firer d4a712abf3 MUL-6771 feat(cli): filter and sort issue list by custom property (#7664)
Expose the `properties=` filter and `sort=property:<id>` that GET /api/issues
already implemented but the CLI never surfaced, so scripts no longer have to
page through everything and filter locally:

  multica issue list --property "Impact=High" --property "Impact=Medium"
  multica issue list --property "QA Status=__none__" --status in_review
  multica issue list --sort property:Impact --direction desc

--property takes one Name=Value per flag; repeating a property ORs its values,
different properties AND. Values resolve by name the way the rest of the
property CLI works. __none__ is the reserved unset filter. A value the server
could never have stored — a non-http(s) url, over-long text, a non-numeric
number, a date in another format — is rejected up front rather than returning
the empty page that reads like a real answer. The >=, <= and != spellings are
reserved so comparison filters can be added later without breaking scripts.

Also fixes propertySortExpr, which sorted a select property by the raw stored
value — the option-id UUID, an arbitrary order. Values now rank by their
position in the definition's option list, so an ordinal scale sorts by meaning
(Low < Medium < High), matching what board grouping already treats as
canonical.

Closes #7663
2026-09-02 18:42:25 +08:00
765321f053 MUL-6951 fix(autopilot): carry the trigger creator's authorization on scheduled runs (#7918)
* fix(autopilot): carry the trigger owner's authorization on scheduled runs

A schedule/webhook autopilot run left originator_user_id NULL, so
canInvokeAgent saw no human and fail-closed against private agents. Arming
a trigger is the same authorizing act as clicking "run now" — deferred, not
absent — so withholding the originator did not remove that authority. It
only forced every capability the automation legitimately needed to arrive
as a separately-bounded borrow path: MUL-4857 for child creation, MUL-6691
for the run's own issues, #7902 for the accountable member's issues, with
sub-issue dispatch queued up as the fourth. Each carries a hand-written
scope that no operator can see in the UI.

attribution.TriggerOwner / RuleOwner now populate UserID (the originator)
instead of only the accountable side. finalizeAttribution mirrors it, so
the originator == accountable invariant that migrations 190/197 enforce
holds without a schema change or backfill. Both autopilot execution modes
resolve through triggerOwnerAttribution, so run_only and create_issue are
covered by the same two lines.

This grants exactly the trigger owner's own rights — canInvokeAgent is
untouched, only the principal handed to it changes — and originator_source
still distinguishes a scheduled fire from a manual one in the audit trail.

The borrowed-authority machinery in handler/agent_access.go is now dead for
newly enqueued runs but is deliberately left in place: tasks enqueued by
the previous backend are still running during a rolling deploy and carry
originator NULL, and they must keep the path they were admitted under.
Deleting it is a follow-up once this is deployed.

Composio connected-app credentials also key on originator_user_id, so
scheduled runs now receive the trigger owner's connected apps. Decoupling
that from authorization was considered and explicitly deferred.

Verification (real PostgreSQL 17):
- internal/attribution, internal/service, internal/handler: PASS
- the five attribution_stamp tests that pinned "originator stays NULL" were
  updated to assert the new principal, and fail against the old constructors
- pre-existing, unrelated failures in internal/daemon/execenv (4 Hermes
  store-path tests) reproduce identically on a clean tree

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

* refactor(autopilot): delete the borrowed-authority machinery

With a scheduled run carrying its trigger owner as originator, every
autopilot-shaped authority path in agent_access.go is unreachable: the
invoke gate resolves a real human from the speaking task and judges it
exactly as it judges that human acting directly.

Removed:
  - autopilotTaskAssignAuthority + autopilotAssignAuthorityFromRequest (MUL-6691)
  - issueBoundToAutopilotTask, taskRunsAutopilotInWorkspace
  - autopilotDelegationAuthority and its FromRequest / FromComment resolvers (MUL-4857)
  - effectiveInvocationAuthorityFromRequest
  - assignAuthorityScope, assignScopeKind and the four scope constructors
  - commentTriggerComputeOptions.AutopilotDelegationAuthorityUserID + effectiveInvoker()

validateAssigneePair and triggerTasksForComment lose their now-meaningless
scope / delegation-authority parameters; every caller passes the run's
originator instead. This also retires the fourth borrow scope #7902 proposed.

CreateIssue still loads the parent issue when assigning, even though the gate
no longer needs the row: it is what turns a cross-workspace or missing parent
into 400 "parent issue not found" instead of a 403. Covered by a test.

Two behaviour notes, both accepted:

  - Authority now propagates down a delegation chain, because the parent run
    finally has an originator to copy. Under the old design each hop had to
    re-borrow. This matches what a manual "run now" already did.
  - The old borrow path required task.Status == "running"; the originator path
    does not, and never did for any non-autopilot run. Task tokens are revoked
    at the terminal transition (TaskService, MUL-2600), so the primary guard
    remains; this only drops a second layer that existed solely for autopilot
    runs.

Tests: the two suites that pinned the borrow bounds (autopilot-created-issue
binding, authored-by-this-task binding) described a mechanism that no longer
exists, so they collapse into one file asserting the rule that replaced them —
rights / no rights / no human at all, across create-top-level, create-child,
update and batch. The mention-authority suite keeps its shape; its fixture now
gives the run a human, as a live run has.

Verification (real PostgreSQL 17): internal/attribution, internal/service and
internal/handler all PASS. Pre-existing unrelated failures in cmd/multica,
internal/cli, internal/daemon and internal/daemon/execenv reproduce identically
on a clean tree.

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

* fix(autopilot): resolve one principal per dispatch and expire it with the run

Addresses Elon's review of #7918. Three findings, all confirmed against the
code before fixing.

P1-1 — admission and the run's identity could be two different people.
Automatic dispatch admitted the assignee agent as the AUTOPILOT CREATOR
(canCreatorInvokeAgent) while the task took its originator from the trigger.
With A owning both the autopilot and a private agent, and B owning the
trigger, a dispatch was admitted as A and then executed as B — rights neither
holds alone. The old suite could not see it because it called dispatchRunOnly
directly, skipping admission.

  - autopilot_trigger gains an IMMUTABLE created_by (migration 449, best-effort
    backfill from published_by). published_by keeps its audit meaning and still
    transfers on a substantive edit; it is no longer an authorization input,
    because letting an editor move it silently re-authorizes the automation as
    that editor. Per Bohan's ruling the run always acts as the trigger CREATOR.
  - ResolveAutopilotTriggerPrincipal is now the single answer to "who does this
    run act as". Admission, the task stamp and every delegated run read it, so
    the fork is unrepresentable. It also binds the trigger to its autopilot
    (GetAutopilotTriggerForAutopilot — the old lookup was WHERE id = $1, which
    would let a foreign trigger select the principal) and re-checks workspace
    membership on every dispatch, so removing a member revokes their triggers.
  - canCreatorInvokeAgent is now unreachable and deleted.
  - RuleOwner reverts to accountable-only. It is a guess at who owns a rule;
    promoting it would hand a legacy trigger somebody's rights without that
    person arming anything. Such runs carry no originator and fail closed.

P1-2 — a terminal task could still spend its human's rights.
invokeOriginatorFromRequest now refuses completed / failed / cancelled tasks.
Token revocation at the terminal transition stays the primary guard, but it is
best-effort, explicitly non-fatal on failure, with a 24h expiry behind it. The
check sits at the authorization boundary rather than the auth boundary, so a
finishing run can still post its final comment but cannot start new work.

P1-3 — the PR and code comments described Composio backwards.
BuildTaskOverlay ignores the originator entirely and builds from the AGENT
OWNER's connections and allow-list (MUL-3963). Naming the originator hands out
no third-party credentials. Comments corrected; no decoupling was ever needed.

Operational note: a trigger with neither created_by nor published_by (predating
migration 186) resolves no principal, so its autopilot stops dispatching until
the trigger is re-saved. That is the intended fail-closed behaviour.

Tests: full dispatch through admission for both fork directions; principal
fails closed on legacy / cross-autopilot / removed-member; terminal task
refused on all three statuses; the edit-transfer tests now assert the principal
STAYS with the creator while published_by moves. Verified the fork test fails
when admission is reverted to canCreatorInvokeAgent.

Verification (real PostgreSQL 17, pristine database): internal/attribution,
internal/service, internal/handler and cmd/server all PASS. Migration 449
up/down round-trips. Pre-existing unrelated failures in cmd/multica,
internal/cli, internal/daemon and internal/daemon/execenv are unchanged.

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

* fix(autopilot): bind the trigger principal to its workspace, correct stale docs

Second round of Elon's review on #7918.

1. The trigger lookup only enforced half of what its comment claimed.
GetAutopilotTriggerForAutopilot matched on (trigger id, autopilot id); the
workspace was used only for the creator's membership check afterwards. That
check proves the resolved human belongs to the workspace PASSED IN, not that
the autopilot does — a member of two workspaces satisfies it while the trigger
came from the other tenant. The query now joins autopilot and filters on
workspace_id, and the resolver passes it through. Regression test seeds two
workspaces sharing one member and asserts the foreign workspace resolves
nobody; verified it fails against the pre-fix query.

2. Several permission-boundary comments still described the pre-MUL-6951
model — that a run's authority and its accountable human transfer to whoever
last edited the trigger. That is now the opposite of the code, and these are
the comments a future reader would use to reason about the authorization
model, so they are corrected at all four sites: the trigger create site, the
SetAutopilotTriggerPublisher query, triggerOwnerAttribution, and the
dispatchRunOnly attribution block. published_by is described as what it now
is: a config-audit column with no bearing on a run.

3. The migration comment now states the backfill's imprecision plainly: for an
already-edited trigger it freezes the last recoverable EDITOR as the immutable
creator, not the historical creator, which the schema never recorded. It also
records that a NULL/NULL trigger has no recovery path by decision (Bohan:
leave them empty) — re-saving re-stamps published_by, not created_by — so its
runs stay refused with a recorded failure_reason.

Not done, per Bohan's ruling: no re-save takeover path for legacy triggers and
no migration-period alerting.

Verification (real PostgreSQL 17, pristine database): internal/attribution,
internal/service, internal/handler and cmd/server PASS; full suite shows only
the four pre-existing unrelated failures (cmd/multica, internal/cli,
internal/daemon, internal/daemon/execenv). go vet ./internal/... and
git diff --check clean.

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

* docs(autopilot): carry the creator-principal semantics into every description

Third round of Elon's review on #7918. Two must-fixes, no behaviour change.

1. The final semantics had not reached every surface that describes them.

Migration 189 wrote COMMENT ON COLUMN text for autopilot_trigger.published_by_*
saying it decides the accountable human of the runs a trigger fires. That is
now false, and because sqlc generates column comments into models.go, the
stalest description of the authorization model sat in generated code every
reader passes through. 189 is released and untouched; migration 449 overrides
both comments and documents created_by_* for the first time, and sqlc is
regenerated. 449's down restores 189's original text verbatim so a rollback
does not leave MUL-6951 wording describing a model the database no longer runs.

The same stale semantics remained in prose at six code sites — the plain and
manual dispatch entry points, the scheduled-plan path, the create_issue enqueue
comment, the autopilot-level republish path, and the webhook trigger create
site. All said a run's authority or accountable human follows the last editor,
or that automation resolves rule_owner. These sit on the authorization path, so
they are corrected rather than left to drift. published_by is now described as
what it is: config audit, with no bearing on a run.

Also corrects the backfill comment's migration reference: trigger publishers
arrived in 189, not 186.

2. The test file this PR added open-coded INSERT ... RETURNING id with matching
t.Cleanup(DELETE ...), which CLAUDE.md prohibits. Rewritten onto
testutil.Fixture: a small principalFixture embeds it and every row — members,
runtimes, private agents, autopilots, triggers, the second workspace — is built
and cleaned up by dbfx. The only remaining DELETE is the mid-test member
removal that IS the behaviour under test. Re-verified after the rewrite that
both regressions still fail against the old code: reverting admission to the
autopilot-creator check reddens the fork test, and dropping the workspace join
reddens the cross-workspace test.

Verification (real PostgreSQL 17, pristine database): full suite shows only the
four pre-existing unrelated failures (cmd/multica, internal/cli,
internal/daemon, internal/daemon/execenv). Migration 449 up/down round-trips
and the down restores 189's comments. go vet ./internal/... and
git diff --check clean.

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

* docs(autopilot): state the backfill widening plainly, drop raw-SQL test seeding

Fourth round of Elon's review on #7918. Both items are leftovers from the
previous round; no behaviour change.

1. Migration 449's backfill comment carried two factual errors.

It said published_by_* arrived in migration 186; it was 189. The previous round
intended to fix this but the string replacement did not match the wrapped line
and silently did nothing, so the wrong number shipped.

It also claimed the backfill "is no wider than the authority those runs carry
today", which is false: before MUL-6951 an automatic run had no originator at
all, only narrowly-scoped borrow paths, so promoting a backfilled editor to the
run's full originator IS a widening. The product decision to accept it stands;
the comment now says so directly as a compatibility tradeoff instead of denying
the widening. A migration comment that understates what it grants is worse than
no comment.

2. The new test file still reached raw SQL transitively.

It called seedAttributionFixture, which hand-writes INSERT and cleanup for its
user / workspace / member / runtime / agent — so the file's own claim that
"every row goes through testutil.Fixture" was untrue and the repository's
fixture rule was still unmet. It now bootstraps the base identity with
Fixture.User / Workspace / Member on an unbound fixture and adopts it, which
also drops the agent and issue that helper builds and these tests never use.
The comment explains why the helper is deliberately not reused.

Re-verified after the fixture change that both regressions still fail against
the old code: reverting admission to the autopilot-creator check reddens the
fork test, and dropping the workspace join reddens the cross-workspace test.

Verification (real PostgreSQL 17, pristine database): full suite shows only the
four pre-existing unrelated failures (cmd/multica, internal/cli,
internal/daemon, internal/daemon/execenv). go vet ./internal/..., gofmt and
git diff --check clean.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 18:29:11 +08:00
d7247fa9fc MUL-6975: preserve departed member identity in timelines (#7931)
* fix(timeline): preserve departed member identity

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

* fix(timeline): preserve identity across live updates

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 17:58:14 +08:00
a6911a9520 MUL-6972 perf(server): sweep delegated recovery every five minutes (#7929)
* perf(server): reduce delegated recovery sweep cadence

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

* refactor(server): isolate delegated recovery sweep

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 16:58:36 +08:00
9fce92f427 MUL-6846: feat(cli): expose active and cross-issue agent runs on issue runs (#7922)
* feat(cli): expose active and cross-issue agent runs on `issue runs`

An agent could already read one issue's task runs, but only as the full
execution history — every past run, hydrated with attribution and token
usage — with no way to ask the one question coordination actually needs:
who is working right now. And nothing at all could answer it across a
parent's sub-issues, which is the case #7768 describes: several agents
claim sibling children in parallel and each starts overlapping work
without knowing the others exist.

The claim-time `## Active sibling runs` block does not cover this. It is
scoped by agent_id — your OWN other in-flight tasks — so a different
agent running on a sibling issue is invisible, and it is a snapshot taken
before the turn started.

Two query params on the existing task-runs endpoint, and the flags that
drive them:

  multica issue runs <id> --active      # in-flight runs on this issue
  multica issue runs <id> --siblings    # ...and across the sub-issue family

`--siblings` roots at the issue's parent, or at the issue itself when it
has none. One rule covers both directions: a child sees its siblings, a
parent sees its children, a standalone issue degenerates to its own
active runs — so a caller does not have to know an issue's shape before
choosing a flag. Rows carry the issue they belong to, ordered
running-first and bounded, because a parent that fanned out hundreds of
children must not turn an advisory read into an unbounded response.

Both reads are advisory. Nothing here reserves an issue or serialises
anything; they tell a run whom to coordinate with, and coordination
still happens in comments.

With neither param the response is byte-identical to before, which the
issue-detail execution log and the CLI's short-task-ID resolver both
depend on.

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

* fix(cli): address review on active/sibling run reads

Four fixes from review, all on the same theme: a coordination read has to
be cheap and has to be honest about what it did not tell you.

- `--active` no longer hydrates usage. ListIssueTaskUsage returns a row
  per (task, provider, model) for every task the issue ever ran, so the
  active path was still paying the full-history cost the filter exists to
  remove — for a column that is near-empty on runs that have not
  finished. The execution log keeps it.

- An invalid `active` value is a 400 instead of a silent fallback.
  `active=tru` used to answer "who is here now?" with the whole history
  under a 200, and a pile of finished runs reads as the live ones. Same
  reason the unknown-scope check already existed, same shape as the
  comment-list boolean params.

- The 50-row family cap now announces itself. The query asks for one row
  past the cap and sets X-Active-Runs-Truncated when it comes back; the
  CLI warns on stderr. Without it, "the answer was cut off" and "no run
  on that sibling" are the same response, and an agent reads the second.
  The cap is also documented in the flag help, the skill and
  CLI_AND_DAEMON.md, along with the ordering it depends on.

- The skill's new agent-facing contract is pinned in
  builtin_skills_test.go: that the claim-time block is same-agent only,
  the `--siblings` command itself, the cap disclosure, and the
  advisory/no-lock boundary — the last a negative anchor, because an
  agent that reads these as a lock skips the coordination they exist to
  prompt.

Also reuses service.IssueIdentifier instead of re-deriving the format.

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

* fix(server): expose the truncation header to CORS, correct the cap rationale

Both from re-review, and both about the cap telling the truth.

- HeaderActiveRunsTruncated was missing from corsExposedHeaders. No
  browser client requests scope=family today, so nothing is broken right
  now — but a custom response header that is not exposed is unreadable
  from JS, which is the exact silent-truncation failure the header was
  added to remove. The registry and its guard test exist because this
  drifted once already (MUL-5492); completing both is two lines.

- The comment justifying 50 was wrong. It claimed real family
  concurrency is bounded far below the cap so 50 sits above any
  legitimate answer. It does not: agentconfig.MaxMaxConcurrentTasks lets
  ONE agent run 50 concurrent tasks, this feature exists for the case
  where several agents work a family at once, and the returned set
  includes queued / dispatched / waiting rows that no execution slot
  bounds at all — a parent that fans out 200 children can have 200
  enqueued. So 50 is a response-size budget and nothing more, truncation
  is an ordinary outcome rather than a pathological one, and that is
  precisely why it has to be reported. Comment and PR body now say that.

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

* refactor(cli): give the family read its own compact row, cap at 20

The family read was reusing AgentTaskResponse — the execution log's row.
Measured against real data that is ~1645 bytes per run, of which the
caller reads almost nothing: attribution (625 bytes, and its own extra
query), result, work_dir, relative_work_dir, trigger_summary,
coalesced comment ids. An agent asking "is anyone working next to me?"
was spending ~20k tokens of its own context on the answer.

ActiveRunSummary carries what the question needs and nothing else: task,
issue, agent, status, and timestamps. 367 bytes per row. It is a separate
type on purpose — not AgentTaskResponse, and not ActiveSiblingRunData
(the claim payload's row, which has no agent field because those runs all
belong to the claiming agent by construction). Reusing either would mean
an optional field only one caller ever sets, which is the shape this
commit removes from AgentTaskResponse.

The query now names its columns instead of embedding the task row, so
Postgres stops detoasting the JSONB blobs too, and the handler drops
attribution hydration along with the field.

Cap goes 50 -> 20 (maintainer call): a handful of runs is the normal
case, so the budget is set to the normal case. Truncation was already
reported and still is, which is what makes a small cap safe.

Worst-case response: 80 KB / ~20k tokens -> 7 KB / ~1.8k tokens.

The CLI's family table drops COMPLETED and ERROR, which are empty on
every row of a read that only returns work still in flight.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 16:21:05 +08:00
3c23c491fa MUL-6924: feat(billing): notify workspaces about autopilot quota (#7681)
* feat(billing): notify workspaces about autopilot quota

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

* fix(billing): isolate and throttle quota notices

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

* fix(billing): tighten quota notice delivery

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

* fix(billing): reuse quota admission snapshot

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

* fix(autopilot): make quota notices actionable

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

* fix(billing): align quota recovery notifications

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

* fix(billing): align quota notice recipients

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

* fix(billing): finish quota notice review

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

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Sol-Boy <[email protected]>
2026-09-02 12:59:26 +08:00
f01eb5a06c MUL-6928 perf(issues): index scalar property contains behind a bigram prefilter (#7878)
* MUL-6928 perf(issues): index scalar property contains behind a bigram prefilter

Scalar `contains` property filtering runs `(properties ->> def) ILIKE
'%needle%'`, which no index can serve, so it degrades to a workspace-wide
sequential scan. At 1M issues that costs ~8x the throughput of an equality
filter, and the pgxpool connections it holds queue every other tenant's cheap
query behind it.

Put a redundant, indexable prefilter in front of the per-key check:
`LOWER(properties::text) LIKE LOWER('%needle%')`, served by a new pg_bigm GIN
index on the same expression. The per-key ILIKE still decides the result; the
prefilter only narrows the candidate set. On a 500k-row pg_trgm proxy a
selective needle goes from a 183ms Parallel Seq Scan to a 1.4ms Bitmap Index
Scan.

The prefilter is skipped, falling back to today's behaviour, for needles it
cannot screen soundly or usefully: jsonb escapes `"`, `\` and control
characters on serialization, so those never appear literally in
properties::text, and a single character carries no bigram to narrow with.

Migration 446 cannot use the DO ... EXCEPTION guard migrations 032 / 036 use
for pg_bigm — a concurrent build cannot run inside one — so the runner gates it
on the operator class instead: databases without pg_bigm record the version
with its SQL skipped and keep the unaccelerated (still correct) filter.
cmd/migrate/README.md documents building it out of band afterwards.

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

* MUL-6928 fix(issues): prefilter short contains needles too

The single-character skip rule was derived from the pg_trgm proxy used to
develop this change, where a 1-character needle can only drive a sequential or
full-index scan. pg_bigm is different — indexing 1- and 2-character keywords is
the capability it exists for over pg_trgm — so the rule denied the target
extension exactly the searches it was chosen to serve, and a single CJK
character is already a word.

Measured on PostgreSQL 16.15 with pg_bigm 1.2 over 1M issues: a rare
1-character CJK needle goes from a 134ms Parallel Seq Scan to a 1.2ms Bitmap
Index Scan (8.3 -> 10527 tps at 4 clients). Only the JSON-escaping guard
remains, which is about soundness rather than benefit.

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

* MUL-6928 fix(issues): analyze the properties expression index after building it

Building an expression index does not collect statistics for its expression.
Until they exist the planner falls back to a pattern-length heuristic that
estimates any single-character LIKE at a few percent of the table, so it cannot
tell a needle matching 50 rows from one matching 50,000 — and migration 446's
index ends up built, valid, ready, and never chosen. Autovacuum does not rescue
a large, quiet issue table promptly: its default threshold is 10% of rows
changed.

Measured on PostgreSQL 16.15 with pg_bigm 1.2 over 1M issues, with
max_parallel_workers_per_gather=4: a rare single-character CJK needle plans as a
Parallel Seq Scan at ~101ms with 446 alone — indistinguishable from the
pre-change baseline — and as a 0.64ms Bitmap Index Scan once the statistics
exist. Whether the wrong estimate actually flips the plan depends on how cheap a
parallel scan looks, so without this the shipped behaviour would vary by
instance sizing.

447 is deliberately not gated on pg_bigm the way 446 is: ANALYZE is valid, cheap
(557ms on 1M rows) and side-effect-free everywhere, and refreshing ordinary
column statistics is useful even where the expression index was skipped.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 12:36:52 +08:00
mrlonelyandL 108407088c MUL-6836: fix(codex): allow explicit standard speed (#7756)
* fix(codex): allow explicit standard speed

* fix(codex): negotiate explicit standard support

---------

Co-authored-by: L <[email protected]>
2026-09-01 13:04:32 +08:00
f1ce9d4542 MUL-6883 perf(server): stop rescanning settled delegated-failure recoveries (#7820)
* feat(server): retire settled delegated-failure recoveries from the outbox scan

ListPendingDelegatedFailureRecoveries had no indexable notion of a finished
recovery. Its partial index (343) selected every recovery comment ever
written, so each sweeper tick re-proved through four joins and two NOT EXISTS
subqueries that history was already handled. LIMIT bounds the rows RETURNED,
never the rows CHECKED, so a tick that found nothing still grew more expensive
as history accumulated.

Add comment.recovery_settled_at and a partial index that holds only unsettled
recovery comments, and write the marker inside the transaction that makes a
task terminal. Every other exclusion the scan applies is reversible — an issue
can leave 'done', a cancelled retry can be superseded — so none of them can be
frozen into an index predicate. A delivery receipt on a terminal task cannot be
taken back, which is what makes it safe to record as durable state.

The marker is deliberately written only once the task is terminal.
SetTaskDeliveredCommentIDs replaces rather than appends a receipt while a task
is still dispatched, so a reclaim by a differently-capable daemon can drop an
id it will not deliver; settling at receipt-write time would freeze that
legitimate transient window into a permanently lost recovery. Exhaustion is the
one terminal outcome whose receipt lands on a row that may still be running, so
it retires the comment directly in the same transaction.

An unmarked row is the safe direction: it is scanned exactly as it was before
this column existed. That is what makes the rolling-deploy window harmless —
an older replica that writes a receipt without the marker only leaves work for
the backfill.

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

* feat(server): backfill delegated-failure recoveries settled before the marker

The marker only covers tasks that reach a terminal status after the writing
code ships. History written before that keeps matching the unsettled index
predicate and keeps being re-checked on every sweeper tick, which is the cost
this change set exists to remove.

The walk retires a candidate only when a terminal task already recorded it in
delivered_comment_ids — the same monotonic condition the runtime path uses.
Its keyset watermark advances over every candidate EXAMINED rather than every
candidate marked: a recovery that is genuinely still pending must be left
alone, and keying on updated rows would hand back the same watermark forever
and never terminate.

Separate from migrations and startup, in the shape the other backfill commands
already use, so a large comment table cannot block a deploy. Running it is a
performance recovery step, not a correctness step — until it finishes the scan
behaves exactly as it does today.

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

* fix(server): close the review gaps in the settled-recovery backfill and paths

Three fixes from review.

1. The backfill reproduced the bug it was written to fix. Its keyset ordered by
   id while idx_comment_delegated_failure_unsettled is (created_at, id), so the
   candidate scan read every unsettled row and sorted it to return LIMIT of
   them; and the receipt test ran a per-candidate EXISTS over agent_task_queue,
   the largest table in the database. The keyset now matches the index, and the
   receipt lookup is inverted into one agent_task_queue read per BATCH that
   hash-joins delivered ids back against the candidates.

   Measured on 500k comments / 200k tasks / 50k unsettled recoveries:
   17,393ms and 816,963 temp blocks before, 235ms and no temp spill after. The
   candidate scan goes from reading 50,000 rows to reading the 1,000 it needs.

   Total walk time is flat across batch size (50x228ms, 10x1016ms, 5x2199ms —
   all ~11s), so batch size only trades how long one statement holds row locks.
   1000 stays, now for a measured reason rather than a guessed one.

2. Bulk cancellation paths could strand a delivered receipt permanently
   unsettled, which lets the new index regrow the history it exists to remove.
   Manual rerun's pending-slot clear and agent archive were the two named in
   review; auditing the rest found comment-edit cancellation, both chat-session
   cancellations, workspace revoke and runtime teardown in the same position.
   All now settle — inside the caller's transaction where one exists, and
   through CaptureCancelledTasks for bulk paths that finalize outside one. The
   invariant is stated on the exported helper so the next such path has
   somewhere to read it.

3. --max-batches could not actually resume. The watermark lived only in the
   process, so a bounded rerun restarted at the front of the index — which is
   precisely where the rows this walk never settles live, so repeated bounded
   runs would rescan the same prefix and never reach the settleable history
   behind it. Adds --checkpoint-file (atomic replace, read on start) and
   explicit --after-created-at/--after-id, and logs the resume cursor on every
   exit path including SIGTERM.

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

* fix(server): make delegated-failure settlement terminal-checked and atomic

Two fixes from review.

1. The settlement SQL trusted its caller. "A delivery receipt is only immutable
   once the task is terminal" is the safety premise of the whole design, but the
   exported helper enforced nothing: passing a dispatched or running task would
   settle a receipt a reclaiming daemon can still replace, and the marker is
   monotonic, so nothing could undo it. The predicate now lives in the SQL, so a
   non-terminal task updates no rows instead of destroying the obligation.

2. Bulk settlement was post-commit best-effort, and the comment claiming the
   sweeper would replay a failed one was simply wrong: the covering task is
   terminal and already holds the receipt, so ListPendingDelegatedFailureRecoveries
   excludes the comment. It would neither replay nor settle — it would sit in
   the partial index forever, restoring the unbounded scan this change set
   removes.

   Every terminal write now commits with its settlement. CancelTasksForIssue /
   ForAgent / ByTriggerComment and the rerun pending-slot clear run in a
   transaction; CancelQueuedChatTasks settles inside the one it already had; the
   sweeper and daemon-recovery bulk fails move behind TaskService wrappers that
   own the transaction; agent archive gets CancelTasksForArchivedAgent, which
   cancels and settles atomically while still skipping the per-task broadcast
   the archive event makes redundant.

   BroadcastCancelledTasks, CaptureCancelledTasks and HandleFailedTasks run
   after their caller committed, so they no longer settle at all — a failure
   there could be neither rolled back nor repaired. The best-effort helper is
   deleted rather than left available; every remaining call site passes a qtx.

Tests: a dispatched/running task with a receipt is not settled (fails without
the SQL predicate), a bulk cancel whose settlement fails rolls the cancel back,
and the archive path settles through its atomic entry point.

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

* docs(server): correct settlement invariant comment and drop leaked test function

The SettleDeliveredDelegatedFailureRecoveries doc still described the
pre-fix contract — settlement reachable through CaptureCancelledTasks or a
post-commit best-effort call — which is exactly the shape the atomicity fix
removed. Restate the rule as it now stands (same qtx as the terminal write,
never from a post-commit helper) so the stale text cannot guide a future
change back into stranding rows in the partial index.

TestBulkCancelRollsBackWhenSettlementFails dropped its trigger but left
reject_recovery_settlement() behind in the shared test database; drop the
function too.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Eve <[email protected]>
2026-09-01 12:11:50 +08:00
7c1d826a98 chore(metrics): remove database-sampled metrics (#7819)
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-31 17:06:17 +08:00
db86c75e12 MUL-6878: avoid per-row issue status resolution (#7814)
* perf(server): avoid per-row issue status resolution

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

* fix(server): address issue status query review nits

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-31 16:00:45 +08:00
Xichang(Seacen) Zhao 1dd6b9ecdb MUL-6570 feat(wecom): route a reply to the replica that holds the bot's socket (#7429)
WeCom is the only channel with no outbound REST path: every write goes over the aibot WebSocket, and the WS lease means exactly one replica holds it. `chat:done` is published on the in-process bus by whichever replica served the daemon's completion — a load-balancer decision, unrelated to the lease. Off-lease, the reply was dropped (#7215, #6890), and the documented workaround was to run the WeCom-enabled backend single-replica.

This routes the reply over the Redis Stream relay the repository already runs for the same shape of problem, so the replica holding the socket sends it.

- At-most-once across replicas and restarts via a Redis claim keyed on the turn, with a TTL derived from the relay's `ReplayGrace` rather than a constant.
- Delivery runs on a bounded dispatcher, never on the shared shard read loop, with per-installation lines that keep two answers to the same person in order even across a re-offer.
- Attachments and `inbox:new` pushes are routed too, not just reply text.
- Every routed reply gets a final outcome, settled exactly once by the replica that routed it: `outbound_dropped_total{reason="no_live_connection"}` when no replica took the delivery. The residual window — every replica mid-reconnect — remains, but is now measurable instead of silent.

Multi-replica WeCom is supported in sharded/dual relay mode; legacy mode and deployments without Redis keep the previous behaviour and the single-replica requirement. `SELF_HOSTING.md` and the four `channels*.mdx` docs state the split.
2026-08-31 15:35:46 +08:00
Xichang(Seacen) Zhao 7c0e750452 MUL-6569 feat(wecom): give every undelivered reply a reason an operator can count (#7428)
Adds outbound delivery accounting to the WeCom adapter: every reply and every file now ends in exactly one counted outcome — delivered, dropped, skipped or unconfirmed — with a closed, low-cardinality reason label.

Closes the ambiguity behind GH #7215 and #6890, where a reply sat in the Multica transcript while the WeCom chat stayed quiet and the server-side evidence could not say why.
2026-08-31 14:59:46 +08:00
importcppandClaude Fable 5 d540eb6fe7 MUL-6783: perf(server): set read-header and idle timeouts on main HTTP server (#7683)
* perf(server): set read-header and idle timeouts on main HTTP server

The main HTTP server was constructed with only Addr and Handler, so
ReadHeaderTimeout and IdleTimeout defaulted to zero (never time out). A
slow or half-open client that dribbles request headers pins a goroutine
and its read buffer indefinitely (Slowloris), and idle keep-alive
connections are never reclaimed. The sibling metrics and profiling
servers already set these.

Set ReadHeaderTimeout to 5s (matching those servers) and IdleTimeout to
120s (looser than their 30s, since this listener serves keep-alive-heavy
browser/CLI/daemon clients). ReadTimeout and WriteTimeout are left unset
on purpose: WebSocket upgrades (/ws, /api/daemon/ws) share this listener
and a global read/write deadline would kill those long-lived
connections — the same reason the profiling server omits them.

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

* test(server): pin main HTTP server timeout defaults

Extract the public HTTP server construction into newMainHTTPServer and add
a regression test asserting the production timeout defaults: ReadHeaderTimeout
5s, IdleTimeout 120s, and ReadTimeout/WriteTimeout deliberately zero.

These are safety settings — without the test, removing them, zeroing them, or
making the whole-request deadlines non-zero (which would break WebSocket
upgrades and large uploads) would pass CI silently.

Addresses review feedback on #7683.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-31 12:28:52 +08:00
64ec7f5416 MUL-6737: fix(timeouts): close the two gaps the 2h inactivity budget left open (#7699)
* fix(timeouts): close the two gaps the 2h inactivity budget left open (MUL-6737)

Two independent places where a timeout still answered the wrong question.

Codex runs a semantic-inactivity timer inside the app-server protocol, and
unlike the daemon's watchdog it cannot see that a tool is in flight: a
commandExecution that prints nothing for the window trips it anyway. At a
fixed 10m ceiling that timer killed a quiet test suite or an
output-buffering `docker build` long before the 2h daemon budget meant to
protect it, which made "we raised the budget to 2h" false for Codex users.
It now derives from the daemon budget, taking the larger of the idle and
tool windows because it stands in for both. A tool budget of 0 means "never
force-stop during a tool", which this timer cannot express, so it falls back
to the idle budget rather than running unbounded; with the watchdog suite
disabled entirely Codex keeps its own built-in default, as it always has.
The env override and flag are unchanged.

Queued tasks expired on a pure wall clock: queued longer than a TTL meant
failed. That conflated "nobody is coming for this" with "the queue ahead is
long", and only the first is a failure. MUL-6558 was the second — a
self-hosted runtime with low concurrency held its own queue past 2h and
healthy work died as queued_expired. The TTL knob added then only moved the
cliff. Expiry now keys on whether the owning runtime still proves liveness,
the same signal FailTasksForOfflineRuntimes already uses for
dispatched/running rows, so a daemon going down retires its queued and its
in-flight work on one clock instead of two. A busy runtime keeps its
backlog for as long as it takes. MULTICA_TASK_QUEUED_TTL is removed with
the wall clock it configured.

Heartbeat age is read directly instead of gating on runtime.status='online'
so a row stuck at 'online' with a dead heartbeat still releases its queue,
and rows with no usable runtime binding — runtime_id IS NULL from before
migration 251, whose CHECK landed NOT VALID, or a runtime row that is gone —
are expired on sight rather than stranded forever by a JOIN that matches
nothing.

Tests: the queued sweep test now runs both phases against the same rows,
asserting the 5h-old task survives while its runtime heartbeats (the
MUL-6558 regression) and only expires once that runtime goes silent.
Assertions are scoped to the test's own task ids, because a runtime-keyed
sweep legitimately also collects queued rows other tests left on the shared
fixture runtime.

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

* fix(sweeper): give a queued task its own reconnect grace before expiring it

Review catch: keying expiry on runtime liveness alone was too aggressive in
one direction. Enqueue binds a task to agent.runtime_id without checking
that the runtime is up (task.go CreateAgentTask call sites), so for a
runtime that has already been dark for longer than the grace, the liveness
clause is satisfied the moment a new task is created — assigning an issue
to a laptop closed overnight would fail it inside one 30s sweep tick
instead of waiting for the machine to come back. That is the opposite of
what "reconnect grace" promises.

Require the row's own age as well: a queued task now waits a full grace,
counted from when it started waiting, before it can be given up on. A
heartbeating runtime still never expires its backlog, so the MUL-6558 fix
is unchanged. The age bound also stops the sweep re-evaluating the runtime
subquery against every queued row on every tick.

Also remove the deploy surfaces the previous commit missed — the knob was
deleted from the server but helm (values + configmap), the self-host
compose file, and the CI-run helm render assertions still advertised it, so
operators would have kept setting a variable nothing reads. Same for five
orphaned comment lines in .env.example describing the removed variable.

Correct the fail-closed comment on the unbound-runtime arms: they are not
reachable for "rows predating migration 251" — runtime_id was NOT NULL from
migration 004 until 251 replaced it with a CHECK that every insert and
update is verified against, and the migration-004 FK is ON DELETE RESTRICT.
The arms stay as defence against a future schema change, which is what the
comment now says.

Tests: a third phase covers the reported case — a task enqueued against an
already-dead runtime survives, then expires once it has waited a full grace
of its own. Phase 2's expectation is corrected accordingly: the row created
at now() no longer expires when its runtime dies, because it has not waited
its own grace yet.

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

* docs(timeouts): state both queued-expiry conditions and correct the FK claim

Review follow-up on b567511.

The user docs still described queued expiry as a single condition — "fails
once the runtime has been silent past the reconnect grace" — which is the
pre-b567511 behaviour and skips exactly the case that commit fixed. A
reader assigning work to a machine that went offline last night would
conclude the task already qualifies to fail. All four locales of tasks,
daemon-runtimes and troubleshooting now state both conditions and why the
second one exists.

The environment-variables pages contradicted themselves: the env table had
been updated to say the Codex semantic budget is derived, while the
`multica config` supported-keys table further down still advertised a 10m
default. That table now describes the resolution rule instead of a value.

The schema comment added in b567511 named the wrong constraint.
agent_task_queue_runtime_id_fkey (migration 004) is ON DELETE CASCADE; the
ON DELETE RESTRICT one is agent_runtime_id_fkey on agent.runtime_id. The
conclusion is unchanged — a queued row cannot reference a deleted runtime —
but the mechanism is a cascade plus migration 251's explicit unbind of
history rows, not a restrict.

Also log the resolved codex_semantic_inactivity at daemon startup, for the
same reason tool_watchdog is logged: it is now a derived value, so without
it the effective Codex budget cannot be read anywhere until a timeout
fires. And correct a test comment that still claimed both non-exempt rows
fail — the fresh row now stays queued, which is the assertion right below it.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-28 18:00:35 +08:00
3fecc553e2 MUL-6786: index GitHub PR head SHA lookups (#7695)
* fix(db): index GitHub PR head SHA lookups

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

* fix(db): narrow GitHub PR head SHA index

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-28 17:02:57 +08:00
importcpp 5228aa25b2 docs(cli): document issue list pagination limit (#7674)
Clarify that `multica issue list --limit` controls a single page and that the server caps each page at 100 issues, pointing users to --offset for pagination. Help-text only; no runtime behavior change.
2026-08-28 15:33:51 +08:00
Bohan Jiang d6ecf4bc8a fix(issues): derive a usable status key from non-ASCII display names (MUL-6749) (#7638)
Creating a custom issue status whose display name is written entirely in a
non-Latin script — 客户确认, 고객 확인, 確認待ち — failed with "cannot derive a
status key from that name; provide one explicitly". The key alphabet is ASCII,
so such a name slugified to nothing, and the settings form exposes no field for
an explicit key: there was no way to create the status through the UI at all.

The key stays an ASCII machine handle — it is what issue.status stores, what the
CLI takes as an argument, and what the column CHECK permits — so there is NO
migration. When the name has nothing to slug the key falls back to the status's
CATEGORY plus an ordinal (in_review_2), which keeps saying which platform
behavior the status inherits.

Also fixed in the same path:

- Two names that collapse onto one slug ("待客户 Review" and "待供应商 Review"
  both slug to "review") no longer make the second create fail with a conflict
  blaming a display name nobody took. Disambiguation counts ARCHIVED rows, since
  idx_issue_status_workspace_key is not partial.
- Every create takes the exclusive catalog lock, not just the deriving one, so
  an explicit-key insert cannot land between a derive's catalog read and its
  insert and hand a 409 to a form with no key field.
- The ordinal scan is bounded by the catalog rather than a constant.

A derived key is opaque, so the places that printed a key alone now carry the
name: issue payloads gain status_name (HTTP and background events alike, from
one catalog read), the unknown-status 400 lists `key (Name)`, creating a status
surfaces the key it minted, and `multica issue status --help` says the argument
is a key rather than a display name.

Fixes #7627. Supersedes #7636.
2026-08-28 14:24:32 +08:00
08a1e6e934 MUL-6742: fix archived-agent runtime garbage collection (#7641)
* fix(runtime): garbage collect archived-agent runtimes

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

* fix(runtime): address GC review feedback

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-28 12:49:08 +08:00
6e2b2877b7 MUL-6747: reduce runtime sweeper scan cost (#7628)
* perf(server): reduce runtime sweeper scan cost

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

* fix(server): raise hourly runtime GC capacity

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

* fix(server): address runtime sweeper review nits

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-27 18:04:20 +08:00
LinYushen d13e76a557 fix(billing): prompt upgrade before quick create (MUL-6746) (#7625)
* fix(billing): prompt upgrade before quick create

* fix(billing): use public core export

* fix(billing): keep issue-limit recovery actionable

* test(handler): provide retry workspace context

* fix(billing): respect issue-limit prompt dismissal

* fix(billing): clarify issue-limit recovery paths
2026-08-27 16:55:03 +08:00
74a7de9218 MUL-6741: retire heartbeat index and runtime sampler metrics (#7622)
* perf(metrics): remove runtime sampler series

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

* perf(db): retire runtime heartbeat index

Based on the root-cause analysis and implementation exploration by @importcpp in multica-ai/multica#7599.

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

* fix(metrics): address runtime sampler review

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-27 15:10:21 +08:00
LinYushen 5fa65bd125 feat(billing): enforce Cloud issue count limits (#7619)
* feat(billing): enforce Cloud issue count limits

* fix(billing): address issue limit review feedback

* test(billing): exercise validation as manager

* fix(billing): resolve second review feedback

* fix(core): preserve entitlement limit discrimination
2026-08-27 14:23:09 +08:00
09a2410e88 refactor(agents): rename starter_prompts to conversation_starters (MUL-6709) (#7592)
* refactor(agents): rename starter_prompts to conversation_starters

The UI, docs and all four locales call this feature "Conversation starters"
(#7573), but every identifier behind it still said `starter_prompts` — the DB
column, the wire field, the `/api/config` capability flag, the TS types, the
i18n keys, and the `?focus=` token that a user can read in their own address
bar. The old name is also the one #7573 rejected on the way in: it promises a
prompt, while selecting a starter only prefills the composer.

Rename it everywhere so the identifier matches the product.

This is a breaking rename with no compatibility shim, and it is free exactly
once: the column arrived in migration 404 (#7261) after the last release tag,
so nothing has shipped. No deployed client reads the old name, no stored data
uses it, and `agent_conversation_starters_supported` has no version skew to
straddle. After this release, the same change would cost a wire alias, a
dual-written capability flag, and older Desktop/Mobile builds silently hiding
the editor.

Migration 404 stays as-shipped; 432 renames the column and its CHECK
constraint, so an already-migrated dev or staging database converges with a
fresh one.

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

* docs(skills): name the conversation-starters surface for agents

`multica-creating-agents` mentioned `conversation_starters` only as a wire
field in two tables. It never said what the feature is called in the product,
where a human configures it, or what selecting one does — so an agent reading
this skill would tell a user about "conversation starters" as a JSON field and
leave them with nowhere to go.

Add the product name (both languages), the Instructions-tab deep link, and the
behaviour that makes the name accurate: selecting one only fills the composer.
Also record that an agent with none configured still shows three built-in
defaults, so "the Chat shows suggestions" is not evidence this agent has any.

Document one gap the field table implied away: the accepted-body list is not a
CLI surface. `conversation_starters` has no `agent create` / `agent update`
flag — only `agent copy` carries an existing value forward — so an agent
following the old text would build a create command that silently drops it.

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

* fix(onboarding): keep the released starter_prompt field on the pre-v3 shim

The rename in ec44cc0ef swept up `starter_prompt` (singular) on
`bootstrapOnboardingRuntimeRequest`. That is a different contract from the
per-agent `starter_prompts` this PR renames: it is a field on the endpoint
marked "DEPRECATED, kept for desktop < v3", and unlike the per-agent one it
HAS shipped — it is in v0.4.34, released since fbd965e5bf.

Renaming it breaks the clients it exists for, and breaks them silently: the
handler decodes with a plain json.Decoder, so an old Desktop build's
`starter_prompt` would be dropped as an unknown field, the request would still
return 200, and the onboarding issue would quietly fall back to the default
description instead of the text the user typed. The test change hid it by
moving the fixture to the new name, removing the only coverage of the old one.

Revert both files. The two features share a word root and nothing else — the
free-rename argument for the per-agent field does not extend to a contract
that is already in the wild.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-26 19:01:01 +08:00
LinYushen 3b1018e9c9 Revert "MUL-6016: feat(task): retry exact Codex capacity failures (#6747)" (#7586)
This reverts commit 8fda4f22d5.
2026-08-26 18:05:17 +08:00
54027ba763 MUL-6639: fix(skills): metadata-only skill listings and stall-based timeouts (#7574)
* MUL-6639: feat(skills): list skill files as metadata, not content

`multica skill get` and `multica skill files list` both downloaded every file
body to render output that shows none of them — `skill get`'s table view prints
four columns, and the file listing prints paths and timestamps. Past a few
hundred KB that stopped being waste and became a hard failure: a 599KB skill
could not be fetched at all, and the listing that would have shown which file
was oversized required downloading all of them first, so the diagnostic died
with the thing it was meant to diagnose (GH #7498).

Both endpoints now take `?include=content|metadata`. The metadata shape carries
path, byte size and content hash per file, plus the size and hash of the
SKILL.md body — computed in Postgres, so the bodies never leave the database.
Response size now tracks file count, not content size.

`GET /api/skills/{id}/files` defaults to metadata: it is a list endpoint, the
CLI is its only caller, and `GET /api/skills` already dropped content for this
exact reason (GH #2174). `GET /api/skills/{id}` keeps returning content by
default — installed web and desktop builds call it for the skill editor and
cannot be retrofitted with a query parameter. The CLI asks for metadata on both
and takes `--with-content` when the bodies are actually wanted.

The file listing gains a SIZE column, which is what makes an oversized skill
diagnosable without downloading it.

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

* MUL-6639: fix(cli): fail skill transfers on stall, not elapsed time

`http.Client.Timeout` is a wall clock on the whole request, body included, so
it punishes exactly the transfer that is working: a 599KB skill arriving
steadily over a slow link was cut off at 30s mid-body, while a genuinely dead
connection was held open for the same 30s (GH #7498). Raising the constant
moves the cliff without removing it — the honest test is not "has this taken
too long?" but "is it still making progress?".

internal/cli/stall.go adds a client that asks the second question: bounded
connect, TLS and response-header phases, and a response body that fails after
15s with no new bytes. The wall clock stays as a loose 10-minute backstop —
demoted, not deleted, because dropping it to 0 would have left a stalled
request upload with no deadline at all.

Failures now say which thing went wrong. A new KindNetworkStalled reports "no
data received for 15s after N bytes" rather than a timeout, because only one of
those is fixed by waiting longer. Body-read errors are classified at all now:
wrapTransport only ever saw errors from http.Client.Do, which returns once the
headers arrive, so the failure #7498 actually reports reached users as a raw
"context deadline exceeded ... while reading body" out of the JSON decoder.

MULTICA_HTTP_STALL_TIMEOUT sets the no-progress budget. An explicitly set
MULTICA_HTTP_TIMEOUT still applies on this path, keeping its plain meaning —
the longest you will wait for a server — instead of silently reverting to a
total-elapsed limit.

Adoption starts with the skill commands, where the failure was reported; the
mechanism itself is not skill-specific. Graduation is one edit: point
NewAPIClient at NewStallAwareHTTPClient, fold StallAwareContext into
APIContext, drop newSkillAPIClient. A permanent second client would leave the
CLI with two timeout personalities, which is worse than either alone.

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

* MUL-6639: fix(skills): hash raw UTF-8, keep the no-include default at content

Review found two problems with the previous two commits.

**The file hash was not over the file's bytes.** `sha256(content::bytea)` runs
the bytea *input* parser over the text rather than taking its bytes, so it
reads backslash escapes: a file containing `\x41` hashed as the single byte
`A`, disagreeing silently with the Go-side hash of the SKILL.md body.

Worse than the mismatch, and not caught earlier because the first check used
accented text with no backslash in it: `content::bytea` *errors* on a bare
backslash. `C:\Users\...`, a regex `\d+`, a LaTeX snippet — ordinary contents
of a skill's supporting files — fail with "invalid input syntax for type
bytea", making the listing 500 on the skills most likely to need it.
`convert_to(content, 'UTF8')` takes the bytes, and matches Go exactly.

**`/api/skills/{id}/files` must not change what a bare request returns.** The
previous commit flipped its default to metadata on the grounds that the CLI is
its only caller. That was the wrong reading of "only caller": the CLI is
installed software too, and an older `skill files list --output json` reads
`content`. It is the same argument that kept the detail endpoint's default —
applied inconsistently to a client that upgrades separately from the server.

Both endpoints now return content when `?include=` is absent. The shrink
travels with the caller instead: the CLI sends `include=metadata` itself, so
GH #7498 is fixed with no synchronized release, and the default can flip once
clients that send `include=content` have aged in. The default is now uniform,
so resolveSkillInclude no longer takes a per-endpoint parameter.

Tests: TestSkillFileHashCoversRawUTF8Bytes covers `\x41`, invalid hex, bare
backslashes, Unicode and empty bodies against the Go hash — verified to fail on
the previous SQL. TestSkillEndpointsWithoutIncludeStillReturnContent pins the
compatibility guarantee on both endpoints.

Not addressed, and noted as follow-up: the metadata path still loads the full
SKILL.md into Go, because loadSkillForUser needs the row for its tenant check.
The HTTP response no longer grows with content; the database read still does.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-26 15:45:06 +08:00
mrlonely 1ef1d65bef fix(cli): remove obsolete autopilot priority flag (#7557) 2026-08-26 14:40:44 +08:00
LinYushen 55fe792483 refactor(cloud): use URL-only internal connection (#7566)
* refactor(cloud): use URL-only internal connection

* refactor(entitlement): remove Cloud rollout action

* refactor(cloud): remove unused client tuning
2026-08-26 14:40:19 +08:00
8fda4f22d5 MUL-6016: feat(task): retry exact Codex capacity failures (#6747)
* docs: record Codex capacity retry design

* docs: add Codex capacity retry implementation plan

* fix(taskfailure): recognize exact Codex capacity error

* feat(task): retry exact Codex capacity failures

* feat(server): configure Codex capacity retries

* docs: expose Codex capacity retry setting

* fix(task): align capacity retry comments

---------

Co-authored-by: cuijs <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-08-26 12:50:20 +08:00
LinYushen baf1bbf340 fix(billing): make Cloud seat enforcement automatic (#7542)
Replace the separate Fleet/capacity URLs and seat-capacity switches with the single managed MULTICA_CLOUD_URL connection. Strict seat admission turns on whenever Multica is Cloud-connected and fails closed when the capacity machine token is absent or invalid; the recovery worker starts only for a valid executor so live intents keep their retry budget during credential outages.

Split invitation throttling into a non-consuming precheck and post-reservation consumption. Persistent capacity_full and capacity_overcommitted rejections charge only the actor budget; transient failures charge none. Preserve every Cloud/proxy HTTP 429 as retryable with both Retry-After forms, defer outbox rows without spending attempts, and use Cloud's rate-limit scope so a workspace 429 delays only that tenant while global or unscoped 429s stop the current batch.

BREAKING: delete/unset MULTICA_FLEET_URL, MULTICA_CLOUD_FLEET_URL, MULTICA_CLOUD_FLEET_TIMEOUT, MULTICA_SUBSCRIPTION_CAPACITY_ENABLED, MULTICA_SUBSCRIPTION_CAPACITY_URL and MULTICA_SUBSCRIPTION_CAPACITY_WORKER_ENABLED before rollout. The startup guard rejects any non-empty value, so MULTICA_SUBSCRIPTION_CAPACITY_ENABLED=false still blocks boot. Configure MULTICA_CLOUD_URL, MULTICA_CLOUD_TIMEOUT and MULTICA_SUBSCRIPTION_CAPACITY_SERVICE_TOKEN instead; entitlements keep their independent MULTICA_ENTITLEMENT_POLICY_* configuration.

Companion: multica-ai/multica-cloud#57
2026-08-26 12:42:42 +08:00
6cbb50378f MUL-6680: fix autopilot list subscribers + make CLI triggers discoverable (#7552)
* fix(autopilot): populate subscribers in the list projection (MUL-6680)

GET /api/autopilots passed a nil subscriber slice to avoid an N+1, but
AutopilotResponse.Subscribers has no omitempty and autopilotToResponse
builds the slice with make(), so a nil input serialized as "subscribers":
[] rather than being omitted. Every listed autopilot therefore claimed to
have no subscribers while GET /api/autopilots/{id} reported the real ones
for the same row at the same moment.

A silently wrong value is harder to work around than a missing one: the
key is present and reads as authoritative — the field's own doc comment
promises exactly that — so callers cannot tell "none configured" from
"not fetched". It was reported after costing a real misdiagnosis
("subscribers keep being cleared").

The N+1 premise was wrong: autopilot_subscriber's primary key leads with
autopilot_id, so one ANY($1) batch is index-supported and needs no new
index. The batch query mirrors the single-autopilot query's member join
and ordering exactly, so list and detail cannot drift apart on who counts
as a subscriber.

Reported at https://github.com/multica-ai/multica/issues/7549

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

* feat(cli): make autopilot triggers discoverable (MUL-6680)

trigger-update, trigger-delete and trigger-rotate-url all take a
<trigger-id>, but no command listed them. The ids were reachable — the
detail payload returns "triggers" as a top-level sibling of "autopilot",
and the prefix resolver already reads it — yet that requires knowing the
envelope shape, so in practice the reported workaround was the web UI.
The prefix resolver's own miss message already told users to "run the
list command", which did not exist.

Add `autopilot trigger-list <autopilot-id>`, reusing the detail payload
and the same webhook-credential redaction `get` applies, so ids, kind,
enabled state and schedule are readable without --output json | jq.

Also add NEXT_RUN to the `autopilot list` table. next_run_at has been in
the list payload all along, but the table dropped it, which made a
correctly scheduled autopilot indistinguishable from one with no trigger
at all — the second misdiagnosis in the same report. Times render
relative ("in 2h", "3d ago", "—") to stay narrow and to make "never
scheduled" a visibly distinct value rather than an empty cell; LAST_RUN
moves to the same format for consistency within that table. The `get`
table keeps absolute timestamps — it is a single-row detail view where
precision matters more than width.

Reported at https://github.com/multica-ai/multica/issues/7549

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

* docs(cli): correct autopilot trigger documentation (MUL-6680)

The trigger section claimed webhook and api kinds were "not surfaced
here" because "there is no server endpoint that fires them yet". Webhook
triggers have both an endpoint and CLI support (`trigger-add --kind
webhook`, `trigger-rotate-url`); only the api kind is unsurfaced.

Also state that `triggers` is a top-level key alongside `autopilot`
rather than nested inside it — reading `.autopilot` and finding no
triggers is what the report described — and document trigger-list.

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

* fix(autopilot): fail closed when the subscriber read fails (MUL-6680)

Both subscriber projections swallowed a query error and continued with an
empty slice. Because subscribers has no omitempty and is documented as
authoritative, that degradation serialized as "subscribers": [] on a 200
— recreating the exact defect this branch set out to remove: the caller
cannot distinguish "none configured" from "read failed", and a client
that round-trips the response into a full-replace PATCH would silently
wipe a real subscriber list.

An empty array is a claim, not an absence. Return 500 on both the list
and detail paths so the failure is visible and retryable.

The detail path carried this degradation before this branch; it is fixed
here rather than deferred because the list/detail agreement asserted by
TestListAutopilots_SubscribersMatchDetail would otherwise still break
under a failed read — leaving the field untrustworthy in exactly the
condition the fix is about.

Addresses review feedback on #7552.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-26 01:28:35 +08:00
Yuri GuiandAgent Op f8ec870f30 MUL-6629: feat(agents): add per-agent starter prompts for chat (#7261)
* feat(agents): add per-agent starter prompts

Co-authored-by: Agent Op <[email protected]>

* fix(agents): default omitted starter prompts

Co-authored-by: Agent Op <[email protected]>

* fix(migrations): renumber agent starter prompts

Co-authored-by: Agent Op <[email protected]>

* fix(chat): make starter prompt prefills reliable

Co-authored-by: Agent Op <[email protected]>

* fix(agents): preserve starter prompt writes

Keep dirty starter-prompt edits across agent refetches and fail closed when an older server cannot persist them. Clarify that copied starter prompts have no dedicated CLI override.

Co-authored-by: Agent Op <[email protected]>

* fix(migrations): avoid starter prompt prefix collision

Main now uses migration prefixes through 403, so move the starter-prompts migration to the next available prefix before requesting another review.

Co-authored-by: Agent Op <[email protected]>

* fix(agents): preserve edits after failed save

Ignore optimistic instruction snapshots while a save is pending so a rejected update can roll back the cache without erasing the user's submitted starter prompts. Cover the delayed optimistic-update and rollback sequence for both label and prompt fields.

Co-authored-by: Agent Op <[email protected]>

---------

Co-authored-by: Agent Op <[email protected]>
2026-08-25 22:43:16 +08:00
YYClaw 9e2d9b6d1c MUL-6661 feat(channels): add /new and /clear conversation controls (#7468)
* feat(channels): add new and clear conversation controls

* fix(dingtalk): resolve bot identity before dispatch

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

* refactor(channels): snapshot task delivery routes
2026-08-25 18:28:01 +08:00
e5f976144b fix(server): keep source-context cleanup off the runtime sweep tick (#7541)
Source-context cleanup (MUL-6555) ran at the top of the 30s runtime sweep
tick, ahead of the stages that flip a dead runtime offline and reclaim its
orphaned tasks. Both cleanup stages call the object store, so a stalled or
throttled endpoint delayed runtime/task recovery: one round's work was
bounded by the number of due intents rather than the batch size, because
the loop counted successes, and the abandoned-capture path deleted objects
on the sweeper's root context with no per-object deadline.

Move both stages onto their own goroutine and ticker, bound each round with
a wall-clock budget, bound the intent loop by attempts, and give every
object delete a deadline. Failed deletes now record their retry backoff on
a context detached from the round budget, so a round cut short cannot leave
an intent claimable with no backoff and starve the rows behind it.

Cleanup cadence moves from 30s to 60s; nothing here is latency-critical
(intents settle for an hour, captures for 30 days).

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-25 17:57:41 +08:00
LinYushen 60a55ec57b feat(billing): purchase a seat from the invite flow (#7538)
* feat(billing): purchase seats from full invite flow

* fix(billing): harden full-capacity invite flow

* fix(billing): explain overcommit with cloud seat occupancy

Cloud refuses a reserve on used + reserved, but the overcommit toast filled
members_over_capacity_description with the product member count. The most
common trigger is a first ledger snapshot whose members alone still fit
inside the purchased seats, so the message read "3 members but only 3
purchased seats" and did not explain the rejection.

Add occupancy_over_capacity_description, which names occupied seats,
purchased seats, members and pending invitations, and feed it from the
Cloud summary. members_over_capacity_description stays as-is: the billing
tab banner is genuinely member-count driven.
2026-08-25 17:19:46 +08:00
39ddc82c9d MUL-6228: resolve full-UUID issue refs locally instead of a resolver GET (#7167)
* perf(cli): resolve full-UUID issue refs locally instead of a resolver GET

A full UUID is self-identifying, yet resolveIssueRef sent it through
GET /api/issues/<uuid> just to learn the id it already had — and agents
fire several multica issue calls back-to-back during run bootstrap, so
the round-trip was paid several times per run (GH #7017).

Full-UUID refs now short-circuit to a lowercased resolvedID with zero
HTTP requests, pinned by test the same way the short-prefix rejection
is. Identifier refs (MUL-123) keep the fetch — that mapping genuinely
needs the server. Two accepted consequences, per the issue: messages
built from Display show the UUID rather than the issue key for
full-UUID invocations, and a nonexistent UUID now 404s on the command's
own request instead of the resolver's — every write path re-validates
server-side (e.g. parent_issue_id on create checks GetIssueInWorkspace),
so nothing is stored on the strength of the ref alone.

* fix(cli): preserve issue keys in reorder messages

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

---------

Co-authored-by: damon.zhou <[email protected]>
Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-25 16:08:15 +08:00
da7131ab59 MUL-6625: enforce prepaid member seat capacity (#7491)
* MUL-6625: enforce prepaid member seat capacity

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

* fix(seat-capacity): address review findings

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

* fix(seat-capacity): close retry and worker races

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

* fix(seat-capacity): bound lock waits during rollback

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

* fix(seat-capacity): pin the cross-repo confirm settlement budget

Multica Cloud must wait longer than this worker's whole retry budget before it
treats a member row as one its ledger never recorded; adopting a member whose
confirm is still retrying counts one person as two seats, and an over-estimate
is what asks a customer to buy seats they do not owe.

That budget was only implicit here — 10 attempts of exponential backoff plus a
tick and a lock wait each — and Cloud's horizon had been set from a single
retry. Extract the backoff into retryBackoff, expose SettlementBudget, and
assert the worst case (29m15s) stays inside Cloud's 45m horizon, so raising
maxAttempts or the backoff cap fails review here instead of widening the window
in which Cloud cannot quantify a ledger.

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

* Revert "fix(seat-capacity): pin the cross-repo confirm settlement budget"

This reverts commit d0ec36f647.

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-25 12:51:44 +08:00
YYClaw 8c563b49be MUL-6555 feat(issues): preserve source context for sub-issues (#7461)
* feat(issues): preserve source context for sub-issues

* fix(issues): address source context review findings

* fix(migrations): guard source context rollback preflight
2026-08-25 12:00:49 +08:00
2284420966 MUL-6622: fix(server): authorize squad activity by task provenance (#7489)
* MUL-6622: fix(server): authorize squad activity by task provenance

`multica squad activity` rejected every issue that was not itself assigned
to a squad, so a leader woken by an `@squad` mention on an agent-owned issue
or running a leader task bound to a child issue could not record its
evaluation. Because the injected `no_action` rule makes that call the
substitute for a comment, the decision left no trace at all.

The claim path already keys the squad briefing and the mandatory-recording
instruction off the task row (`is_leader_task` + `squad_id`), not off the
issue's assignee. `RecordSquadLeaderEvaluation` now uses the same source:

- load the `X-Task-ID` row first and require it to be a leader task with a
  stamped squad; resolve the squad from `task.squad_id`;
- drop the `issue.assignee_type == "squad"` precondition;
- authorize against `task.agent_id` rather than the live `squad.leader_id`,
  and record `activity_log.actor_id` as `task.agent_id`. A mid-run leader
  change no longer discards a real evaluation, and no longer misattributes
  it in a way that silently desyncs the `no_action` comment suppression
  lookup (which matches on `task.agent_id`);
- name the task's issue in the cross-issue rejection, since a stage-barrier
  wake runs on the parent and reaching for the child id is the common slip.

Leader agents running a NON-leader task are now rejected: they are not
running as the leader, and the runtime only mandates the call for leader
tasks. This is the one behavior narrowing, covered by a test.

The injected instructions also gain a failure fallback. The comment
prohibition is already conditional on the activity existing
(comment.go:1851), so a leader whose recording call errored may comment —
it just was not told to. It now is, closing the silent-drop path for any
remaining failure cause.

Fixes #7487

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

* MUL-6622: fix(server): close review findings on squad activity authorization

Addresses the four items from PR review.

1. Tenant isolation. `GetAgentTask` is a global lookup by id and the
   cross-issue rejection quotes the task's issue id, so an unrelated task
   probed through a readable issue echoed back a foreign workspace's issue
   id. The task is now loaded with `GetAgentTaskInWorkspace`, and check
   order is explicit: tenant scope, then "caller owns this task", and only
   then any message naming a task-derived id.

2. Claim-time role divergence. `is_leader_task` on the row is enqueue-time
   intent: when the leader is swapped before the claim, the claim path
   clears `resp.IsLeaderTask` and runs the task as an ordinary agent turn
   while the row keeps the flag set. Trusting the row alone let such a
   downgraded run write a leader verdict and suppress its own comment, so
   the live `squad.leader_id == caller` gate is restored on top of the task
   ownership gate. `activity_log.actor_id` stays `task.agent_id`, which is
   what the no_action suppression lookup matches on. Removing the live gate
   later requires persisting the role the claim actually delivered.

3. Failure fallback vs. one comment per turn. The new "comment if the
   recording call fails" instruction contradicted the exactly-one-comment
   rule on the `action` path, where a delegation comment already exists. All
   four injection sites now scope the fallback to turns that have not
   commented yet and cap it at one comment. Pinned by
   TestSquadOperatingProtocolRecordsAgainstTheTurnsIssue and
   TestBuildPromptSquadLeaderNoActionFailureFallback.

4. Shared test fixtures. The new handler tests are rebuilt on
   `internal/testutil` (`dbfx.Issue` / `dbfx.Task` / `dbfx.Squad`,
   `testutil.Call(...).Want(...)`) instead of open-coded
   `INSERT ... RETURNING` and recorder quartets, per CLAUDE.md. The shared
   `runningSquadLeaderTaskFixture` moved over too.

New negative coverage: foreign-workspace task and foreign-agent rejections
both assert the response body does not contain the other issue's id, and
a leader change before recording is now asserted to be refused.

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-24 14:38:07 +08:00
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
8d2da6b8db MUL-6479 feat(dev): make a local environment a named object with one verb per lifecycle step (#7325)
* MUL-6479 feat(dev): make a local environment a named object with one verb per lifecycle step

Local development had six entry points, thirty-four make targets and no way to
see or delete what they created. `make up` / `status` / `list` / `down` /
`destroy` / `gc` / `env-exec` replace that with one set of verbs, shared by
humans and agents, with `C=api,web,daemon,desktop` selecting components.

Three defects made the old flow untrustworthy rather than merely verbose.

1. The database the tooling created was not the one the application reached.
   `ensure-postgres.sh` creates through `docker exec`; when a native PostgreSQL
   owns 5432 the container never binds the host port, so the create lands in a
   server the backend never talks to. It printed "✓ PostgreSQL ready" and the
   next step died with `SQLSTATE 3D000`. On the machine this was written on the
   two servers had already diverged: 94 same-named databases, 69 more in the
   container with no counterpart. `up` now creates through DATABASE_URL and
   treats a successful `migrate up` — which pings that same string — as the only
   proof; failure diagnoses the port owner instead of reporting a missing
   database.

2. Identity was computed, not allocated. Ports, database names and CLI profiles
   all came from `cksum($PWD) % 1000`, and a collision was silent: the loser
   fails to bind while the winner keeps answering 200. Allocation now happens
   under a lock, probing from the same path hash so a checkout keeps its
   familiar numbers, and requires the slot to be both unregistered and free of a
   live listener. The manifest in ~/.multica/dev/envs/<name>/ is what every
   later command reads.

3. Creating an environment left no record of how to destroy it, so there was no
   destroy — only archaeology. `down` stops processes and keeps the data;
   `destroy` consumes the manifest: processes, database, profile, slot. `list`
   makes leaks visible and `gc` collects environments whose checkout is gone or
   whose TTL passed.

/health now reports pid, commit and started_at. A 200 alone proves something is
listening, which is exactly what let a failed restart keep serving the previous
build; `up` compares started_at against its own launch and refuses an answer
that predates it.

Two constraints are enforced rather than documented: the daemon is started from
a built server/bin/multica, never `go run` (the daemon re-execs its own path for
every task, and `go run` deletes that binary when the launcher exits), and
`C=daemon` under a .multica/daemon_task_context.json marker stops with that
explanation up front instead of spending a login on a refusal.

scripts/dev-env.test.sh covers the registry verbs against a throwaway
MULTICA_DEV_HOME with no services running, and pins the regression that made
`down` exit 1 after reporting success: on bash 3.2 a command substitution whose
function ends in a failing command aborts the script under `set -e`, and "no
process is listening" is that function's normal answer.

`make dev` is unchanged and still runs in the foreground.

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

* fix(dev): close local environment lifecycle gaps

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-23 16:43:58 +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
Martin MontesandMartin Montes 5a80f802d3 MUL-6558 feat(server): make task queue expiry configurable via MULTICA_TASK_QUEUED_TTL (#7418)
Lets self-hosted deployments raise the queued-task expiry window above the built-in 2h default, so low-concurrency runtimes stop losing legitimately-pending work to queued_expired. Default behavior is unchanged. Covers .env.example, Compose, Helm values/ConfigMap, and the behavior docs in all four locales.

Co-authored-by: Martin Montes <[email protected]>
2026-08-23 12:56: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