11 Commits
Author SHA1 Message Date
90e0bdf830 MUL-7586 Atomically create issues with custom properties (#8689)
* feat(issues): create with properties atomically

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

* fix(issues): recover unavailable properties in create drafts

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

* test(issues): isolate commit failure task assertions

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

---------

Co-authored-by: Forge-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-22 16:44:14 +08:00
c7f259c70a revert(server): withdraw the reserved triage status key (#8450)
The `triage` status key was reserved so the server could read
`status = 'triage'` as "in Triage": migration 475 added a CHECK against it,
476 renamed any pre-existing custom `triage` status (and the issues and saved
views pointing at it), and issuestatus refused the key on every write path.

Migration 483 then moved Triage onto issue.triage_state, and nothing reads the
status that way any more. What is left is a rewrite of live customer data in
the deploy path that buys nothing, plus a name the product does not need to
own — users see a status's display name, not its key, so reserving the key
never stopped anyone from calling a custom status "Triage".

475-477 are emptied rather than reverted with a new migration: they have never
run outside development and CI, because releases deploy from tags and the
newest tag predates them. Migration 486 drops the constraint on databases that
did migrate off main, which would otherwise refuse a key the server now
accepts; 476's rename is not reversed, since the replacement is an ordinary
custom key by then. 478 loses the matching `triage` fast-path entry, where the
key falls through to the same answer either way.

The Go side goes with it: the Triage constant, ErrReservedStatus, the Resolve
refusal, IsBuiltIn's extra name, ErrStatusReservedForTriage and its two handler
mappings, the leading Triage rank in the issue sort, and the CLI and docs lines
that told users the key was reserved. Every triage_state path is untouched.

Tests now pin the reverted behavior: the emptied migrations leave a workspace's
custom `triage` status, issues and saved views exactly as they were, 486 drops
the barrier on a database that already has it, and `triage` works end to end as
an ordinary custom status key.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-15 18:12:35 +08:00
8908fcfbc4 feat(server): reserve the triage status key (MUL-7212) (#8298)
Make `triage` a platform-reserved issue status: it has no catalog row, is not
a category a custom status can declare, and no ordinary write can set it or
move an issue out of it.

- issuestatus: Triage constant; IsBuiltIn covers it, so ValidateKey, DeriveKey
  and firstFreeKey refuse or skip the key; Resolve refuses it with
  ErrReservedStatus; Effective and Category resolve it without a catalog read
- migrations: a barrier file adds CHECK (key <> 'triage') NOT VALID on its own,
  so it commits before the repair file renames a pre-existing custom `triage`
  key per workspace (firstFreeKey rule) across the catalog, issues and saved
  views and VALIDATEs; issue_effective_status() gains 'triage' on its fast path
- writes: status_reserved_for_triage on create/update/batch; issue_in_triage
  when status, project_id or parent_issue_id is written on an issue in Triage;
  merged PRs no longer auto-advance a Triage issue
- status sort ranks triage first; duplicate guard ignores triage issues
- notification label, CLI help, TriageV1 feature flag key
- cmd/migrate forwards notices marked 'migration report: ' to its log

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-14 15:20:19 +08:00
Bohan Jiang a9e82c7973 MUL-7282: fix(comments): delete only the selected comment and keep its replies (#8323)
Deleting a comment removed every reply under it, through the parent_id ON DELETE CASCADE. Now delete removes only that comment: one with replies becomes a tombstone (deleted_at set, body/attachments/reactions/resolution cleared) so the replies keep their parent, and one without replies is removed along with any tombstone ancestor left empty.

Tombstones are read-only and are never agent input. Every write to a comment's children takes its owners first (issue -> comment -> child), and a comment commits together with the attachments it was posted with. Clients only promise that replies are kept when the server declares comment_delete_keep_replies_supported, and then delete through DELETE /api/comments/{id}/keep-replies, which older servers do not route.
2026-09-13 19:13:43 +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
eddb0dc2ad MUL-6884 feat(metrics): attribute agent_runtime lookups by product source (#7822)
* feat(metrics): attribute agent_runtime lookups by product source

MUL-6884 asked which product API is behind the hot GetAgentRuntime query.
It has no single answer: a dozen unrelated paths issue the same primary-key
read, so pg_stat_statements sums them into one row and can report that the
statement is busy without saying which behaviour is driving it. Deciding
between "raise the heartbeat interval" and "back off the 500ms browser polls"
needs that split, and today it is a guess.

Add multica_agent_runtime_lookup_total{source,result} and route every
production read through service.RuntimeLookup, which carries the source with
the connection and classifies pgx.ErrNoRows as not_found rather than as an
error — a deleted runtime is the daemon's self-heal signal, not an outage.

The labels are a closed enum (15 sources x 3 results, all prewarmed at zero)
because this fires on the hottest read in the system; an unclassified call
site degrades to "other" instead of minting a series. No query, no timing
histogram, and no extra SQL on /metrics — this PR only makes the existing
calls countable.

An AST lint test fails CI on any direct Queries.GetAgentRuntime call outside
the wrapper, since a bypassed call site would quietly undercount whichever
source it belonged to and send the follow-up work after the wrong caller.

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

* refactor(metrics): drop the standalone handler lookup file

The two helpers it held are not an endpoint — no route, no HTTP handler, no
new file under cmd/ — but a new agent_runtime_lookup.go under internal/handler
reads like one, and a file name that has to be opened before it can be ruled
out is a bad file name.

Move both into runtime.go next to requireRuntimeReadAccess, where the rest of
the runtime read helpers already live, and name the metrics test after what it
tests. No behaviour change.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-31 17:46:56 +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
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
Multica Eve 7b8c783991 fix(issues): let verified autopilot runs assign private agents (#7572) 2026-08-26 15:55:26 +08:00
iiwish 3d47913c85 fix(issues): allow verified autopilot child assignment (MUL-6161) (#7275)
* fix(issues): allow verified autopilot child assignment

* test(issues): cover autopilot private squad assignment

* test(issues): harden autopilot assignment coverage
2026-08-25 18:44:39 +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