mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* fix(autopilot): settle create_issue runs by first task outcome, not issue blocked (MUL-8200) Co-authored-by: multica-agent <[email protected]> * fix(autopilot): settle create_issue runs only from their own dispatch chain (MUL-8200 review) Co-authored-by: multica-agent <[email protected]> * fix(autopilot): root create_issue dispatch chain at the recorded task and let its outcome win (MUL-8200 re-review) Co-authored-by: multica-agent <[email protected]> * fix(autopilot): resolve unlinked create_issue dispatch by agent and wait for pending enqueue (MUL-8200 review) Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]>
829 lines
33 KiB
SQL
829 lines
33 KiB
SQL
-- =====================
|
|
-- Autopilot CRUD
|
|
-- =====================
|
|
|
|
-- name: ListAutopilots :many
|
|
-- List rows carry three derived columns the list UI needs (trigger badges,
|
|
-- next run, last-run outcome) so the page never has to N+1 into the detail
|
|
-- endpoint. trigger_kinds/next_run_at only consider ENABLED triggers — the
|
|
-- columns answer "how does this fire today", not "what is configured".
|
|
-- last_run_status is COALESCEd to '' (never ran) because sqlc cannot infer
|
|
-- nullability through a scalar subquery; the handler maps '' back to omitted.
|
|
SELECT
|
|
sqlc.embed(a),
|
|
(
|
|
SELECT array_agg(DISTINCT t.kind ORDER BY t.kind)
|
|
FROM autopilot_trigger t
|
|
WHERE t.autopilot_id = a.id AND t.enabled
|
|
)::text[] AS trigger_kinds,
|
|
(
|
|
SELECT min(t.next_run_at)
|
|
FROM autopilot_trigger t
|
|
WHERE t.autopilot_id = a.id AND t.enabled AND t.kind = 'schedule'
|
|
)::timestamptz AS next_run_at,
|
|
COALESCE((
|
|
SELECT r.status
|
|
FROM autopilot_run r
|
|
WHERE r.autopilot_id = a.id
|
|
ORDER BY r.triggered_at DESC
|
|
LIMIT 1
|
|
), '')::text AS last_run_status
|
|
FROM autopilot a
|
|
WHERE a.workspace_id = $1
|
|
AND (
|
|
(sqlc.narg('status')::text IS NULL AND a.status <> 'archived')
|
|
OR a.status = sqlc.narg('status')
|
|
)
|
|
ORDER BY a.created_at DESC;
|
|
|
|
-- name: GetAutopilot :one
|
|
SELECT * FROM autopilot
|
|
WHERE id = $1;
|
|
|
|
-- name: GetAutopilotInWorkspace :one
|
|
SELECT * FROM autopilot
|
|
WHERE id = $1 AND workspace_id = $2;
|
|
|
|
-- name: LockAutopilotForUpdate :one
|
|
-- UpdateAutopilot is a patch assembled from a pre-transaction snapshot. Lock
|
|
-- and compare that row before applying the patch so a concurrent retarget or
|
|
-- Runtime teardown cannot be overwritten by stale assignee/status fields.
|
|
SELECT * FROM autopilot
|
|
WHERE id = $1 AND workspace_id = $2
|
|
FOR UPDATE;
|
|
|
|
-- name: CreateAutopilot :one
|
|
INSERT INTO autopilot (
|
|
workspace_id, title, description, assignee_type, assignee_id,
|
|
status, execution_mode, issue_title_template, project_id,
|
|
created_by_type, created_by_id
|
|
) VALUES (
|
|
$1, $2, sqlc.narg('description'), $3, $4,
|
|
$5, $6, sqlc.narg('issue_title_template'), sqlc.narg('project_id'),
|
|
$7, $8
|
|
) RETURNING *;
|
|
|
|
-- name: UpdateAutopilot :one
|
|
UPDATE autopilot SET
|
|
title = COALESCE(sqlc.narg('title'), title),
|
|
description = COALESCE(sqlc.narg('description'), description),
|
|
assignee_type = COALESCE(sqlc.narg('assignee_type'), assignee_type),
|
|
assignee_id = COALESCE(sqlc.narg('assignee_id')::uuid, assignee_id),
|
|
status = COALESCE(sqlc.narg('status'), status),
|
|
pause_reason = CASE
|
|
WHEN sqlc.narg('status')::text IS NOT NULL THEN NULL
|
|
ELSE pause_reason
|
|
END,
|
|
execution_mode = COALESCE(sqlc.narg('execution_mode'), execution_mode),
|
|
issue_title_template = sqlc.narg('issue_title_template'),
|
|
project_id = sqlc.narg('project_id'),
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: ArchiveAutopilot :exec
|
|
UPDATE autopilot
|
|
SET status = 'archived', pause_reason = NULL, updated_at = now()
|
|
WHERE id = $1;
|
|
|
|
-- name: PauseAutopilotsByUnboundAgents :many
|
|
-- A runtime delete is a persistent admission failure, not a per-tick event.
|
|
-- Pause direct-agent automations and squad automations whose leader was
|
|
-- unbound, preserving the full configuration for an explicit resume after
|
|
-- rebind. Restrict to active so repeated teardown/retry is idempotent.
|
|
UPDATE autopilot a
|
|
SET status = 'paused',
|
|
pause_reason = 'agent_runtime_required',
|
|
updated_at = now()
|
|
WHERE a.status = 'active'
|
|
AND (
|
|
(a.assignee_type = 'agent' AND a.assignee_id = ANY(@agent_ids::uuid[]))
|
|
OR (
|
|
a.assignee_type = 'squad'
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM squad s
|
|
WHERE s.id = a.assignee_id
|
|
AND s.leader_id = ANY(@agent_ids::uuid[])
|
|
)
|
|
)
|
|
)
|
|
RETURNING a.*;
|
|
|
|
-- name: PauseAutopilotsByUnrunnableSquad :many
|
|
-- Rotating a squad to an already-unbound leader has the same persistent
|
|
-- admission failure as Runtime teardown. Pause only automations assigned to
|
|
-- this squad; direct automations targeting that Agent are unrelated.
|
|
UPDATE autopilot
|
|
SET status = 'paused',
|
|
pause_reason = 'agent_runtime_required',
|
|
updated_at = now()
|
|
WHERE status = 'active'
|
|
AND assignee_type = 'squad'
|
|
AND assignee_id = @squad_id
|
|
RETURNING *;
|
|
|
|
-- name: UpdateAutopilotLastRunAt :exec
|
|
UPDATE autopilot SET last_run_at = now(), updated_at = now()
|
|
WHERE id = $1;
|
|
|
|
-- =====================
|
|
-- Autopilot Rule Version (rule_owner attribution, MUL-4302 §3.4)
|
|
-- =====================
|
|
|
|
-- name: CreateAutopilotRuleVersion :one
|
|
-- Append one immutable rule-version snapshot on a substantive publish (create /
|
|
-- enable / resume / target / execution-mode change). published_by_* is the acting
|
|
-- member (or 'system' with NULL id for the failure monitor); config_summary is the
|
|
-- effective config at publish time. Dispatch reads the latest row for the autopilot
|
|
-- as the run's rule_owner accountable human.
|
|
INSERT INTO autopilot_rule_version (
|
|
autopilot_id, workspace_id, published_by_type, published_by_id, config_summary
|
|
)
|
|
VALUES (
|
|
@autopilot_id, @workspace_id, @published_by_type, sqlc.narg(published_by_id),
|
|
COALESCE(sqlc.narg(config_summary), '{}'::jsonb)
|
|
)
|
|
RETURNING *;
|
|
|
|
-- name: GetActiveAutopilotRuleVersion :one
|
|
-- The active version is the newest published row for the autopilot. Scoped by
|
|
-- workspace_id per the workspace query rule; autopilot_id is globally unique so the
|
|
-- workspace filter is a guard, not the selector.
|
|
SELECT * FROM autopilot_rule_version
|
|
WHERE workspace_id = $1 AND autopilot_id = $2
|
|
ORDER BY created_at DESC
|
|
LIMIT 1;
|
|
|
|
-- =====================
|
|
-- Autopilot Trigger CRUD
|
|
-- =====================
|
|
|
|
-- name: ListAutopilotTriggers :many
|
|
SELECT * FROM autopilot_trigger
|
|
WHERE autopilot_id = $1
|
|
ORDER BY created_at ASC;
|
|
|
|
-- name: GetAutopilotTrigger :one
|
|
SELECT * FROM autopilot_trigger
|
|
WHERE id = $1;
|
|
|
|
-- name: GetAutopilotTriggerForAutopilot :one
|
|
-- Trigger lookup BOUND to both the autopilot it must belong to AND that
|
|
-- autopilot's workspace. Since MUL-6951 the trigger row decides which human a run
|
|
-- acts as, so an unbound `WHERE id = $1` would let a trigger id from another
|
|
-- autopilot select the principal. The workspace join closes the other half: the
|
|
-- caller's membership check proves the resolved human belongs to the workspace it
|
|
-- passed, not that the AUTOPILOT does, and a member of two workspaces would
|
|
-- satisfy the former while the trigger came from the other tenant. Callers
|
|
-- resolving an authorization principal must use this, not GetAutopilotTrigger.
|
|
SELECT t.* FROM autopilot_trigger t
|
|
JOIN autopilot a ON a.id = t.autopilot_id
|
|
WHERE t.id = $1 AND t.autopilot_id = $2 AND a.workspace_id = $3;
|
|
|
|
-- name: CreateAutopilotTrigger :one
|
|
INSERT INTO autopilot_trigger (
|
|
autopilot_id, kind, enabled, cron_expression, timezone,
|
|
next_run_at, webhook_token, label, provider, event_filters,
|
|
published_by_type, published_by_id,
|
|
created_by_type, created_by_id
|
|
) VALUES (
|
|
$1, $2, $3, sqlc.narg('cron_expression'), sqlc.narg('timezone'),
|
|
sqlc.narg('next_run_at'), sqlc.narg('webhook_token'), sqlc.narg('label'),
|
|
COALESCE(sqlc.narg('provider')::text, 'generic'),
|
|
sqlc.narg('event_filters'),
|
|
sqlc.narg('published_by_type'), sqlc.narg('published_by_id'),
|
|
sqlc.narg('created_by_type'), sqlc.narg('created_by_id')
|
|
) RETURNING *;
|
|
|
|
-- name: SetAutopilotTriggerPublisher :exec
|
|
-- Re-stamp a single trigger's responsible publisher after a substantive edit of
|
|
-- THAT trigger (cron / filter / enabled / webhook security), recording who is now
|
|
-- responsible for its config (MUL-4302).
|
|
--
|
|
-- Since MUL-6951 this changes NOTHING about the runs it fires: they act as, and
|
|
-- are accountable to, the trigger's immutable created_by. An edit must not be able
|
|
-- to re-authorize the automation as the editor (Bohan's ruling), so this statement
|
|
-- deliberately does not touch created_by, and published_by is now a config-audit
|
|
-- column only.
|
|
UPDATE autopilot_trigger
|
|
SET published_by_type = $2, published_by_id = $3, updated_at = now()
|
|
WHERE id = $1;
|
|
|
|
-- name: SetAutopilotTriggerPublishersByAutopilot :exec
|
|
-- Re-stamp ALL of an autopilot's triggers' responsible publisher after a substantive
|
|
-- AUTOPILOT-level edit (target / instructions / assignee / execution-mode / enable).
|
|
-- Such a change governs every trigger's future runs, so responsibility transfers to
|
|
-- the editing member for all of them; a per-trigger edit uses the single-trigger
|
|
-- variant so it never reassigns another trigger (MUL-4302).
|
|
UPDATE autopilot_trigger
|
|
SET published_by_type = $2, published_by_id = $3, updated_at = now()
|
|
WHERE autopilot_id = $1;
|
|
|
|
-- name: UpdateAutopilotTrigger :one
|
|
UPDATE autopilot_trigger SET
|
|
enabled = COALESCE(sqlc.narg('enabled')::boolean, enabled),
|
|
cron_expression = COALESCE(sqlc.narg('cron_expression'), cron_expression),
|
|
timezone = COALESCE(sqlc.narg('timezone'), timezone),
|
|
next_run_at = sqlc.narg('next_run_at'),
|
|
label = COALESCE(sqlc.narg('label'), label),
|
|
event_filters = COALESCE(sqlc.narg('event_filters'), event_filters),
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: DeleteAutopilotTrigger :exec
|
|
DELETE FROM autopilot_trigger WHERE id = $1;
|
|
|
|
-- name: AdvanceTriggerNextRun :exec
|
|
UPDATE autopilot_trigger
|
|
SET next_run_at = sqlc.narg('next_run_at'),
|
|
last_fired_at = now(),
|
|
updated_at = now()
|
|
WHERE id = $1;
|
|
|
|
-- name: GetWebhookTriggerByToken :one
|
|
-- Look up a webhook trigger by its public bearer token. Joined to autopilot
|
|
-- so the webhook handler can derive the workspace from the trigger's parent
|
|
-- without trusting any request header. The handler still re-loads the
|
|
-- Autopilot via GetAutopilot and cross-checks WorkspaceID matches the row's
|
|
-- autopilot_workspace_id.
|
|
SELECT t.*, a.workspace_id AS autopilot_workspace_id
|
|
FROM autopilot_trigger t
|
|
JOIN autopilot a ON a.id = t.autopilot_id
|
|
WHERE t.kind = 'webhook'
|
|
AND t.webhook_token = $1;
|
|
|
|
-- name: TouchAutopilotTriggerFiredAt :exec
|
|
-- Bumps last_fired_at after a webhook fires, regardless of whether the
|
|
-- dispatch succeeded, was admission-skipped, or even if Autopilot status
|
|
-- transitioned to paused/disabled at exactly the wrong moment. Disabled /
|
|
-- paused early-return paths in the handler never call this.
|
|
UPDATE autopilot_trigger
|
|
SET last_fired_at = now(),
|
|
updated_at = now()
|
|
WHERE id = $1;
|
|
|
|
-- name: RotateAutopilotTriggerWebhookToken :one
|
|
-- Rotates the bearer token for a webhook trigger. Restricted to kind='webhook'
|
|
-- so an accidental call against a schedule/api trigger is a no-op (returns no
|
|
-- rows) rather than corrupting unrelated state.
|
|
UPDATE autopilot_trigger
|
|
SET webhook_token = $2,
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
AND kind = 'webhook'
|
|
RETURNING *;
|
|
|
|
-- name: SetAutopilotTriggerWebhookToken :one
|
|
-- Sets the webhook token at creation time. CreateAutopilotTrigger inserts the
|
|
-- row first (using its full 8-arg signature), then this query attaches the
|
|
-- token. Splitting the create + token-set keeps the existing CreateAutopilotTrigger
|
|
-- query usable by the schedule path without forcing every caller to think
|
|
-- about webhook_token.
|
|
UPDATE autopilot_trigger
|
|
SET webhook_token = $2,
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: SetAutopilotTriggerSigningSecret :one
|
|
-- Writes the signing secret for a webhook trigger. Kept as a dedicated query
|
|
-- (not a field on UpdateAutopilotTrigger) so the request body for the
|
|
-- write-only endpoint only ever carries the secret value, with no risk of an
|
|
-- accidental log line leaking it alongside other fields. Restricted to
|
|
-- webhook triggers to avoid corrupting unrelated state.
|
|
UPDATE autopilot_trigger
|
|
SET signing_secret = sqlc.narg('signing_secret'),
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
AND kind = 'webhook'
|
|
RETURNING *;
|
|
|
|
-- =====================
|
|
-- Autopilot Run Management
|
|
-- =====================
|
|
|
|
-- name: CreateAutopilotRun :one
|
|
-- squad_id is an attribution hook: set to the assignee squad when the
|
|
-- parent autopilot has assignee_type='squad', NULL otherwise. The executing
|
|
-- agent_id on agent_task_queue still records who actually ran the work
|
|
-- (the squad leader); squad_id lets reports group by squad without a join.
|
|
--
|
|
-- planned_at carries the canonical UTC fire time for scheduled triggers
|
|
-- (source='schedule'); it stays NULL for manual / webhook / api sources
|
|
-- which have no canonical occurrence. Combined with the partial unique
|
|
-- index uq_autopilot_run_trigger_planned, this gives dispatch-layer
|
|
-- idempotency: a stale-steal retry at the same plan_time cannot create
|
|
-- a second run for the same (trigger_id, planned_at) pair (MUL-3551).
|
|
INSERT INTO autopilot_run (
|
|
autopilot_id, trigger_id, source, status, trigger_payload, squad_id, planned_at,
|
|
webhook_delivery_id, quota_reservation_id, reason_code, id
|
|
) VALUES (
|
|
$1, sqlc.narg('trigger_id'), $2, $3, sqlc.narg('trigger_payload'),
|
|
sqlc.narg('squad_id'), sqlc.narg('planned_at'),
|
|
sqlc.narg('webhook_delivery_id'), sqlc.narg('quota_reservation_id'),
|
|
sqlc.narg('reason_code'), COALESCE(sqlc.narg('id')::uuid, gen_random_uuid())
|
|
) RETURNING *;
|
|
|
|
-- name: GetAutopilotRunByTriggerAndPlanned :one
|
|
-- Idempotent lookup used by DispatchAutopilotForPlan to detect a
|
|
-- crash-during-dispatch retry: if a row already exists for this
|
|
-- (trigger_id, planned_at), the caller reuses it instead of creating a
|
|
-- duplicate. The partial unique index covers the same key, so a race
|
|
-- between "look up then insert" still resolves to a single row — this
|
|
-- query is just the fast path that lets us skip the INSERT when we
|
|
-- can see the prior row clearly. Returns no rows for the (much more
|
|
-- common) first-time dispatch.
|
|
SELECT * FROM autopilot_run
|
|
WHERE trigger_id = $1
|
|
AND planned_at = $2
|
|
LIMIT 1;
|
|
|
|
-- name: GetAutopilotRunByWebhookDelivery :one
|
|
SELECT * FROM autopilot_run
|
|
WHERE webhook_delivery_id = $1
|
|
LIMIT 1;
|
|
|
|
-- name: GetAutopilotRunByQuotaReservation :one
|
|
SELECT * FROM autopilot_run
|
|
WHERE quota_reservation_id = $1
|
|
LIMIT 1;
|
|
|
|
-- name: RecoverPartialAutopilotRun :one
|
|
-- Recovers a partial-state autopilot_run from a crashed first attempt
|
|
-- (the runner wrote the run row but died before creating the downstream
|
|
-- issue/task) so that a subsequent DispatchAutopilotForPlan call can
|
|
-- create a fresh run at the same (trigger_id, planned_at).
|
|
--
|
|
-- Setting planned_at = NULL clears the partial-unique slot held by
|
|
-- uq_autopilot_run_trigger_planned, letting the new INSERT proceed.
|
|
-- The row stays in autopilot_run as a FAILED record (with a recovery
|
|
-- reason) so ops still see the abandoned attempt in the run history —
|
|
-- it is not silently deleted.
|
|
WITH updated_run AS (
|
|
UPDATE autopilot_run AS ar
|
|
SET status = 'failed',
|
|
completed_at = now(),
|
|
failure_reason = 'recovered partial dispatch (crashed before downstream creation)',
|
|
reason_code = 'internal_error',
|
|
planned_at = NULL
|
|
WHERE ar.id = $1
|
|
AND (
|
|
ar.status = 'pending'
|
|
OR (ar.status = 'issue_created' AND ar.issue_id IS NULL)
|
|
OR (ar.status = 'running' AND ar.task_id IS NULL)
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM agent_task_queue task
|
|
WHERE task.autopilot_run_id = ar.id
|
|
)
|
|
RETURNING ar.quota_reservation_id
|
|
), locked_reservation AS MATERIALIZED (
|
|
SELECT qr.*
|
|
FROM autopilot_quota_reservation qr
|
|
JOIN updated_run ar ON ar.quota_reservation_id = qr.id
|
|
WHERE qr.state = 'reserved'
|
|
FOR UPDATE
|
|
), released_reservation AS (
|
|
UPDATE autopilot_quota_reservation AS qr
|
|
SET state = 'released', finalized_at = now()
|
|
FROM locked_reservation AS locked
|
|
WHERE qr.id = locked.id
|
|
AND EXISTS (
|
|
SELECT 1 FROM autopilot_quota_period p
|
|
WHERE p.workspace_id = locked.workspace_id
|
|
AND p.period_start = locked.period_start
|
|
AND p.period_end = locked.period_end
|
|
)
|
|
RETURNING locked.workspace_id, locked.period_start, locked.period_end
|
|
), settled_period AS (
|
|
UPDATE autopilot_quota_period AS p
|
|
SET reserved_count = reserved_count - 1,
|
|
updated_at = now()
|
|
FROM released_reservation AS released
|
|
WHERE p.workspace_id = released.workspace_id
|
|
AND p.period_start = released.period_start
|
|
AND p.period_end = released.period_end
|
|
RETURNING p.workspace_id
|
|
)
|
|
SELECT count(*)::bigint FROM updated_run;
|
|
|
|
-- name: GetAutopilotRun :one
|
|
SELECT * FROM autopilot_run
|
|
WHERE id = $1;
|
|
|
|
-- name: ListAutopilotRuns :many
|
|
SELECT * FROM autopilot_run
|
|
WHERE autopilot_id = $1
|
|
ORDER BY created_at DESC
|
|
LIMIT $2 OFFSET $3;
|
|
|
|
-- name: UpdateAutopilotRunIssueCreated :one
|
|
UPDATE autopilot_run
|
|
SET status = 'issue_created', issue_id = $2
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: SetAutopilotRunDispatchTask :execrows
|
|
-- Records the agent task a create_issue run actually enqueued, so lifecycle
|
|
-- sync can tell the run's own dispatch apart from mention/comment tasks on
|
|
-- the same issue. Status stays issue_created; only an unlinked active run is
|
|
-- written. (MUL-8200)
|
|
UPDATE autopilot_run
|
|
SET task_id = $2
|
|
WHERE id = $1 AND status = 'issue_created' AND task_id IS NULL;
|
|
|
|
-- name: UpdateAutopilotRunRunning :one
|
|
UPDATE autopilot_run
|
|
SET status = 'running', task_id = $2
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateAutopilotRunCompleted :one
|
|
-- Quota safety: only use for a run known to have no reservation. Normal
|
|
-- terminal paths must call UpdateAutopilotRunTerminalWithQuota instead.
|
|
UPDATE autopilot_run
|
|
SET status = 'completed', completed_at = now(), result = sqlc.narg('result')
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateAutopilotRunFailed :one
|
|
-- Quota safety: only use for a run known to have no reservation. Normal
|
|
-- terminal paths must call UpdateAutopilotRunTerminalWithQuota instead.
|
|
UPDATE autopilot_run
|
|
SET status = 'failed', completed_at = now(), failure_reason = $2,
|
|
reason_code = sqlc.narg('reason_code')
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateAutopilotRunSkipped :one
|
|
-- Quota safety: this is only for pre-admission skipped rows, which never have
|
|
-- reservations. Post-admission skips use UpdateAutopilotRunTerminalWithQuota.
|
|
-- Marks an autopilot_run as skipped without enqueueing any task. Used by the
|
|
-- pre-flight admission check when the assignee agent's runtime is offline:
|
|
-- creating an issue / task in that state would just pile a doomed job onto
|
|
-- agent_task_queue (the canonical "持续给离线 local agent 入队" symptom from
|
|
-- MUL-1899). Recording the skip + reason gives the UI / failure monitor / ops
|
|
-- a paper trail without polluting the failure ratio.
|
|
UPDATE autopilot_run
|
|
SET status = 'skipped', completed_at = now(), failure_reason = $2,
|
|
reason_code = sqlc.narg('reason_code')
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateAutopilotRunTerminalWithQuota :one
|
|
-- Finalizes a run and its still-reserved quota slot in one statement. Runs
|
|
-- created while the entitlement gate is off have a NULL reservation ID, so
|
|
-- the quota CTEs become empty without an extra BEGIN/COMMIT round trip.
|
|
-- Consumed slots are deliberately immutable: create_issue is chargeable once
|
|
-- the issue exists, even if that issue is later blocked, cancelled, or deleted.
|
|
-- The CTE sequence is: update the run, lock a still-reserved slot, finalize
|
|
-- that slot exactly once, then move one unit from reserved_count to either
|
|
-- used_count (consume) or nowhere (release). used_count never decreases.
|
|
WITH updated_run AS (
|
|
UPDATE autopilot_run AS ar
|
|
SET status = @terminal_status::text,
|
|
completed_at = now(),
|
|
result = CASE
|
|
WHEN @terminal_status::text = 'completed' THEN sqlc.narg('result')::jsonb
|
|
ELSE ar.result
|
|
END,
|
|
failure_reason = CASE
|
|
WHEN @terminal_status::text IN ('failed', 'skipped') THEN sqlc.narg('failure_reason')::text
|
|
ELSE ar.failure_reason
|
|
END,
|
|
reason_code = CASE
|
|
WHEN @terminal_status::text IN ('failed', 'skipped') THEN sqlc.narg('reason_code')::text
|
|
ELSE ar.reason_code
|
|
END
|
|
WHERE ar.id = @run_id
|
|
RETURNING ar.*
|
|
), locked_reservation AS MATERIALIZED (
|
|
SELECT qr.*
|
|
FROM autopilot_quota_reservation qr
|
|
JOIN updated_run ar ON ar.quota_reservation_id = qr.id
|
|
WHERE qr.state = 'reserved'
|
|
FOR UPDATE
|
|
), finalized_reservation AS (
|
|
UPDATE autopilot_quota_reservation AS qr
|
|
SET state = CASE WHEN @consume::boolean THEN 'consumed' ELSE 'released' END,
|
|
finalized_at = now()
|
|
FROM locked_reservation AS locked
|
|
WHERE qr.id = locked.id
|
|
AND EXISTS (
|
|
SELECT 1 FROM autopilot_quota_period p
|
|
WHERE p.workspace_id = locked.workspace_id
|
|
AND p.period_start = locked.period_start
|
|
AND p.period_end = locked.period_end
|
|
)
|
|
RETURNING locked.workspace_id, locked.period_start, locked.period_end
|
|
), settled_period AS (
|
|
UPDATE autopilot_quota_period AS p
|
|
SET reserved_count = reserved_count - 1,
|
|
used_count = used_count + CASE WHEN @consume::boolean THEN 1 ELSE 0 END,
|
|
updated_at = now()
|
|
FROM finalized_reservation AS finalized
|
|
WHERE p.workspace_id = finalized.workspace_id
|
|
AND p.period_start = finalized.period_start
|
|
AND p.period_end = finalized.period_end
|
|
RETURNING p.workspace_id
|
|
)
|
|
SELECT * FROM updated_run;
|
|
|
|
-- name: UpdateAutopilotRunSkippedWithResult :one
|
|
-- Quota safety: legacy no-reservation helper. New terminal paths must call
|
|
-- UpdateAutopilotRunTerminalWithQuota instead.
|
|
UPDATE autopilot_run
|
|
SET status = 'skipped',
|
|
completed_at = now(),
|
|
failure_reason = $2,
|
|
result = sqlc.narg('result')
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- =====================
|
|
-- Scheduler Queries
|
|
-- =====================
|
|
|
|
-- name: ListSchedulableAutopilotTriggers :many
|
|
-- Lists every schedule trigger the autopilot_schedule_dispatch JobSpec
|
|
-- should consider this tick. Returns just the columns the scheduler's
|
|
-- scope provider + PlansForScope hook need; the full trigger row is
|
|
-- re-loaded by the handler so a trigger update between scope-list and
|
|
-- handler-run sees the latest enabled / cron values.
|
|
--
|
|
-- last_fired_at is read so the planner hook can anchor cold-start
|
|
-- enumeration on the most recent successful fire (set by either the
|
|
-- legacy goroutine before the new scheduler took over, or the new
|
|
-- scheduler's own post-dispatch advance — AdvanceTriggerNextRun, falling
|
|
-- back to TouchAutopilotTriggerFiredAt on a cron parse error). Without it,
|
|
-- a trigger that was created days ago and fired by the legacy code
|
|
-- looks like a brand-new trigger to the new scheduler on first tick
|
|
-- and the half-open `(created_at, now]` enumeration replays the most
|
|
-- recent already-fired occurrence — exactly the post-deploy
|
|
-- spurious-fire reported on MUL-3551 dev.
|
|
--
|
|
-- Filters out webhook / api triggers, disabled triggers, paused/archived
|
|
-- autopilots, and any trigger missing its cron expression. ORDER BY id
|
|
-- keeps the per-tick scope list stable across replicas.
|
|
SELECT t.id, t.autopilot_id, t.cron_expression, t.timezone, t.created_at, t.last_fired_at
|
|
FROM autopilot_trigger t
|
|
JOIN autopilot a ON a.id = t.autopilot_id
|
|
WHERE t.kind = 'schedule'
|
|
AND t.enabled = TRUE
|
|
AND a.status = 'active'
|
|
AND t.cron_expression IS NOT NULL
|
|
AND t.cron_expression <> ''
|
|
ORDER BY t.id;
|
|
|
|
-- =====================
|
|
-- Task Queue (run_only mode)
|
|
-- =====================
|
|
|
|
-- name: CreateAutopilotTask :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).
|
|
-- run_only autopilot dispatch. Attribution depends on the trigger:
|
|
-- * schedule / webhook / api: no human authorized the run, so originator_user_id
|
|
-- stays NULL and accountable_user_id is the rule_owner (the publisher of the
|
|
-- autopilot's active rule version), with rule_version_id recording the snapshot
|
|
-- (MUL-4302 §3.4) — the accountable-diverges-from-originator case.
|
|
-- * manual: a member clicked "run now", a direct human action, so originator and
|
|
-- accountable are BOTH that member (originator_source='direct_human'); no rule
|
|
-- version is involved (MUL-4302 §4).
|
|
-- When no version/publisher resolves on the non-manual path, the caller passes NULL
|
|
-- accountable + originator_source='unattributed' so the row is still not a
|
|
-- NULL-source bypass (MUL-4302 §2).
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id, status, priority, autopilot_run_id, trigger_summary,
|
|
originator_user_id, accountable_user_id, rule_version_id,
|
|
originator_source, trigger_evidence_kind, trigger_evidence_ref_id,
|
|
id
|
|
)
|
|
SELECT
|
|
$1, $2, NULL, 'queued', $3, $4, sqlc.narg(trigger_summary),
|
|
sqlc.narg(originator_user_id),
|
|
sqlc.narg(accountable_user_id),
|
|
sqlc.narg(rule_version_id),
|
|
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: GetAutopilotTaskByRun :one
|
|
-- Repairs the narrow run_only crash window where the task INSERT committed
|
|
-- but the following autopilot_run.task_id update did not.
|
|
SELECT * FROM agent_task_queue
|
|
WHERE autopilot_run_id = $1
|
|
ORDER BY created_at
|
|
LIMIT 1;
|
|
|
|
-- =====================
|
|
-- Run lookup by linked entities
|
|
-- =====================
|
|
|
|
-- name: GetAutopilotRunByIssue :one
|
|
SELECT * FROM autopilot_run
|
|
WHERE issue_id = $1 AND status IN ('issue_created', 'running')
|
|
LIMIT 1;
|
|
|
|
-- name: GetLatestAutopilotRunForIssueAttribution :one
|
|
-- Attribution survives run completion; lifecycle callers still use the active-run lookup.
|
|
SELECT r.* FROM autopilot_run r
|
|
JOIN autopilot a ON a.id = r.autopilot_id
|
|
WHERE r.issue_id = sqlc.arg(issue_id) AND r.autopilot_id = sqlc.arg(autopilot_id)
|
|
AND a.workspace_id = sqlc.arg(workspace_id)
|
|
ORDER BY r.created_at DESC, r.id DESC
|
|
LIMIT 1;
|
|
|
|
-- name: FailAutopilotRunsByIssue :many
|
|
-- Fails active autopilot runs linked to a given issue.
|
|
-- Must be called BEFORE issue deletion (ON DELETE SET NULL clears issue_id).
|
|
-- Only still-reserved run_only slots are released. A create_issue slot was
|
|
-- consumed when the issue was created and remains counted after deletion.
|
|
WITH updated_runs AS (
|
|
UPDATE autopilot_run
|
|
SET status = 'failed', completed_at = now(), failure_reason = 'linked issue was deleted'
|
|
WHERE issue_id = $1
|
|
AND status IN ('issue_created', 'running')
|
|
RETURNING *
|
|
), locked_reservations AS MATERIALIZED (
|
|
SELECT qr.*
|
|
FROM autopilot_quota_reservation qr
|
|
JOIN updated_runs ar ON ar.quota_reservation_id = qr.id
|
|
WHERE qr.state = 'reserved'
|
|
FOR UPDATE
|
|
), released_reservations AS (
|
|
UPDATE autopilot_quota_reservation AS qr
|
|
SET state = 'released', finalized_at = now()
|
|
FROM locked_reservations AS locked
|
|
WHERE qr.id = locked.id
|
|
AND EXISTS (
|
|
SELECT 1 FROM autopilot_quota_period p
|
|
WHERE p.workspace_id = locked.workspace_id
|
|
AND p.period_start = locked.period_start
|
|
AND p.period_end = locked.period_end
|
|
)
|
|
RETURNING locked.workspace_id, locked.period_start, locked.period_end
|
|
), released_by_period AS (
|
|
SELECT workspace_id, period_start, period_end, count(*)::bigint AS released_count
|
|
FROM released_reservations
|
|
GROUP BY workspace_id, period_start, period_end
|
|
), settled_periods AS (
|
|
UPDATE autopilot_quota_period AS p
|
|
SET reserved_count = reserved_count - released.released_count,
|
|
updated_at = now()
|
|
FROM released_by_period AS released
|
|
WHERE p.workspace_id = released.workspace_id
|
|
AND p.period_start = released.period_start
|
|
AND p.period_end = released.period_end
|
|
RETURNING p.workspace_id
|
|
)
|
|
SELECT * FROM updated_runs;
|
|
|
|
-- =====================
|
|
-- Failure-rate auto-pause
|
|
-- =====================
|
|
|
|
-- name: SelectAutopilotsExceedingFailureThreshold :many
|
|
-- Find active autopilots whose recent run failure rate exceeds the threshold.
|
|
-- Counts only "real" terminal runs (completed | failed). 'skipped' is
|
|
-- excluded from BOTH numerator and denominator: an admission-skipped run
|
|
-- (e.g. assignee runtime offline at dispatch time, MUL-1899) is neither a
|
|
-- success nor a failure, so it must not dilute the failure ratio (which
|
|
-- would let a 100%-failing autopilot mask itself behind a wall of skips)
|
|
-- nor inflate it. issue_created/running are still excluded so in-flight
|
|
-- work isn't penalised.
|
|
-- Used by the failure monitor to auto-pause sustained-failure autopilots
|
|
-- (the canonical example from MUL-1336 was an autopilot scheduled every 5 min
|
|
-- that 100% failed for days, burning ~1.5k useless tasks per week).
|
|
WITH stats AS (
|
|
SELECT autopilot_id,
|
|
count(*) FILTER (WHERE status IN ('completed', 'failed')) AS total,
|
|
count(*) FILTER (WHERE status = 'failed') AS failed
|
|
FROM autopilot_run
|
|
WHERE created_at >= sqlc.arg('since')::timestamptz
|
|
GROUP BY autopilot_id
|
|
)
|
|
SELECT a.id, a.workspace_id, a.title, a.assignee_id,
|
|
a.created_by_type, a.created_by_id,
|
|
s.total::bigint AS total_runs,
|
|
s.failed::bigint AS failed_runs
|
|
FROM autopilot a
|
|
JOIN stats s ON s.autopilot_id = a.id
|
|
WHERE a.status = 'active'
|
|
AND s.total >= sqlc.arg('min_runs')::bigint
|
|
AND s.failed::float8 / NULLIF(s.total, 0)::float8 >= sqlc.arg('fail_ratio_threshold')::float8
|
|
ORDER BY s.failed DESC, a.id ASC;
|
|
|
|
-- name: SystemPauseAutopilot :one
|
|
-- Atomically pauses an autopilot only if it is currently active. Returns no
|
|
-- rows when the autopilot was already paused/archived (or another worker
|
|
-- raced first), letting the caller treat that as a benign no-op rather than
|
|
-- an error.
|
|
UPDATE autopilot
|
|
SET status = 'paused', pause_reason = NULL, updated_at = now()
|
|
WHERE id = $1 AND status = 'active'
|
|
RETURNING *;
|
|
|
|
-- =====================
|
|
-- Autopilot Subscribers
|
|
-- =====================
|
|
|
|
-- name: ListAutopilotSubscribers :many
|
|
-- Only current workspace members are effective subscribers. The membership
|
|
-- join makes legacy rows left behind by older member-removal code inert on
|
|
-- both the detail response and the dispatch path, so clients never round-trip
|
|
-- a hidden departed member into an otherwise valid update.
|
|
-- ORDER BY created_at keeps chip rendering stable across refreshes.
|
|
SELECT s.* FROM autopilot_subscriber AS s
|
|
JOIN autopilot AS a ON a.id = s.autopilot_id
|
|
JOIN member AS m
|
|
ON m.workspace_id = a.workspace_id
|
|
AND m.user_id = s.user_id
|
|
WHERE s.autopilot_id = $1
|
|
AND s.user_type = 'member'
|
|
ORDER BY s.created_at ASC, s.user_id ASC;
|
|
|
|
-- name: ListAutopilotSubscribersForAutopilots :many
|
|
-- Batch form of ListAutopilotSubscribers for the list endpoint, which must not
|
|
-- issue one query per row. The autopilot_subscriber primary key leads with
|
|
-- autopilot_id, so ANY($1) is index-supported and no extra index is needed.
|
|
-- The member join and ordering are identical to the single-autopilot query on
|
|
-- purpose: list and detail have to agree on who counts as a subscriber, or the
|
|
-- two projections disagree again (MUL-6680).
|
|
SELECT s.* FROM autopilot_subscriber AS s
|
|
JOIN autopilot AS a ON a.id = s.autopilot_id
|
|
JOIN member AS m
|
|
ON m.workspace_id = a.workspace_id
|
|
AND m.user_id = s.user_id
|
|
WHERE s.autopilot_id = ANY($1::uuid[])
|
|
AND s.user_type = 'member'
|
|
ORDER BY s.autopilot_id ASC, s.created_at ASC, s.user_id ASC;
|
|
|
|
-- name: AddAutopilotSubscriber :exec
|
|
INSERT INTO autopilot_subscriber (autopilot_id, user_type, user_id)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (autopilot_id, user_type, user_id) DO NOTHING;
|
|
|
|
-- name: DeleteAutopilotSubscribersForAutopilot :exec
|
|
-- Paired with a re-insert loop to implement full-replace PATCH semantics.
|
|
DELETE FROM autopilot_subscriber
|
|
WHERE autopilot_id = $1;
|
|
|
|
-- name: DeleteAutopilotSubscribersByMember :exec
|
|
-- Autopilot subscribers carry no FK, so member removal must prune them in the
|
|
-- same application transaction. Scope through the autopilot's workspace: the
|
|
-- same user may remain subscribed to autopilots in another workspace.
|
|
DELETE FROM autopilot_subscriber AS s
|
|
USING autopilot AS a
|
|
WHERE s.autopilot_id = a.id
|
|
AND a.workspace_id = sqlc.arg(workspace_id)
|
|
AND s.user_type = 'member'
|
|
AND s.user_id = sqlc.arg(user_id);
|
|
|
|
-- =====================
|
|
-- Autopilot Collaborators
|
|
-- =====================
|
|
|
|
-- name: ListAutopilotCollaborators :many
|
|
-- ORDER BY created_at keeps row rendering stable across refreshes.
|
|
SELECT * FROM autopilot_collaborator
|
|
WHERE autopilot_id = $1
|
|
ORDER BY created_at ASC, user_id ASC;
|
|
|
|
-- name: AddAutopilotCollaborator :one
|
|
-- Re-granting an existing collaborator is a no-op that refreshes granted_by,
|
|
-- so the call is idempotent from the API boundary.
|
|
INSERT INTO autopilot_collaborator (autopilot_id, user_type, user_id, granted_by)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (autopilot_id, user_type, user_id)
|
|
DO UPDATE SET granted_by = EXCLUDED.granted_by
|
|
RETURNING *;
|
|
|
|
-- name: DeleteAutopilotCollaborator :exec
|
|
DELETE FROM autopilot_collaborator
|
|
WHERE autopilot_id = $1 AND user_type = $2 AND user_id = $3;
|
|
|
|
-- name: DeleteAutopilotCollaboratorsForAutopilot :exec
|
|
-- Application-layer cleanup run inside the autopilot delete transaction.
|
|
DELETE FROM autopilot_collaborator
|
|
WHERE autopilot_id = $1;
|
|
|
|
-- name: IsAutopilotCollaborator :one
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM autopilot_collaborator
|
|
WHERE autopilot_id = $1 AND user_type = 'member' AND user_id = $2
|
|
) AS is_collaborator;
|
|
|
|
-- name: ListAutopilotIDsForCollaborator :many
|
|
-- Powers the per-row can_write flag on the list endpoint without an N+1.
|
|
SELECT autopilot_id FROM autopilot_collaborator
|
|
WHERE user_type = 'member' AND user_id = $1;
|