mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 09:26:50 +08:00
* refactor(issues): remove the assignment handoff note (MUL-6743) Usage data puts the handoff note at 0.2% of assignments, so the box occupied the confirm dialog's centre for the other 99.8% and bought them nothing. Removing it takes the whole feature with it: the note input and its runtime version gate, the `handoff_note` write field on PATCH/batch issue updates, the task column write, and the daemon's opening-prompt + issue_context.md handoff sections. The confirm dialog itself stays — it still names who is about to run and still offers "Don't start yet" (suppress_run). The `*WithHandoff` enqueue methods only ever varied by that note, so they become `EnqueueTaskForIssueByActor` / `EnqueueTaskForSquadLeaderByActor` — the actor-carrying variants autopilot already called with an empty note. `preview-trigger` drops `handoff_supported`, which nothing read after the gate moved client-side. `agent_task_queue.handoff_note` is deliberately left in place: it holds historical rows and this is the expand half of an expand-contract pair. Nothing reads or writes it now, so a follow-up migration can drop it once no deployed binary selects it. Co-authored-by: multica-agent <[email protected]> * docs(issues): reshoot the assignment confirm dialog (MUL-6743) The asset still showed the handoff note box the surrounding prose no longer describes. Recaptured from the app at the same 600x450 frame; the confirm button now carries the send-chord keycaps it has shipped with since MUL-5694. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]>
2713 lines
128 KiB
SQL
2713 lines
128 KiB
SQL
-- 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: ListAllAgentsAnyKind :many
|
||
-- Every agent row in the workspace, INCLUDING `kind = 'system'` execution
|
||
-- carriers (agent builder sessions) that ListAllAgents deliberately hides.
|
||
--
|
||
-- Only for surfaces that aggregate over raw `agent_task_queue` / task_usage
|
||
-- rows, which carry no kind filter of their own: a system carrier runs real
|
||
-- tasks and books real usage, so a per-agent rollup returns it whether or not
|
||
-- any list endpoint will ever name it. Those surfaces need the full population
|
||
-- to decide what to hide (MUL-5409). Do NOT use this to build a user-facing
|
||
-- agent list — `kind = 'system'` must stay out of every picker and assignee
|
||
-- surface.
|
||
SELECT * FROM agent
|
||
WHERE workspace_id = $1
|
||
ORDER BY created_at ASC;
|
||
|
||
-- name: GetAgent :one
|
||
SELECT * FROM agent
|
||
WHERE id = $1;
|
||
|
||
-- name: GetAgentForUpdate :one
|
||
-- Serializes read-modify-write updates to disabled_runtime_skills so two
|
||
-- concurrent per-skill toggles cannot overwrite each other.
|
||
SELECT * FROM agent
|
||
WHERE id = $1
|
||
FOR UPDATE;
|
||
|
||
-- name: GetAgentInWorkspace :one
|
||
SELECT * FROM agent
|
||
WHERE id = $1 AND workspace_id = $2 AND kind = 'user';
|
||
|
||
-- name: LockAgentForAutopilotAssignment :one
|
||
-- Serializes creating, retargeting, or resuming an active Autopilot with
|
||
-- Runtime teardown. Teardown takes FOR UPDATE on this same Agent row before it
|
||
-- clears runtime_id and pauses matching Autopilots. FOR SHARE also stabilizes
|
||
-- runtime_id and archived_at against concurrent ordinary Agent updates. The
|
||
-- shared lock makes the
|
||
-- two outcomes exhaustive:
|
||
-- * assignment commits first, then teardown sees and pauses it; or
|
||
-- * teardown commits first, then assignment re-reads runtime_id=NULL and
|
||
-- rejects the active Autopilot.
|
||
SELECT * FROM agent
|
||
WHERE id = $1 AND workspace_id = $2 AND kind = 'user'
|
||
FOR SHARE;
|
||
|
||
-- 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,
|
||
service_tier, conversation_starters,
|
||
composio_toolkit_allowlist, permission_mode
|
||
) VALUES (
|
||
$1, $2, $3, $4, $5,
|
||
$6, $7, $8, $9, $10,
|
||
$11, $12, $13, $14, $15, $16,
|
||
$17, COALESCE(sqlc.narg('conversation_starters')::jsonb, '[]'::jsonb),
|
||
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: RebindAgentBuilderRuntime :one
|
||
-- Re-points a builder carrier at another runtime mid-conversation. The carrier
|
||
-- is what SendDirectChatMessage reads to stamp a chat task's runtime_id, so this
|
||
-- UPDATE is the only thing that actually moves subsequent replies; the live-draft
|
||
-- picker alone never did. model is reset wholesale because model ids are
|
||
-- per-runtime — the new runtime resolves its own default instead of inheriting an
|
||
-- id it may not serve. The kind/system_key guard mirrors DeleteSystemAgentByID so
|
||
-- this path can never touch a user-authored agent.
|
||
--
|
||
-- Callers MUST hold LockChatSessionForRuntimeBind on the owning chat_session for
|
||
-- the whole transaction, otherwise a concurrent send can stamp a task with the
|
||
-- pre-switch runtime after the pending-task check has already passed (MUL-5163).
|
||
--
|
||
-- chat_session.runtime_id is deliberately left untouched: the daemon only resumes
|
||
-- a stored provider session when chat_session.runtime_id matches the claiming
|
||
-- task's runtime (see the chat claim path), so leaving the old pointer in place is
|
||
-- exactly what makes the new runtime start a fresh provider session.
|
||
UPDATE agent
|
||
SET runtime_id = @runtime_id,
|
||
runtime_mode = @runtime_mode,
|
||
model = sqlc.narg('model'),
|
||
updated_at = now()
|
||
WHERE id = @id AND kind = 'system' AND system_key LIKE 'agent_builder:%'
|
||
RETURNING *;
|
||
|
||
-- 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),
|
||
service_tier = COALESCE(sqlc.narg('service_tier'), service_tier),
|
||
conversation_starters = COALESCE(sqlc.narg('conversation_starters'), conversation_starters),
|
||
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: ClearAgentServiceTier :one
|
||
-- Explicit NULL-clear for service_tier. COALESCE-based UpdateAgent cannot
|
||
-- set the column back to NULL, so the API routes "Runtime default" here.
|
||
UPDATE agent SET service_tier = 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: UpdateAgentDisabledRuntimeSkills :one
|
||
UPDATE agent
|
||
SET disabled_runtime_skills = $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.
|
||
--
|
||
-- System agents are exempt: they belong to the workspace rather than to the
|
||
-- member who happened to create them, and the workspace's entry point runs
|
||
-- through one. Archiving Mika because a colleague left would take the default
|
||
-- agent away from everyone. Its runtime does go offline with the departure, so
|
||
-- it needs rebinding — but it stays visible and recoverable instead of
|
||
-- vanishing.
|
||
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
|
||
AND (system_key IS NULL OR system_key = '')
|
||
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 unbound 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 stable.
|
||
SELECT * FROM agent
|
||
WHERE runtime_id = $1 AND archived_at IS NULL AND kind = 'user'
|
||
ORDER BY name ASC
|
||
FOR UPDATE;
|
||
|
||
-- name: ListUserAgentsByRuntimeForUpdate :many
|
||
-- Locks active AND archived user agents before a runtime teardown. Locking only
|
||
-- the active snapshot leaves a restore race: an archived row can become active
|
||
-- after confirmation and then be silently unbound. Runtime/profile delete
|
||
-- locks the parent runtime first (blocking new FK references), then calls this
|
||
-- in stable ID order (blocking restore/archive/move on existing rows).
|
||
SELECT * FROM agent
|
||
WHERE runtime_id = $1 AND kind = 'user'
|
||
ORDER BY id
|
||
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
|
||
-- Fenced against workspace teardown: lock_task_owner_rows (migration 284)
|
||
-- locks the owners' workspace rows in the writer's own transaction and returns
|
||
-- false once they are gone, so this statement writes no row instead of stranding
|
||
-- a task in a workspace that has just been deleted (MUL-5999).
|
||
-- 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.
|
||
-- id is minted by the application as a UUIDv7 (pkg/dbid) so consecutive
|
||
-- enqueues cluster in a narrow contiguous primary-key range instead of
|
||
-- scattering across the B-tree. On a table with existing v4 ids, that range
|
||
-- is not necessarily the tree's right edge.
|
||
-- COALESCE keeps the column's gen_random_uuid() default reachable, so a caller
|
||
-- that passes no id still inserts — it just gets a random v4, exactly as before.
|
||
-- The same pattern is used by every INSERT listed in pkg/dbid's write table.
|
||
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,
|
||
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,
|
||
id
|
||
)
|
||
SELECT
|
||
$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(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),
|
||
COALESCE(sqlc.narg('id')::uuid, gen_random_uuid())
|
||
WHERE lock_task_owner_rows($1, $3, $2)
|
||
RETURNING *;
|
||
|
||
-- name: CreateDeferredChannelIssueTask :one
|
||
-- Fenced against workspace teardown: lock_task_owner_rows (migration 284)
|
||
-- locks the owners' workspace rows in the writer's own transaction and returns
|
||
-- false once they are gone, so this statement writes no row instead of stranding
|
||
-- a task in a workspace that has just been deleted (MUL-5999).
|
||
-- Channel /issue media resolves after issue creation. Persist the assigned
|
||
-- issue task up front for crash safety, but keep it inert until attachment
|
||
-- binding settles or the fire_at fallback is promoted by the normal sweeper.
|
||
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,
|
||
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, fire_at,
|
||
id
|
||
)
|
||
SELECT
|
||
$1, $2, $3, 'deferred', $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(squad_id),
|
||
jsonb_strip_nulls(jsonb_build_object(
|
||
'head_sha', NULLIF(COALESCE(sqlc.narg('head_sha')::text, ''), ''),
|
||
'channel_issue_media_pending', TRUE
|
||
)),
|
||
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),
|
||
@fire_at,
|
||
COALESCE(sqlc.narg('id')::uuid, gen_random_uuid())
|
||
WHERE lock_task_owner_rows($1, $3, $2)
|
||
RETURNING *;
|
||
|
||
-- name: PromoteDeferredChannelIssueTask :one
|
||
-- Early promotion is idempotent at the service layer: a task already promoted
|
||
-- by the fire_at sweeper no longer matches and is treated as settled.
|
||
UPDATE agent_task_queue
|
||
SET status = 'queued', fire_at = NULL
|
||
WHERE id = $1 AND issue_id IS NOT NULL AND status = 'deferred'
|
||
RETURNING *;
|
||
|
||
-- name: SetDeferredChannelIssueTaskRuntimeOverlay :execrows
|
||
-- The issue and its inert media-gated task commit atomically before the optional
|
||
-- external overlay is built. Only hydrate the untouched original plan: a comment
|
||
-- merge may already have replaced the trigger, attribution, and matching overlay.
|
||
UPDATE agent_task_queue
|
||
SET runtime_mcp_overlay = sqlc.narg(runtime_mcp_overlay),
|
||
runtime_connected_apps = sqlc.narg(runtime_connected_apps)
|
||
WHERE id = @id
|
||
AND status = 'deferred'
|
||
AND context->>'channel_issue_media_pending' = 'true'
|
||
AND trigger_comment_id IS NULL
|
||
AND originator_user_id IS NOT DISTINCT FROM sqlc.narg(expected_originator_user_id)::uuid;
|
||
|
||
-- name: CreateQuickCreateTask :one
|
||
-- Fenced against workspace teardown: lock_task_owner_rows (migration 284)
|
||
-- locks the owners' workspace rows in the writer's own transaction and returns
|
||
-- false once they are gone, so this statement writes no row instead of stranding
|
||
-- a task in a workspace that has just been deleted (MUL-5999).
|
||
-- 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,
|
||
id
|
||
)
|
||
SELECT
|
||
$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),
|
||
COALESCE(sqlc.narg('id')::uuid, gen_random_uuid())
|
||
WHERE lock_task_owner_rows($1, NULL, $2)
|
||
RETURNING *;
|
||
|
||
-- name: CreateDeferredAgentTask :one
|
||
-- Fenced against workspace teardown: lock_task_owner_rows (migration 284)
|
||
-- locks the owners' workspace rows in the writer's own transaction and returns
|
||
-- false once they are gone, so this statement writes no row instead of stranding
|
||
-- a task in a workspace that has just been deleted (MUL-5999).
|
||
-- 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,
|
||
id
|
||
)
|
||
SELECT
|
||
@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),
|
||
COALESCE(sqlc.narg('id')::uuid, gen_random_uuid())
|
||
WHERE lock_task_owner_rows($1, $3, $2)
|
||
RETURNING *;
|
||
|
||
-- name: LinkTaskToIssue :exec
|
||
-- Fenced against workspace teardown: lock_task_owner_rows (migration 284)
|
||
-- locks the owners' workspace rows in the writer's own transaction and returns
|
||
-- false once they are gone, so this statement writes no row instead of stranding
|
||
-- a task in a workspace that has just been deleted (MUL-5999).
|
||
-- 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
|
||
AND lock_task_owner_rows(NULL, $2, NULL);
|
||
|
||
-- name: CreateRetryTask :one
|
||
-- Fenced against workspace teardown: lock_task_owner_rows (migration 284)
|
||
-- locks the owners' workspace rows in the writer's own transaction and returns
|
||
-- false once they are gone, so this statement writes no row instead of stranding
|
||
-- a task in a workspace that has just been deleted (MUL-5999).
|
||
--
|
||
-- Fenced against slot contention too: ON CONFLICT DO NOTHING yields the single
|
||
-- queued/dispatched slot idx_one_pending_task_per_issue_agent_v2 allows per
|
||
-- (issue, agent) rather than raising 23505. A manual rerun may now be enqueued
|
||
-- behind a still-running task, and it can commit at any point — including
|
||
-- between a caller's "is a successor already pending?" check and this insert,
|
||
-- since that check takes no lock. Raising here would abort the caller's
|
||
-- transaction, and on the FailTask path that transaction also carries the
|
||
-- parent's failed status, so the failure would roll back and leave the task
|
||
-- stuck in 'running'. Yielding instead makes the policy explicit: whoever
|
||
-- already holds the slot keeps it, and a deliberate human rerun therefore wins
|
||
-- over an automatic retry.
|
||
--
|
||
-- Both fences surface the same way — zero rows, i.e. pgx.ErrNoRows from this
|
||
-- :one query. Callers must treat that as "no retry was created" and still commit
|
||
-- their own work, NOT as a failure.
|
||
-- 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.
|
||
--
|
||
-- fire_at arms a backoff before the retry: when non-NULL the child is inserted
|
||
-- as 'deferred' with that fire_at and stays inert until the existing
|
||
-- PromoteDueDeferredTasksForRuntime sweeper (run promote-first on every claim
|
||
-- poll) flips it to 'queued'. Used for runtime_offline so the child waits for a
|
||
-- healthy runtime, and for provider_network's final attempt so it waits ~5s
|
||
-- instead of firing back-to-back with the immediate retry (MUL-4910). NULL
|
||
-- keeps the historical behaviour: an immediately-claimable 'queued' child.
|
||
--
|
||
-- max_attempts overrides the inherited budget when non-NULL (NULL inherits
|
||
-- p.max_attempts unchanged). Callers persist the reason-aware effective ceiling
|
||
-- here so the row stays self-consistent — e.g. provider_network's chain records
|
||
-- attempt=3, max_attempts=3 rather than leaking attempt=3, max_attempts=2 to the
|
||
-- task API (MUL-4910). The Go retryAttemptCeiling already refuses to raise a
|
||
-- disabled (max_attempts<=1) task, so this only ever widens, never revives.
|
||
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, fire_at,
|
||
channel_context_revision, id
|
||
)
|
||
SELECT
|
||
p.agent_id, p.runtime_id, p.issue_id, p.chat_session_id, p.autopilot_run_id,
|
||
CASE WHEN sqlc.narg(fire_at)::timestamptz IS NOT NULL THEN 'deferred' ELSE 'queued' END,
|
||
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, COALESCE(sqlc.narg(max_attempts)::int, 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, sqlc.narg(fire_at),
|
||
p.channel_context_revision,
|
||
-- Named new_task_id, not id: $1 above is the PARENT task's id.
|
||
COALESCE(sqlc.narg('new_task_id')::uuid, gen_random_uuid())
|
||
FROM agent_task_queue p
|
||
WHERE p.id = $1
|
||
AND lock_task_owner_rows(p.agent_id, p.issue_id, p.runtime_id)
|
||
ON CONFLICT (issue_id, agent_id) WHERE status IN ('queued', 'dispatched')
|
||
OR (status = 'deferred' AND context->>'channel_issue_media_pending' = 'true')
|
||
DO NOTHING
|
||
RETURNING *;
|
||
|
||
-- name: CreateManualQuickCreateRetryTask :one
|
||
-- A human retry of an issue-less quick-create is a new direct_human run, not
|
||
-- an automatic retry. It preserves the immutable quick-create context JSON
|
||
-- (including source_context_id), but deliberately starts a fresh session and
|
||
-- records rerun_of_task_id so attribution/reporting can distinguish the human
|
||
-- action from CreateRetryTask's retry_of_task_id lineage.
|
||
--
|
||
-- The caller locks and transfers the pending issue_source_context in the same
|
||
-- transaction. lock_task_owner_rows provides the usual workspace-teardown
|
||
-- fence; a deleted workspace therefore yields pgx.ErrNoRows and no task.
|
||
INSERT INTO agent_task_queue (
|
||
agent_id, runtime_id, status, priority, context,
|
||
force_fresh_session, is_leader_task, squad_id,
|
||
originator_user_id, accountable_user_id,
|
||
runtime_mcp_overlay, runtime_connected_apps,
|
||
originator_source, rerun_of_task_id, id
|
||
)
|
||
SELECT
|
||
p.agent_id, p.runtime_id, 'queued', p.priority, p.context,
|
||
TRUE, p.is_leader_task, p.squad_id,
|
||
sqlc.arg(actor_user_id), sqlc.arg(actor_user_id),
|
||
sqlc.narg(runtime_mcp_overlay), sqlc.narg(runtime_connected_apps),
|
||
'direct_human', p.id, sqlc.arg(new_task_id)
|
||
FROM agent_task_queue p
|
||
WHERE p.id = sqlc.arg(source_task_id)
|
||
AND p.status = 'failed'
|
||
AND p.issue_id IS NULL
|
||
AND p.chat_session_id IS NULL
|
||
AND p.autopilot_run_id IS NULL
|
||
AND lock_task_owner_rows(p.agent_id, p.issue_id, p.runtime_id)
|
||
RETURNING *;
|
||
|
||
-- name: DeleteUnstartedQuickCreateRetryTask :execrows
|
||
-- FailTask creates an automatic retry in the same transaction as the parent
|
||
-- terminal write. An issue-less source-context retry has no pending-slot
|
||
-- unique key, so a competing retry or an already-attached context can make
|
||
-- the subsequent attach-authority transfer lose after this row was inserted.
|
||
-- Remove only that still-uncommitted child and let the parent's failure commit.
|
||
DELETE FROM agent_task_queue
|
||
WHERE id = sqlc.arg(task_id)
|
||
AND status IN ('queued', 'deferred')
|
||
AND issue_id IS NULL
|
||
AND chat_session_id IS NULL
|
||
AND autopilot_run_id IS NULL;
|
||
|
||
-- 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: CancelPendingTasksByIssueAndAgent :many
|
||
-- Cancels the not-yet-started tasks for a single (issue, agent) pair, so the
|
||
-- manual rerun flow can enqueue a replacement without colliding with
|
||
-- idx_one_pending_task_per_issue_agent_v2 and without dropping the rerun's own
|
||
-- attribution onto a row somebody else created.
|
||
--
|
||
-- 'running' and 'waiting_local_directory' are deliberately NOT cancelled: an
|
||
-- agent is executing in them. Neither status appears in that unique index, so a
|
||
-- fresh queued row can be inserted alongside one, and ClaimAgentTask's
|
||
-- per-(issue, agent) serialization holds the new row until the active run
|
||
-- reaches a terminal state. Cancelling them made a manual rerun kill the pass
|
||
-- the agent was still working on; interrupting an in-flight run is CancelTask's
|
||
-- job, not rerun's.
|
||
--
|
||
-- Everything that has NOT begun executing is still cleared, including deferred
|
||
-- escalations, so rerun keeps its prior "replace the pending plan" behaviour for
|
||
-- those rows.
|
||
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', '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 / CancelPendingTasksByIssueAndAgent
|
||
-- (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: GetAgentTaskForDelegatedFailureUpdate :one
|
||
-- Serializes the idempotent delegated-failure recovery signal for one failed
|
||
-- task. FailTask and the stale-task sweepers can converge on the same row; the
|
||
-- lock ensures they cannot create two recovery comments/tasks for it.
|
||
SELECT * FROM agent_task_queue
|
||
WHERE id = $1
|
||
FOR UPDATE;
|
||
|
||
-- name: HasRetryTaskForParent :one
|
||
-- Defense-in-depth for the recovery path: a delegated failure with any retry
|
||
-- child is still intermediate and must not wake its coordinator.
|
||
SELECT count(*) > 0
|
||
FROM agent_task_queue
|
||
WHERE parent_task_id = $1
|
||
AND status <> 'cancelled';
|
||
|
||
-- 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 on one healthy runtime, 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 = @agent_id
|
||
AND atq.runtime_id = @runtime_id
|
||
AND atq.status = 'queued'
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM agent a
|
||
JOIN agent_runtime r ON r.id = atq.runtime_id
|
||
WHERE a.id = atq.agent_id
|
||
-- A task's persisted runtime is not authority after an agent rebind.
|
||
AND a.runtime_id = atq.runtime_id
|
||
-- Private runtimes only execute their owner's agents. Ownerless
|
||
-- runtime/agent rows remain claimable only so the handler can
|
||
-- settle them explicitly before daemon delivery; filtering them
|
||
-- here would leave every task silently queued until the TTL.
|
||
-- Public runtimes remain shareable across agent owners.
|
||
AND (
|
||
r.visibility = 'public'
|
||
OR (
|
||
r.visibility = 'private'
|
||
AND (
|
||
r.owner_id IS NULL
|
||
OR a.owner_id IS NULL
|
||
OR r.owner_id = a.owner_id
|
||
)
|
||
)
|
||
)
|
||
AND r.status = 'online'
|
||
AND COALESCE(r.last_seen_at, r.updated_at) >=
|
||
now() - make_interval(secs => @runtime_stale_secs::double precision)
|
||
)
|
||
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, atq.id 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())
|
||
AND EXISTS (
|
||
-- Keep this authorization fence in sync with ClaimAgentTask.
|
||
SELECT 1
|
||
FROM agent a
|
||
JOIN agent_runtime r ON r.id = atq.runtime_id
|
||
WHERE a.id = atq.agent_id
|
||
AND a.runtime_id = atq.runtime_id
|
||
AND (
|
||
r.visibility = 'public'
|
||
OR (
|
||
r.visibility = 'private'
|
||
AND (
|
||
r.owner_id IS NULL
|
||
OR a.owner_id IS NULL
|
||
OR r.owner_id = a.owner_id
|
||
)
|
||
)
|
||
)
|
||
AND r.status = 'online'
|
||
AND COALESCE(r.last_seen_at, r.updated_at) >=
|
||
now() - make_interval(secs => @runtime_stale_secs::double precision)
|
||
)
|
||
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())
|
||
AND EXISTS (
|
||
-- Keep this authorization fence in sync with ClaimAgentTask.
|
||
SELECT 1
|
||
FROM agent a
|
||
JOIN agent_runtime r ON r.id = atq.runtime_id
|
||
WHERE a.id = atq.agent_id
|
||
AND a.runtime_id = atq.runtime_id
|
||
AND (
|
||
r.visibility = 'public'
|
||
OR (
|
||
r.visibility = 'private'
|
||
AND (
|
||
r.owner_id IS NULL
|
||
OR a.owner_id IS NULL
|
||
OR r.owner_id = a.owner_id
|
||
)
|
||
)
|
||
)
|
||
AND r.status = 'online'
|
||
AND COALESCE(r.last_seen_at, r.updated_at) >=
|
||
now() - make_interval(secs => @runtime_stale_secs::double precision)
|
||
)
|
||
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
|
||
-- session_rollout_missing (MUL-5305): when true the daemon withheld this task's
|
||
-- Codex session because its rollout was never written to the store. Forcing
|
||
-- session_id NULL and flagging the row happen in THIS terminal transaction so an
|
||
-- auto-retry created and woken by the same commit can never observe the bad
|
||
-- pointer or a missing gap flag.
|
||
--
|
||
-- retired_session_id (GH #6066): a session this run abandoned as unresumable.
|
||
-- Recorded even on the completed path, because that is exactly the case the
|
||
-- resume lookups could not see — a fresh-session retry that SUCCEEDS still has
|
||
-- to retire the transcript it retried away from, or an older completed row
|
||
-- pointing at the same id resurrects it on the next run.
|
||
UPDATE agent_task_queue
|
||
SET status = 'completed', completed_at = now(), result = $2,
|
||
session_id = CASE WHEN sqlc.arg('session_rollout_missing') THEN NULL ELSE $3 END,
|
||
work_dir = $4,
|
||
durable_work_dir = COALESCE(sqlc.narg('durable_work_dir'), durable_work_dir),
|
||
branch_name = COALESCE(sqlc.narg('branch_name'), branch_name),
|
||
session_rollout_missing = sqlc.arg('session_rollout_missing'),
|
||
retired_session_id = COALESCE(sqlc.narg('retired_session_id'), retired_session_id),
|
||
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 'completed', 'failed' AND 'cancelled' 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.
|
||
--
|
||
-- A cancelled task is in exactly the same position, and excluding it was the
|
||
-- issue-side half of GH #6340: the pinned session is real (the provider emitted
|
||
-- it), the user stopped the run rather than the provider rejecting it, and
|
||
-- cancellation records no failure_reason/error for the poison filters below to
|
||
-- match on. So a stop-then-comment-again sequence keeps its context instead of
|
||
-- silently starting cold. A user who wants a clean slate has manual rerun,
|
||
-- which never takes this path (see below).
|
||
--
|
||
-- Manual rerun (TaskService.RerunIssue) does NOT take this path. The claim
|
||
-- handler branches on rerun_of_task_id FIRST and resolves the session/workdir
|
||
-- from that exact source task (so a parallel task on the same issue can't be
|
||
-- resumed by mistake), reusing the source workdir and resuming its session only
|
||
-- when the source failure is resume-safe. The rerun row still carries
|
||
-- force_fresh_session=true purely as a rollback-safe signal: an OLD claim
|
||
-- handler that predates the rerun_of_task_id branch falls back to this query,
|
||
-- and force_fresh_session=true makes it start clean instead of resuming the
|
||
-- wrong execution (MUL-4869).
|
||
--
|
||
-- 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, agent_error.context_overflow,
|
||
-- codex_resume_oversized)
|
||
-- 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.), a Codex semantic inactivity
|
||
-- timeout whose recorded session may replay the same stuck state, a context
|
||
-- window overflow that would immediately overflow again on resume, or a Codex
|
||
-- thread/resume response too large to read back (MUL-5722). Keep this
|
||
-- list in sync with resumeUnsafeFailureReason and GetLastChatTaskSession.
|
||
--
|
||
-- 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.
|
||
--
|
||
-- Selection is per-session, not per-row: we first reduce to the single most
|
||
-- recent terminal row FOR EACH session_id (DISTINCT ON), then apply the
|
||
-- resume-unsafe filter. This closes a poisoning wormhole (GH #5975): when the
|
||
-- SAME session_id has both an older 'completed' row (the turn that first baked
|
||
-- in the bad history) and a newer poisoned 'failed' row, a plain row-level
|
||
-- filter would drop the poisoned row and happily fall back to the older
|
||
-- completed row — which points at the exact same dead session. Judging by each
|
||
-- session's LATEST terminal state instead means:
|
||
-- - a newer poisoned row invalidates the whole session, older completed rows
|
||
-- included;
|
||
-- - a different, healthy session (distinct id) is still eligible as a
|
||
-- fallback;
|
||
-- - if that same id later terminates cleanly again, the newer completed row
|
||
-- restores its resumability.
|
||
--
|
||
-- The oversized-image ILIKE (dimension phrase AND image.source.base64.data) is
|
||
-- the GH #5975 analogue of the api_invalid_request defense-in-depth above: a
|
||
-- Kiro/ACP resume rejected for an oversized historical image is classified
|
||
-- 'api_invalid_request' at write time, but this text clause also blocks a
|
||
-- legacy/unclassified row (or a new-server + old-daemon deploy window) from
|
||
-- resuming the poisoned session. Both markers are required so the clause stays
|
||
-- exactly as narrow as classifyPoisonedError and the Kiro detector — an
|
||
-- unrelated error that only mentions image dimensions is NOT excluded.
|
||
--
|
||
-- MUL-5722 needed a different shape, and the reason is worth stating because
|
||
-- the first attempt got it wrong: a row-level guard like the ones above CANNOT
|
||
-- work for an overflowed resume. That failure happens before the turn starts,
|
||
-- so the backend has no session id to report and the row lands with session_id
|
||
-- NULL — which the latest_per_session CTE drops before any filter runs. The
|
||
-- only row naming the oversized thread is the OLDER completed one, which looks
|
||
-- perfectly healthy. Hence resume_overflow_at below: block by TIME, which needs
|
||
-- nothing from the failed row but its existence. See the clause for details.
|
||
--
|
||
-- The final pair of regexes is the provider-agnostic version of the same
|
||
-- guard, and it matters most for self-hosted installs: daemons upgrade on
|
||
-- their own cadence, so a host still running a daemon without
|
||
-- taskfailure.UnresumableHistory reports an empty-message rejection as
|
||
-- agent_error.unknown and would otherwise keep resuming the transcript the
|
||
-- provider just refused (GH #6066). Both regexes must match — an emptiness
|
||
-- complaint AND a locator pointing into the message history — mirroring
|
||
-- emptyContentRe and historyMessageLocatorRe in pkg/taskfailure/resume.go.
|
||
-- Keep the three in sync.
|
||
--
|
||
-- retired_sessions is the explicit half of the same rule (GH #6066). The
|
||
-- per-session latest state above can only judge sessions that some row still
|
||
-- POINTS at, so it cannot see a session a run deliberately abandoned: a fresh
|
||
-- retry that succeeded reports its own new id (or none), leaving the poisoned
|
||
-- id visible on nothing but an older completed row, which then looks perfectly
|
||
-- healthy. retired_session_id records the abandonment itself, so one row
|
||
-- retiring a session removes it from every later lookup no matter how many
|
||
-- clean rows still reference it.
|
||
WITH retired_sessions AS (
|
||
SELECT DISTINCT r.retired_session_id AS session_id
|
||
FROM agent_task_queue r
|
||
WHERE r.agent_id = $1 AND r.issue_id = $2
|
||
AND r.retired_session_id IS NOT NULL
|
||
), resume_overflow_at AS (
|
||
SELECT MAX(COALESCE(t.completed_at, t.started_at, t.dispatched_at, t.created_at)) AS at
|
||
FROM agent_task_queue t
|
||
WHERE t.agent_id = $1 AND t.issue_id = $2
|
||
AND t.status = 'failed'
|
||
AND (
|
||
COALESCE(t.failure_reason, '') = 'codex_resume_oversized'
|
||
OR (COALESCE(t.error, '') ILIKE '%thread/resume failed%' AND COALESCE(t.error, '') ILIKE '%token too long%')
|
||
)
|
||
), latest_per_session AS (
|
||
SELECT DISTINCT ON (t.session_id)
|
||
t.session_id, t.work_dir, t.runtime_id, t.status, t.failure_reason, t.error,
|
||
COALESCE(t.completed_at, t.started_at, t.dispatched_at, t.created_at) AS terminal_at
|
||
FROM agent_task_queue t
|
||
WHERE t.agent_id = $1 AND t.issue_id = $2
|
||
AND t.session_id IS NOT NULL
|
||
AND t.status IN ('completed', 'failed', 'cancelled')
|
||
ORDER BY t.session_id, COALESCE(t.completed_at, t.started_at, t.dispatched_at, t.created_at) DESC
|
||
)
|
||
SELECT session_id, work_dir, runtime_id FROM latest_per_session
|
||
WHERE session_id NOT IN (SELECT session_id FROM retired_sessions)
|
||
AND (
|
||
status IN ('completed', 'cancelled')
|
||
OR (
|
||
status = 'failed'
|
||
AND COALESCE(failure_reason, '') NOT IN ('iteration_limit', 'agent_fallback_message', 'api_invalid_request', 'codex_semantic_inactivity', 'agent_error.context_overflow', 'codex_resume_oversized')
|
||
AND NOT (COALESCE(error, '') ILIKE '%400%' AND COALESCE(error, '') ILIKE '%invalid_request_error%')
|
||
AND NOT (COALESCE(error, '') ILIKE '%image dimensions exceed max allowed size%' AND COALESCE(error, '') ILIKE '%image.source.base64.data%')
|
||
-- A provider credential-resolution failure ("Could not resolve
|
||
-- authentication method...") is deterministic on resume: the missing
|
||
-- api_key / auth_token / header is baked into the session's provider
|
||
-- state, so replaying it reproduces the same provider error forever. It
|
||
-- is classified agent_error.unknown (resume-safe), so this text guard is
|
||
-- the only thing that keeps a wedged issue from resuming the same dead
|
||
-- session on its next trigger — there is no daemon upgrade to wait for.
|
||
-- Keep in sync with ResumeUnsafeFailure and GetLastChatTaskSession.
|
||
-- The phrase itself lives in taskfailure.AuthMethodUnresolved, which the
|
||
-- daemon's in-turn fresh-session retry reads (GH #6777). This guard stays
|
||
-- because it is the only protection for rows an older daemon wrote.
|
||
AND NOT (COALESCE(error, '') ILIKE '%could not resolve authentication method%')
|
||
AND NOT (COALESCE(error, '') ~* 'must not be empty|must be non-?empty|must have non-?empty|non-?empty content|cannot be empty|should not be empty'
|
||
AND COALESCE(error, '') ~* 'role[^a-z0-9]{0,2}assistant|assistant message|message at position|messages\.[0-9]|messages\[[0-9]')
|
||
)
|
||
)
|
||
-- MUL-5722: a resume that overflowed the reader names no session, so it can
|
||
-- only be excluded by time, not by matching the failed row. Drop every
|
||
-- session whose last terminal activity predates the newest such failure: one
|
||
-- of them IS the oversized thread, and the row that would tell us which is
|
||
-- exactly the row that could not be written. Anything that terminated after
|
||
-- the overflow is the fresh thread that replaced it, so this un-blocks itself
|
||
-- as soon as one succeeds.
|
||
AND (
|
||
(SELECT at FROM resume_overflow_at) IS NULL
|
||
OR terminal_at > (SELECT at FROM resume_overflow_at)
|
||
)
|
||
ORDER BY terminal_at DESC
|
||
LIMIT 1;
|
||
|
||
-- name: GetLatestTaskRolloutMissing :one
|
||
-- Reports whether the most recent terminal task for (agent_id, issue_id)
|
||
-- withheld its Codex session because the rollout was missing (MUL-5305). When
|
||
-- true, GetLastTaskSession fell back to an older session, so the next run must
|
||
-- disclose that the most recent turn's context could not be carried over. Any
|
||
-- later task that records a real session resets this to FALSE by being the new
|
||
-- most-recent row, so the disclosure fires once and then clears.
|
||
SELECT COALESCE(session_rollout_missing, FALSE) FROM agent_task_queue
|
||
WHERE agent_id = $1 AND issue_id = $2
|
||
AND status IN ('completed', 'failed')
|
||
AND started_at IS NOT NULL
|
||
ORDER BY COALESCE(completed_at, started_at, dispatched_at, created_at) DESC
|
||
LIMIT 1;
|
||
|
||
-- name: GetLatestChatTaskRolloutMissing :one
|
||
-- Chat-session counterpart of GetLatestTaskRolloutMissing (MUL-5305): reports
|
||
-- whether the most recent terminal task on this chat session withheld its Codex
|
||
-- session because the rollout was missing. When true the next chat claim resumed
|
||
-- an older session (or none), so it must disclose the continuity gap.
|
||
SELECT COALESCE(session_rollout_missing, FALSE) FROM agent_task_queue
|
||
WHERE chat_session_id = sqlc.arg('chat_session_id')
|
||
AND (
|
||
sqlc.narg('channel_context_revision')::bigint IS NULL
|
||
OR COALESCE(channel_context_revision, 1) = sqlc.narg('channel_context_revision')::bigint
|
||
)
|
||
AND status IN ('completed', 'failed')
|
||
AND started_at 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.
|
||
--
|
||
-- session_rollout_missing (MUL-5305): when true the daemon withheld this task's
|
||
-- Codex session (its rollout was missing). Force session_id NULL — overriding
|
||
-- the COALESCE that would otherwise preserve a stale mid-flight pin — and flag
|
||
-- the row, in the SAME transaction that creates and wakes the auto-retry, so the
|
||
-- retry can never claim the bad pointer or miss the continuity gap.
|
||
UPDATE agent_task_queue
|
||
SET status = 'failed',
|
||
completed_at = now(),
|
||
error = $2,
|
||
failure_reason = COALESCE(sqlc.narg('failure_reason'), 'agent_error'),
|
||
session_id = CASE WHEN sqlc.arg('session_rollout_missing') THEN NULL ELSE COALESCE(sqlc.narg('session_id'), session_id) END,
|
||
work_dir = COALESCE(sqlc.narg('work_dir'), work_dir),
|
||
durable_work_dir = COALESCE(sqlc.narg('durable_work_dir'), durable_work_dir),
|
||
branch_name = COALESCE(sqlc.narg('branch_name'), branch_name),
|
||
session_rollout_missing = sqlc.arg('session_rollout_missing'),
|
||
retired_session_id = COALESCE(sqlc.narg('retired_session_id'), retired_session_id),
|
||
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.
|
||
--
|
||
-- A row the user just cancelled accepts the pin too, but only while its
|
||
-- session slot is still empty (GH #6340). The pin is asynchronous — for Codex
|
||
-- it waits for the rollout to reach the store — so a cancel landing in that
|
||
-- window used to drop the session id for good, and the cancelled turn's
|
||
-- context with it. Filling an EMPTY slot on the run's own row is exactly what
|
||
-- the mid-flight pin is for; the `session_id IS NULL` guard is what keeps this
|
||
-- from being an overwrite, and completed/failed rows stay untouchable so a
|
||
-- straggler goroutine can never contradict a terminal report.
|
||
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')
|
||
OR (status = 'cancelled' AND session_id IS NULL)
|
||
);
|
||
|
||
-- 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. An expired lease may fail immediately on
|
||
-- a healthy runtime, but a disconnected runtime gets the same reconnect
|
||
-- grace as a running task.
|
||
--
|
||
-- * 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 is not
|
||
-- killed until that heartbeat has been absent for the full reconnect
|
||
-- grace, even when its own wall-clock timeout elapsed earlier. This keeps
|
||
-- healthy multi-hour work alive through a network partition.
|
||
--
|
||
-- The daemon-dead case is recovered immediately when that daemon restarts via
|
||
-- RecoverOrphanedTasksForRuntime. Until then, this query and
|
||
-- FailTasksForOfflineRuntimes share the same bounded reconnect grace.
|
||
--
|
||
-- 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())
|
||
AND (
|
||
runtime_id IS NULL
|
||
OR NOT EXISTS (
|
||
SELECT 1 FROM agent_runtime r
|
||
WHERE r.id = agent_task_queue.runtime_id
|
||
)
|
||
OR EXISTS (
|
||
SELECT 1 FROM agent_runtime r
|
||
WHERE r.id = agent_task_queue.runtime_id
|
||
AND (
|
||
(
|
||
r.status = 'online'
|
||
AND COALESCE(r.last_seen_at, r.updated_at) >=
|
||
now() - make_interval(secs => @runtime_stale_secs::double precision)
|
||
)
|
||
OR COALESCE(r.last_seen_at, r.updated_at) <
|
||
now() - make_interval(secs => @runtime_reconnect_grace_secs::double precision)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
OR (
|
||
status = 'running'
|
||
AND started_at < now() - make_interval(secs => @running_timeout_secs::double precision)
|
||
AND (
|
||
runtime_id IS NULL
|
||
OR NOT EXISTS (
|
||
SELECT 1 FROM agent_runtime r
|
||
WHERE r.id = agent_task_queue.runtime_id
|
||
AND COALESCE(r.last_seen_at, r.updated_at) >=
|
||
now() - make_interval(secs => @runtime_reconnect_grace_secs::double precision)
|
||
)
|
||
)
|
||
)
|
||
RETURNING *;
|
||
|
||
-- name: ExpireStaleQueuedTasks :many
|
||
-- Fails queued tasks whose runtime can no longer prove it is alive.
|
||
--
|
||
-- This used to be a pure wall clock: queued for longer than a TTL (default 2h)
|
||
-- meant failed. That conflated "nobody is coming for this task" with "the
|
||
-- queue ahead of it is long", and the second one is not a failure. MUL-6558
|
||
-- was exactly that — a self-hosted runtime with low task concurrency held its
|
||
-- own queue past 2h and healthy work died as queued_expired. The TTL knob
|
||
-- added then (MULTICA_TASK_QUEUED_TTL) only moved the cliff; it did not stop
|
||
-- a busy runtime from eventually crossing it.
|
||
--
|
||
-- So the question is not "how long has this waited" but "is anything still
|
||
-- able to pick it up". A runtime that keeps heartbeating is busy, not dead,
|
||
-- and its backlog must be allowed to drain however long that takes. The
|
||
-- liveness signal is the same one FailTasksForOfflineRuntimes uses for
|
||
-- dispatched/running rows, so a daemon going down now retires its queued and
|
||
-- its in-flight work on one clock instead of two.
|
||
--
|
||
-- The row must ALSO have been queued for a full grace of its own. Enqueue binds
|
||
-- a task to agent.runtime_id without checking that the runtime is up, so
|
||
-- runtime liveness alone would fail a task the instant it is assigned to a
|
||
-- runtime that has been offline a while — a laptop closed overnight would turn
|
||
-- "assign this issue" into a failure inside one 30s sweep tick instead of
|
||
-- waiting for the machine to come back. Requiring the task's own age keeps the
|
||
-- promise the name makes: a queued task gets one full reconnect grace before it
|
||
-- is given up on, counted from when it started waiting. It also bounds the
|
||
-- scan, which would otherwise re-evaluate the runtime subquery against every
|
||
-- queued row on every tick.
|
||
--
|
||
-- Heartbeat age is read directly rather than gated on runtime.status='online',
|
||
-- so a row stuck at 'online' with a long-dead heartbeat still releases its
|
||
-- queue.
|
||
--
|
||
-- The runtime_id IS NULL / missing-runtime arms are fail-closed defence, not
|
||
-- live paths: the schema already excludes both. runtime_id was NOT NULL from
|
||
-- migration 004 until 251 replaced it with CHECK (runtime_id IS NOT NULL OR
|
||
-- completed_at IS NOT NULL), which every insert and update is checked against
|
||
-- (NOT VALID only skips the backfill scan), so no queued row can be unbound.
|
||
-- A dangling reference is impossible because agent_task_queue_runtime_id_fkey
|
||
-- (migration 004) is ON DELETE CASCADE: deleting a runtime removes the rows
|
||
-- referencing it rather than orphaning them, and the normal delete path
|
||
-- (migration 251, MUL-5559) explicitly unbinds history tasks first in the same
|
||
-- transaction. They are kept so a future schema change cannot silently strand
|
||
-- rows that have no liveness signal at all.
|
||
--
|
||
-- A retry created by runtime_offline is exempt: it deliberately waits for that
|
||
-- runtime to reconnect, and FailExpiredRuntimeReconnectRetries owns its exit.
|
||
--
|
||
-- 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 liveness 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 => @reconnect_grace_secs::double precision)
|
||
AND (
|
||
runtime_id IS NULL
|
||
OR NOT EXISTS (
|
||
SELECT 1 FROM agent_runtime r WHERE r.id = agent_task_queue.runtime_id
|
||
)
|
||
OR EXISTS (
|
||
SELECT 1 FROM agent_runtime r
|
||
WHERE r.id = agent_task_queue.runtime_id
|
||
AND COALESCE(r.last_seen_at, r.updated_at) <
|
||
now() - make_interval(secs => @reconnect_grace_secs::double precision)
|
||
)
|
||
)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM agent_task_queue retry_parent
|
||
WHERE retry_parent.id = agent_task_queue.parent_task_id
|
||
AND retry_parent.failure_reason = 'runtime_offline'
|
||
)
|
||
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 = 'runtime unavailable while task was queued',
|
||
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 => @reconnect_grace_secs::double precision)
|
||
AND (
|
||
t.runtime_id IS NULL
|
||
OR NOT EXISTS (SELECT 1 FROM agent_runtime r WHERE r.id = t.runtime_id)
|
||
OR EXISTS (
|
||
SELECT 1 FROM agent_runtime r
|
||
WHERE r.id = t.runtime_id
|
||
AND COALESCE(r.last_seen_at, r.updated_at) <
|
||
now() - make_interval(secs => @reconnect_grace_secs::double precision)
|
||
)
|
||
)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM agent_task_queue retry_parent
|
||
WHERE retry_parent.id = t.parent_task_id
|
||
AND retry_parent.failure_reason = 'runtime_offline'
|
||
)
|
||
RETURNING t.*;
|
||
|
||
-- name: FailExpiredRuntimeReconnectRetries :many
|
||
-- A runtime_offline retry waits in deferred until its runtime returns healthy.
|
||
-- Give that recovery state a bounded exit: after one full reconnect grace,
|
||
-- fail it with a non-retryable reason so issues, agents, and runtime GC can
|
||
-- converge. A runtime that is healthy when this query runs wins the race and
|
||
-- remains deferred for the next daemon poll to promote. The parent join is the
|
||
-- lineage discriminator; provider backoff and other deferred task types are
|
||
-- intentionally excluded.
|
||
WITH victims AS (
|
||
SELECT retry.id
|
||
FROM agent_task_queue retry
|
||
JOIN agent_task_queue parent ON parent.id = retry.parent_task_id
|
||
WHERE retry.status = 'deferred'
|
||
AND retry.fire_at < now() - make_interval(secs => @reconnect_grace_secs::double precision)
|
||
AND parent.failure_reason = 'runtime_offline'
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM agent_runtime runtime
|
||
WHERE runtime.id = retry.runtime_id
|
||
AND runtime.status = 'online'
|
||
AND COALESCE(runtime.last_seen_at, runtime.updated_at) >=
|
||
now() - make_interval(secs => @runtime_stale_secs::double precision)
|
||
)
|
||
ORDER BY retry.fire_at, retry.created_at
|
||
LIMIT @max_per_tick::int
|
||
FOR UPDATE OF retry SKIP LOCKED
|
||
)
|
||
UPDATE agent_task_queue AS retry
|
||
SET status = 'failed',
|
||
completed_at = now(),
|
||
error = 'runtime did not reconnect within the configured grace period',
|
||
failure_reason = 'runtime_reconnect_timeout',
|
||
wait_reason = NULL,
|
||
prepare_lease_expires_at = NULL
|
||
FROM victims
|
||
WHERE retry.id = victims.id
|
||
AND retry.status = 'deferred'
|
||
AND retry.fire_at < now() - make_interval(secs => @reconnect_grace_secs::double precision)
|
||
AND EXISTS (
|
||
SELECT 1 FROM agent_task_queue parent
|
||
WHERE parent.id = retry.parent_task_id
|
||
AND parent.failure_reason = 'runtime_offline'
|
||
)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM agent_runtime runtime
|
||
WHERE runtime.id = retry.runtime_id
|
||
AND runtime.status = 'online'
|
||
AND COALESCE(runtime.last_seen_at, runtime.updated_at) >=
|
||
now() - make_interval(secs => @runtime_stale_secs::double precision)
|
||
)
|
||
RETURNING retry.*;
|
||
|
||
-- name: CancelAgentTask :one
|
||
-- Automatic cancellation without an explicit persisted failure reason. Unlike
|
||
-- CancelAgentTaskByUser, this deliberately leaves recovery inputs replayable.
|
||
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: CancelAgentTaskByUser :one
|
||
-- An explicit user cancellation is a terminal acknowledgement for any
|
||
-- delegated-failure recovery signal planned into this task. Server-initiated
|
||
-- cancellations keep using CancelAgentTask / CancelAgentTaskWithReason so a
|
||
-- stale plan, claim failure, or other automatic repair can still be replayed.
|
||
UPDATE agent_task_queue AS task
|
||
SET status = 'cancelled',
|
||
completed_at = now(),
|
||
prepare_lease_expires_at = NULL,
|
||
delivered_comment_ids = CASE
|
||
-- Chat and ordinary issue tasks almost never carry a delegated-failure
|
||
-- recovery signal. Keep their high-frequency user-cancel path to a
|
||
-- no-join update; only validate task lineage after the cheap comment
|
||
-- shape probe finds a possible recovery signal.
|
||
WHEN task.trigger_comment_id IS NULL
|
||
AND COALESCE(cardinality(task.coalesced_comment_ids), 0) = 0
|
||
THEN task.delivered_comment_ids
|
||
WHEN NOT EXISTS (
|
||
SELECT 1
|
||
FROM comment recovery_signal
|
||
WHERE (
|
||
recovery_signal.id = task.trigger_comment_id
|
||
OR recovery_signal.id = ANY(task.coalesced_comment_ids)
|
||
)
|
||
AND recovery_signal.author_type = 'system'
|
||
AND recovery_signal.type = 'progress_update'
|
||
AND recovery_signal.source_task_id IS NOT NULL
|
||
) THEN task.delivered_comment_ids
|
||
ELSE (
|
||
SELECT COALESCE(array_agg(DISTINCT receipt.id), '{}')::uuid[]
|
||
FROM unnest(array_cat(
|
||
task.delivered_comment_ids,
|
||
ARRAY(
|
||
SELECT recovery.id
|
||
FROM comment recovery
|
||
JOIN agent_task_queue failed ON failed.id = recovery.source_task_id
|
||
JOIN agent_task_queue source ON source.id = failed.delegated_from_task_id
|
||
WHERE (
|
||
recovery.id = task.trigger_comment_id
|
||
OR recovery.id = ANY(task.coalesced_comment_ids)
|
||
)
|
||
AND recovery.author_type = 'system'
|
||
AND recovery.type = 'progress_update'
|
||
AND recovery.source_task_id IS NOT NULL
|
||
AND failed.status = 'failed'
|
||
AND failed.delegated_from_task_id IS NOT NULL
|
||
AND failed.autopilot_run_id IS NULL
|
||
AND failed.trigger_evidence_kind IS DISTINCT FROM 'delegated_failure'
|
||
AND source.autopilot_run_id IS NULL
|
||
AND source.issue_id = task.issue_id
|
||
AND source.agent_id = task.agent_id
|
||
AND recovery.issue_id = source.issue_id
|
||
)
|
||
)) AS receipt(id)
|
||
)
|
||
END
|
||
WHERE task.id = $1
|
||
AND task.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
|
||
RETURNING task.*;
|
||
|
||
-- name: SetAgentTaskBranchName :exec
|
||
-- Records the delivered branch on a CANCELLED task. Needed because the daemon
|
||
-- finalizes its worktree (committing whatever the agent produced) BEFORE it
|
||
-- learns the task was cancelled, so the branch exists but the cancel path has
|
||
-- no result payload to carry it. Without this the branch is real and
|
||
-- completely undiscoverable from the UI.
|
||
--
|
||
-- Never overwrites a name already recorded by a complete/fail callback, and
|
||
-- never touches any OTHER terminal state: the daemon acks on every terminal
|
||
-- status it observes, so a late or replayed ack from a stale run could
|
||
-- otherwise write its branch into a completed/failed row whose own callback —
|
||
-- the authoritative channel for those states — recorded nothing. status is
|
||
-- stable once terminal, so this CAS cannot race a legitimate write.
|
||
UPDATE agent_task_queue
|
||
SET branch_name = COALESCE(branch_name, sqlc.arg('branch_name'))
|
||
WHERE id = sqlc.arg('id') AND status = 'cancelled';
|
||
|
||
-- name: SetAgentTaskDurableWorkDir :exec
|
||
-- Records the durable replacement for a disposable worktree on a CANCELLED
|
||
-- task. The daemon only sends this after Finalize confirms the task worktree
|
||
-- is gone. As with branch_name, the status CAS rejects stale acknowledgements
|
||
-- and COALESCE makes replays idempotent.
|
||
UPDATE agent_task_queue
|
||
SET durable_work_dir = COALESCE(durable_work_dir, sqlc.arg('durable_work_dir'))
|
||
WHERE id = sqlc.arg('id') AND status = 'cancelled';
|
||
|
||
-- name: SetAgentTaskErrorIfEmpty :exec
|
||
-- Companion to SetAgentTaskBranchName for the cancel-ack path. A cancelled
|
||
-- worktree task whose Finalize ABORTED has no branch to deliver — the error
|
||
-- text carrying the preserved-worktree path is the only pointer to the agent's
|
||
-- work, and the cancel flow discarded the rest of the result. A user-initiated
|
||
-- cancel leaves error NULL, so filling it here never overwrites a reason
|
||
-- recorded by a fail callback or the claim gate; the status CAS keeps a late
|
||
-- ack from stamping an error onto a completed/failed row (see
|
||
-- SetAgentTaskBranchName above).
|
||
UPDATE agent_task_queue
|
||
SET error = sqlc.arg('error'),
|
||
failure_reason = COALESCE(failure_reason, sqlc.arg('failure_reason'))
|
||
WHERE id = sqlc.arg('id') AND (error IS NULL OR error = '') AND status = 'cancelled';
|
||
|
||
-- name: CancelAgentTaskWithReason :one
|
||
-- Cancels a task AND records why, for cancellations the user did not ask for.
|
||
--
|
||
-- CancelAgentTaskByUser leaves error/failure_reason NULL because the user knows
|
||
-- why. A server-initiated refusal is the opposite: without a persisted reason
|
||
-- the run surfaces as an unexplained "cancelled", and the only trace is a 4xx
|
||
-- in a daemon log the user never sees. Retrying cannot help either (the task is
|
||
-- refused for a durable reason), so this terminal state carries its explanation.
|
||
UPDATE agent_task_queue
|
||
SET status = 'cancelled',
|
||
completed_at = now(),
|
||
error = sqlc.arg('error'),
|
||
failure_reason = sqlc.arg('failure_reason'),
|
||
prepare_lease_expires_at = NULL
|
||
WHERE id = sqlc.arg('id') AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
|
||
RETURNING *;
|
||
|
||
-- name: CancelQueuedAgentTask :one
|
||
-- Queue editing is a compare-and-set: never cancel a task that the daemon
|
||
-- promoted between the user's click and this statement.
|
||
UPDATE agent_task_queue
|
||
SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL
|
||
WHERE id = sqlc.arg('id')
|
||
AND chat_session_id = sqlc.arg('chat_session_id')
|
||
AND status = 'queued'
|
||
RETURNING *;
|
||
|
||
-- name: CancelQueuedAgentTasksForSession :many
|
||
-- Clear only positional follow-ups. The first visible task is current even
|
||
-- when it has not been claimed yet, so an all-queued session must preserve its
|
||
-- priority/FIFO head. Keep this selector in lockstep with the visible-head
|
||
-- invariant documented above ListChatMessages in chat.sql.
|
||
WITH head AS MATERIALIZED (
|
||
SELECT candidate.id
|
||
FROM agent_task_queue AS candidate
|
||
WHERE candidate.chat_session_id = $1
|
||
AND candidate.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
|
||
AND candidate.regenerate_quick_actions_for IS NULL
|
||
ORDER BY
|
||
CASE
|
||
WHEN candidate.status IN ('dispatched', 'running', 'waiting_local_directory') THEN 0
|
||
WHEN candidate.status = 'deferred' THEN 1
|
||
ELSE 2
|
||
END,
|
||
candidate.priority DESC,
|
||
candidate.created_at ASC,
|
||
candidate.id ASC
|
||
LIMIT 1
|
||
)
|
||
UPDATE agent_task_queue AS queued
|
||
SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL
|
||
WHERE queued.chat_session_id = $1
|
||
AND queued.status = 'queued'
|
||
AND queued.id IS DISTINCT FROM (SELECT id FROM head)
|
||
RETURNING queued.*;
|
||
|
||
-- 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
|
||
-- waiting_local_directory remains capacity-bearing until resume admission has
|
||
-- an atomic reservation/CAS gate. Consequently a waiter-only agent is reported
|
||
-- idle by RefreshAgentStatusFromTasks but still cannot claim additional work;
|
||
-- removing it here alone could exceed max_concurrent_tasks when it resumes.
|
||
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, or the explicitly-marked deferred task whose channel
|
||
-- media must bind before the issue agent runs. 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')
|
||
OR (status = 'deferred' AND context->>'channel_issue_media_pending' = 'true')
|
||
)
|
||
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')
|
||
OR (status = 'deferred' AND context->>'channel_issue_media_pending' = 'true')
|
||
)
|
||
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 a pre-claim task on purpose (MUL-4195 review rounds
|
||
-- 2–4). This merge is reached when HasPendingTaskForIssueAndAgent matched a
|
||
-- 'queued'/'dispatched' task, or the channel-media deferred task described
|
||
-- below. '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. Ordinary 'deferred' rows remain excluded:
|
||
-- assignee-fallback escalations have their own fire_at lifecycle and may coexist
|
||
-- with a queued primary. The one exception is a task explicitly marked
|
||
-- channel_issue_media_pending. It is the issue's sole assigned-agent task and
|
||
-- has not been claimable yet, so a new comment must merge into it instead of
|
||
-- creating a competing queued task. 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'
|
||
OR (t.status = 'deferred' AND t.context->>'channel_issue_media_pending' = 'true')
|
||
)
|
||
-- Head-scoped (TEN-356, #5914): never fold across HEADs. The physical
|
||
-- unique index is only (issue_id, agent_id), so an insert-race loser can
|
||
-- collide with a pending task stamped for a DIFFERENT head_sha; merging
|
||
-- into it would give a new-HEAD comment old-HEAD review coverage. Empty/
|
||
-- absent head_sha (no linked PR) matches any task, preserving coalescing.
|
||
AND (
|
||
COALESCE(sqlc.narg('head_sha')::text, '') = ''
|
||
OR t.context->>'head_sha' = sqlc.narg('head_sha')::text
|
||
)
|
||
ORDER BY t.created_at DESC
|
||
LIMIT 1
|
||
)
|
||
RETURNING id, coalesced_comment_ids;
|
||
|
||
-- name: RegisterPlannedCommentForActiveTask :one
|
||
-- #5914: durably register a comment that lost an enqueue race as a PLANNED
|
||
-- (undelivered) input on the same-(issue, agent) ACTIVE task whose queued row
|
||
-- MergeCommentIntoPendingTask could no longer target (it was claimed →
|
||
-- dispatched/running). The losing comment can predate that task's created_at,
|
||
-- so completion reconciliation's `created_at > since` window cannot see it;
|
||
-- appending it to coalesced_comment_ids (the planned set) WITHOUT touching
|
||
-- delivered_comment_ids makes reconcileCommentsOnCompletion replay it as a
|
||
-- single bounded follow-up (planned-but-not-delivered ⇒ follow-up).
|
||
--
|
||
-- Head-scoped (TEN-356): only a task stamped with the SAME head_sha is a target,
|
||
-- so a new-HEAD comment is never attached to an old-HEAD run. Returns
|
||
-- pgx.ErrNoRows when no same-head active task exists (different HEAD, or the task
|
||
-- just terminated) so the caller falls back to the active-task decision.
|
||
--
|
||
-- 'queued' is DELIBERATELY EXCLUDED (#5914, Elon round 3): a not-yet-claimed
|
||
-- task has no claim receipt, so a comment merged into it WILL be recorded as
|
||
-- delivered at claim time and never earns a completion follow-up under its own
|
||
-- attribution. A queued target must therefore go through the ATOMIC
|
||
-- MergeCommentIntoPendingTask (which re-stamps trigger/originator/accountable/
|
||
-- overlay), never a bare planned append — otherwise a second member's comment
|
||
-- could execute under the first member's identity/connected-apps (MUL-4302).
|
||
-- Only claim-receipt statuses (already-built delivered set) are safe planned-id
|
||
-- targets.
|
||
UPDATE agent_task_queue
|
||
SET coalesced_comment_ids = (
|
||
SELECT COALESCE(array_agg(DISTINCT e), '{}')
|
||
FROM unnest(array_append(coalesced_comment_ids, @comment_id::uuid)) AS e
|
||
WHERE e IS NOT NULL
|
||
)
|
||
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 IN ('dispatched', 'running', 'waiting_local_directory')
|
||
AND (
|
||
COALESCE(sqlc.narg('head_sha')::text, '') = ''
|
||
OR t.context->>'head_sha' = sqlc.narg('head_sha')::text
|
||
)
|
||
ORDER BY t.created_at DESC
|
||
LIMIT 1
|
||
)
|
||
RETURNING id, coalesced_comment_ids;
|
||
|
||
-- name: MergeDelegatedFailureCommentIntoPendingTask :one
|
||
-- A delegated failure is a platform-owned input, not a new human instruction:
|
||
-- fold it into the coordinator's pre-claim task without replacing that task's
|
||
-- attribution snapshot. The recovery comment is the newest input, so it becomes
|
||
-- the trigger and the prior trigger joins the coalesced plan. This preserves the
|
||
-- claim/prompt invariant that trigger_comment_id names the newest comment while
|
||
-- retaining the pending task's original human authority and connected apps.
|
||
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 <> @comment_id::uuid
|
||
),
|
||
trigger_comment_id = @comment_id::uuid,
|
||
trigger_summary = sqlc.narg('trigger_summary')
|
||
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'
|
||
OR (t.status = 'deferred' AND t.context->>'channel_issue_media_pending' = 'true')
|
||
)
|
||
AND t.trigger_comment_id IS DISTINCT FROM @comment_id::uuid
|
||
AND NOT (@comment_id::uuid = ANY(t.coalesced_comment_ids))
|
||
ORDER BY t.created_at DESC
|
||
LIMIT 1
|
||
)
|
||
RETURNING *;
|
||
|
||
-- name: HasTaskCoveringDelegatedFailureComment :one
|
||
-- Durable idempotency check for a recovery comment. The completion reconciler
|
||
-- excludes its own just-completed task when replaying a planned-but-undelivered
|
||
-- signal. A planned comment is covered only while its task can still execute;
|
||
-- after a task becomes terminal, delivered_comment_ids is the sole durable
|
||
-- receipt. This prevents cancelled/failed pre-delivery tasks from swallowing
|
||
-- the recovery obligation while still avoiding a loop after real delivery.
|
||
SELECT count(*) > 0 AS covered
|
||
FROM agent_task_queue
|
||
WHERE issue_id = @issue_id
|
||
AND agent_id = @agent_id
|
||
AND (
|
||
@comment_id::uuid = ANY(delivered_comment_ids)
|
||
OR (
|
||
id IS DISTINCT FROM sqlc.narg('exclude_task_id')::uuid
|
||
AND (
|
||
status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
||
OR (status = 'deferred' AND context->>'channel_issue_media_pending' = 'true')
|
||
)
|
||
AND (trigger_comment_id = @comment_id::uuid OR @comment_id::uuid = ANY(coalesced_comment_ids))
|
||
)
|
||
);
|
||
|
||
-- name: CountDelegatedFailureRecoveryTasks :one
|
||
-- Counts dedicated coordinator wakeups for one failed delegated task. Merged
|
||
-- recovery signals do not create a new row and therefore do not consume an
|
||
-- automatic attempt until they need a standalone successor.
|
||
SELECT count(*)
|
||
FROM agent_task_queue
|
||
WHERE trigger_evidence_kind = 'delegated_failure'
|
||
AND trigger_evidence_ref_id = @failed_task_id;
|
||
|
||
-- name: AcknowledgeExhaustedDelegatedFailureRecovery :one
|
||
-- Once the bounded automatic attempts are exhausted, record a terminal
|
||
-- acknowledgement on the newest recovery task. The outbox treats this receipt
|
||
-- as settled, while the service writes a separate visible system comment that
|
||
-- tells the user why no further task will be generated.
|
||
UPDATE agent_task_queue AS acknowledged
|
||
SET delivered_comment_ids = (
|
||
SELECT COALESCE(array_agg(DISTINCT receipt.id), '{}')::uuid[]
|
||
FROM unnest(array_append(acknowledged.delivered_comment_ids, @comment_id::uuid)) AS receipt(id)
|
||
)
|
||
WHERE acknowledged.id = (
|
||
SELECT attempt.id
|
||
FROM agent_task_queue attempt
|
||
WHERE attempt.trigger_evidence_kind = 'delegated_failure'
|
||
AND attempt.trigger_evidence_ref_id = @failed_task_id
|
||
ORDER BY attempt.created_at DESC, attempt.id DESC
|
||
LIMIT 1
|
||
FOR UPDATE
|
||
)
|
||
AND (
|
||
SELECT count(*)
|
||
FROM agent_task_queue attempt_count
|
||
WHERE attempt_count.trigger_evidence_kind = 'delegated_failure'
|
||
AND attempt_count.trigger_evidence_ref_id = @failed_task_id
|
||
) >= @max_attempts::int
|
||
RETURNING acknowledged.*;
|
||
|
||
-- name: SettleDelegatedFailureRecoveriesForTask :execrows
|
||
-- Retire every delegated-failure recovery comment this task actually received.
|
||
--
|
||
-- Callers MUST run this inside the same transaction that makes the task
|
||
-- terminal, and only there. While a task is still dispatched its receipt is
|
||
-- REPLACED rather than appended (SetTaskDeliveredCommentIDs), because a reclaim
|
||
-- by a differently-capable daemon must be able to drop an id it will not
|
||
-- deliver. Settling at receipt-write time would freeze that legitimate
|
||
-- transient window into a permanently lost recovery. Once the task is terminal
|
||
-- the receipt is final, which is exactly the guarantee this marker records.
|
||
--
|
||
-- A comment that was only planned into the task and never delivered is absent
|
||
-- from delivered_comment_ids, so an automatic cancellation correctly leaves it
|
||
-- pending for the sweeper to replay.
|
||
--
|
||
-- The terminal-status predicate is the guarantee itself, not a caller
|
||
-- convention. Settling a dispatched or running task's receipt would freeze the
|
||
-- reclaim window into a permanently lost recovery, and this marker is
|
||
-- monotonic — there is no later pass that could undo it. A caller that passes a
|
||
-- non-terminal task therefore updates nothing rather than silently destroying
|
||
-- the obligation.
|
||
UPDATE comment recovery
|
||
SET recovery_settled_at = now()
|
||
FROM agent_task_queue task
|
||
WHERE task.id = @task_id
|
||
AND task.status IN ('completed', 'failed', 'cancelled')
|
||
AND recovery.id = ANY(task.delivered_comment_ids)
|
||
AND recovery.author_type = 'system'
|
||
AND recovery.type = 'progress_update'
|
||
AND recovery.source_task_id IS NOT NULL
|
||
AND recovery.recovery_settled_at IS NULL;
|
||
|
||
-- name: SettleDelegatedFailureRecoveryComment :execrows
|
||
-- Retire one recovery comment by id, for the terminal outcomes that are not a
|
||
-- task reaching a terminal status: exhaustion of the bounded automatic attempts
|
||
-- writes its receipt onto an attempt row that may still be running, and the
|
||
-- user-visible explanation comment is what closes the obligation. Callers run
|
||
-- this in the same transaction that records that outcome.
|
||
UPDATE comment
|
||
SET recovery_settled_at = now()
|
||
WHERE id = @comment_id
|
||
AND author_type = 'system'
|
||
AND type = 'progress_update'
|
||
AND source_task_id IS NOT NULL
|
||
AND recovery_settled_at IS NULL;
|
||
|
||
-- name: ListPendingDelegatedFailureRecoveries :many
|
||
-- Durable outbox scan for platform recovery comments that are not yet owned by
|
||
-- an executable task and have no terminal delivery receipt. Starting from the
|
||
-- explicit recovery signal avoids retroactively waking unrelated historical
|
||
-- delegated failures. A bounded runtime sweeper replays these comments after a
|
||
-- transient dispatch error or process restart.
|
||
--
|
||
-- recovery_settled_at is what keeps this scan from growing with history. Every
|
||
-- other exclusion below is reversible — an issue can leave 'done', a cancelled
|
||
-- retry can be superseded — so none of them can be frozen into the index
|
||
-- predicate. A delivery receipt on a terminal task cannot be taken back, so
|
||
-- that one condition is recorded as durable state instead of being re-proven
|
||
-- through four joins and two NOT EXISTS subqueries on every tick. The predicate
|
||
-- of idx_comment_delegated_failure_unsettled matches the first four conditions,
|
||
-- so LIMIT now bounds the rows CHECKED and not just the rows RETURNED.
|
||
SELECT recovery.*
|
||
FROM comment recovery
|
||
JOIN agent_task_queue failed ON failed.id = recovery.source_task_id
|
||
JOIN agent_task_queue source ON source.id = failed.delegated_from_task_id
|
||
JOIN issue source_issue ON source_issue.id = source.issue_id
|
||
JOIN agent source_agent ON source_agent.id = source.agent_id
|
||
LEFT JOIN issue_status source_status
|
||
ON source_status.workspace_id = source_issue.workspace_id
|
||
AND source_status.key = source_issue.status
|
||
WHERE recovery.author_type = 'system'
|
||
AND recovery.type = 'progress_update'
|
||
AND recovery.source_task_id IS NOT NULL
|
||
AND recovery.recovery_settled_at IS NULL
|
||
AND recovery.issue_id = source_issue.id
|
||
AND recovery.workspace_id = source_issue.workspace_id
|
||
AND failed.status = 'failed'
|
||
AND failed.delegated_from_task_id IS NOT NULL
|
||
AND failed.autopilot_run_id IS NULL
|
||
AND failed.trigger_evidence_kind IS DISTINCT FROM 'delegated_failure'
|
||
AND source.autopilot_run_id IS NULL
|
||
AND source.issue_id IS NOT NULL
|
||
AND source.agent_id <> failed.agent_id
|
||
AND COALESCE(source_status.category, source_issue.status) NOT IN ('done', 'cancelled', 'backlog')
|
||
AND source_agent.archived_at IS NULL
|
||
AND source_agent.runtime_id IS NOT NULL
|
||
AND source_agent.workspace_id = source_issue.workspace_id
|
||
AND NOT EXISTS (
|
||
SELECT 1
|
||
FROM agent_task_queue retry
|
||
WHERE retry.parent_task_id = failed.id
|
||
AND retry.status <> 'cancelled'
|
||
)
|
||
AND NOT EXISTS (
|
||
SELECT 1
|
||
FROM agent_task_queue covering
|
||
WHERE covering.issue_id = source_issue.id
|
||
AND covering.agent_id = source.agent_id
|
||
AND (
|
||
recovery.id = ANY(covering.delivered_comment_ids)
|
||
OR (
|
||
(
|
||
covering.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
||
OR (
|
||
covering.status = 'deferred'
|
||
AND covering.context->>'channel_issue_media_pending' = 'true'
|
||
)
|
||
)
|
||
AND (
|
||
covering.trigger_comment_id = recovery.id
|
||
OR recovery.id = ANY(covering.coalesced_comment_ids)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
ORDER BY recovery.created_at ASC, recovery.id ASC
|
||
LIMIT @max_per_tick;
|
||
|
||
-- 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, waiting_local_directory, or the explicitly-marked
|
||
-- channel-media deferred state. 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')
|
||
OR (status = 'deferred' AND context->>'channel_issue_media_pending' = 'true')
|
||
);
|
||
|
||
-- 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 is authorized to 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 atq.* FROM agent_task_queue atq
|
||
WHERE atq.runtime_id = $1
|
||
AND atq.status = 'queued'
|
||
AND EXISTS (
|
||
-- Keep this authorization fence in sync with ClaimAgentTask.
|
||
SELECT 1
|
||
FROM agent a
|
||
JOIN agent_runtime r ON r.id = atq.runtime_id
|
||
WHERE a.id = atq.agent_id
|
||
AND a.runtime_id = atq.runtime_id
|
||
AND (
|
||
r.visibility = 'public'
|
||
OR (
|
||
r.visibility = 'private'
|
||
AND (
|
||
r.owner_id IS NULL
|
||
OR a.owner_id IS NULL
|
||
OR r.owner_id = a.owner_id
|
||
)
|
||
)
|
||
)
|
||
)
|
||
ORDER BY atq.priority DESC, atq.created_at ASC;
|
||
|
||
-- name: CancelSupersededDeferredRetriesForRuntimes :many
|
||
-- Cancels deferred auto-retry rows that a newer active task has already
|
||
-- superseded, so one rerun click still means exactly one more run.
|
||
--
|
||
-- The narrow race: a manual rerun clears the pending slot, a concurrent FailTask
|
||
-- commits a deferred retry (runtime_offline / provider_network's final attempt
|
||
-- arm fire_at), and the rerun's own enqueue then succeeds because 'deferred' is
|
||
-- outside idx_one_pending_task_per_issue_agent_v2. Both rows now exist. Merely
|
||
-- refusing to promote the retry is not enough: once the rerun stops occupying the
|
||
-- slot the retry promotes and runs a SECOND time, duplicating the agent's
|
||
-- comments, side effects and cost with nothing to signal it. That contradicts
|
||
-- both "a manual rerun replaces the pending plan" and FailTask's own "a runnable
|
||
-- successor already exists, so no retry is needed".
|
||
--
|
||
-- Scope is deliberately tight:
|
||
-- * retry_of_task_id IS NOT NULL — only auto-retry clones, never a fresh run.
|
||
-- * escalation_for_task_id IS NULL — assignee-fallback escalations own their
|
||
-- fire_at lifecycle and are SUPPOSED to coexist with an active primary.
|
||
-- * channel-media pending rows are excluded for the same reason: that deferred
|
||
-- row is the issue's own task waiting on media, not a superseded retry.
|
||
-- * issue_id IS NOT NULL — chat / quick-create tasks have no slot semantics.
|
||
--
|
||
-- 'running' and 'waiting_local_directory' count as superseding: the duplicate
|
||
-- fires precisely when the rerun has STARTED, which is when the row would
|
||
-- otherwise no longer look blocked.
|
||
UPDATE agent_task_queue r
|
||
SET status = 'cancelled', completed_at = now(), prepare_lease_expires_at = NULL
|
||
WHERE r.runtime_id = ANY(@runtime_ids::uuid[])
|
||
AND r.status = 'deferred'
|
||
AND r.issue_id IS NOT NULL
|
||
AND r.retry_of_task_id IS NOT NULL
|
||
AND r.escalation_for_task_id IS NULL
|
||
AND COALESCE(r.context->>'channel_issue_media_pending', '') <> 'true'
|
||
AND EXISTS (
|
||
SELECT 1 FROM agent_task_queue successor
|
||
WHERE successor.issue_id = r.issue_id
|
||
AND successor.agent_id = r.agent_id
|
||
AND successor.id <> r.id
|
||
AND successor.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
||
)
|
||
RETURNING *;
|
||
|
||
-- name: PromoteDueDeferredTasksForRuntime :many
|
||
-- Promotion is fenced against the single queued/dispatched slot
|
||
-- idx_one_pending_task_per_issue_agent_v2 allows per (issue, agent). A deferred
|
||
-- row is NOT covered by that index, so it can legitimately coexist with a queued
|
||
-- one — a manual rerun enqueued behind a running task, plus the deferred retry
|
||
-- that task's failure armed (runtime_offline, provider_network's final attempt).
|
||
-- Flipping such a row to 'queued' unconditionally violates the index, and because
|
||
-- the claim loop promotes before it claims, that error blocked every claim on the
|
||
-- runtime — including the rerun the operator was waiting for.
|
||
--
|
||
-- Two fences: skip a row whose (issue, agent) slot is already occupied, and
|
||
-- promote at most ONE row per (issue, agent) so a single statement cannot collide
|
||
-- with itself. A skipped row stays deferred with fire_at in the past and is
|
||
-- promoted by a later tick once the slot frees, so nothing is lost — the human's
|
||
-- rerun simply goes first. Chat / quick-create rows (issue_id NULL) are outside
|
||
-- the index and bypass both fences.
|
||
WITH due AS (
|
||
SELECT t.id,
|
||
t.issue_id,
|
||
row_number() OVER (
|
||
PARTITION BY t.issue_id, t.agent_id
|
||
ORDER BY t.priority DESC, t.created_at ASC, t.id
|
||
) AS rn
|
||
FROM agent_task_queue t
|
||
WHERE t.runtime_id = @runtime_id
|
||
AND t.status = 'deferred'
|
||
AND t.fire_at <= now()
|
||
AND EXISTS (
|
||
SELECT 1 FROM agent_runtime r
|
||
WHERE r.id = t.runtime_id
|
||
AND r.status = 'online'
|
||
AND COALESCE(r.last_seen_at, r.updated_at) >=
|
||
now() - make_interval(secs => @runtime_stale_secs::double precision)
|
||
)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM agent_task_queue occupant
|
||
WHERE occupant.issue_id = t.issue_id
|
||
AND occupant.agent_id = t.agent_id
|
||
AND occupant.id <> t.id
|
||
AND (
|
||
occupant.status IN ('queued', 'dispatched')
|
||
OR (occupant.status = 'deferred' AND occupant.context->>'channel_issue_media_pending' = 'true')
|
||
)
|
||
)
|
||
)
|
||
UPDATE agent_task_queue
|
||
SET status = 'queued'
|
||
WHERE id IN (SELECT id FROM due WHERE issue_id IS NULL OR rn = 1)
|
||
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 atq.* FROM agent_task_queue atq
|
||
WHERE atq.runtime_id = ANY(@runtime_ids::uuid[])
|
||
AND atq.status = 'queued'
|
||
AND EXISTS (
|
||
-- Keep this authorization fence in sync with ClaimAgentTask.
|
||
SELECT 1
|
||
FROM agent a
|
||
JOIN agent_runtime r ON r.id = atq.runtime_id
|
||
WHERE a.id = atq.agent_id
|
||
AND a.runtime_id = atq.runtime_id
|
||
AND (
|
||
r.visibility = 'public'
|
||
OR (
|
||
r.visibility = 'private'
|
||
AND (
|
||
r.owner_id IS NULL
|
||
OR a.owner_id IS NULL
|
||
OR r.owner_id = a.owner_id
|
||
)
|
||
)
|
||
)
|
||
)
|
||
ORDER BY atq.priority DESC, atq.created_at ASC;
|
||
|
||
-- name: PromoteDueDeferredTasksForRuntimes :many
|
||
-- Batch variant of PromoteDueDeferredTasksForRuntime (MUL-4257): promotes all
|
||
-- due deferred tasks across the runtime set in one UPDATE. Carries the same two
|
||
-- fences as the singular query; see its comment for why.
|
||
WITH due AS (
|
||
SELECT t.id,
|
||
t.issue_id,
|
||
row_number() OVER (
|
||
PARTITION BY t.issue_id, t.agent_id
|
||
ORDER BY t.priority DESC, t.created_at ASC, t.id
|
||
) AS rn
|
||
FROM agent_task_queue t
|
||
WHERE t.runtime_id = ANY(@runtime_ids::uuid[])
|
||
AND t.status = 'deferred'
|
||
AND t.fire_at <= now()
|
||
AND EXISTS (
|
||
SELECT 1 FROM agent_runtime r
|
||
WHERE r.id = t.runtime_id
|
||
AND r.status = 'online'
|
||
AND COALESCE(r.last_seen_at, r.updated_at) >=
|
||
now() - make_interval(secs => @runtime_stale_secs::double precision)
|
||
)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM agent_task_queue occupant
|
||
WHERE occupant.issue_id = t.issue_id
|
||
AND occupant.agent_id = t.agent_id
|
||
AND occupant.id <> t.id
|
||
AND (
|
||
occupant.status IN ('queued', 'dispatched')
|
||
OR (occupant.status = 'deferred' AND occupant.context->>'channel_issue_media_pending' = 'true')
|
||
)
|
||
)
|
||
)
|
||
UPDATE agent_task_queue
|
||
SET status = 'queued'
|
||
WHERE id IN (SELECT id FROM due WHERE issue_id IS NULL OR rn = 1)
|
||
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: ListActiveTasksByIssueFamily :many
|
||
-- Cross-issue coordination read for parallel sub-issue work (#7768). Given a
|
||
-- family root — the target issue's parent, or the target itself when it has
|
||
-- none — return every in-flight task on the root and on all of its children,
|
||
-- so a run can see who else is already working in the family before it starts
|
||
-- overlapping work. Advisory only: nothing here gates, queues, or serialises
|
||
-- anything.
|
||
--
|
||
-- Same active set as ListActiveTasksByIssue, including 'queued': a queued
|
||
-- sibling cannot answer you yet, but it is about to touch the same code, which
|
||
-- is exactly what the caller is trying to find out. The status column tells the
|
||
-- two apart.
|
||
--
|
||
-- Issue identity is joined in because the caller renders runs from several
|
||
-- issues in one list and cannot label a row from the task alone. agent_id is
|
||
-- here for the same reason: this read spans agents, and which one is on a
|
||
-- sibling is the answer, not a detail.
|
||
--
|
||
-- Columns are named rather than embedded. This is the coordination question,
|
||
-- not the execution log: result and context are JSONB blobs, and work_dir /
|
||
-- trigger_summary / the attribution ids are all execution-log fields that a
|
||
-- caller asking "who else is here?" never reads. Selecting them would make
|
||
-- Postgres detoast and ship roughly 5x the bytes per row for nothing.
|
||
--
|
||
-- Ordered running-first so the truncation the LIMIT may impose drops the least
|
||
-- interesting rows, and bounded because a parent with hundreds of children must
|
||
-- not turn one coordination read into an unbounded scan.
|
||
SELECT
|
||
atq.id AS task_id,
|
||
atq.agent_id,
|
||
atq.issue_id,
|
||
atq.status,
|
||
atq.created_at,
|
||
atq.started_at,
|
||
w.issue_prefix,
|
||
i.number AS issue_number,
|
||
i.title AS issue_title
|
||
FROM agent_task_queue atq
|
||
JOIN issue i ON i.id = atq.issue_id
|
||
JOIN workspace w ON w.id = i.workspace_id
|
||
WHERE i.workspace_id = @workspace_id
|
||
AND (i.id = @root_issue_id::uuid OR i.parent_issue_id = @root_issue_id::uuid)
|
||
AND atq.status IN ('queued', 'dispatched', 'running', 'waiting_local_directory')
|
||
ORDER BY
|
||
CASE atq.status
|
||
WHEN 'running' THEN 0
|
||
WHEN 'dispatched' THEN 1
|
||
WHEN 'waiting_local_directory' THEN 2
|
||
ELSE 3
|
||
END,
|
||
atq.created_at DESC
|
||
LIMIT @row_limit;
|
||
|
||
-- 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 the front-end reads off one workspace-wide snapshot:
|
||
-- - All active tasks (queued / dispatched / running / waiting_local_directory)
|
||
-- — the current workload: working signal + counts (see derive-presence.ts).
|
||
-- - Each agent's most recent OUTCOME task (completed / failed) — NOT part of
|
||
-- presence since #1823; it is the "last activity" line the Squad hover card
|
||
-- renders (agent-live-peek-card.tsx). Kept in this response because shipped
|
||
-- desktop builds read it from here; see MUL-5436 for the plan to move it to
|
||
-- a dedicated lazy endpoint.
|
||
--
|
||
-- 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 failure stays
|
||
-- the agent's last real outcome.
|
||
--
|
||
-- The outcome half is a per-agent LATERAL Top-1 rather than a workspace-wide
|
||
-- DISTINCT ON: with idx_agent_task_queue_agent_terminal_latest (migration 233)
|
||
-- each agent costs one ordered index scan + LIMIT 1, so the cost no longer
|
||
-- grows with how much terminal history the workspace has accumulated. The
|
||
-- (created_at, id) tie-break makes the pick deterministic when completed_at
|
||
-- ties or is NULL — plain completed_at DESC returned an arbitrary row there.
|
||
-- Row shape and row set are unchanged.
|
||
--
|
||
-- Both halves JOIN / scan agent because agent_task_queue has no workspace_id.
|
||
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 latest.* FROM agent a
|
||
JOIN LATERAL (
|
||
SELECT atq.*
|
||
FROM agent_task_queue atq
|
||
WHERE atq.agent_id = a.id
|
||
AND atq.status IN ('completed', 'failed')
|
||
ORDER BY atq.completed_at DESC NULLS LAST, atq.created_at DESC, atq.id DESC
|
||
LIMIT 1
|
||
) latest ON TRUE
|
||
WHERE a.workspace_id = $1;
|
||
|
||
-- name: ListWorkspaceWorkingAgents :many
|
||
-- Workspace-level source for consumers that show currently working agents.
|
||
-- One row per visible, user-authored agent with at least one task that has
|
||
-- actually started running. work_type is optional (empty = every source);
|
||
-- source-specific reads use the same precedence as computeTaskKind:
|
||
-- chat > autopilot > issue. "issue" intentionally groups direct and
|
||
-- comment-triggered issue work. Quick-create work is present only in the
|
||
-- unfiltered projection because it has no source FK yet. mine_relation is
|
||
-- optional (empty = workspace); when set it narrows issue work to the
|
||
-- authenticated member's My Issues relation. parent_issue_id is optional
|
||
-- (NULL = workspace); when set it narrows issue work to the direct children
|
||
-- of that issue, so an issue detail's sub-issue header reads the same
|
||
-- projection as the Issues list header instead of deriving its own count.
|
||
SELECT
|
||
a.id,
|
||
a.name,
|
||
a.avatar_url,
|
||
COUNT(*)::int AS running_task_count,
|
||
COALESCE(
|
||
ARRAY_AGG(DISTINCT atq.issue_id ORDER BY atq.issue_id)
|
||
FILTER (WHERE atq.issue_id IS NOT NULL),
|
||
ARRAY[]::uuid[]
|
||
)::uuid[] AS issue_ids
|
||
FROM agent a
|
||
JOIN agent_task_queue atq ON atq.agent_id = a.id
|
||
WHERE a.workspace_id = $1
|
||
AND a.kind = 'user'
|
||
AND a.archived_at IS NULL
|
||
AND atq.status = 'running'
|
||
AND (
|
||
@work_type::text = ''
|
||
OR (@work_type::text = 'chat' AND atq.chat_session_id IS NOT NULL)
|
||
OR (
|
||
@work_type::text = 'autopilot'
|
||
AND atq.chat_session_id IS NULL
|
||
AND atq.autopilot_run_id IS NOT NULL
|
||
)
|
||
OR (
|
||
@work_type::text = 'issue'
|
||
AND atq.chat_session_id IS NULL
|
||
AND atq.autopilot_run_id IS NULL
|
||
AND atq.issue_id IS NOT NULL
|
||
)
|
||
)
|
||
AND (
|
||
@mine_relation::text = ''
|
||
OR EXISTS (
|
||
SELECT 1
|
||
FROM issue i
|
||
WHERE i.id = atq.issue_id
|
||
AND i.workspace_id = a.workspace_id
|
||
AND (
|
||
(
|
||
@mine_relation::text IN ('assigned', 'any')
|
||
AND i.assignee_type = 'member'
|
||
AND i.assignee_id = @member_id::uuid
|
||
)
|
||
OR (
|
||
@mine_relation::text IN ('created', 'any')
|
||
AND i.creator_type = 'member'
|
||
AND i.creator_id = @member_id::uuid
|
||
)
|
||
OR (
|
||
@mine_relation::text IN ('involved', 'any')
|
||
AND (
|
||
(
|
||
i.assignee_type = 'agent'
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM agent owned_agent
|
||
WHERE owned_agent.id = i.assignee_id
|
||
AND owned_agent.workspace_id = a.workspace_id
|
||
AND owned_agent.owner_id = @member_id::uuid
|
||
)
|
||
)
|
||
OR (
|
||
i.assignee_type = 'squad'
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM squad s
|
||
WHERE s.id = i.assignee_id
|
||
AND s.workspace_id = a.workspace_id
|
||
AND (
|
||
EXISTS (
|
||
SELECT 1
|
||
FROM squad_member sm
|
||
WHERE sm.squad_id = s.id
|
||
AND sm.member_type = 'member'
|
||
AND sm.member_id = @member_id::uuid
|
||
)
|
||
OR EXISTS (
|
||
SELECT 1
|
||
FROM agent leader
|
||
WHERE leader.id = s.leader_id
|
||
AND leader.workspace_id = a.workspace_id
|
||
AND leader.owner_id = @member_id::uuid
|
||
)
|
||
OR EXISTS (
|
||
SELECT 1
|
||
FROM squad_member sm
|
||
JOIN agent owned_member ON owned_member.id = sm.member_id
|
||
WHERE sm.squad_id = s.id
|
||
AND sm.member_type = 'agent'
|
||
AND owned_member.workspace_id = a.workspace_id
|
||
AND owned_member.owner_id = @member_id::uuid
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
AND (
|
||
@parent_issue_id::uuid IS NULL
|
||
OR EXISTS (
|
||
SELECT 1
|
||
FROM issue child
|
||
WHERE child.id = atq.issue_id
|
||
AND child.workspace_id = a.workspace_id
|
||
AND child.parent_issue_id = @parent_issue_id::uuid
|
||
)
|
||
)
|
||
GROUP BY a.id, a.name, a.avatar_url, a.created_at
|
||
ORDER BY a.created_at ASC;
|
||
|
||
-- 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
|
||
-- Persisted agent.status has no queued/resource-wait bucket. Keep dispatched
|
||
-- as working because the daemon is actively preparing that task, but do not
|
||
-- let a task parked on a local_directory mutex masquerade as executing work.
|
||
-- The status guard makes a correct status a no-op: :one returns no rows, so
|
||
-- callers neither rewrite updated_at nor publish a redundant status event.
|
||
WITH desired AS (
|
||
SELECT CASE WHEN EXISTS (
|
||
SELECT 1 FROM agent_task_queue q
|
||
WHERE q.agent_id = $1 AND q.status IN ('dispatched', 'running')
|
||
) THEN 'working' ELSE 'idle' END AS status
|
||
)
|
||
UPDATE agent AS a
|
||
SET status = desired.status,
|
||
updated_at = now()
|
||
FROM desired
|
||
WHERE a.id = $1 AND a.status IS DISTINCT FROM desired.status
|
||
RETURNING a.*;
|
||
|
||
-- name: GetAgentBySystemKey :one
|
||
-- Resolves a workspace's built-in agent by its stable system_key. This is the
|
||
-- identity lookup for system agents: their display name is owner-editable, so
|
||
-- nothing server-side may key off it.
|
||
SELECT * FROM agent
|
||
WHERE workspace_id = $1 AND system_key = $2 AND archived_at IS NULL
|
||
ORDER BY created_at ASC, id ASC
|
||
LIMIT 1;
|
||
|
||
-- name: CreateSystemUserAgent :one
|
||
-- Creates a product-defined agent that members can still see, chat with, and
|
||
-- assign issues to. Deliberately kind='user': kind='system' hides the row from
|
||
-- agent lists and assignment surfaces and hard deletes it with its runtime.
|
||
-- Every product-owned field is a server constant; instructions stays empty
|
||
-- because the system half ships with the binary and is layered in at claim
|
||
-- time, leaving this column free for the workspace's own notes.
|
||
--
|
||
-- CONTRACT: call only while holding the per-workspace mika advisory lock. The
|
||
-- one-per-workspace invariant rests on a check inside that lock, not on a
|
||
-- unique index — migration 172's index keys on (workspace_id, owner_id,
|
||
-- runtime_id, system_key), so two different owners or runtimes are distinct
|
||
-- tuples and would both insert.
|
||
INSERT INTO agent (
|
||
workspace_id, name, description, avatar_url, runtime_mode, runtime_config,
|
||
runtime_id, model, visibility, permission_mode, max_concurrent_tasks,
|
||
owner_id, instructions, custom_env, custom_args, kind, system_key
|
||
) VALUES (
|
||
@workspace_id, @name, @description, @avatar_url, @runtime_mode, '{}'::jsonb,
|
||
@runtime_id, @model, @visibility, @permission_mode, @max_concurrent_tasks,
|
||
@owner_id, '', '{}'::jsonb, '[]'::jsonb, 'user', @system_key
|
||
)
|
||
RETURNING *;
|