Files
multica/server/pkg/db/queries/agent.sql
Bohan Jiang 4fac8d772f feat(attribution): Human Attribution Phase 1 (MUL-4302) (#5150)
* feat(attribution): Phase 1 foundation — provenance schema + resolver (MUL-4302)

Human Attribution, Phase 1 (地基) first increment. Every agent run must be
traceable to exactly one accountable human AND record at which waterfall level
that human was resolved, so a NULL originator can be told apart from a genuine
'no human in the chain'.

- migration 149: add originator_source (waterfall label) + delegation/retry/
  rerun/rule-version lineage + kind-tagged trigger evidence to agent_task_queue.
  No FK, no cascade, no CHECK on the source enum (MUL-4302 §7); nullable ADD
  COLUMNs = fast metadata-only change on the hot queue table.
- internal/attribution: the accountable-human vocabulary (Source, EvidenceKind,
  TriggerKind) + pure, unit-tested classification rules (ClassifyComment/
  ClassifyDirect). No DB, no authorization — provenance labeling only.
- service: attributionFor{IssueTask,TriggerComment} gather facts and delegate to
  the pure classifier; the legacy originator resolvers now delegate here so
  there is one source of truth. originator_user_id's VALUE is unchanged, so the
  Composio-overlay and canInvokeAgent A2A authorization boundaries are
  byte-for-byte preserved (MUL-4302 §1.3).
- enqueueIssueTask / enqueueMentionTask stamp originator_source + evidence;
  CreateRetryTask carries the parent attribution forward and records
  retry_of_task_id so retry and manual rerun stay separable (MUL-4302 §5).

Verified: go build ./..., go vet, gofmt clean; new attribution unit tests +
enqueue stamping integration test green; existing resolve_originator tests
unchanged.

Co-authored-by: multica-agent <github@multica.ai>

* feat(attribution): split accountable_user_id from originator, close enqueue bypasses (MUL-4302)

Phase 1, per Bohan's decision on the MUL-4302 thread: audit and authorization
answer different questions and get different columns.

- Migration 151 adds agent_task_queue.accountable_user_id (no FK, no cascade).
  Authorization keeps reading ONLY originator_user_id (canInvokeAgent A2A gate,
  Composio overlay); audit/UI/usage read accountable_user_id + source + evidence.
- Invariant (finalizeAttribution, single chokepoint + §11 tests): originator
  non-null ⟹ accountable equals it. The two diverge only when originator is null
  (autopilot / degraded fallback), which is the deferred rule_owner/owner_fallback
  increment; this lands the column + mirror-write so that split has a home.

- Close the NULL-source enqueue bypasses Elon flagged: chat, quick-create,
  deferred-fallback and run_only-autopilot now stamp originator_source + evidence
  (+ accountable where a human exists). Autopilot stays unattributed until the
  rule-version snapshot table lands, but is no longer a silent NULL-source row.
  Retry inherits accountable_user_id like the rest of the attribution lineage.

- Fix assign/promote attribution (§4): a member who assigns/promotes an existing
  issue is now the accountable human (and, by the invariant, originator) ahead of
  the issue creator. Threaded as an OPTIONAL actor override, so comment/rerun/
  autopilot paths keep today's resolution and create-with-assignee (creator ==
  actor) is unchanged. The squad leader gate already judged the same member.

Also merges origin/main: renumbers the attribution migration 149→150 (main took
149 for issue_origin_agent_create) and folds agent_create into ClassifyDirect's
origin inheritance.

go build/vet/gofmt clean; attribution unit tests + service stamp/actor tests +
handler suite pass on a fresh DB migrated through 151.

Co-authored-by: multica-agent <github@multica.ai>

* docs(attribution): fix accountable NULL semantics + close chat/quick-create evidence boundary (MUL-4302)

Addresses Elon's 2nd-round review on PR #5150 (pre-merge doc/evidence items):

- Migration 151 no longer overclaims NULL. accountable_user_id is NULL not only
  on pre-migration rows but on NEW rows whose audit source resolved no human yet
  (run_only autopilot writes originator_source='unattributed' with NULL
  accountable until rule_owner lands). Header + COMMENT ON COLUMN reworded so a
  schema reader does not misjudge the invariant.

- Chat now uses the UNIFORM evidence pair (kind=chat, ref=chat_session_id), like
  autopilot_run/issue_assignment, instead of relying only on the dedicated
  chat_session_id column — new EvidenceChat kind. Added a service test asserting
  chat stamps direct_human + chat evidence.

- Quick-create is documented as the ONE intentional no-antecedent-row path: no
  comment/issue/session/run exists at enqueue time (the run creates the issue), so
  trigger_evidence_kind/ref stay NULL while the human rides originator/accountable
  and source is direct_human — not a NULL-source bypass.

No authorization behavior change. attribution + service + handler suites pass on a
DB migrated through 151.

Co-authored-by: multica-agent <github@multica.ai>

* chore(attribution): renumber migrations 150/151 → 157/158 after merging main (MUL-4302)

main's #5162 ("unblock release migrations") renumbered the chat migrations and
took 150 (agent_task_coalesced_comments) and 151 (chat_read_cursor), colliding
with this branch's attribution migrations. Renumber them above main's new highest
(156) so TestMigrationNumericPrefixesStayUniqueAfterLegacySet passes:

- 150_agent_task_attribution      → 157_agent_task_attribution
- 151_agent_task_accountable_user → 158_agent_task_accountable_user

Fixed the internal "migration 150" references in 158's header to 157. Migrations
apply cleanly through 158 on a fresh DB; migration lint green.

Co-authored-by: multica-agent <github@multica.ai>

* feat(attribution): autopilot rule_owner — accountable = rule version publisher (MUL-4302)

Implements rule_owner (MUL-4302 §3.4), the first attribution source where the
accountable human diverges from the (NULL) authorization originator.

- Migration 159 adds the append-only autopilot_rule_version snapshot table (no FK,
  no cascade); migration 160 adds its CONCURRENTLY lookup index.
- Write-on-publish: CreateAutopilot appends v1 (publisher = creator); UpdateAutopilot
  appends a new version when a SUBSTANTIVE autopilot-row field changes (assignee /
  status / execution_mode) — cosmetic edits (title/description/template) write none.
  Both run inside the existing handler tx (atomic with the autopilot write).
- Dispatch resolution: both autopilot execution modes now resolve the active rule
  version and stamp originator_source='rule_owner', accountable_user_id=publisher,
  rule_version_id=<snapshot>, with originator_user_id left NULL (authorization
  unchanged). run_only stamps CreateAutopilotTask directly; create_issue resolves in
  attributionForIssueTask so both modes attribute identically. A missing version /
  non-member publisher degrades to unattributed — never fabricates a human.
- finalizeAttribution now enforces the invariant ONE-WAY: it mirrors originator onto
  accountable only when originator is valid, leaving an explicitly-set accountable
  (rule_owner / future owner_fallback) intact when originator is NULL. Added
  rule_version_id to CreateAgentTask so the create_issue path persists it too.

Also merges origin/main and renumbers this branch's attribution migrations
150/151 → 157/158 (main's #5162 took 150/151); rule_version table is 159/160.

Tests: attribution unit RuleOwner + one-way invariant table; service integration
tests proving an autopilot-origin issue stamps rule_owner + rule_version_id (and
degrades to unattributed with no version). Full service/attribution/handler/
migration suites pass on a DB migrated through 160; build/vet/gofmt clean.

Deferred (same PR): trigger-table republish (cron/webhook/event_filters) and
system-pause/archive versioning; owner_fallback + fail-closed; manual rerun.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): manual autopilot trigger → direct_human to the triggering member (MUL-4302)

Elon's blocking finding: a member manually triggering an autopilot was attributed
rule_owner (accountable = rule publisher, originator NULL) like a schedule/webhook
run, so member B triggering member A's autopilot landed accountable=A and carried
no originator authorization context for the run. Per MUL-4302 §4 a manual "run now"
is a direct human action and must attribute direct_human to the triggering member.

- Thread the triggering member from TriggerAutopilot into dispatch: new
  DispatchAutopilotManual carries actorUserID (resolved via resolveActor +
  memberActorUserID, so only a member actor is a human; an A2A agent actor falls
  back to rule_owner). DispatchAutopilot / DispatchAutopilotForPlan keep their
  public signatures (pass an invalid actor); only the internal dispatchAutopilot /
  dispatchCreateIssue / dispatchRunOnly gained the param, so the many existing
  callers are untouched.
- run_only: dispatchRunOnly stamps direct_human (originator == accountable ==
  actor, no rule_version) for a manual actor, else rule_owner. CreateAutopilotTask
  gains an originator_user_id param for the manual case.
- create_issue: dispatchCreateIssue enqueues a manual trigger via the actor-carrying
  *WithHandoff entry points; attributionForIssueTask's autopilot-origin rule_owner
  branch is now guarded on !actorUserID.Valid, so a valid actor falls through to the
  direct_human override. Both execution modes attribute identically.
- schedule / webhook keep rule_owner (no actor). Trigger-table + system-pause/archive
  versioning remain the pre-merge follow-ups.

Tests: the run_only row assertion Elon asked for (schedule → rule_owner row on
CreateAutopilotTask), plus manual direct_human on BOTH modes (run_only and
create_issue), including a manual actor distinct from the rule publisher. Full
service/attribution/handler/migration/scheduler/cmd suites pass on a DB migrated
through 160; build/vet/gofmt clean.

Co-authored-by: multica-agent <github@multica.ai>

* feat(attribution): owner_fallback + fail-closed policy, and manual-rerun direct_human (MUL-4302)

Two of the three remaining Phase 1 items (trigger-table / system-pause versioning
is deferred — see PR description).

owner_fallback + fail-closed (§1/§3.5) — the never-null accountable guarantee:
- attribution.OwnerFallback degrades an UNATTRIBUTED result to owner_fallback:
  accountable = agent owner, originator stays NULL (audit-only, authz untouched),
  Source.Precise()==false. finalizeAttribution's one-way invariant already allows
  accountable-set / originator-NULL divergence, so nothing else changes.
- Migration 161 adds workspace.attribution_fail_closed (default FALSE) + a lean
  GetWorkspaceAttributionFailClosed read. (Also added the column to ListWorkspaces'
  explicit column list so its row type stays db.Workspace.)
- applyAttributionFallback is applied at every enqueue boundary (issue, mention,
  chat, quick-create, deferred-fallback, autopilot run_only): unattributed →
  owner_fallback (agent owner) by default, or ErrAttributionFailClosed when the
  workspace is fail-closed, which the caller surfaces to refuse the enqueue (the
  run does not start). So no run is left without an accountable human, and a
  compliance workspace can block unattributable runs instead.

manual rerun (§5) — a rerun is a NEW direct_human trigger to the rerunning member:
- RerunIssue threads the acting member (resolved in the handler via resolveActor)
  down to enqueueRerunTask, and attributionForIssueTask is now actor-first so the
  actor wins over an INHERITED trigger comment (a rerun keeps the comment for the
  daemon's prompt context but must attribute to whoever clicked rerun, not the
  original comment's human).
- rerun_of_task_id lineage is recorded via a targeted SetAgentTaskRerunOf update on
  the rerun path only (keeping the shared CreateAgentTask insert untouched), so
  system retry (retry_of_task_id) and human rerun stay separable in reporting.

Tests: OwnerFallback unit test; owner_fallback + fail-closed-refusal + manual-rerun
(direct_human + rerun_of_task_id) service tests; the prior "degrades to unattributed"
test updated to owner_fallback. Full service/attribution/handler/migration/scheduler/
cmd suites pass on a DB migrated through 161; build/vet/gofmt clean.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): close fail-open holes + move rerun_of_task_id into creation snapshot (MUL-4302)

Addresses Elon's two must-fixes on PR #5150.

1. accountable-never-null / fail-closed had fail-open holes. applyAttributionFallback
   now, for an UNATTRIBUTED run, refuses the enqueue (ErrAttributionFailClosed) in
   THREE cases instead of silently degrading to a runnable NULL-accountable task:
   - workspace policy read fails (or no workspace) → fail closed; we cannot confirm
     fallback is permitted, so we don't run an unattributable task on a DB hiccup.
     (Only the rare unattributed path pays this; precise runs never read the policy.)
   - workspace is fail-closed → refuse (unchanged).
   - owner_fallback has no valid agent owner → refuse rather than enqueue a task with
     a NULL accountable_user_id.
   ErrAttributionFailClosed's doc now covers all three "cannot guarantee an
   accountable human" refusals. Added missing-owner / policy-read-failure /
   precise-passthrough tests.

2. manual rerun rerun_of_task_id was a post-notify UPDATE (race: the queued event /
   daemon claim could see rerun_of_task_id = NULL, and a failed update degraded the
   run to a plain direct_human). It now rides the CreateAgentTask insert — threaded
   through enqueueIssueTask / enqueueMentionTask as a creation param (like
   retry_of_task_id) so it is written in the same statement before the daemon is
   notified. Removed the SetAgentTaskRerunOf follow-up query.

Also merges origin/main (unrelated CLI fix #5167, no conflict). Full service /
attribution / handler / migration / scheduler / cmd suites pass on a DB migrated
through 161; build / vet / gofmt clean.

Co-authored-by: multica-agent <github@multica.ai>

* feat(attribution): rule_owner versioning on trigger edits + system-pause/archive (MUL-4302)

The final remaining Phase 1 item: substantive publishes beyond the autopilot row now
republish the rule version, so a run's rule_owner accountable follows whoever last
changed what the rule does.

- Extracted the config-summary + insert into service.RecordAutopilotRuleVersion so
  the handler and the (different-package) failure monitor share one writer; the
  handler's recordAutopilotRuleVersion is now a thin wrapper.
- Trigger edits: UpdateAutopilotTrigger and DeleteAutopilotTrigger republish the rule
  version with the acting member as publisher, ATOMICALLY (tx-wrapped mutation +
  version write, mirroring CreateAutopilot/UpdateAutopilot). CreateAutopilotTrigger
  republishes best-effort — the webhook path mints its token with a retry loop that
  cannot share one tx, and a create is usually initial setup already covered by v1;
  a failed write there is benign (active version stays the current publisher, the new
  trigger fires under it, no immediate daemon claim rides it).
- Archive (DeleteAutopilot) republishes (member, status=archived), tx-wrapped.
- System auto-pause (failure monitor) republishes with a 'system' publisher,
  best-effort — a background sweep to a non-dispatching state (a paused autopilot
  never dispatches; a later member resume supersedes).
- RotateWebhookToken / SetSigningSecret deliberately do NOT version: they rotate
  credentials, not the rule's behavior (not §3.4 substantive).

Semantics: a system-published (no-member) active version degrades dispatch to
unattributed → owner_fallback, never fabricating a human.

Tests: republish-reattributes (member A → member B supersedes → dispatch resolves to
B; system publisher → unattributed). Full service/attribution/handler/migration/
scheduler/cmd suites pass on a DB migrated through 161; build/vet/gofmt clean.
Also merges origin/main (unrelated frontend feature #5074).

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): make trigger-create rule-version republish atomic (MUL-4302)

Addresses Elon's final Phase 1 blocking finding: CreateAutopilotTrigger recorded the
rule-version republish best-effort AFTER the trigger insert. If member B added a
schedule/webhook trigger to member A's autopilot and the version write failed, future
schedule/webhook dispatches would keep attributing to A — violating the rule_owner
invariant that the last member to substantively change the rule owns future runs
("no immediate daemon claim" doesn't save it, since the miss surfaces at the LATER
trigger firing).

Both create paths now write the version in the SAME tx as the trigger INSERT:
- schedule create: wrap CreateAutopilotTrigger + recordAutopilotRuleVersion in one tx.
- webhook create: each mint-with-retry attempt runs in its own tx (insert + version
  commit together; a token collision rolls that attempt back and retries with a fresh
  token; a version-write failure rolls the trigger back). Passes ap + the acting
  member id into the helper.
- removed the best-effort recordTriggerRuleVersionBestEffort helper (and the now-unused
  slog import).

Test: TestCreateTrigger_RepublishesRuleVersionAtomically drives both create paths
through the handler and asserts a rule version is published by the acting member.
Existing webhook/trigger/archive handler tests still pass. Also merges origin/main
(unrelated avatar feature #5074). Full service/attribution/handler/migration/
scheduler/cmd suites pass on a DB migrated through 161; build/vet/gofmt clean.

Co-authored-by: multica-agent <github@multica.ai>

* feat(attribution): Phase 2.1 — surface run attribution on the task API (MUL-4302 §9)

First Phase 2 (visibility) increment: the agent-task API now returns the resolved
accountable-human provenance so the UI can render an "on behalf of" badge.

- AgentTaskResponse gains an `attribution` object: source label (never blank —
  pre-migration NULL renders "unattributed") + `precise` flag (false for the degraded
  owner_fallback / backfill / unattributed sources), the initiator (accountable) and
  originator (authorization) user refs, the evidence {kind, ref_id} pointer, and the
  rule_version / delegated / retry / rerun lineage ids.
- The label + evidence + raw ids are built in the PURE taskToResponse (no DB), so
  every task response carries them. Names are hydrated separately, only on the
  user-facing surfaces (ListAgentTasks, ListWorkspaceAgentTaskSnapshot, RerunIssue,
  CancelTaskByUser) — daemon-claim paths stay lean.
- Hydration resolves initiator/originator from the GLOBAL user table (departed-member
  safe) via a new batch GetUsersByIDs query (no N+1); best-effort, so a lookup hiccup
  leaves the raw ids intact.

Tests: pure taskAttributionBase (direct_human / rule_owner NULL-originator /
owner_fallback degraded / pre-migration→unattributed) + DB hydration (fills known
ref, leaves unknown id un-filled, skips nil). Full handler/service/attribution/
migration/scheduler/cmd suites pass on a DB migrated through 161; build/vet/gofmt
clean. The field is additive — the frontend's parseWithFallback ignores unknown keys,
so nothing breaks until the UI increment consumes it.

Also merges origin/main (unrelated editor feature #5090).

Remaining Phase 2 (next increments, same PR): frontend zod schema + "on behalf of"
badge + evidence-chain jump; append-only correction events (write + display).

Co-authored-by: multica-agent <github@multica.ai>

* feat(attribution): Phase 2.2 — on-behalf-of badge in the execution log (MUL-4302 §9)

Surface the accountable human on every agent run row:
- AttributionBadge composes Badge + ActorAvatar, shows "on behalf of <member>"
  with the resolution source as a tooltip; degraded (non-precise) attribution
  gets a warning tone, and an unresolved initiator renders an explicit
  "no responsible member" chip.
- Wire the badge into both active and past rows of the execution log.
- Mirror the attribution shape into AgentTaskResponseSchema (defensive, .loose())
  so the cancel-task path carries it through zod; add parse tests.
- Export TaskAttribution/AttributionUser/TaskEvidence from @multica/core/types
  and add the attribution block to all four issues.json locales.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): hydrate initiator names on issue-facing task endpoints + bound the badge (MUL-4302 §9)

Address Elon's PR #5150 review:
- ListTasksByIssue (the execution-log data source), GetActiveTaskForIssue and the
  issue-scoped CancelTask now call hydrateTaskAttributions, so the "on behalf of
  <member>" badge shows the real member name on issue detail instead of falling
  back to "someone". Mirrors the existing ListAgentTasks / snapshot behavior.
- AttributionBadge: cap width (max-w-40, min-w-0) and truncate the name span so a
  long name / narrow right column can't squeeze out trigger/status/actions; keep
  the avatar shrink-0.
- Add a handler test asserting the issue task list returns a hydrated
  attribution.initiator.name.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): use semantic AvatarSize 'xs' for the badge avatar

main refactored ActorAvatar.size from a raw pixel number to the semantic
AvatarSize union (packages/ui/lib/avatar-size). Switch the on-behalf-of badge
avatar from size={14} to size="xs" (16px) after merging main.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): stage-cascade falls back to parent-issue provenance, not agent owner (MUL-4302)

When closing the last sub-issue in a Stage wakes the parent's assignee agent, the
run was enqueued via a system-authored child-done comment with no actor, which the
resolver classified as unattributed and then degraded to owner_fallback (the agent's
own owner). That is the wrong accountable human: the woken run should be accountable
to whoever caused the parent issue to exist.

attributionForIssueTask now detects a system-authored trigger comment and falls
through to the parent issue's own provenance — the same creator / agent_create-origin
/ autopilot-origin chain a direct enqueue resolves (so an agent-decomposed parent
attributes via delegation to the human who drove it; a member-created parent to that
member; an autopilot parent to the rule publisher). owner_fallback is now only the
last resort when the parent provenance itself has no human.

- Extract attributionFromComment so attributionForIssueTask can inspect author_type
  without a second GetComment; authorization resolution stays byte-identical.
- Add a DB-backed test asserting a system child-done comment resolves to the parent
  issue's origin human (delegation), not owner_fallback.

Co-authored-by: multica-agent <github@multica.ai>

* feat(attribution): autopilot runs attribute to the firing trigger's creator (MUL-4302)

Per Bohan: an autopilot schedule/webhook run should be accountable to the human
who created the SPECIFIC trigger that fired it, not the rule publisher. (Manual
triggers already attribute to the invoking member via direct_human — unchanged.)

- Migration 162: add autopilot_trigger.created_by_type/created_by_id (nullable, no
  FK/cascade). Capture the creating member at both trigger-create sites (schedule +
  webhook).
- New precise source trigger_owner: originator stays NULL (an autonomous fire
  carries no human authorization — same authz-safe divergence as rule_owner),
  accountable = the trigger's member creator.
- triggerOwnerAttribution resolves run.trigger_id → creator; wired into run_only
  dispatch and the create_issue path (bridging issue → active run → trigger_id).
  Legacy triggers with no recorded creator, and agent-created triggers, degrade to
  rule_owner then owner_fallback — nothing regresses.
- Frontend: trigger_owner source label in all four locales + badge switch case.
- Tests: attribution TriggerOwner unit + Precise/invariant; DB-backed resolver
  tests (member creator → trigger_owner; creatorless → rule_owner fallback).

Co-authored-by: multica-agent <github@multica.ai>

* chore(attribution): re-trigger CI (dropped synchronize event on 249090260)

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): mirror accountable_user_id on comment-coalescing merge (MUL-4302)

The one-way invariant is 'originator_user_id IS NOT NULL ⟹ accountable_user_id =
originator_user_id', enforced at finalizeAttribution for enqueues and preserved by
the retry-clone (copies both columns). But MergeCommentIntoPendingTask (main #5192)
re-stamps originator_user_id to the newly-coalesced comment's human WITHOUT touching
accountable_user_id — so folding member B's comment into member A's queued task left
originator=B / accountable=A, violating the invariant. Re-stamp accountable to mirror
the new originator (same thing finalizeAttribution does). Add a DB-backed regression
test.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): Elon's 3 must-fixes + DB cross-column invariant CHECK (MUL-4302)

Bohan approved Elon's plan; this closes the three attribution boundaries he flagged
and locks the one-way invariant at the DB.

1. Delegation now inherits the parent's ACCOUNTABLE, not just its originator. An
   autopilot-rooted chain (parent originator NULL, accountable = trigger creator)
   @mentioning an agent / creating a sub-issue used to drop to unattributed →
   owner_fallback and fail-closed workspaces wrongly rejected the fan-out. Added
   ParentAccountable/OriginAccountable to CommentFacts/DirectFacts; ClassifyComment/
   ClassifyDirect copy accountable down (source=delegation, precise, originator NULL)
   so the chain root stays stable at any depth (§3.2).
2. The direct-chat send path (SendDirectChatMessage, MUL-4351) wrote only
   originator_user_id — no accountable/source/evidence, a NULL-source bypass. It now
   stamps the full direct_human attribution like EnqueueChatTask.
3. Comment-coalescing merge re-attribution is now ATOMIC: MergeCommentIntoPendingTask
   re-stamps the whole snapshot (person columns + source + delegation lineage + rule
   version + evidence) of the new comment, not just the two person fields, so a merged
   run never shows B accountable while pointing at A's stale source/evidence.
4. Migration 169: NOT VALID CHECK (originator_user_id IS NULL OR (accountable_user_id
   IS NOT NULL AND accountable_user_id = originator_user_id)). Enforces the invariant
   on every new write (the class of bug #5192 introduced); historical rows not blocked,
   VALIDATE after Phase 3 backfill. Updated test fixtures that seeded originator-only
   rows to also set accountable.

Merged latest main (renumbered attribution migrations 163–168 after main took 161/162;
merged the retry-clone chat_input_task_id + attribution columns). Verified: go build/
vet, attribution/service/handler/cmd-server tests on a migrated DB, frontend
typecheck/lint/tests.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): trigger responsibility transfers to effective publisher; fail-closed comment merge

Elon final-review must-fix 1 (MUL-4302): trigger_owner now resolves to the
member CURRENTLY responsible for the firing trigger's effective config, not the
fixed creator. Per-trigger published_by on autopilot_trigger, seeded to the
creator and re-stamped to the editor on a substantive edit — a trigger-scoped
edit bumps only that row (UpdateAutopilotTrigger), an autopilot-level edit bumps
all its triggers (UpdateAutopilot). Editing one trigger never reassigns another.
Adds real dispatchRunOnly transfer test + resolver-level isolation test.

Must-fix 3: AttributionForMergedComment reuses applyAttributionFallback and
returns ErrAttributionFailClosed; the merge caller refuses on fail-closed,
keeping the queued task's original precise snapshot instead of degrading it to
owner_fallback. Adds regression test.

Renumbered attribution migrations to 166-172 after merging main.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): make migration 172 invariant CHECK upgrade-safe (Option A)

Elon final-review must-fix 2 (MUL-4302), rollout chosen by Bohan. The NOT VALID
CHECK still checks a pre-existing row on any later UPDATE (even one not touching
attribution columns), so cross-deployment stale queued/running tasks (originator
set, accountable NULL from before migration 167) would fail on their next
claim/complete/cancel. Exempt exactly those legacy rows via 'originator_source
IS NULL' — that column was added in 166 with no backfill, so it is NULL only on
pre-migration rows and non-NULL on every attribution-aware write. New writes
stay fully enforced; the #5192 bypass class (source always set) is unaffected.
Phase 3 backfills legacy rows then drops+re-adds the strict form + VALIDATE.

Adds TestAttributionInvariantCheck_ExemptsLegacyRows (legacy row survives a
status UPDATE) and updates the reject-bypass test to the enforced regime.

Co-authored-by: multica-agent <github@multica.ai>

* fix(attribution): pin substantive/cosmetic edit boundary for trigger transfer

Elon re-review must-fix 1: an autopilot-level edit only transfers trigger_owner
responsibility when it changes what the automation instructs or who/whether it
runs. autopilotRuleSubstantiveChange now includes description (the run Prompt)
and issue_title_template; title and project_id stay cosmetic/routing.
UpdateAutopilotTrigger no longer transfers on every PATCH — it compares the
persisted before/after and transfers only on a real cron/timezone/enabled/
event_filters change, not a label-only or no-op PATCH. Adds real handler tests
(prompt->all, title->none, cron->one+isolation, label->none, no-op->none).

Must-fix 3: real merge-path regression (TestMergeCommentIntoPendingTask_
FailClosedKeepsOriginalSnapshot) drives mergeCommentIntoPendingTask and asserts
a fail-closed workspace preserves the queued task's full snapshot; fail-open
control completes the owner_fallback merge.

Docs: migration 172 comment reworded to legacy-writer/unbackfilled-lineage
semantics (source NULL is not strictly pre-migration); PR description synced.

Migration renumber vs latest main (must-fix 2) deferred to pre-launch per Bohan.

Co-authored-by: multica-agent <github@multica.ai>

* chore(attribution): renumber migrations to 167-173 after syncing main

Merged latest main (which took 166_project_dates) and shifted the attribution
migration series off the 166 collision: 166->167 agent_task_attribution,
167->168 accountable_user, 168->169 rule_version, 169->170 rule_version_index,
170->171 fail_closed, 171->172 trigger_publisher, 172->173 invariant_check.
Updated the internal cross-references in the migration comments accordingly.
Fixes TestMigrationNumericPrefixesStayUniqueAfterLegacySet on the merge tree.

Co-authored-by: multica-agent <github@multica.ai>

* feat(admission): unify dispatch outcome + close rerun/chat/autopilot invoke holes (MUL-4525)

P0 first increment toward a platform-wide execution-admission contract so a
user who names an execution target always gets a definite result and never a
silent no-op, and so blocked targets are reported without leaking private-agent
details.

Backend:
- New shared contract (handler/admission.go): DispatchOutcome / DispatchStatus
  (queued/coalesced/deferred/blocked) + stable, enumeration-safe
  DispatchReasonCode set, plus writeDispatchBlocked() whose legacy `error`
  string never reveals target existence.
- Rerun (task.go / task_lifecycle.go): re-validate the operator can invoke the
  RESOLVED target agent (historical agent for a task_id rerun) before any
  cancel/enqueue; blocked returns a structured 403 and mutates nothing
  (ErrRerunInvokeNotAllowed).
- Chat send (chat.go): re-run canInvokeAgent on every send, not just the softer
  canAccessPrivateAgent view gate; a revoked permission blocks before the
  message/attachments/task persist.
- Autopilot manual "run now" (service/autopilot.go): admission now keys on the
  current CLICKER, not the autopilot creator — clicker admission and clicker
  attribution no longer fork. Automation (schedule/webhook) still falls back to
  the creator gate. Added reason_code to the run response for the UI.

Frontend:
- triggerAutopilot response is schema-parsed; handleRunNow branches on run
  status and shows a localized, reason_code-based warning for skipped/failed
  instead of a false-success toast.
- Chat send and rerun surface the structured 403 reason_code as localized
  toasts (dispatchReasonCode helper) instead of a generic failure.
- Additive fields only; older clients keep working. i18n added to all 4 locales.

Tests: rerun fail-before-mutation gate, autopilot clicker-vs-creator fork
(service + handler), reason-code classification, and a malformed-response
schema test. Backend handler+service suites and FE typecheck/lint green.

Co-authored-by: multica-agent <github@multica.ai>

* test(cmd/server): thread nil invoke gate through RerunIssue call sites (MUL-4525)

Co-authored-by: multica-agent <github@multica.ai>

* fix(admission): typed reason codes + run-now whitelist + real security tests (MUL-4525)

Addresses Elon's review of the P0 first increment (must-fix 1–3).

1. Run now no longer has a false-success branch. handleRunNow now classifies on
   a whitelist via a pure runNowToastKind(): only issue_created/running →
   success; skipped → warning; failed and any unknown/future status → error. Add
   run-now-toast unit test over all five status classes plus reason-code → key
   mapping.

2. reason_code is a typed value decided at the admission source, not
   reverse-engineered from English failure text. New leaf package
   internal/dispatch holds the canonical ReasonCode enum (shared by handler +
   service so they can never drift). shouldSkipDispatch / errDispatchSkipped /
   the fail path now carry a typed code through DispatchAutopilotManual straight
   into the response; the substring classifier is deleted. Fixes the two missed
   branches: attribution fail-closed → attribution_blocked (typed errors.Is),
   "agent has no runtime bound" → runtime_offline. Regression tests for both.

3. Security acceptance tests exercise the REAL handlers, not injected callbacks:
   - Chat: create session while invokable → revoke invoke (flip to private, keep
     owner-view) → send returns 403 + reason_code with zero chat_message / task
     writes.
   - Rerun: private historical agent through RerunIssue + canInvokeAgent — a
     non-invoking workspace owner is refused 403 + reason_code and mutates
     nothing (fail-before-mutation); the agent owner is allowed 202.

No migration; reason_code is a decision-time value only the manual "run now"
response carries. Additive on the wire. Backend build/vet/handler+service
suites and FE typecheck/lint/vitest green. (Migration-prefix collision with main
remains the deferred pre-merge renumber.)

Co-authored-by: multica-agent <github@multica.ai>

* test(admission): make must-fix 3 acceptance tests prove the invariant (MUL-4525)

Addresses Elon's round-3 narrow review — the two security tests were not yet
falsifiable against the bugs they must catch.

- Rerun: after enqueuing the historical task, reassign the issue to a SECOND
  agent that the denied user CAN invoke. Now the current assignee and the
  task_id agent differ, so a rerun that wrongly validated the current assignee
  would let the denied user through — the 403 proves the gate is keyed on the
  historical private agent. The allow case now asserts the reran task's agent_id
  is the historical agent, not the current assignee.

- Chat: the blocked send now carries a valid, still-unbound attachment. After
  the 403 the test asserts the attachment's chat_session_id and chat_message_id
  are both still NULL, guarding against anyone moving attachment binding ahead
  of the invoke gate.

Test-only. Full internal/handler Go suite green.

Co-authored-by: multica-agent <github@multica.ai>

* feat(comments): surface blocked @mention trigger_outcomes instead of silent no-op (MUL-4525 §2)

A comment that @mentions an agent/squad the author cannot invoke used to save
with zero feedback — the user assumed a bug. Now the explicit-mention path
reports a per-target outcome on both preview and create/edit.

Backend (server/internal/handler/comment.go):
- resolveMentionedAgentCommentTriggers collects blocked outcomes instead of a
  silent `continue`. The invoke gate is evaluated BEFORE any archived/runtime
  state is read, so a caller who cannot invoke a private target only ever sees
  the generic invocation_not_allowed and can never enumerate its existence.
- enqueueCommentAgentTriggers returns queued/coalesced/deferred/blocked per
  explicit mention; enqueue errors are typed (attribution_blocked via
  errors.Is), not swallowed. Implicit routing (assignee/thread/conversation)
  carries no outcome — the user never named those targets.
- trigger-preview returns `blocked[]`; create/edit return additive
  `trigger_outcomes[]`. One blocked mention never fails the comment.

Frontend:
- Composer shows a warning chip for blocked mentions before sending; after
  sending, a "posted, but N not triggered" toast (blocked-only; coalesced/
  deferred are success-shaped). Additive schema + defensive parse; i18n ×4.

Tests: handler partial-success + enumeration-safety acceptance tests; core
outcome-parse + preview-schema tests; hook/parity updated. Backend
handler+service suites and FE typecheck/lint/vitest green.

Co-authored-by: multica-agent <github@multica.ai>

* i18n(admission): clearer, consistent blocked-trigger copy (MUL-4525)

Reword the awkward "you are not allowed to run this autopilot's assignee" and
polish all MUL-4525 blocked/partial copy across en/zh-Hans/ja/ko:

- Unify on "you don't have permission to use this <agent|target>" (zh: 没有…的
  使用权限) for autopilot Run now, comment mentions, rerun, and chat send —
  replacing the various "not allowed to run/trigger" phrasings.
- Align zh to the glossary term 智能体 (was mixed "Agent"), matching the
  surrounding UI voice (e.g. agent_link_no_access).
- Drop jargon: "blocked by an admission policy"/"被准入策略拦截" → "the run was
  blocked"/"本次运行已被拦截"; "attributed"/"归因" → plainer wording.
- Comment copy counts "mentions" (was "targets") for consistency with the chip.

Backend dispatchBlockedFallbackMessage (old-client English fallback) reworded to
match; its enumeration-safety test assertion updated. Copy-only — no key/logic
changes; parity + typecheck green.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): one outcome per explicit mention + FE success whitelist (MUL-4525)

Addresses Elon's round-2 review of the §2 comment trigger_outcomes.

1. Separate execution dedup from per-target outcomes. resolveMentionedAgent-
   CommentTriggers now returns triggers (deduped by executing agent) AND one
   commentMentionTarget per EXPLICIT mention. enqueueCommentAgentTriggers returns
   a per-executing-agent result map; commentTriggerOutcomes fans each agent's
   status to every target that resolved to it. So @Agent A + @Squad S(leader=A)
   coalesces to ONE task but yields TWO outcomes. The squad-leader self-suppress
   branch now returns a definite `deferred` outcome instead of no result. New
   CreateComment test asserts 1 task, 2 outcomes.

2. Frontend no longer treats an unknown status as success. unhandledComment-
   TriggerOutcomes whitelists queued/coalesced/deferred as handled; blocked and
   any unknown/future/empty status warn (mirrors the Run now whitelist). The
   preview schema's `blocked` now drops malformed entries INDIVIDUALLY instead of
   z.array(...).catch([]) discarding the whole set. Regression tests for the
   unknown status and the per-item drop.

Backend handler suite + go vet, FE typecheck/lint/vitest green.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): honest role + status in trigger_outcomes fan-out (MUL-4525)

Addresses Elon's round-3 review of the §2 fan-out.

1. Execution merge preserves the squad-leader role instead of first-mention-
   wins. The dedup now UPGRADES an already-added plain @agent trigger to a
   @squad-leader trigger for the same agent, so @Agent A + @Squad S(leader=A)
   always runs as a leader task (is_leader_task + squad_id=S) regardless of
   mention order — the daemon still injects S's briefing. When two DIFFERENT
   squads share one leader, the single run carries one squad's context and the
   other squad is reported `coalesced` (folded), never a second `queued`. The
   enqueue result now records the executed squad so the fan-out can tell them
   apart. Tests assert the task role in both orders and the two-squad split.

2. Squad-leader self-suppression no longer fakes success. The self-trigger
   guard keys on the latest task ROLE with no status filter, so a long-completed
   task also suppresses; reporting `deferred/already_active` when nothing is
   active was a false success. The branch now reports `deferred` only when a
   real non-terminal task is active (its reconcile covers the comment), else a
   non-success `blocked` + new `already_handled` reason. Fixed the reversed
   helper-semantics comment. New handler test covers the completed-task branch.

Backend handler+service suites and go vet green.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): fail-closed active-task check, never fake deferred (MUL-4525)

Elon round-4 must-fix: hasActiveTaskForIssueAndAgent swallowed DB errors by
returning true, so both call sites could turn a query failure into a
success-shaped `deferred/already_active` — a silent false success, exactly what
this issue forbids for admission-query failures.

- hasActiveTaskForIssueAndAgent now returns (bool, error).
- Two pure decision helpers govern the branches, fail closed on error:
  - decidePostMergeMiss: on query error, do NOT enqueue a fresh task (duplicate
    concurrent-run risk) AND report non-success blocked/internal_error; a
    confirmed active task defers; a confirmed-none enqueues fresh.
  - decideSuppressedLeaderOutcome: on query error, blocked/internal_error; a
    confirmed active run defers; else self_trigger_suppressed. Never a fabricated
    deferred.
- Renamed reason already_handled -> self_trigger_suppressed (Elon non-blocking
  note): the old name implied the new comment was already processed, but the
  real meaning is a suppressed self-trigger.

Deterministic unit tests cover the query-failure branch at both call sites
(no fresh enqueue, non-success outcome) — a real DB fault can't be forced
through valid handler inputs, and the decision is what governs the behavior.
Backend handler+service suites and go vet green.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): honest merge outcome — refused merge is blocked, not fake coalesced (MUL-4525)

A pending-task merge previously reported success (coalesced) even when it
was refused or failed: attribution fail-closed and unknown DB errors both
returned handled=true, so the caller recorded coalesced for a merge that
never happened. mergeCommentIntoPendingTask now returns a distinguishable
commentMergeResult; commentMergeTerminalOutcome maps a real merge to
coalesced, a fail-closed refusal to blocked/attribution_blocked, and any
other failure to blocked/internal_error. Only "no queued task to fold"
falls through to the active-task decision. Adds pure coverage of the
mapping plus a fail-closed regression asserting the non-success outcome
and unchanged task count.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): name blocked @mentions in preview + toast instead of a vague count (MUL-4525)

The blocked-trigger preview showed a red "1 mention won't trigger" with no
name, and the post-send toast said "but 1 mention wasn't triggered" — the
user can't tell which target or why. Now each blocked mention renders its own
chip named from the mention markup the user typed ("Go · No permission"),
with an error indicator and a short reason; the toast names the single target
too. The wire outcome still omits the target name (enumeration-safety) — the
label comes from the user's own draft, so nothing new is disclosed.

Shares a blocked-trigger-copy module (long + short reason labels) between the
chip and the toast, and a pure mentionLabelsByTarget/parseMentions helper in
core (fresh regex per call — a shared global leaked lastIndex). Adds core +
chip tests; drops the now-unused trigger_blocked_count keys across locales.

Co-authored-by: multica-agent <github@multica.ai>

* feat(attribution): accountable-member avatar on agent task rows + transcript header (MUL-4302)

Surface who each agent run is on behalf of where runs are actually
browsed:
- Agent detail activity tab: an avatar-only AttributionBadge on every
  task row's meta line (Now + Recent work), tooltip carries the name +
  resolution source.
- Execution-record (transcript) dialog header: the full on-behalf-of
  badge next to the status pill.

Adds a compact variant="avatar" mode to AttributionBadge, reusing its
source-label mapping and degraded-attribution tone. Renders nothing when
a run has no resolved accountable member.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 03:09:56 +08:00

1185 lines
56 KiB
SQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- name: ListAgents :many
SELECT * FROM agent
WHERE workspace_id = $1 AND archived_at IS NULL AND kind = 'user'
ORDER BY created_at ASC;
-- name: ListAllAgents :many
SELECT * FROM agent
WHERE workspace_id = $1 AND kind = 'user'
ORDER BY created_at ASC;
-- name: GetAgent :one
SELECT * FROM agent
WHERE id = $1;
-- name: GetAgentInWorkspace :one
SELECT * FROM agent
WHERE id = $1 AND workspace_id = $2 AND kind = 'user';
-- name: CreateAgent :one
INSERT INTO agent (
workspace_id, name, description, avatar_url, runtime_mode,
runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id,
instructions, custom_env, custom_args, mcp_config, model, thinking_level,
composio_toolkit_allowlist, permission_mode
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16,
sqlc.narg('composio_toolkit_allowlist')::text[],
COALESCE(sqlc.narg('permission_mode'), 'private')
)
RETURNING *;
-- name: CreateAgentBuilder :one
-- One hidden builder agent per creation session. Keeping the execution carrier
-- session-scoped freezes its model/runtime configuration when multiple builder
-- flows are open concurrently, while `kind = 'system'` keeps it out of normal
-- agent lists and assignment surfaces.
INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config, runtime_id,
visibility, permission_mode, max_concurrent_tasks, owner_id, instructions,
custom_env, custom_args, model, kind, system_key
) VALUES (
@workspace_id, @name, '', @runtime_mode, '{}'::jsonb, @runtime_id,
'private', 'private', 1, @owner_id, @instructions,
'{}'::jsonb, '[]'::jsonb, sqlc.narg('model'), 'system', @system_key
)
RETURNING *;
-- name: DeleteSystemAgentByID :exec
-- Builder sessions own their hidden execution agent. Deleting the session
-- removes that carrier and its task rows; the kind guard prevents this cleanup
-- path from ever deleting a user-authored agent.
DELETE FROM agent
WHERE id = $1 AND kind = 'system' AND system_key LIKE 'agent_builder:%';
-- name: UpdateAgent :one
-- composio_toolkit_allowlist is set wholesale: the API layer is responsible
-- for normalising the request payload to either (a) the new slug list — sent
-- here verbatim — or (b) an empty array to explicitly disable Composio.
-- Distinguish "field omitted" (preserve) from "explicit clear" via
-- ClearAgentComposioToolkitAllowlist below, mirroring the
-- thinking_level / mcp_config two-query pattern: COALESCE can't restore NULL.
UPDATE agent SET
name = COALESCE(sqlc.narg('name'), name),
description = COALESCE(sqlc.narg('description'), description),
avatar_url = COALESCE(sqlc.narg('avatar_url'), avatar_url),
runtime_config = COALESCE(sqlc.narg('runtime_config'), runtime_config),
runtime_mode = COALESCE(sqlc.narg('runtime_mode'), runtime_mode),
runtime_id = COALESCE(sqlc.narg('runtime_id'), runtime_id),
visibility = COALESCE(sqlc.narg('visibility'), visibility),
permission_mode = COALESCE(sqlc.narg('permission_mode'), permission_mode),
status = COALESCE(sqlc.narg('status'), status),
max_concurrent_tasks = COALESCE(sqlc.narg('max_concurrent_tasks'), max_concurrent_tasks),
instructions = COALESCE(sqlc.narg('instructions'), instructions),
custom_env = COALESCE(sqlc.narg('custom_env'), custom_env),
custom_args = COALESCE(sqlc.narg('custom_args'), custom_args),
mcp_config = COALESCE(sqlc.narg('mcp_config'), mcp_config),
model = COALESCE(sqlc.narg('model'), model),
thinking_level = COALESCE(sqlc.narg('thinking_level'), thinking_level),
composio_toolkit_allowlist = COALESCE(sqlc.narg('composio_toolkit_allowlist')::text[], composio_toolkit_allowlist),
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: ClearAgentComposioToolkitAllowlist :one
-- Explicit NULL-clear for composio_toolkit_allowlist. The COALESCE-based
-- UpdateAgent cannot set the column back to NULL — sending an empty array
-- through there would persist `{}` (still a non-NULL, equivalent to "no
-- toolkits" but distinct from "field never configured"). The API uses this
-- dedicated query when the agent owner removes every toolkit; subsequent
-- dispatch decisions treat NULL identically to `{}` (both -> no overlay).
UPDATE agent SET composio_toolkit_allowlist = NULL, updated_at = now()
WHERE id = $1
RETURNING *;
-- name: ClearAgentThinkingLevel :one
-- Explicit NULL-clear for thinking_level. COALESCE-based UpdateAgent cannot
-- set the column back to NULL, so the API layer routes "user picked Default"
-- through this dedicated query.
UPDATE agent SET thinking_level = NULL, updated_at = now()
WHERE id = $1
RETURNING *;
-- name: ClearAgentMcpConfig :one
UPDATE agent SET mcp_config = NULL, updated_at = now()
WHERE id = $1
RETURNING *;
-- name: UpdateAgentCustomEnv :one
-- Replaces an agent's custom_env map wholesale. Used by the dedicated
-- env-management endpoint (POST/PUT /api/agents/{id}/env), which is the
-- only post-creation write path for env. UpdateAgent has been stripped
-- of custom_env handling so all env mutations flow through here and the
-- handler's audit-log + **** sentinel guard.
UPDATE agent
SET custom_env = $2, updated_at = now()
WHERE id = $1
RETURNING *;
-- name: ArchiveAgent :one
UPDATE agent SET archived_at = now(), archived_by = $2, updated_at = now()
WHERE id = $1
RETURNING *;
-- name: ArchiveAgentsByRuntime :many
-- Bulk-archives every active agent bound to any runtime in the given set.
-- Used when revoking a leaving member's runtimes so agents pinned to those
-- runtimes can no longer be assigned new work. Returns the affected rows so
-- the caller can broadcast agent:archived per agent.
UPDATE agent
SET archived_at = now(), archived_by = @archived_by, updated_at = now()
WHERE runtime_id = ANY(@runtime_ids::uuid[]) AND archived_at IS NULL
RETURNING *;
-- name: ArchiveAgentsByIDs :many
-- Narrow archive that only touches the explicit ID list. Used by the
-- cascade-delete endpoint so the user's expected_active_agent_ids list
-- is the authoritative bound on what gets archived: any agent that
-- appeared on the runtime after the user opened the dialog is filtered
-- out here so it can't be silently archived even in the (vanishingly
-- rare) case where a row-level race slips past the runtime FOR UPDATE
-- lock. Returns the affected rows so the caller can broadcast
-- agent:archived per agent.
UPDATE agent
SET archived_at = now(), archived_by = @archived_by, updated_at = now()
WHERE id = ANY(@agent_ids::uuid[]) AND archived_at IS NULL
RETURNING *;
-- name: ListActiveAgentsByRuntime :many
-- Returns every non-archived agent bound to a runtime. Backs the cascade
-- delete dialog: when DELETE /api/runtimes/:id refuses with
-- runtime_has_active_agents, the response carries this list so the front-end
-- can render exactly the agents that will be archived if the user confirms,
-- and so the cascade endpoint's expected_active_agent_ids check has a stable
-- snapshot to compare against. Ordered by name for a deterministic display.
SELECT * FROM agent
WHERE runtime_id = $1 AND archived_at IS NULL AND kind = 'user'
ORDER BY name ASC;
-- name: ListActiveAgentsByRuntimeForUpdate :many
-- FOR UPDATE variant used inside the cascade-delete transaction. Locks
-- each currently-active agent row so a concurrent archive/move of one
-- of those rows blocks until our transaction commits. Pair with
-- LockAgentRuntime, which holds the runtime row exclusively to also
-- block FK-validated INSERTs / runtime_id updates that would otherwise
-- add a new agent to the runtime mid-cascade. Together they guarantee
-- that the set we compared against expected_active_agent_ids is exactly
-- the set ArchiveAgentsByIDs will operate on — no race window.
SELECT * FROM agent
WHERE runtime_id = $1 AND archived_at IS NULL AND kind = 'user'
ORDER BY name ASC
FOR UPDATE;
-- name: RestoreAgent :one
UPDATE agent SET archived_at = NULL, archived_by = NULL, updated_at = now()
WHERE id = $1
RETURNING *;
-- name: ListAgentTasks :many
SELECT * FROM agent_task_queue
WHERE agent_id = $1
ORDER BY created_at DESC;
-- name: CreateAgentTask :one
-- head_sha stamps the commit under review into the task's context JSONB so the
-- reviewer-loop dedup (HasPendingTaskForIssueAndAgent) can tell a pending run
-- against an OLD head apart from a fresh request against a NEW head (TEN-356).
-- Empty/absent head_sha leaves context NULL, preserving pre-TEN-356 behavior for
-- issues with no linked PR. Issue-linked tasks never hit quick-create context
-- parsing (parseQuickCreateContext short-circuits on IssueID.Valid), so this
-- key rides harmlessly alongside.
INSERT INTO agent_task_queue (
agent_id, runtime_id, issue_id, status, priority, trigger_comment_id,
coalesced_comment_ids, trigger_summary, force_fresh_session, is_leader_task, handoff_note,
squad_id, context, originator_user_id, accountable_user_id, runtime_mcp_overlay, runtime_connected_apps,
originator_source, delegated_from_task_id, rule_version_id, rerun_of_task_id, trigger_evidence_kind, trigger_evidence_ref_id
)
VALUES (
$1, $2, $3, 'queued', $4, sqlc.narg(trigger_comment_id),
COALESCE(sqlc.narg(coalesced_comment_ids)::uuid[], '{}'),
sqlc.narg(trigger_summary),
COALESCE(sqlc.narg('force_fresh_session')::boolean, FALSE),
COALESCE(sqlc.narg('is_leader_task')::boolean, FALSE),
sqlc.narg(handoff_note),
sqlc.narg(squad_id),
CASE
WHEN COALESCE(sqlc.narg('head_sha')::text, '') <> ''
THEN jsonb_build_object('head_sha', sqlc.narg('head_sha')::text)
ELSE NULL
END,
sqlc.narg(originator_user_id),
sqlc.narg(accountable_user_id),
sqlc.narg(runtime_mcp_overlay),
sqlc.narg(runtime_connected_apps),
sqlc.narg(originator_source),
sqlc.narg(delegated_from_task_id),
sqlc.narg(rule_version_id),
sqlc.narg(rerun_of_task_id),
sqlc.narg(trigger_evidence_kind),
sqlc.narg(trigger_evidence_ref_id)
)
RETURNING *;
-- name: CreateQuickCreateTask :one
-- Quick-create tasks have no issue / chat / autopilot link; the entire job
-- description (prompt, requester, workspace) lives in context JSONB. The
-- daemon detects this variant via context.type == "quick_create".
-- The requester who opened the quick-create modal is a direct_human originator
-- and accountable; attribution provenance is stamped so this path is not a
-- NULL-source enqueue bypass (MUL-4302 §2).
INSERT INTO agent_task_queue (
agent_id, runtime_id, issue_id, status, priority, context, originator_user_id,
accountable_user_id, runtime_mcp_overlay, runtime_connected_apps,
originator_source, trigger_evidence_kind, trigger_evidence_ref_id
)
VALUES (
$1, $2, NULL, 'queued', $3, $4,
sqlc.narg(originator_user_id),
sqlc.narg(accountable_user_id),
sqlc.narg(runtime_mcp_overlay),
sqlc.narg(runtime_connected_apps),
sqlc.narg(originator_source),
sqlc.narg(trigger_evidence_kind),
sqlc.narg(trigger_evidence_ref_id)
)
RETURNING *;
-- name: CreateDeferredAgentTask :one
-- Deferred tasks are inert until PromoteDueDeferredTasksForRuntime flips them
-- to queued. Used for comment-routing escalation: a thread-owner primary task
-- gets a delayed assignee fallback without waking both agents at t=0.
-- Attribution is resolved and stamped at creation (not at promotion), from the
-- same trigger comment as the primary task, so the fallback assignee's run
-- carries a non-NULL source and evidence rather than bypassing attribution
-- (MUL-4302 §2).
INSERT INTO agent_task_queue (
agent_id, runtime_id, issue_id, status, priority, trigger_comment_id,
trigger_summary, is_leader_task, squad_id, escalation_for_task_id, fire_at,
originator_user_id, accountable_user_id, originator_source,
delegated_from_task_id, trigger_evidence_kind, trigger_evidence_ref_id
)
VALUES (
@agent_id, @runtime_id, @issue_id, 'deferred', @priority,
sqlc.narg(trigger_comment_id),
sqlc.narg(trigger_summary),
COALESCE(sqlc.narg('is_leader_task')::boolean, FALSE),
sqlc.narg(squad_id),
@escalation_for_task_id,
@fire_at,
sqlc.narg(originator_user_id),
sqlc.narg(accountable_user_id),
sqlc.narg(originator_source),
sqlc.narg(delegated_from_task_id),
sqlc.narg(trigger_evidence_kind),
sqlc.narg(trigger_evidence_ref_id)
)
RETURNING *;
-- name: LinkTaskToIssue :exec
-- Attaches the issue a quick-create task produced back to the task row, once
-- the agent has finished and the issue exists. Guarded by `issue_id IS NULL`
-- so this never overwrites an issue id that was set at task creation (only
-- quick-create tasks land here unset). Fixes the activity row staying on
-- "Creating issue" forever after completion.
UPDATE agent_task_queue
SET issue_id = $2
WHERE id = $1 AND issue_id IS NULL;
-- name: CreateRetryTask :one
-- Clones a parent task into a fresh queued attempt. Carries forward the
-- agent's resume context (session_id/work_dir) so the child can continue
-- the conversation when the backend supports it. Resume-unsafe failures are
-- retried as fresh sessions so the child does not inherit a stuck agent
-- conversation. Keep the CASE WHEN predicates in sync with
-- resumeUnsafeFailureReason and the resume lookup blacklists. attempt is
-- incremented; max_attempts, trigger_comment_id, coalesced_comment_ids,
-- is_leader_task, and squad_id are inherited so the retried task receives the
-- parent's complete planned comment batch and keeps the same squad-role
-- provenance. delivered_comment_ids intentionally stays at its '{}' default:
-- the child must earn its own delivery receipt at claim time.
--
-- originator_user_id is inherited so the Composio overlay decision sees the
-- same top-of-chain human across the retry: the user behind the original
-- run has not changed. The Composio overlay follows the agent's invocation
-- permission and uses the agent owner's connection (MUL-3963); originator is
-- carried for A2A/audit, not as an originator == agent.owner_id gate.
-- A system retry is NOT a new attribution event (MUL-4302 §5): it inherits the
-- parent's accountable human, source label, delegation lineage, rule version,
-- and trigger evidence UNCHANGED, and records retry_of_task_id = p.id so retry
-- and manual rerun stay separable in reporting. parent_task_id keeps its
-- existing meaning for the retry/resume machinery; retry_of_task_id is the
-- attribution-facing lineage column.
--
-- chat_input_task_id is inherited straight from the parent so the whole retry
-- chain keeps consuming the ORIGINAL root input batch (MUL-4351): the root
-- direct task set it to its own id, every descendant copies that value, and a
-- claim always reads the same user messages. A plain copy (not
-- COALESCE(parent.chat_input_task_id, parent.id)) is deliberate: legacy/channel
-- parents carry NULL and must stay NULL so their retries keep the trailing
-- selector — promoting a pre-migration NULL row to the task-owned path on retry
-- would risk replaying untagged history during a rolling deploy.
--
-- Chat retries are queued at GREATEST(priority, 3) so a transiently-failed
-- earlier turn is re-claimed ahead of any fresh chat task (priority 2) the user
-- queued while the failing turn was still running — the retry continues the
-- older turn first. Combined with creating the retry inside FailTask's
-- transaction, this leaves no window for a newer input task to jump ahead.
INSERT INTO agent_task_queue (
agent_id, runtime_id, issue_id, chat_session_id, autopilot_run_id,
status, priority, trigger_comment_id, coalesced_comment_ids, trigger_summary, context,
session_id, work_dir,
attempt, max_attempts, parent_task_id, force_fresh_session, is_leader_task,
squad_id, originator_user_id, accountable_user_id, runtime_mcp_overlay, runtime_connected_apps,
originator_source, delegated_from_task_id, rule_version_id,
trigger_evidence_kind, trigger_evidence_ref_id, retry_of_task_id,
chat_input_task_id
)
SELECT
p.agent_id, p.runtime_id, p.issue_id, p.chat_session_id, p.autopilot_run_id,
'queued',
CASE WHEN p.chat_session_id IS NOT NULL THEN GREATEST(p.priority, 3) ELSE p.priority END,
p.trigger_comment_id, p.coalesced_comment_ids, p.trigger_summary, p.context,
CASE WHEN p.failure_reason IS NOT DISTINCT FROM 'codex_semantic_inactivity' THEN NULL ELSE p.session_id END,
CASE WHEN p.failure_reason IS NOT DISTINCT FROM 'codex_semantic_inactivity' THEN NULL ELSE p.work_dir END,
p.attempt + 1, p.max_attempts, p.id,
p.failure_reason IS NOT DISTINCT FROM 'codex_semantic_inactivity',
p.is_leader_task,
p.squad_id,
p.originator_user_id,
p.accountable_user_id,
sqlc.narg(runtime_mcp_overlay),
sqlc.narg(runtime_connected_apps),
p.originator_source, p.delegated_from_task_id, p.rule_version_id,
p.trigger_evidence_kind, p.trigger_evidence_ref_id, p.id,
p.chat_input_task_id
FROM agent_task_queue p
WHERE p.id = $1
RETURNING *;
-- name: CancelAgentTasksByIssue :many
-- Cancels every active task on the issue and returns the affected rows so the
-- caller can reconcile each agent's status and broadcast task:cancelled events
-- (#1587). Prior :exec form silently dropped that info, leaving agents stuck at
-- status="working" with no self-correction. Only issue-deletion cleanup calls
-- this now; a status flip to cancelled/done no longer does (MUL-4465).
UPDATE agent_task_queue
SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL
WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
RETURNING *;
-- name: CancelAgentTasksByIssueAndAgent :many
-- Cancels active tasks for a single (issue, agent) pair without touching
-- tasks belonging to other agents on the same issue. Used by the manual
-- rerun flow so re-running the assignee doesn't collateral-cancel a
-- still-running @-mention agent on the same issue.
UPDATE agent_task_queue
SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL
WHERE issue_id = $1 AND agent_id = $2 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
RETURNING *;
-- name: CancelAgentTasksByAgent :many
-- Bulk-cancel every active (queued/dispatched/running) task for an agent.
-- Returns the affected rows so callers can broadcast task:cancelled events.
-- Mirrors the shape of CancelAgentTasksByIssue / CancelAgentTasksByIssueAndAgent
-- (also :many + RETURNING + completed_at) so the three sibling cancel paths
-- behave consistently.
UPDATE agent_task_queue
SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL
WHERE agent_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
RETURNING *;
-- name: CancelAgentTasksByTriggerComment :many
-- Cancels active tasks whose planned batch contains the edited/deleted comment.
-- The body may already have been embedded as either the primary trigger or a
-- coalesced input; cancellation prevents an agent from acting on a stale or
-- deleted version. Must run before deletion clears trigger_comment_id.
UPDATE agent_task_queue
SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL
WHERE (trigger_comment_id = $1 OR $1 = ANY(coalesced_comment_ids))
AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
RETURNING *;
-- name: CancelAgentTasksByChatSession :many
-- Cancels active tasks belonging to a chat session. Called from
-- DeleteChatSession so the daemon doesn't keep running work whose result
-- has nowhere to land. Must run BEFORE the chat_session row is deleted —
-- the FK ON DELETE SET NULL would otherwise nullify chat_session_id and we
-- could no longer reach those tasks.
UPDATE agent_task_queue
SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL
WHERE chat_session_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
RETURNING *;
-- name: GetAgentTask :one
SELECT * FROM agent_task_queue
WHERE id = $1;
-- name: GetAgentTaskInWorkspace :one
-- Loads a task only when its owning agent lives in the given workspace.
-- agent_id is NOT NULL on every task row (and ON DELETE CASCADE, so the agent
-- always exists), which makes this the universal tenant guard for
-- user-initiated cancellation — independent of which optional source FK
-- (issue / chat_session / autopilot_run) happens to be set. It is what lets
-- run_only autopilot tasks and quick_create tasks (whose issue does not exist
-- yet) be cancelled at all, instead of 404-ing on a missing source FK.
SELECT atq.* FROM agent_task_queue atq
JOIN agent a ON a.id = atq.agent_id
WHERE atq.id = $1 AND a.workspace_id = $2;
-- name: ClaimAgentTask :one
-- Claims the next queued task for an agent, enforcing per-(issue, agent) serialization:
-- a task is only claimable when no other task for the same issue AND same agent is
-- already dispatched or running. This allows different agents to work on the same
-- issue in parallel while preventing a single agent from running duplicate tasks.
-- Chat tasks (issue_id IS NULL) use chat_session_id for serialization instead.
-- Quick-create tasks have no issue / chat / autopilot link, so they serialize on
-- "any other quick-create-shaped task" (all four FKs NULL) for the same agent —
-- otherwise a user mashing the create button could fire concurrent quick-creates
-- whose completion lookup would race over "most recent issue by this agent".
UPDATE agent_task_queue
SET status = 'dispatched',
dispatched_at = now(),
prepare_lease_expires_at = now() + make_interval(secs => @prepare_lease_secs::double precision)
WHERE id = (
SELECT atq.id FROM agent_task_queue atq
WHERE atq.agent_id = $1 AND atq.status = 'queued'
AND NOT EXISTS (
SELECT 1 FROM agent_task_queue active
WHERE active.agent_id = atq.agent_id
AND active.status IN ('dispatched', 'running', 'waiting_local_directory')
AND (
(atq.issue_id IS NOT NULL AND active.issue_id = atq.issue_id)
OR (atq.chat_session_id IS NOT NULL AND active.chat_session_id = atq.chat_session_id)
OR (
atq.issue_id IS NULL
AND atq.chat_session_id IS NULL
AND atq.autopilot_run_id IS NULL
AND active.issue_id IS NULL
AND active.chat_session_id IS NULL
AND active.autopilot_run_id IS NULL
)
)
)
ORDER BY atq.priority DESC, atq.created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING *;
-- name: SetTaskDeliveredCommentIDs :one
-- Replace (rather than append to) the delivery receipt for this claim. A stale
-- dispatched task may be reclaimed by a daemon with different capabilities,
-- so only the ids embedded in the newest response count as delivered. The CAS
-- keeps a stale handler from writing after execution starts, and the subset
-- guard prevents acknowledging an id outside the task's enqueue-time plan.
UPDATE agent_task_queue
SET delivered_comment_ids = @delivered_comment_ids::uuid[]
WHERE id = @task_id
AND runtime_id = @runtime_id
AND status = 'dispatched'
AND started_at IS NULL
AND dispatched_at = @dispatched_at
AND trigger_comment_id IS NOT DISTINCT FROM sqlc.narg(expected_trigger_comment_id)::uuid
AND NOT EXISTS (
SELECT 1
FROM unnest(@delivered_comment_ids::uuid[]) AS delivered(id)
WHERE delivered.id IS NULL
OR (
delivered.id IS DISTINCT FROM trigger_comment_id
AND NOT (delivered.id = ANY(coalesced_comment_ids))
)
)
RETURNING delivered_comment_ids;
-- name: RequeueAgentTaskAfterClaimFailure :one
-- Claim finalization (task token + optional comment receipt) failed before any
-- response bytes were written. Return only that exact claim generation to the
-- queue so another poll can retry immediately instead of waiting for stale
-- dispatch recovery. The dispatched_at CAS prevents an old handler from
-- rolling back a newer reclaim.
UPDATE agent_task_queue
SET status = 'queued',
dispatched_at = NULL,
prepare_lease_expires_at = NULL,
delivered_comment_ids = '{}'
WHERE id = @task_id
AND runtime_id = @runtime_id
AND status = 'dispatched'
AND started_at IS NULL
AND dispatched_at = @dispatched_at
RETURNING *;
-- name: ReclaimStaleDispatchedTaskForRuntime :one
-- Re-delivers a task whose previous claim likely succeeded server-side but
-- whose response never reached the daemon. The task is still in `dispatched`
-- with no `started_at`, so the daemon has not acknowledged it via StartTask.
-- Refresh dispatched_at so the server-side dispatch timeout measures from the
-- recovered delivery attempt.
UPDATE agent_task_queue
SET dispatched_at = now(),
prepare_lease_expires_at = now() + make_interval(secs => @prepare_lease_secs::double precision)
WHERE id = (
SELECT atq.id FROM agent_task_queue atq
WHERE atq.runtime_id = $1
AND atq.status = 'dispatched'
AND atq.started_at IS NULL
AND atq.dispatched_at < now() - make_interval(secs => @claim_recovery_secs::double precision)
AND (atq.prepare_lease_expires_at IS NULL OR atq.prepare_lease_expires_at < now())
ORDER BY atq.priority DESC, atq.dispatched_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING *;
-- name: ReclaimStaleDispatchedTasksForRuntimes :many
-- Batch variant of ReclaimStaleDispatchedTaskForRuntime (MUL-4257): re-delivers
-- up to @max_tasks tasks across the whole runtime set in one round trip, so a
-- machine-level batch claim recovers lost-response dispatches for every runtime
-- it hosts without one query per runtime. Same eligibility as the singular
-- query (dispatched, never started, past the recovery window, expired/absent
-- prepare lease) and the same dispatched_at refresh; only the runtime filter
-- (= ANY) and the LIMIT (max_tasks instead of 1) differ.
UPDATE agent_task_queue
SET dispatched_at = now(),
prepare_lease_expires_at = now() + make_interval(secs => @prepare_lease_secs::double precision)
WHERE id IN (
SELECT atq.id FROM agent_task_queue atq
WHERE atq.runtime_id = ANY(@runtime_ids::uuid[])
AND atq.status = 'dispatched'
AND atq.started_at IS NULL
AND atq.dispatched_at < now() - make_interval(secs => @claim_recovery_secs::double precision)
AND (atq.prepare_lease_expires_at IS NULL OR atq.prepare_lease_expires_at < now())
ORDER BY atq.priority DESC, atq.dispatched_at ASC
LIMIT @max_tasks::int
FOR UPDATE SKIP LOCKED
)
RETURNING *;
-- name: ExtendAgentTaskPrepareLease :one
-- Keeps a dispatched task protected while the daemon resolves/cache/materializes
-- startup inputs before StartTask. Once the daemon stops extending this short
-- lease, the stale-dispatched reclaim path can recover the task without waiting
-- for a long global recovery window.
UPDATE agent_task_queue
SET prepare_lease_expires_at = now() + make_interval(secs => @lease_secs::double precision)
WHERE id = $1
AND runtime_id = $2
AND status IN ('dispatched', 'waiting_local_directory')
AND started_at IS NULL
RETURNING *;
-- name: StartAgentTask :one
-- Transitions a task to running. Accepts either 'dispatched' (the normal
-- claim → run flow) or 'waiting_local_directory' (the daemon held the row in
-- a wait state while another task owned the local_directory path lock; once
-- the lock was acquired the daemon flips here). wait_reason is cleared on
-- the transition so a future read can't conflate "currently waiting" with
-- "previously waited".
UPDATE agent_task_queue
SET status = 'running',
started_at = now(),
wait_reason = NULL,
prepare_lease_expires_at = NULL
WHERE id = $1 AND status IN ('dispatched', 'waiting_local_directory')
RETURNING *;
-- name: MarkAgentTaskWaitingLocalDirectory :one
-- Transitions a freshly-dispatched task into 'waiting_local_directory' while
-- the daemon waits for another in-flight task to release the path lock on a
-- project_resource of type local_directory. wait_reason carries a short
-- human-readable hint (typically the contested path) that the UI surfaces
-- alongside the status.
--
-- The CHECK only allows the transition from 'dispatched' so a daemon can't
-- mark an already-running or terminal task as waiting; the StartAgentTask
-- mutation handles the reverse transition once the lock is acquired.
UPDATE agent_task_queue
SET status = 'waiting_local_directory',
wait_reason = $2,
prepare_lease_expires_at = now() + make_interval(secs => @prepare_lease_secs::double precision)
WHERE id = $1 AND status = 'dispatched'
RETURNING *;
-- name: CompleteAgentTask :one
UPDATE agent_task_queue
SET status = 'completed', completed_at = now(), result = $2, session_id = $3, work_dir = $4, prepare_lease_expires_at = NULL
WHERE id = $1 AND status = 'running'
RETURNING *;
-- name: GetLastTaskSession :one
-- Returns the session_id and work_dir from the most recent task for a given
-- (agent_id, issue_id) pair, used for session resumption on the auto-retry
-- path. We accept both 'completed' and 'failed' tasks: a failed task may
-- have established a real agent session before crashing (orphaned by a
-- daemon restart, runtime offline, or sweeper timeout), and the daemon pins
-- the resume pointer mid-flight via UpdateAgentTaskSession. Without this,
-- an auto-retry of a mid-run failure would silently start a fresh
-- conversation and lose the in-flight context — exactly what MUL-1128's B
-- branch is meant to fix.
--
-- Manual rerun (TaskService.RerunIssue) does NOT take this path: it sets
-- force_fresh_session=true on the new task, and the daemon claim handler
-- skips this lookup entirely. The user already judged the prior output bad;
-- resuming the same conversation would replay a poisoned state.
--
-- Tasks that ended in a known "poisoned" terminal state are also excluded
-- here so even auto-retry does not inherit the bad session. The daemon
-- classifies these failures (iteration_limit, agent_fallback_message,
-- api_invalid_request, codex_semantic_inactivity) when it detects either an
-- agent fallback marker in the output, an upstream API 400 that means the
-- conversation history itself is unprocessable (oversized image, malformed
-- base64, etc.), or a Codex semantic inactivity timeout whose recorded
-- session may replay the same stuck state.
--
-- The error-text ILIKE clause is defense-in-depth for the api_invalid_request
-- shape: a legacy row tagged 'agent_error' (pre-MUL-1921), a deploy-window
-- row that the old code wrote between migration and rollout, or a future
-- error format that escapes the daemon classifier all still get filtered
-- here as long as the canonical Anthropic 400 marker is present in the
-- error text. Migration 079 backfills the failure_reason column itself,
-- so observability stays accurate; this clause guarantees session resume
-- never picks up a bad session even when failure_reason hasn't caught up.
SELECT session_id, work_dir, runtime_id FROM agent_task_queue
WHERE agent_id = $1 AND issue_id = $2
AND (
status = 'completed'
OR (
status = 'failed'
AND COALESCE(failure_reason, '') NOT IN ('iteration_limit', 'agent_fallback_message', 'api_invalid_request', 'codex_semantic_inactivity')
AND NOT (COALESCE(error, '') ILIKE '%400%' AND COALESCE(error, '') ILIKE '%invalid_request_error%')
)
)
AND session_id IS NOT NULL
ORDER BY COALESCE(completed_at, started_at, dispatched_at, created_at) DESC
LIMIT 1;
-- name: GetLastTaskStartedAtForIssueAndAgent :one
-- Returns the started_at of the most recent prior task for this (agent, issue)
-- pair, used as the "since" anchor for counting comments that arrived since the
-- agent's last run. Any terminal state counts as "a run happened". Tasks with
-- no started_at (never dispatched / the just-claimed current task) are excluded,
-- so this never returns the current claim's own row. MUST use started_at, never
-- completed_at: a long run would otherwise miss comments posted while it ran.
SELECT started_at FROM agent_task_queue
WHERE agent_id = $1 AND issue_id = $2 AND started_at IS NOT NULL
ORDER BY started_at DESC
LIMIT 1;
-- name: FailAgentTask :one
-- Marks a task as failed. session_id and work_dir are merged via COALESCE so
-- if the agent already established a real session before failing (e.g. it
-- crashed mid-conversation, was cancelled, or hit a tool error) the resume
-- pointer is preserved on the task row. The next chat task can then fall
-- back to GetLastChatTaskSession and continue the conversation instead of
-- silently starting over.
--
-- failure_reason is a coarse classifier consumed by the auto-retry path;
-- 'agent_error' is the safe default when the daemon doesn't supply one.
UPDATE agent_task_queue
SET status = 'failed',
completed_at = now(),
error = $2,
failure_reason = COALESCE(sqlc.narg('failure_reason'), 'agent_error'),
session_id = COALESCE(sqlc.narg('session_id'), session_id),
work_dir = COALESCE(sqlc.narg('work_dir'), work_dir),
prepare_lease_expires_at = NULL
WHERE id = $1 AND status IN ('dispatched', 'running', 'waiting_local_directory')
RETURNING *;
-- name: UpdateAgentTaskSession :exec
-- Pins the resume pointer mid-flight so a daemon crash leaves a usable
-- session_id/work_dir on the task row. No-op if the task is no longer
-- in dispatched/running. waiting_local_directory tasks have no session yet
-- so this query intentionally skips them.
UPDATE agent_task_queue
SET session_id = COALESCE(sqlc.narg('session_id'), session_id),
work_dir = COALESCE(sqlc.narg('work_dir'), work_dir)
WHERE id = $1 AND status IN ('dispatched', 'running');
-- name: RecoverOrphanedTasksForRuntime :many
-- Called by the daemon at startup. Atomically fails any dispatched/running/
-- waiting_local_directory task that the prior incarnation of this runtime
-- owned but did not finalize. Returns the failed rows so callers can hand
-- them to the auto-retry path. waiting_local_directory rows are included
-- because the daemon holding the path lock is the same process that just
-- died — without us, the row would sit waiting forever.
UPDATE agent_task_queue
SET status = 'failed',
completed_at = now(),
error = 'daemon restarted while task was in flight',
failure_reason = 'runtime_recovery',
wait_reason = NULL,
prepare_lease_expires_at = NULL
WHERE runtime_id = $1 AND status IN ('dispatched', 'running', 'waiting_local_directory')
RETURNING *;
-- name: FailStaleTasks :many
-- Fails tasks stuck in dispatched/running beyond the given thresholds.
--
-- Each branch pairs a wall-clock deadline with a task-appropriate liveness
-- signal, so the sweeper only kills tasks whose owning daemon is no longer
-- proving it is alive:
--
-- * Dispatched: `prepare_lease_expires_at` is refreshed every 15s by the
-- daemon between claim and StartTask (see startTaskPrepareLeaseExtender).
-- A live lease excludes the row.
--
-- * Running: no per-task lease is renewed once StartTask fires, so we key
-- off the daemon-wide heartbeat instead — `agent_runtime.last_seen_at`,
-- which the daemon bumps every ~15s while it is up. A running task whose
-- runtime is `online` AND whose `last_seen_at` is within
-- @runtime_stale_secs is treated as alive and is NOT killed by this
-- wall-clock backstop, even after `started_at` exceeds the running
-- timeout. This is what lets healthy multi-hour research / training runs
-- survive on self-hosted deployments (MUL-4107): the daemon side is
-- bounded only by inactivity watchdogs (idle / per-tool), so the
-- server-side wall clock must not shadow that with a coarser cap.
--
-- The daemon-dead case is the primary responsibility of `sweepStaleRuntimes`
-- (which mixes DB `last_seen_at` with the Redis LivenessStore and calls
-- `FailTasksForOfflineRuntimes` in the same tick). The wall-clock branch
-- here is a defensive backstop for pathological cases where a runtime row
-- somehow retains status='online' with a stale DB heartbeat for longer than
-- the wall clock allows.
--
-- runtime_id IS NULL: a running row with no runtime is by definition not
-- proving liveness, so the wall clock is allowed to fire — same shape as
-- the legacy pure-wall-clock behavior for that (rare / historical) case.
--
-- waiting_local_directory rows are intentionally excluded: the daemon owns
-- the wait (with its own ctx-driven timeout) and a legitimate queue ahead
-- of this task can exceed the dispatch / running timeouts without being
-- "stuck". If the daemon dies, RecoverOrphanedTasksForRuntime reclaims
-- those rows at restart.
UPDATE agent_task_queue
SET status = 'failed', completed_at = now(), error = 'task timed out',
failure_reason = 'timeout',
prepare_lease_expires_at = NULL
WHERE (
status = 'dispatched'
AND dispatched_at < now() - make_interval(secs => @dispatch_timeout_secs::double precision)
AND (prepare_lease_expires_at IS NULL OR prepare_lease_expires_at < now())
)
OR (
status = 'running'
AND started_at < now() - make_interval(secs => @running_timeout_secs::double precision)
AND NOT EXISTS (
SELECT 1 FROM agent_runtime r
WHERE r.id = agent_task_queue.runtime_id
AND r.status = 'online'
AND r.last_seen_at >= now() - make_interval(secs => @runtime_stale_secs::double precision)
)
)
RETURNING *;
-- name: ExpireStaleQueuedTasks :many
-- Fails tasks that have been sitting in 'queued' for longer than the TTL.
-- This is the cleanup arm of the MUL-1899 "queued backlog" fix: even with the
-- new dispatch-time admission gate that refuses to enqueue when the runtime
-- is offline, we still need to drain the historical 87k+ doomed rows and
-- handle edge cases where a runtime goes offline AFTER a task is already
-- queued (the admission check protects new enqueues, not in-flight queue
-- depth).
--
-- Concurrency safety: the daemon's claim path may race with this sweeper to
-- transition the same row out of 'queued'. We protect against that two
-- ways:
-- 1. The CTE selects victims with FOR UPDATE SKIP LOCKED so a row that is
-- currently being claimed (or otherwise locked) is skipped — no lock
-- contention with the dispatch path, and we won't queue up behind it.
-- 2. The outer UPDATE re-checks status='queued' AND the TTL predicate at
-- apply time. If a daemon claimed the row between selection and update
-- (e.g. lock released after the claim transaction commits), the row is
-- already 'dispatched'/'running' and the WHERE clause filters it out
-- so we cannot clobber an in-flight task.
-- Capped via LIMIT inside the CTE so a single sweep tick cannot monopolise
-- the DB when the backlog is large — the sweeper drains the rest on
-- subsequent ticks.
WITH victims AS (
SELECT id FROM agent_task_queue
WHERE status = 'queued'
AND created_at < now() - make_interval(secs => @ttl_secs::double precision)
ORDER BY created_at ASC
LIMIT @max_per_tick::int
FOR UPDATE SKIP LOCKED
)
UPDATE agent_task_queue t
SET status = 'failed',
completed_at = now(),
error = 'task expired in queue',
failure_reason = 'queued_expired',
prepare_lease_expires_at = NULL
FROM victims v
WHERE t.id = v.id
AND t.status = 'queued'
AND t.created_at < now() - make_interval(secs => @ttl_secs::double precision)
RETURNING t.*;
-- name: CancelAgentTask :one
UPDATE agent_task_queue
SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL
WHERE id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
RETURNING *;
-- name: MarkChatFinalizeDeferred :one
-- Arms the deferred chat-finalize marker for a cancelled chat task whose
-- empty-transcript judgment must wait for the daemon's flush ack (#5219).
UPDATE agent_task_queue
SET chat_finalize_deferred_at = now()
WHERE id = $1
RETURNING *;
-- name: ClaimChatFinalizeDeferred :one
-- Atomically claims the deferred marker so the daemon ack and the sweeper
-- cannot both finalize the same task (double-"Stopped." guard).
UPDATE agent_task_queue
SET chat_finalize_deferred_at = NULL
WHERE id = $1 AND chat_finalize_deferred_at IS NOT NULL
RETURNING *;
-- name: ListChatFinalizeDeferredExpired :many
-- Deferred chat finalizations whose grace period elapsed without a daemon
-- ack (dead or partitioned daemon). Batch-capped like the other sweeper
-- queries so one tick can't monopolise the DB.
SELECT * FROM agent_task_queue
WHERE chat_finalize_deferred_at IS NOT NULL
AND chat_finalize_deferred_at < now() - make_interval(secs => @grace_secs::double precision)
ORDER BY chat_finalize_deferred_at
LIMIT @max_per_tick::int;
-- name: CountRunningTasks :one
SELECT count(*) FROM agent_task_queue
WHERE agent_id = $1 AND status IN ('dispatched', 'running', 'waiting_local_directory');
-- name: GetAgentForClaimUpdate :one
SELECT * FROM agent
WHERE id = $1
FOR UPDATE;
-- name: HasActiveTaskForIssue :one
-- Returns true if there is any queued, dispatched, waiting_local_directory,
-- or running task for the issue.
SELECT count(*) > 0 AS has_active FROM agent_task_queue
WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory');
-- name: HasPendingTaskForIssue :one
-- Returns true if there is a queued or dispatched (but not yet running) task for the issue.
-- Used by the coalescing queue: allow enqueue when a task is running (so
-- the agent picks up new comments on the next cycle) but skip if a pending
-- task already exists (natural dedup).
SELECT count(*) > 0 AS has_pending FROM agent_task_queue
WHERE issue_id = $1 AND status IN ('queued', 'dispatched');
-- name: HasPendingTaskForIssueAndAgent :one
-- Returns true if a specific agent already has a queued or dispatched task
-- for the given issue. Used by @mention trigger dedup.
--
-- head_sha keys the dedup on the commit under review (TEN-356): when a caller
-- passes a non-empty head_sha, a pending task only dedups if it was stamped
-- with the SAME head_sha at enqueue time. If HEAD advanced since the pending
-- task's run began (its context head_sha differs, or predates the stamp and is
-- NULL), the dedup MISSES and a fresh review enqueues against the new HEAD.
-- When head_sha is empty/NULL (issue has no linked PR) the check falls back to
-- the pre-TEN-356 (issue_id, agent_id) key so non-PR issues keep coalescing.
SELECT count(*) > 0 AS has_pending FROM agent_task_queue
WHERE issue_id = $1 AND agent_id = $2 AND status IN ('queued', 'dispatched')
AND (
COALESCE(sqlc.narg('head_sha')::text, '') = ''
OR context->>'head_sha' = sqlc.narg('head_sha')::text
);
-- name: HasPendingTaskForIssueAndAgentExcludingTriggerComment :one
-- Same as HasPendingTaskForIssueAndAgent, but ignores tasks triggered by the
-- current comment being edited. Edit preview needs this because save cancels
-- that comment's old queued/dispatched tasks before re-computing triggers.
-- Carries the same head_sha dedup key as HasPendingTaskForIssueAndAgent (TEN-356).
SELECT count(*) > 0 AS has_pending FROM agent_task_queue
WHERE issue_id = @issue_id
AND agent_id = @agent_id
AND status IN ('queued', 'dispatched')
AND trigger_comment_id IS DISTINCT FROM @exclude_trigger_comment_id::uuid
AND (
COALESCE(sqlc.narg('head_sha')::text, '') = ''
OR context->>'head_sha' = sqlc.narg('head_sha')::text
);
-- name: MergeCommentIntoPendingTask :one
-- MUL-4195: fold a newly-arrived comment into an existing task for (issue,
-- agent) that has NOT yet been claimed, instead of letting the
-- HasPendingTaskForIssueAndAgent dedup silently DROP it. The task's prior
-- trigger_comment_id becomes a coalesced ("also cover") comment and
-- @new_trigger_comment_id becomes the new trigger, so the injected prompt shows
-- the latest deliberate instruction while the single run is still told to
-- address every folded comment.
--
-- Target is restricted to the single 'queued' task on purpose (MUL-4195 review
-- rounds 24). This merge is only reached when HasPendingTaskForIssueAndAgent
-- matched a 'queued'/'dispatched' task, and 'dispatched' is deliberately NOT a
-- target: a dispatched / waiting_local_directory / running task has already had
-- its claim response built. Folding afterward would add a planned id that is
-- absent from that response's delivered_comment_ids receipt; completion
-- reconciliation handles it instead. 'deferred' is also NOT
-- a target: a deferred row is an assignee-fallback escalation with its own
-- fire_at/promotion lifecycle, and it never sets AlreadyPending
-- (HasPendingTaskForIssueAndAgent only looks at queued/dispatched). If a newer
-- deferred fallback and an older queued task coexisted, a status-IN target would
-- pick the deferred one by created_at and steal the coalescing target away from
-- the queued run that is actually about to be claimed — so we match ONLY the
-- queued row (the idx_one_pending_task_per_issue_agent unique index guarantees
-- at most one). coalesced_comment_ids remains the pre-claim plan; the claim
-- path records the actual embedded subset in delivered_comment_ids.
--
-- Recompute-on-merge (MUL-4195 review must-fix #1): originator_user_id,
-- runtime_mcp_overlay and runtime_connected_apps are re-stamped to the NEW
-- trigger comment's originator (computed by the caller). Earlier this only
-- repointed the trigger and kept the old originator's overlay/attribution, so a
-- run answering user B's comment could execute under user A's connected-app
-- capabilities and audit identity. Re-stamping means the single coalescing run
-- carries the latest deliberate instruction's originator and the matching
-- overlay — no cross-user capability bleed, no stale attribution. This also
-- removes the previous originator gate + fresh-enqueue fallback, which could not
-- create a second task anyway (the idx_one_pending_task_per_issue_agent unique
-- index allows only one queued/dispatched task per (issue, agent)) and therefore
-- silently dropped the mismatched-originator comment.
--
-- Returns pgx.ErrNoRows when no queued task exists (it was claimed/started
-- between the dedup check and this call, or the only task is already
-- dispatched/running). The caller must NOT blindly enqueue a fresh task in that
-- case — a dispatched sibling would trip the unique index — it defers to
-- completion reconciliation unless no active task exists at all.
UPDATE agent_task_queue
SET coalesced_comment_ids = (
SELECT COALESCE(array_agg(DISTINCT e), '{}')
FROM unnest(array_append(coalesced_comment_ids, trigger_comment_id)) AS e
WHERE e IS NOT NULL AND e <> @new_trigger_comment_id::uuid
),
trigger_comment_id = @new_trigger_comment_id::uuid,
trigger_summary = COALESCE(sqlc.narg('new_trigger_summary'), trigger_summary),
-- Re-attribution is ATOMIC (MUL-4302): folding a newly-arrived comment moves the
-- WHOLE attribution snapshot to that comment's human — person columns, source
-- label, delegation lineage, rule version, and evidence — computed by the caller
-- as one attribution.Result. Re-stamping only the person columns would leave a
-- run showing B accountable while still pointing at A's stale source / evidence /
-- level. accountable comes from the resolved Result (finalizeAttribution already
-- guaranteed originator ⟹ accountable == originator; the cross-column CHECK backs it).
originator_user_id = sqlc.narg('new_originator_user_id')::uuid,
accountable_user_id = sqlc.narg('new_accountable_user_id')::uuid,
originator_source = sqlc.narg('new_originator_source'),
delegated_from_task_id = sqlc.narg('new_delegated_from_task_id')::uuid,
rule_version_id = sqlc.narg('new_rule_version_id')::uuid,
trigger_evidence_kind = sqlc.narg('new_trigger_evidence_kind'),
trigger_evidence_ref_id = sqlc.narg('new_trigger_evidence_ref_id')::uuid,
runtime_mcp_overlay = sqlc.narg('new_runtime_mcp_overlay'),
runtime_connected_apps = sqlc.narg('new_runtime_connected_apps')
WHERE id = (
SELECT t.id FROM agent_task_queue t
WHERE t.issue_id = @issue_id
AND t.agent_id = @agent_id
AND t.status = 'queued'
ORDER BY t.created_at DESC
LIMIT 1
)
RETURNING id, coalesced_comment_ids;
-- name: HasActiveTaskForIssueAndAgent :one
-- MUL-4195: true when the (issue, agent) pair has any non-terminal task in a
-- state whose completion will run completion reconciliation — queued,
-- dispatched, running, or waiting_local_directory. Used by the comment enqueue
-- path: when a merge into a pre-claim task fails (the task is already
-- dispatched/running, or a mismatched pre-claim task exists), a fresh queued
-- INSERT would collide with idx_one_pending_task_per_issue_agent AND would risk
-- a duplicate run. Instead the caller relies on that active task's completion
-- reconcile to schedule the guaranteed follow-up, and only enqueues fresh when
-- NO active task exists.
SELECT count(*) > 0 AS has_active FROM agent_task_queue
WHERE issue_id = $1 AND agent_id = $2
AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory');
-- name: GetLatestTaskRoleForIssueAndAgent :one
-- Returns the role markers from the agent's most recent task on this issue.
-- Used by the squad-leader self-trigger guard to tell apart leader tasks,
-- same-squad worker tasks, and generic agent tasks such as direct mentions or
-- thread-parent replies.
SELECT is_leader_task, squad_id FROM agent_task_queue
WHERE issue_id = $1 AND agent_id = $2
ORDER BY created_at DESC
LIMIT 1;
-- name: ListPendingTasksByRuntime :many
SELECT * FROM agent_task_queue
WHERE runtime_id = $1 AND status IN ('queued', 'dispatched')
ORDER BY priority DESC, created_at ASC;
-- name: ListQueuedClaimCandidatesByRuntime :many
-- Returns rows the runtime can attempt to claim. Status is restricted to
-- 'queued' (in contrast to ListPendingTasksByRuntime which also includes
-- 'dispatched') because dispatched rows are by definition already owned
-- and cannot be re-claimed — including them in the candidate list pads
-- the result with rows that always lose the per-(issue, agent) race in
-- ClaimAgentTask, wasting CPU and a SELECT every poll cycle when the
-- runtime is busy on a long-running task. Backed by the partial index
-- idx_agent_task_queue_claim_candidates so the warm path is cheap.
SELECT * FROM agent_task_queue
WHERE runtime_id = $1 AND status = 'queued'
ORDER BY priority DESC, created_at ASC;
-- name: PromoteDueDeferredTasksForRuntime :many
UPDATE agent_task_queue
SET status = 'queued'
WHERE runtime_id = @runtime_id
AND status = 'deferred'
AND fire_at <= now()
RETURNING *;
-- name: ListQueuedClaimCandidatesByRuntimes :many
-- Batch variant of ListQueuedClaimCandidatesByRuntime (MUL-4257): returns
-- queued claim candidates across every runtime_id in the input set in ONE round
-- trip, so a daemon can list candidates for all of its runtimes with a single
-- query instead of one per runtime. Ordering matches the singular query
-- (priority, then FIFO) so the batch claim loop keeps the same fairness. The
-- runtime_id filter is served by the partial index
-- idx_agent_task_queue_claim_candidates; the cross-runtime ORDER BY still needs
-- a sort step (each runtime's slice is index-ordered, but merging several
-- runtimes' rows into one priority/FIFO order is not). The per-machine
-- candidate set is small, so this is cheap in practice.
SELECT * FROM agent_task_queue
WHERE runtime_id = ANY(@runtime_ids::uuid[]) AND status = 'queued'
ORDER BY priority DESC, created_at ASC;
-- name: PromoteDueDeferredTasksForRuntimes :many
-- Batch variant of PromoteDueDeferredTasksForRuntime (MUL-4257): promotes all
-- due deferred tasks across the runtime set in one UPDATE.
UPDATE agent_task_queue
SET status = 'queued'
WHERE runtime_id = ANY(@runtime_ids::uuid[])
AND status = 'deferred'
AND fire_at <= now()
RETURNING *;
-- name: CancelDeferredEscalationsForTask :many
UPDATE agent_task_queue
SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL
WHERE escalation_for_task_id = $1
AND status IN ('deferred', 'queued', 'dispatched', 'waiting_local_directory')
RETURNING *;
-- name: CancelDeferredEscalationsForIssueAgent :many
WITH cancelled AS (
UPDATE agent_task_queue fallback
SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL
FROM agent_task_queue primary_task
WHERE fallback.escalation_for_task_id = primary_task.id
AND fallback.status IN ('deferred', 'queued', 'dispatched', 'waiting_local_directory')
AND primary_task.issue_id = @issue_id
AND primary_task.agent_id = @agent_id
RETURNING fallback.*
)
SELECT * FROM cancelled;
-- name: ListActiveTasksByIssue :many
-- Backs the issue-detail "agent live" banner. Includes 'queued' so the
-- banner shows up the moment a task is enqueued — not only after a runtime
-- claims it. The queued window can be long when the runtime is offline or
-- busy on a prior task, and a silent UI during that window looks like the
-- platform never received the trigger.
SELECT * FROM agent_task_queue
WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
ORDER BY created_at DESC;
-- name: GetWorkspaceAgentRunCounts :many
-- Total task runs per agent over the trailing 30 days, used by the Agents
-- list RUNS column. 30-day window keeps the count meaningful (a long-dormant
-- agent shouldn't show "5,420 runs from 2 years ago") and keeps the scan
-- bounded as the workspace ages.
SELECT
atq.agent_id,
COUNT(*)::int AS run_count
FROM agent_task_queue atq
JOIN agent a ON a.id = atq.agent_id
WHERE a.workspace_id = $1
AND atq.created_at > now() - INTERVAL '30 days'
GROUP BY atq.agent_id;
-- name: GetWorkspaceAgentActivity30d :many
-- Returns per-agent daily activity buckets for the last 30 days. Single
-- workspace-wide read backs both surfaces:
-- - Agents list ACTIVITY column — uses only the trailing 7 buckets
-- - Agent detail "Last 30 days" panel — uses the full 30
-- 30 days contains 7 days, so one fetch + a client-side .slice(-7) wins
-- over fetching twice. Days with no completion produce no row; the
-- front-end zero-fills.
--
-- Anchored on completed_at (not created_at) because the sparkline answers
-- "what did this agent produce?" not "what was queued at it?". A task that's
-- still in flight has no completed_at and contributes nothing here — that's
-- correct: in-flight tasks are surfaced via the live presence indicator,
-- not the historical trend.
SELECT
atq.agent_id,
DATE_TRUNC('day', atq.completed_at)::timestamptz AS bucket,
COUNT(*)::int AS task_count,
COUNT(*) FILTER (WHERE atq.status = 'failed')::int AS failed_count
FROM agent_task_queue atq
JOIN agent a ON a.id = atq.agent_id
WHERE a.workspace_id = $1
AND atq.completed_at IS NOT NULL
AND atq.completed_at > now() - INTERVAL '30 days'
GROUP BY atq.agent_id, bucket
ORDER BY atq.agent_id, bucket;
-- name: ListWorkspaceAgentTaskSnapshot :many
-- Returns the tasks needed to derive each agent's current presence:
-- - All active tasks (queued / dispatched / running) — for working signal + counts
-- - Each agent's most recent OUTCOME task (completed / failed) — for sticky
-- failed signal
-- The front-end picks "active wins, else latest outcome" — see derive-presence.ts.
--
-- Cancelled tasks are excluded from the outcome half on purpose: cancel is a
-- procedural signal ("attempt aborted"), not an outcome. It tells us nothing
-- about whether the agent works, so it must NOT be allowed to mask a prior
-- failure. Concretely: if an agent fails and then the user cancels the queued
-- retry (or the parent issue closes and cascades cancels), the failed signal
-- has to stay red. Only a real success (completed) or a fresh attempt (active)
-- clears it.
--
-- No UI windows in SQL: stickiness is decided by "is the latest outcome a
-- failure?", not a 2-minute clock. JOINs agent because agent_task_queue has
-- no workspace_id column.
SELECT atq.* FROM agent_task_queue atq
JOIN agent a ON a.id = atq.agent_id
WHERE a.workspace_id = $1
AND atq.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
UNION ALL
SELECT t.* FROM (
SELECT DISTINCT ON (atq.agent_id) atq.*
FROM agent_task_queue atq
JOIN agent a ON a.id = atq.agent_id
WHERE a.workspace_id = $1
AND atq.status IN ('completed', 'failed')
ORDER BY atq.agent_id, atq.completed_at DESC NULLS LAST
) t;
-- name: ListTasksByIssue :many
SELECT * FROM agent_task_queue
WHERE issue_id = $1
ORDER BY created_at DESC;
-- name: UpdateAgentStatus :one
UPDATE agent SET status = $2, updated_at = now()
WHERE id = $1
RETURNING *;
-- name: RefreshAgentStatusFromTasks :one
UPDATE agent AS a
SET status = CASE WHEN EXISTS (
SELECT 1 FROM agent_task_queue q
WHERE q.agent_id = a.id AND q.status IN ('dispatched', 'running', 'waiting_local_directory')
) THEN 'working' ELSE 'idle' END,
updated_at = now()
WHERE a.id = $1
RETURNING *;