/** * Agent Run Dispatcher — ADR-0030 Phase 2 (dry-run mode). * * In-process worker that ticks every `_workflow_config.poll_interval_ms` * (default 30s) and drives the run-state machine on tickets table 1708. * * Phase 2 scope (dry-run): * - Tick Part A: stall detection — no-op pass while no live runs exist. * - Tick Part B: claim ready tickets and immediately cancel them with * terminal_reason='phase2_dryrun'. Validates state machine + DB * mutations + concurrency guards without running real agent work. * * Real agent execution (worktree, claude CLI, stream handler, TOTP gate) * lands in Phases 3–5. * * Module Lifecycle (ADR-0025): exports init / shutdown / health. * Manual tick trigger for tests: * POST /api/v3/admin/agent-run-dispatcher/tick (admin-only). * * Feature flag: AGENT_RUN_DISPATCHER_ENABLED. Defaults to false in * Phase 2 — must be flipped to 'true' explicitly per environment. * * @see ADR-0030 §3.2 (_workflow_config), §3.3 (tick), §3.4 (run_* cols). */ import { dbAll, dbGet, dbRun } from '../../database/connection.js'; import { apiLogger } from '../../utils/logger.js'; import { createWorkspace, destroyWorkspace } from './workspace-manager.js'; import { buildRunPrompt } from './build-run-prompt.mjs'; import { runStreamHandler, eventTranslator, warnLegacyOnce } from './run-stream-handler.mjs'; import { STATES, SIDE_EFFECTS, INITIAL, transition, } from './state-machine.js'; import { generateApprovalCode, persistApprovalRequest, awaitApproval, APPROVAL_OUTCOMES, APPROVAL_CONSTANTS, } from './approval-gate.js'; import { classify as classifySecondarySignals, VERDICT } from './secondary-signals.js'; import { isCompletionIntent as evtIsCompletionIntent } from './completion-intent.js'; const log = apiLogger.child({ module: 'agent_run_dispatcher' }); // Phase env flag (ADR-0030 Phase 4): // 'dryrun' → Phase 2 behavior (default, safe). Cancel immediately // with run_terminal_reason='phase2_dryrun'. // 'workspace_only' → Phase 3 behavior. Materialize git worktree, store // path on ticket, then cancel with reason // 'phase3_workspace_only'. Still no claude CLI. // 'live' → Phase 4 behavior. Materialize worktree, build prompt // from ticket+agent, spawn `claude --print` via the // runner script, stream NDJSON, then transition to // run_state='succeeded'|'failed' (NOT terminal ticket // state). Workspace destroyed in finally. // Read once at module load. Operators flip via env + restart. const RUN_DISPATCHER_PHASE = (() => { const v = (process.env.RUN_DISPATCHER_PHASE || 'dryrun').toLowerCase(); if (v !== 'dryrun' && v !== 'workspace_only' && v !== 'live') { // Unknown phase value → fall back to safest (dryrun) and warn at init. return 'dryrun'; } return v; })(); // Phase 5 env flag: gate live runs behind a TOTP approval step. // 'true' → generate 6-digit code, post to ticket chat, block until // approve/deny/expire. DEFAULT in 'live' phase (security-critical). // 'false' → skip the gate entirely (P4 behavior). Used by smoke tests and // one-off operator overrides only. // We intentionally default to 'true' in 'live' phase; in dryrun/workspace_only // the gate is irrelevant (we never reach processLive). const RUN_REQUIRE_APPROVAL = (() => { const v = (process.env.RUN_REQUIRE_APPROVAL || '').toLowerCase(); if (v === 'false' || v === '0' || v === 'no') return false; if (v === 'true' || v === '1' || v === 'yes') return true; // Default: ON when phase is 'live'. return (process.env.RUN_DISPATCHER_PHASE || 'dryrun').toLowerCase() === 'live'; })(); // Audit-log rate limit for noisy stream events (per-ticket). const AUDIT_RATE_LIMIT_MS = 5_000; // ─── Constants (table IDs are stable per CLAUDE.md) ──────────── const TICKETS_TABLE_ID = 1708; const WORKFLOW_CONFIG_TABLE_ID = 100000; // _workflow_config is a singleton table — id is autoincrement, fetch the // only row by table_id rather than guessing an id. const FIRST_TICK_DELAY_MS = 5_000; const FALLBACK_POLL_INTERVAL_MS = 30_000; const CONFIG_RELOAD_INTERVAL_MS = 5 * 60 * 1000; // Terminal `state` values from pipeline-config.js Space 11 — tickets in // these states MUST NOT be claimed by the run loop ("not closed" filter // per ADR-0030 §3.3). const TICKET_TERMINAL_STATES = ['24278', '24277', '43438', '43437']; // done review rejected control // Run states eligible for claim (idle is the implicit default for tickets // that never started a run — `data->>'run_state'` is NULL on those rows; // we cover both NULL and 'idle' explicitly). const CLAIMABLE_RUN_STATES = ['idle', 'queued', 'retry_after']; // ADR-0030 Phase 6 — run states that are subject to stall detection. Note: // 'awaiting_approval' is INTENTIONALLY excluded — humans are slow on purpose. // 'streaming' is currently a synonym used by some flows for 'running'; both // are checked so we don't miss a hung run that landed in either bucket. const STALL_CHECK_RUN_STATES = ['preparing', 'running', 'streaming']; // SIGKILL grace after SIGTERM during stall-kill. Mirrors the runner's own // timeout path — keep them aligned so a hung child gets the same treatment // regardless of which timer fires first. const STALL_SIGKILL_GRACE_MS = 5_000; // Default config (used only if _workflow_config row vanishes — defensive). // // ADR-0042 §10 (lines 296–317) is authoritative for the smart-liveness keys // below. Flat keys at top level so they round-trip through the existing // shallow merge in `loadConfig()`; nested objects (`tool_timeout_ms`, // `secondary_signal_thresholds`) are JSONB blobs replaced wholesale on // override. Operators tune via the existing `_workflow_config` MCP UI. const DEFAULT_CONFIG = { poll_interval_ms: FALLBACK_POLL_INTERVAL_MS, max_concurrent_runs: 3, max_per_agent: 1, max_attempts: 3, paused: false, // Phase 6 stall detection knobs (legacy single-threshold path). stall_timeout_ms: 300_000, // 5 min between events before a run is considered stalled (legacy alias) // ADR-150 P0 — meaningful-event freshness window. Replaces `stall_timeout_ms` // semantically; legacy key is read as a fallback during the deprecation // window. Default raised to 10 min: agents legitimately go quiet during // long Bash/WebFetch tools and the per-state FSM no longer governs this // path. Operators can lower via `_workflow_config`. meaningful_event_idle_ms: 600_000, retry_backoff_min_ms: 10_000, // attempt 1 → 10s retry_backoff_max_ms: 40_000, // cap so we don't push retries past 40s window // ─── ADR-0042 smart-liveness FSM (Task 3) ───────────────────── // AC8 rollback: setting `false` reverts to the legacy 30-min cap path // driven by `stall_timeout_ms`. smart_liveness_enabled: true, // Per-state idle thresholds (ADR §10, §State Machine table lines 62–68). idle_idle_ms: 300_000, // 5 min thinking_idle_ms: 360_000, // 6 min closing_grace_ms: 90_000, // 90 s — no kill, only grace before backstop stuck_check_window_ms: 60_000, // 60 s observation in stuck_check // Per-tool timeouts (used when state=tool_active). Lookup is // `config.tool_timeout_ms[toolName] ?? config.tool_timeout_ms.default`. // ADR §Per-Tool Timeout Map (lines 196–215). tool_timeout_ms: { Bash: 900_000, // 15 min — long builds WebFetch: 300_000, // 5 min — slow upstreams deep_scrape: 600_000, // 10 min — multi-page crawls web_search: 180_000, // 3 min Grep: 60_000, Glob: 60_000, Read: 60_000, Edit: 60_000, Write: 60_000, MultiEdit: 60_000, TodoWrite: 30_000, manage_plan: 30_000, default: 300_000, // fallback for unlisted / MCP tools }, // Secondary-signal classifier (Task 2 owns sampling/classify; // dispatcher reads these on stuck_check entry). // // ADR-0042 hybrid (variant C): CPU% delta-vs-baseline is the PRIMARY // liveness axis — sockets/children remain snapshot-only. // - cpu_liveness_threshold_pct: default 1% — over threshold → ALIVE. // Conservative: even a barely-alive event loop ticks above this. // - cpu_alive_pct / cpu_dead_pct / require_all_dead are LEGACY keys // from pre-hybrid Task 2; left in place so older config rows that // reference them don't crash. Remove once §3 migration completes. secondary_signal_thresholds: { cpu_liveness_threshold_pct: 1, cpu_alive_pct: 0.5, // legacy cpu_dead_pct: 0.05, // legacy require_all_dead: true, // legacy }, // Backstop — wall-clock hard guard. Single warn entry at 75% elapsed. // ADR §Stream Handler Changes (lines 232–239). runner_backstop_ms: 4 * 60 * 60 * 1000, // 4h backstop_warn_ratio: 0.75, // warn at 3h // Completion-intent predicate inputs. Locked decision (per Task 3 brief): // setter-tools (`update_ticket_status`, `update_table_row`, // `mcp__godcrm__update_table_row`) participate when their `input.status` // is in `completion_intent_terminal_states` — the FSM module's // `transition()` enforces the guard; the list here is the universe. completion_intent_tools: [ 'send_chat_message', 'send_widget_message', 'send_ticket_message', 'ExitPlanMode', 'EndTurn', 'update_ticket_status', 'update_table_row', 'mcp__godcrm__update_table_row', ], completion_intent_terminal_states: ['Done', 'Closed', 'Resolved'], }; // Map FSM state → the DEFAULT_CONFIG key holding its idle threshold. // Extracted for `effectiveThresholdMs` so the lookup stays declarative. const STATE_TIMEOUT_KEY = Object.freeze({ idle: 'idle_idle_ms', thinking: 'thinking_idle_ms', tool_active: null, // resolved per-tool; falls back via DEFAULT closing: 'closing_grace_ms', stuck_check: 'stuck_check_window_ms', }); /** * ADR-0042 §10 — resolve the effective idle threshold (ms) for a given * (state, toolName) pair. Pure function, no side effects, no I/O. * * Priority: * 1. If `toolName` is provided AND `config.tool_timeout_ms[toolName]` is * set → return that. (Per-tool override always wins, regardless of * state — this matches AC3: a long Bash run survives the 6-min * thinking threshold because Bash is 15 min.) * 2. Else if `state` maps to a flat key (e.g. `idle_idle_ms`) and the * config has a numeric value there → return that. * 3. Else → `config.tool_timeout_ms.default` (per-tool fallback). * 4. Else → DEFAULT_CONFIG values for the same lookup chain. * * Defensive: nullish/missing config never throws. * * @param {string|null|undefined} state — one of FSM STATES values * @param {string|null|undefined} toolName — current tool name when state=tool_active * @param {object|null|undefined} config — usually the merged loadConfig() result * @returns {number} threshold in milliseconds */ export function effectiveThresholdMs(state, toolName, config) { const cfg = config && typeof config === 'object' ? config : null; // 1. Per-tool override. if (toolName) { const cfgTools = cfg && cfg.tool_timeout_ms; if (cfgTools && typeof cfgTools === 'object') { const v = cfgTools[toolName]; if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v; } const defTools = DEFAULT_CONFIG.tool_timeout_ms; const dv = defTools[toolName]; if (typeof dv === 'number' && Number.isFinite(dv) && dv > 0) return dv; } // 2. Per-state flat key. if (state) { const key = STATE_TIMEOUT_KEY[state]; if (key) { if (cfg && typeof cfg[key] === 'number' && Number.isFinite(cfg[key]) && cfg[key] > 0) { return cfg[key]; } const dv = DEFAULT_CONFIG[key]; if (typeof dv === 'number' && Number.isFinite(dv) && dv > 0) return dv; } } // 3. Per-tool default fallback. const cfgTools = cfg && cfg.tool_timeout_ms; if (cfgTools && typeof cfgTools.default === 'number' && Number.isFinite(cfgTools.default) && cfgTools.default > 0) { return cfgTools.default; } // 4. Hard-coded ultimate fallback. return DEFAULT_CONFIG.tool_timeout_ms.default; } // ─── Module state ────────────────────────────────────────────── let intervalHandle = null; let firstTickTimer = null; let isTicking = false; let cachedConfig = null; let configLoadedAt = 0; let currentPollIntervalMs = FALLBACK_POLL_INTERVAL_MS; // In-memory tracker for "currently running" attempts. In dry-run we // transition straight to canceled within a single tick, so this stays // at zero in practice — but the framework is in place for Phase 3+. const _activeAttempts = new Map(); // ticket_id → { agent_id, claimedAt } /** * Load the singleton _workflow_config row, with caching. * Re-fetches from DB at most every CONFIG_RELOAD_INTERVAL_MS. */ export async function loadConfig({ force = false } = {}) { const now = Date.now(); if (!force && cachedConfig && now - configLoadedAt < CONFIG_RELOAD_INTERVAL_MS) { return cachedConfig; } try { const row = await dbGet( `SELECT data FROM table_rows WHERE table_id = $1 ORDER BY id ASC LIMIT 1`, [WORKFLOW_CONFIG_TABLE_ID] ); if (!row?.data) { log.warn( { table_id: WORKFLOW_CONFIG_TABLE_ID }, '_workflow_config row missing — using DEFAULT_CONFIG' ); cachedConfig = { ...DEFAULT_CONFIG }; } else { const parsed = typeof row.data === 'string' ? JSON.parse(row.data) : row.data; cachedConfig = { ...DEFAULT_CONFIG, ...parsed }; } configLoadedAt = now; return cachedConfig; } catch (err) { log.error({ err }, 'failed to load _workflow_config — keeping previous cache'); return cachedConfig || { ...DEFAULT_CONFIG }; } } /** * Append a transition entry to the ticket's data.run_audit_log JSONB array. * Read-modify-write within the existing transaction is unnecessary here: * jsonb_insert keeps the operation server-side and atomic per row. */ async function appendAuditLog(ticketId, entry) { try { await dbRun( `UPDATE table_rows SET data = jsonb_set( COALESCE(data, '{}'::jsonb), '{run_audit_log}', COALESCE(data->'run_audit_log', '[]'::jsonb) || $2::jsonb, true ), updated_at = NOW() WHERE table_id = $1 AND id = $3`, [TICKETS_TABLE_ID, JSON.stringify([entry]), ticketId] ); } catch (err) { log.warn({ err, ticket_id: ticketId }, 'audit log append failed (non-blocking)'); } } /** * ADR-0030 Phase 6 — exponential backoff for retry scheduling. * * Pure function, exported for unit-test introspection. Attempt is the * 1-based index of the run that just stalled (so the FIRST stall is * attempt=1 → minWindow). The next attempt's wait grows by powers of 2 * but is clamped to retry_backoff_max_ms. * * attempt=1 → min (10000ms default) * attempt=2 → min*2 (20000ms) * attempt=3 → min*4 (40000ms — hits cap by default) * attempt=N → min(min*2^(N-1), max) */ export function computeBackoff(attempt, config = DEFAULT_CONFIG) { const min = Number(config?.retry_backoff_min_ms) || DEFAULT_CONFIG.retry_backoff_min_ms; const max = Number(config?.retry_backoff_max_ms) || DEFAULT_CONFIG.retry_backoff_max_ms; const a = Math.max(1, Number(attempt) | 0); // 2^(a-1) — guard against absurd attempts saturating Math.pow. const exp = Math.min(a - 1, 30); return Math.min(min * Math.pow(2, exp), max); } /** * Probe whether a process exists. `process.kill(pid, 0)` does NOT signal — * it just throws if the PID is gone (ESRCH) or refused (EPERM, treated as * "exists but ours-not-to-touch"). Returns false for ESRCH / invalid pid, * true otherwise so we can decide whether to escalate to SIGKILL. */ function probeAlive(pid) { if (!pid || typeof pid !== 'number' || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (err) { // ESRCH = no such process. Anything else (e.g., EPERM) we treat as // "still around" since we can't confirm it's gone. if (err && err.code === 'ESRCH') return false; return true; } } /** * Send SIGTERM, then SIGKILL after grace if still alive. Best-effort — * each kill is wrapped so a missing PID never bubbles. Async because we * sleep between term and kill. */ async function killStalledProcess(pid, ticketId) { if (!probeAlive(pid)) { log.debug({ ticket_id: ticketId, pid }, 'stall: pid already gone (no signal needed)'); return { signaled: false, reason: 'already_gone' }; } try { process.kill(pid, 'SIGTERM'); log.info({ ticket_id: ticketId, pid }, 'stall: SIGTERM sent'); } catch (err) { if (err && err.code === 'ESRCH') { return { signaled: false, reason: 'esrch_on_term' }; } log.warn({ err: err.message, pid, ticket_id: ticketId }, 'stall: SIGTERM failed'); } await new Promise((r) => setTimeout(r, STALL_SIGKILL_GRACE_MS)); if (probeAlive(pid)) { try { process.kill(pid, 'SIGKILL'); log.warn({ ticket_id: ticketId, pid }, 'stall: SIGKILL sent (SIGTERM grace expired)'); return { signaled: true, escalated: true }; } catch (err) { if (err && err.code === 'ESRCH') { return { signaled: true, escalated: false }; } log.warn({ err: err.message, pid, ticket_id: ticketId }, 'stall: SIGKILL failed'); } } return { signaled: true, escalated: false }; } // ─── ADR-0042 Task 4 — FSM persistence helpers ───────────────── // // All helpers are best-effort (warn + swallow on failure). The FSM is // pure; this layer mediates between FSM `sideEffects[]` outputs and the // JSONB columns on the ticket row. Heartbeat ordering: bump after the // state write so a state flip is never visible without its associated // `run_last_event_at` advance. /** * Persist `data.run_liveness_state` AND bump `run_state_entered_at` to NOW(). * Called when transition() returns a state different from the previously- * persisted one. The `entered_at` stamp is what the stall-detect tick * compares against `effectiveThresholdMs(state, tool)`, so it MUST advance * on every state flip — otherwise a long thinking → tool_active → thinking * cycle would inherit the original entered_at and trip the threshold. */ export async function persistStateChange(rowId, newState) { if (!rowId || typeof newState !== 'string') return; try { const ts = new Date().toISOString(); await dbRun( `UPDATE table_rows SET data = data || jsonb_build_object( 'run_liveness_state', $2::text, 'run_state_entered_at', $3::text ), updated_at = NOW() WHERE table_id = $1 AND id = $4`, [TICKETS_TABLE_ID, newState, ts, rowId] ); } catch (err) { log.warn({ err: err.message, ticket_id: rowId, newState }, 'persistStateChange failed (non-blocking)'); } } /** * Persist `data.run_current_tool` (object | null). Set on tool_started, * cleared on tool_finished. Shape: `{name, tool_use_id, attempt_idx}`. */ export async function persistCurrentTool(rowId, toolName) { if (!rowId) return; try { if (toolName == null) { // Clear key entirely. await dbRun( `UPDATE table_rows SET data = data - 'run_current_tool', updated_at = NOW() WHERE table_id = $1 AND id = $2`, [TICKETS_TABLE_ID, rowId] ); } else { // toolName may be the full {name,...} object or a bare string. const payload = typeof toolName === 'object' ? toolName : { name: String(toolName) }; await dbRun( `UPDATE table_rows SET data = jsonb_set(data, '{run_current_tool}', $2::jsonb, true), updated_at = NOW() WHERE table_id = $1 AND id = $3`, [TICKETS_TABLE_ID, JSON.stringify(payload), rowId] ); } } catch (err) { log.warn({ err: err.message, ticket_id: rowId }, 'persistCurrentTool failed (non-blocking)'); } } /** * Persist a stuck-check baseline snapshot. ADR-0042 Task 5 shape: * { * baseline: , // raw classify() proof * prev_state: '', // for restore * } * * Called once on entry into `stuck_check` state. The dispatcher's * `_runStallDetect` tick re-reads this on the *next* tick — if the second * sample says alive, we restore prev_state; if dead, we kill; if * inconclusive past a window, we kill `stuck_inconclusive`. * * `entered_at` is intentionally NOT duplicated here — that lives in * `data.run_state_entered_at` and is set by `persistStateChange`. */ export async function persistStuckCheckBaseline(rowId, payload) { if (!rowId) return; try { const blob = payload && typeof payload === 'object' ? { baseline: payload.baseline ?? null, prev_state: typeof payload.prev_state === 'string' ? payload.prev_state : null, } : { baseline: null, prev_state: null }; await dbRun( `UPDATE table_rows SET data = jsonb_set(data, '{run_stuck_check_baseline}', $2::jsonb, true), updated_at = NOW() WHERE table_id = $1 AND id = $3`, [TICKETS_TABLE_ID, JSON.stringify(blob), rowId] ); } catch (err) { log.warn({ err: err.message, ticket_id: rowId }, 'persistStuckCheckBaseline failed (non-blocking)'); } } /** * ADR-0042 Task 5 — clear the stuck-check baseline + restore prior FSM state. * Used when a `stuck_check` re-sample classifies the process as 'alive': * the ticket goes back to whatever state it came from, the entered_at clock * resets to NOW so the next idle-window starts fresh, and the baseline blob * is dropped. * * Best-effort: any failure is warned + swallowed. The next tick will simply * re-evaluate on stale data (worst case: another stuck_check entry). */ export async function restorePriorState(rowId, prevState) { if (!rowId || typeof prevState !== 'string' || prevState.length === 0) return; try { const ts = new Date().toISOString(); await dbRun( `UPDATE table_rows SET data = (data || jsonb_build_object( 'run_liveness_state', $2::text, 'run_state_entered_at', $3::text ) ) - 'run_stuck_check_baseline', updated_at = NOW() WHERE table_id = $1 AND id = $4`, [TICKETS_TABLE_ID, prevState, ts, rowId] ); } catch (err) { log.warn({ err: err.message, ticket_id: rowId, prevState }, 'restorePriorState failed (non-blocking)'); } } /** * ADR-150 P0 — Bump `data.run_last_heartbeat_at` to NOW(). Distinct from * `bumpLastEventAt` which writes `run_last_event_at`. The split lets the * stall detector tell apart "process is alive" (heartbeat fresh) from * "agent is doing meaningful work" (event fresh). Cheap UPDATE, single * jsonb_set, fire-and-forget. * * BUG-FIX (ADR-150 P0): previously this bumped `run_last_event_at`, which * masked real stalls because the 15s timer kept the freshness clock fresh * even when the agent was completely silent. */ export async function bumpHeartbeatAt(rowId) { if (!rowId) return; try { const ts = new Date().toISOString(); await dbRun( `UPDATE table_rows SET data = jsonb_set(data, '{run_last_heartbeat_at}', to_jsonb($2::text), true) WHERE table_id = $1 AND id = $3`, [TICKETS_TABLE_ID, ts, rowId] ); } catch (err) { log.warn({ err: err.message, ticket_id: rowId }, 'bumpHeartbeatAt failed (non-blocking)'); } } /** * ADR-0042 Task 6 — record the single backstop-warn edge for a live run. * * Fired by the runner's `onBackstopWarn` callback when wall-clock elapsed * crosses `backstop_ms * backstop_warn_ratio` (≈3h with the 4h default). * The runner already enforces per-run idempotency via a single-shot timer; * this helper is the DB-level idempotency net for callers that may resume * a long-lived run across dispatcher restarts. The conditional UPDATE * writes only when `data.run_backstop_warned_at` is missing/null, so a * second invocation is a no-op. * * Returns `true` iff the row was actually updated (i.e. this is the FIRST * warn for this run). Caller uses the return value to decide whether to * append the audit entry — we don't want a `backstop_warn` line per * resumed tick. * * Best-effort: any failure is warned + swallowed. */ export async function persistBackstopWarn(rowId, info) { if (!rowId || !info || typeof info !== 'object') return false; const at = typeof info.at === 'string' && info.at ? info.at : new Date().toISOString(); try { const result = await dbRun( `UPDATE table_rows SET data = jsonb_set( COALESCE(data, '{}'::jsonb), '{run_backstop_warned_at}', to_jsonb($2::text), true ), updated_at = NOW() WHERE table_id = $1 AND id = $3 AND (data->>'run_backstop_warned_at') IS NULL`, [TICKETS_TABLE_ID, at, rowId] ); return (result?.changes ?? 0) > 0; } catch (err) { log.warn({ err: err.message, ticket_id: rowId }, 'persistBackstopWarn failed (non-blocking)'); return false; } } /** * Bump `data.run_completion_intent_at`. Fired once on the entry edge * into `closing` so observers can compute "time since the agent first * tried to wrap up" without scanning the audit log. */ export async function bumpCompletionIntentAt(rowId) { if (!rowId) return; try { const ts = new Date().toISOString(); await dbRun( `UPDATE table_rows SET data = jsonb_set(data, '{run_completion_intent_at}', to_jsonb($2::text), true), updated_at = NOW() WHERE table_id = $1 AND id = $3`, [TICKETS_TABLE_ID, ts, rowId] ); } catch (err) { log.warn({ err: err.message, ticket_id: rowId }, 'bumpCompletionIntentAt failed (non-blocking)'); } } // In-memory FSM state per active row. Reset on dispatcher restart — we // re-read `data.run_liveness_state` lazily on the first event after // startup. Keyed by ticketId. const _fsmState = new Map(); // rowId → { state, currentTool } /** * ADR-0042 Task 4 — single entry point invoked from the streaming * attach handler for every NDJSON event. Translates the legacy event, * runs the FSM, applies side-effects. * * Pipeline: * 1. eventTranslator() → AnthropicEvent | null (skip if null) * 2. Read prev FSM state (from in-mem cache, falling back to row). * 3. transition(prev, evt, config) → {state, currentTool, sideEffects} * 4. Apply each side effect via persistence helpers. * * Best-effort: each persistence step is independently try/caught inside * its helper. A bug here MUST NOT take the runner down. * * @param {number|string} rowId * @param {object} event — raw legacy NDJSON event from the runner * @param {object} [opts] * @param {object} [opts.config] — usually the dispatcher's loaded config */ export async function onMeaningfulEvent(rowId, event, opts = {}) { if (!rowId) return; const translated = eventTranslator(event); if (!translated) return; // info / unknown — heartbeat-only via the runner's own onHeartbeat. // Pull prev state from in-mem cache. On the first event for a row we // seed from the last persisted value (handles dispatcher restart // mid-run); on subsequent events we trust the cache. Reads are // best-effort — on failure we fall back to INITIAL. let prev = _fsmState.get(rowId); if (!prev) { let dbState = STATES.IDLE; let dbTool = null; try { const row = await dbGet( `SELECT data->>'run_liveness_state' AS s, data->'run_current_tool' AS t FROM table_rows WHERE table_id = $1 AND id = $2`, [TICKETS_TABLE_ID, rowId] ); if (row?.s && typeof row.s === 'string') dbState = row.s; if (row?.t && typeof row.t === 'object') dbTool = row.t; } catch (err) { log.debug({ err: err.message, ticket_id: rowId }, 'onMeaningfulEvent: prev state lookup failed'); } prev = { state: dbState, currentTool: dbTool }; } const config = opts.config || cachedConfig || DEFAULT_CONFIG; const completionTools = Array.isArray(config?.completion_intent_tools) ? config.completion_intent_tools : DEFAULT_CONFIG.completion_intent_tools; const terminalStates = Array.isArray(config?.completion_intent_terminal_states) ? config.completion_intent_terminal_states : DEFAULT_CONFIG.completion_intent_terminal_states; let next; try { next = transition(prev, translated, { completionTools, terminalStates }); } catch (err) { log.warn({ err: err.message, ticket_id: rowId }, 'onMeaningfulEvent: transition threw (non-blocking)'); return; } // Update in-mem cache eagerly so successive events see the new state // even if a persistence write is still in flight. _fsmState.set(rowId, { state: next.state, currentTool: next.currentTool }); const sideEffects = Array.isArray(next.sideEffects) ? next.sideEffects : []; // Persist state change FIRST, then heartbeat, so a tail observer never // sees a fresh event timestamp paired with the old state. if (next.state !== prev.state) { await persistStateChange(rowId, next.state); } // tool started/finished: keep `run_current_tool` in lockstep with FSM. if (sideEffects.includes(SIDE_EFFECTS.TOOL_STARTED) && next.currentTool) { await persistCurrentTool(rowId, next.currentTool); } if (sideEffects.includes(SIDE_EFFECTS.TOOL_FINISHED)) { await persistCurrentTool(rowId, null); } // Completion-intent edge: fire bumpCompletionIntentAt once. if (sideEffects.includes(SIDE_EFFECTS.BUMP_COMPLETION_INTENT)) { await bumpCompletionIntentAt(rowId); } // stuck_check entry → record baseline. The FSM never enters stuck_check // by itself in the meaningful-event path (it's the dispatcher tick that // promotes a stale tool_active/thinking into stuck_check); but if a // stuck_check transition does happen here, write a baseline blob with // prev_state + an initial /proc CPU sample. The dispatcher's // `_runStallDetect` will pass this `baseline.cpu` to `classify(pid, ...)` // on the next tick to compute a CPU%-delta verdict (ADR-0042 §9 hybrid). if (next.state === STATES.STUCK_CHECK && prev.state !== STATES.STUCK_CHECK) { let baseline = null; const entry = _activeAttempts.get(rowId); const pid = entry && Number.isFinite(entry.pid) ? entry.pid : null; if (pid) { try { // First call: pass baseline=null to capture the initial CPU // sample. We only care about `proof.cpu` here — the verdict // itself is irrelevant on entry (we're recording, not deciding). const r = await classifySecondarySignals(pid, null); if (r && r.proof && r.proof.cpu) { baseline = { cpu: r.proof.cpu }; } } catch (err) { // Non-blocking: persist a null baseline if classify failed; the // next tick's classify(pid, null) on retry will re-capture. log.debug( { err: err.message, ticket_id: rowId }, 'stuck_check entry: classify baseline capture failed' ); } } await persistStuckCheckBaseline(rowId, { baseline, prev_state: prev.state }); } // ADR-150 P0: BUMP_EVENT is a meaningful-event signal — bump the // event-freshness clock (not the heartbeat clock). if (sideEffects.includes(SIDE_EFFECTS.BUMP_EVENT)) { await bumpLastEventAt(rowId); } } // Test-only accessor for the in-memory FSM cache. export function _getFsmStateForTest() { return _fsmState; } /** * ADR-0030 Phase 6 — Tick Part A. * * Find tickets stuck in (preparing, running, streaming) where * now() - run_last_event_at > config.stall_timeout_ms * and either retry-enqueue or terminally fail them. * * `awaiting_approval` is intentionally NOT in STALL_CHECK_RUN_STATES — * humans are slow on purpose; the approval gate has its own TTL. * * Steps per stalled row: * 1. SIGTERM/SIGKILL the tracked PID (if any). * 2. Best-effort destroyWorkspace. * 3. If run_attempt < max_attempts: → retry_after with exponential backoff. * Else: terminal failed + run_terminal_reason='stall'. * 4. Audit log entry. * 5. Free the _activeAttempts slot. * * Updates `stats` in place with: stalled, stall_retried, stall_failed. * Returns the count of stalled rows handled. */ export async function runStallDetect({ source = 'manual', stats = null, config = null } = {}) { // Smoke-test affordance: when called directly, ensure config + stats are // populated. The internal tick path always passes both. if (!config) config = await loadConfig(); if (!stats) stats = { stalled: 0, stall_retried: 0, stall_failed: 0, errors: 0 }; return _runStallDetect({ source, stats, config }); } /** * ADR-0042 Task 5 — kill a ticket because its FSM said it's stuck. * * Mirrors the legacy retry/terminal decision but with a parametrized reason * so the smart path can write `stall`, `stuck`, or `stuck_inconclusive`. Also * SIGTERMs the tracked PID and best-effort destroys the workspace. * * Optional `deps` for tests: * - kill — replacement for killStalledProcess * - destroy — replacement for destroyWorkspace * - audit — replacement for appendAuditLog * - dbRunFn — replacement for dbRun * - activeMap — replacement for the _activeAttempts Map */ async function killTicket(ticketId, { prevState, attempt, reason, // 'stall' | 'stuck' | 'stuck_inconclusive' mode, // ADR-150 P0: 'smart' | 'legacy' — recorded in audit source, config, stats, deps = {}, } = {}) { const _kill = deps.kill || killStalledProcess; const _destroy = deps.destroy || destroyWorkspace; const _audit = deps.audit || appendAuditLog; const _dbRun = deps.dbRunFn || dbRun; const _activeMap = deps.activeMap || _activeAttempts; const maxAttempts = Number(config?.max_attempts) || DEFAULT_CONFIG.max_attempts; const ticketIdNum = Number(ticketId); const attemptNum = Number(attempt) || 0; // 1. Kill the live process if we tracked it. const tracked = _activeMap.get(ticketIdNum); if (tracked && tracked.pid) { try { await _kill(tracked.pid, ticketIdNum); } catch (killErr) { log.warn({ err: killErr.message, ticket_id: ticketIdNum }, 'stall: killStalledProcess threw (non-blocking)'); } } // 2. Cleanup workspace — best-effort. try { await _destroy(ticketIdNum); } catch (wsErr) { log.warn({ err: wsErr.message, ticket_id: ticketIdNum }, 'stall: workspace cleanup failed (non-blocking)'); } // 3. Retry vs terminal. const now = new Date(); const ts = now.toISOString(); let toState; if (attemptNum < maxAttempts) { const backoffMs = computeBackoff(attemptNum, config); const nextAt = new Date(now.getTime() + backoffMs).toISOString(); toState = 'retry_after'; try { await _dbRun( `UPDATE table_rows SET data = (data || jsonb_build_object( 'run_state', 'retry_after', 'run_next_attempt_after', $2::text, 'run_last_event_at', $3::text ) ) - 'run_stuck_check_baseline' - 'run_liveness_state' - 'run_state_entered_at' - 'run_current_tool', updated_at = NOW() WHERE table_id = $1 AND id = $4`, [TICKETS_TABLE_ID, nextAt, ts, ticketIdNum] ); } catch (err) { if (stats) stats.errors = (stats.errors || 0) + 1; log.error({ err, ticket_id: ticketIdNum }, 'stall: failed to flip → retry_after'); return { outcome: 'error' }; } if (stats) stats.stall_retried = (stats.stall_retried || 0) + 1; log.info( { ticket_id: ticketIdNum, attempt: attemptNum, backoffMs, nextAt, source, reason }, 'stall: enqueued retry' ); } else { toState = 'failed'; try { await _dbRun( `UPDATE table_rows SET data = (data || jsonb_build_object( 'run_state', 'failed', 'run_terminal_reason', $2::text, 'run_finished_at', $3::text, 'run_last_event_at', $3::text ) ) - 'run_next_attempt_after' - 'run_stuck_check_baseline' - 'run_liveness_state' - 'run_state_entered_at' - 'run_current_tool', updated_at = NOW() WHERE table_id = $1 AND id = $4`, [TICKETS_TABLE_ID, reason, ts, ticketIdNum] ); } catch (err) { if (stats) stats.errors = (stats.errors || 0) + 1; log.error({ err, ticket_id: ticketIdNum }, 'stall: failed to flip → failed'); return { outcome: 'error' }; } if (stats) stats.stall_failed = (stats.stall_failed || 0) + 1; log.warn( { ticket_id: ticketIdNum, attempt: attemptNum, source, reason }, 'stall: max_attempts exhausted — terminal failure' ); } // 4. Audit. ADR-150 P0: include `mode` so observers can tell the smart // and legacy paths apart in the run history. await _audit(ticketIdNum, { at: ts, from: prevState, to: toState, attempt: attemptNum, reason, mode: mode || (config?.smart_liveness_enabled === false ? 'legacy' : 'smart'), source: 'tick_part_a', tick_source: source, }); // 5. Free slot + clear in-mem FSM cache. _activeMap.delete(ticketIdNum); _fsmState.delete(ticketIdNum); return { outcome: toState }; } /** * ADR-150 P0 §AC5 — legacy conflated-clock stall path. * * Used when `config.smart_liveness_enabled === false`. Heartbeat and * meaningful event share `run_last_event_at`, so the threshold check is * effectively a wall-clock cap. Pure-DB filter (no /proc, no FSM): any * ticket in (preparing|running|streaming) whose `run_last_event_at` is * older than the threshold gets the kill path. * * Threshold lookup chain (matches the smart path so the same * `_workflow_config` row works in both modes): * `meaningful_event_idle_ms` → `stall_timeout_ms` (legacy alias) → * default 600_000. * * Returns the count of rows handled. */ export async function legacyStallKillIfStuck({ source = 'manual', stats, config, deps = {} } = {}) { const stallTimeoutMs = Number(config?.meaningful_event_idle_ms) || Number(config?.stall_timeout_ms) || DEFAULT_CONFIG.meaningful_event_idle_ms; const stallStateList = STALL_CHECK_RUN_STATES.map((s) => `'${s}'`).join(','); const _dbAll = deps.dbAllFn || dbAll; const rows = await _dbAll( ` SELECT id, COALESCE((data->>'run_attempt')::int, 0) AS run_attempt, data->>'run_state' AS run_state, (data->>'run_last_event_at')::timestamptz AS last_event_at FROM table_rows WHERE table_id = $1 AND data->>'run_state' IN (${stallStateList}) AND (data->>'run_last_event_at') IS NOT NULL AND (data->>'run_last_event_at')::timestamptz < NOW() - ($2::bigint * INTERVAL '1 millisecond') ORDER BY id ASC FOR UPDATE SKIP LOCKED `, [TICKETS_TABLE_ID, stallTimeoutMs] ); if (rows.length === 0) return 0; log.info({ source, count: rows.length, stallTimeoutMs, mode: 'legacy' }, 'stall_detect (legacy): found stalled tickets'); for (const row of rows) { if (stats) stats.stalled = (stats.stalled || 0) + 1; await killTicket(row.id, { prevState: row.run_state, attempt: row.run_attempt, reason: 'stall', mode: 'legacy', source, config, stats, deps, }); } return rows.length; } /** * ADR-150 P0 — split-timestamp stall detection tick. * * The check is a pure-DB filter on two JSONB timestamps: * - `run_last_event_at` must be older than `meaningful_event_idle_ms` * - `run_completion_intent_at` must be NULL or older than `closing_grace_ms` * * The completion-intent grace clause is the key change vs. the legacy path: * once the agent has fired `send_chat_message` / `update_ticket_status` → * Done / etc., it gets a 90 s window where no kill path can fire even if * `run_last_event_at` is technically over the idle threshold. This eliminates * the false-positive kill that used to happen during the natural quiet tail * of a turn. * * Threshold lookup chain (matches `legacyStallKillIfStuck` so the same * `_workflow_config` row works in both modes): * `meaningful_event_idle_ms` → `stall_timeout_ms` (legacy alias) → * default 600_000. * * AC8 rollback: `smart_liveness_enabled === false` skips this entirely and * delegates to `legacyStallKillIfStuck`. * * `deps` injection lets tests bypass the real DB / runner registry. Returns * the count of rows examined. */ async function _runStallDetect({ source, stats, config, deps = {} } = {}) { // AC8 — operator can flip back to legacy conflated-clock path. Two // escape hatches are wired: // - per-config `smart_liveness_enabled === false` (ADR-150 P0 carry-over) // - env `AGENT_LIVENESS_LEGACY=1` for runtime override without DB write if ( config?.smart_liveness_enabled === false || process.env.AGENT_LIVENESS_LEGACY === '1' ) { return legacyStallKillIfStuck({ source, stats, config, deps }); } const _dbAll = deps.dbAllFn || dbAll; const _classify = deps.classify || classifySecondarySignals; const _activeMap = deps.activeMap || _activeAttempts; const _now = typeof deps.nowMs === 'number' ? deps.nowMs : Date.now(); const stallStateList = STALL_CHECK_RUN_STATES.map((s) => `'${s}'`).join(','); const meaningfulIdleMs = Number(config?.meaningful_event_idle_ms) || Number(config?.stall_timeout_ms) || DEFAULT_CONFIG.meaningful_event_idle_ms; const closingGraceMs = Number(config?.closing_grace_ms) || DEFAULT_CONFIG.closing_grace_ms; // ADR-0042 §10 — stuck_check observation window. Resolved through the // canonical `effectiveThresholdMs` so an operator override on // `stuck_check_window_ms` propagates without code changes. const stuckCheckWindowMs = effectiveThresholdMs( STATES.STUCK_CHECK, null, config ); // ─── Sweep 1 (ADR-0042 Task 5b) — stuck_check rows with a baseline ── // For tickets the FSM has parked in `stuck_check`, the second-opinion // gate runs here: re-sample /proc, compare to the captured baseline, // and let the verdict drive the kill decision before the meaningful- // event idle clock or 4h backstop ever fire. // // We query separately from the legacy event-idle sweep because the // semantics differ: this sweep doesn't care whether `last_event_at` is // stale (the FSM has already decided the run is suspect by promoting // it into stuck_check); it cares about the verdict + state-window. const stuckRows = await _dbAll( ` SELECT id, COALESCE((data->>'run_attempt')::int, 0) AS run_attempt, data->>'run_state' AS run_state, data->>'run_liveness_state' AS liveness_state, (data->>'run_state_entered_at')::timestamptz AS state_entered_at, data->'run_stuck_check_baseline' AS stuck_check_baseline FROM table_rows WHERE table_id = $1 AND data->>'run_state' IN (${stallStateList}) AND data->>'run_liveness_state' = 'stuck_check' ORDER BY id ASC FOR UPDATE SKIP LOCKED `, [TICKETS_TABLE_ID] ); let secondarySignalKills = 0; for (const row of stuckRows) { const tracked = _activeMap.get(row.id); const pid = tracked && Number.isFinite(tracked.pid) ? tracked.pid : null; const baseline = row.stuck_check_baseline && typeof row.stuck_check_baseline === 'object' ? row.stuck_check_baseline.baseline : null; const baselineCpu = baseline && typeof baseline === 'object' ? baseline.cpu : null; if (!pid || !baselineCpu) { // Missing pid (ticket re-claimed by another worker?) or missing // baseline blob (entry path failed quietly). Fall through to the // legacy event-idle sweep — it will eventually catch this row. continue; } let verdict; let reasons = []; try { const result = await _classify(pid, baselineCpu); verdict = result?.verdict; reasons = Array.isArray(result?.reasons) ? result.reasons : []; } catch (err) { log.warn( { err: err.message, ticket_id: row.id, pid }, 'stall_detect: classify threw — leaving row for next tick' ); continue; } if (verdict === VERDICT.ALIVE) { // FSM will lift the row out of stuck_check on the next meaningful // event (`stuck_check is a hold` per state-machine.js). No-op here. log.debug( { ticket_id: row.id, pid, reasons }, 'stall_detect: stuck_check verdict=alive — keep waiting' ); continue; } if (verdict === VERDICT.DEAD) { if (stats) stats.stalled = (stats.stalled || 0) + 1; await killTicket(row.id, { prevState: row.run_state, attempt: row.run_attempt, reason: 'secondary_signals_dead', mode: 'smart', source, config, stats, deps, }); log.warn( { ticket_id: row.id, pid, reasons }, 'stall_detect: stuck_check verdict=dead — killed' ); secondarySignalKills += 1; continue; } // verdict === INCONCLUSIVE — kill only if the stuck_check window // has elapsed. Otherwise let the next tick re-sample (the baseline // stays put; classify() will get a fresh delta against it). const enteredAtMs = row.state_entered_at ? new Date(row.state_entered_at).getTime() : null; const dwellMs = enteredAtMs ? (_now - enteredAtMs) : null; if (dwellMs !== null && dwellMs > stuckCheckWindowMs) { if (stats) stats.stalled = (stats.stalled || 0) + 1; await killTicket(row.id, { prevState: row.run_state, attempt: row.run_attempt, reason: 'inconclusive_timeout', mode: 'smart', source, config, stats, deps, }); log.warn( { ticket_id: row.id, pid, dwellMs, stuckCheckWindowMs, reasons }, 'stall_detect: stuck_check inconclusive past window — killed' ); secondarySignalKills += 1; continue; } log.debug( { ticket_id: row.id, pid, dwellMs, stuckCheckWindowMs, reasons }, 'stall_detect: stuck_check verdict=inconclusive within window — re-sample next tick' ); } // ─── Sweep 2 — meaningful-event idle (legacy ADR-150 P0 path) ────── // Catches rows that never made it into stuck_check (FSM never promoted // them) but whose meaningful-event clock has nevertheless elapsed. // This path remains the safety net and matches pre-Task-5b behavior. const rows = await _dbAll( ` SELECT id, COALESCE((data->>'run_attempt')::int, 0) AS run_attempt, data->>'run_state' AS run_state, (data->>'run_last_event_at')::timestamptz AS last_event_at, (data->>'run_completion_intent_at')::timestamptz AS completion_intent_at FROM table_rows WHERE table_id = $1 AND data->>'run_state' IN (${stallStateList}) AND (data->>'run_last_event_at') IS NOT NULL AND (data->>'run_last_event_at')::timestamptz < NOW() - ($2::bigint * INTERVAL '1 millisecond') AND ( (data->>'run_completion_intent_at') IS NULL OR (data->>'run_completion_intent_at')::timestamptz < NOW() - ($3::bigint * INTERVAL '1 millisecond') ) ORDER BY id ASC FOR UPDATE SKIP LOCKED `, [TICKETS_TABLE_ID, meaningfulIdleMs, closingGraceMs] ); if (rows.length === 0) return secondarySignalKills; log.info( { source, count: rows.length, meaningfulIdleMs, closingGraceMs, mode: 'smart' }, 'stall_detect (smart): found stalled tickets' ); for (const row of rows) { if (stats) stats.stalled = (stats.stalled || 0) + 1; await killTicket(row.id, { prevState: row.run_state, attempt: row.run_attempt, reason: 'stall', mode: 'smart', source, config, stats, deps, }); } return rows.length + secondarySignalKills; } // Test-only export: lets the table-driven test drive the tick with mocked // classify/dbAll/activeMap/now without monkey-patching the live module. export const _runStallDetectImpl = (opts) => _runStallDetect(opts); /** * Atomically claim up to `limit` tickets ready for a run. * * Strategy: PostgreSQL `FOR UPDATE SKIP LOCKED` on table_rows row-locks, * combined with a JSONB-merge UPDATE that flips run_state to 'preparing' * and bumps run_attempt. Two concurrent dispatchers will never claim the * same row — the second sees `SKIP LOCKED` and moves on. * * Filter: * - table_id = TICKETS_TABLE_ID * - run_state IS NULL OR IN CLAIMABLE_RUN_STATES * - assigned_to IS NOT NULL AND assigned_to != '' * - state NOT IN terminal states (don't claim closed tickets) * - run_next_attempt_after IS NULL OR <= now() (retry backoff respect) * * Returns: claimed rows with id, agent_id, prev_run_state, new run_attempt. */ async function claimReady(limit) { if (limit <= 0) return []; const terminalList = TICKET_TERMINAL_STATES.map(s => `'${s}'`).join(','); const claimableList = CLAIMABLE_RUN_STATES.map(s => `'${s}'`).join(','); // The CTE selects + locks candidate rows; the UPDATE then flips them. // Doing it in one statement keeps the lock window minimal. // // Tickets must explicitly opt into the run loop by setting // run_state='idle' (or queued/retry_after). NULL run_state means // "not enrolled" and is intentionally excluded — most pre-P1 tickets // have no run_state field and must not be auto-claimed. const sql = ` WITH candidate AS ( SELECT id FROM table_rows WHERE table_id = $1 AND data->>'run_state' IN (${claimableList}) AND COALESCE(NULLIF(data->>'assigned_to', ''), NULL) IS NOT NULL AND COALESCE(data->>'state', '') NOT IN (${terminalList}) AND ( data->>'run_next_attempt_after' IS NULL OR (data->>'run_next_attempt_after')::timestamptz <= NOW() ) ORDER BY created_at ASC, id ASC LIMIT $2 FOR UPDATE SKIP LOCKED ) UPDATE table_rows tr SET data = tr.data || jsonb_build_object( 'run_state', 'preparing', 'run_started_at', COALESCE(tr.data->>'run_started_at', to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')), 'run_last_event_at', to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), 'run_attempt', COALESCE((tr.data->>'run_attempt')::int, 0) + 1 ), updated_at = NOW() FROM candidate c WHERE tr.id = c.id RETURNING tr.id, tr.data->>'assigned_to' AS assigned_to, (tr.data->>'run_attempt')::int AS run_attempt `; return await dbAll(sql, [TICKETS_TABLE_ID, limit]); } /** * Dry-run terminal: flip preparing → canceled with phase2_dryrun reason. * Phase 3 retains this for `RUN_DISPATCHER_PHASE='dryrun'` (default) and * for rollback. Phase 4 will replace the workspace path with handler spawn. */ async function dryRunCancel(ticketId) { const ts = new Date().toISOString(); await dbRun( `UPDATE table_rows SET data = data || jsonb_build_object( 'run_state', 'canceled', 'run_terminal_reason', 'phase2_dryrun', 'run_finished_at', $2::text, 'run_last_event_at', $2::text ), updated_at = NOW() WHERE table_id = $1 AND id = $3`, [TICKETS_TABLE_ID, ts, ticketId] ); } /** * Phase 3 terminal: workspace was materialized, store its path, then flip * preparing → canceled with phase3_workspace_only reason. The cancel keeps * the loop terminating cleanly until Phase 4 wires the claude CLI. */ async function phase3WorkspaceOnlyCancel(ticketId, workspacePath) { const ts = new Date().toISOString(); await dbRun( `UPDATE table_rows SET data = data || jsonb_build_object( 'run_state', 'canceled', 'run_terminal_reason', 'phase3_workspace_only', 'run_workspace_path', $4::text, 'run_finished_at', $2::text, 'run_last_event_at', $2::text ), updated_at = NOW() WHERE table_id = $1 AND id = $3`, [TICKETS_TABLE_ID, ts, ticketId, workspacePath] ); } /** * Phase 4 helpers. * * Flip preparing → running. We don't carry prev state back from RETURNING * in claimReady, so we always set 'running' unconditionally — concurrency * is already protected by the row-level lock. */ async function transitionToRunning(ticketId) { const ts = new Date().toISOString(); await dbRun( `UPDATE table_rows SET data = data || jsonb_build_object( 'run_state', 'running', 'run_last_event_at', $2::text ), updated_at = NOW() WHERE table_id = $1 AND id = $3`, [TICKETS_TABLE_ID, ts, ticketId] ); } /** * ADR-150 P0 — bump `run_last_event_at`. Fires for every meaningful NDJSON * event the runner emits; this is the freshness signal stall detection * actually reads. Cheap UPDATE: single jsonb_set, fire-and-forget. */ export async function bumpLastEventAt(ticketId) { if (!ticketId) return; const ts = new Date().toISOString(); try { await dbRun( `UPDATE table_rows SET data = jsonb_set(data, '{run_last_event_at}', to_jsonb($2::text), true) WHERE table_id = $1 AND id = $3`, [TICKETS_TABLE_ID, ts, ticketId] ); } catch (err) { log.warn({ err: err.message, ticket_id: ticketId }, 'bumpLastEventAt failed (non-blocking)'); } } /** * Apply terminal Phase 4 transition based on stream handler summary. * Does NOT touch ticket `state` — only run_* columns. The ticket's * lifecycle stays under the human's control. */ async function applyRunTerminal(ticketId, summary) { const ts = new Date().toISOString(); let runState; let terminalReason; if (summary.finalStatus === 'success') { runState = 'succeeded'; terminalReason = 'completed'; } else if (summary.finalStatus === 'timeout') { runState = 'failed'; // ADR-150 P0: stream handler now emits terminalReason='backstop' on // wall-clock kill. Honor it; otherwise keep the historical 'timeout'. terminalReason = summary.terminalReason || 'timeout'; } else { runState = 'failed'; terminalReason = summary.terminalReason || 'runner_failed'; } const updates = { run_state: runState, run_terminal_reason: terminalReason, run_finished_at: ts, run_last_event_at: ts, run_duration_ms: summary.durationMs, run_event_count: summary.eventCount, }; if (summary.lastError) { // Truncate to 500 chars per brief. updates.run_terminal_error = String(summary.lastError).slice(-500); } if (summary.exitCode != null) { updates.run_exit_code = summary.exitCode; } await dbRun( `UPDATE table_rows SET data = data || $2::jsonb, updated_at = NOW() WHERE table_id = $1 AND id = $3`, [TICKETS_TABLE_ID, JSON.stringify(updates), ticketId] ); } /** * Resolve the conversation_id bound to a given ticket. Returns null if no * conversation is attached — caller should silently skip the chat post. * * Two strategies, in order: * 1. ticket.data.chat_conversation_id (explicit denorm — preferred). * 2. SELECT id FROM conversations WHERE bound_table_id=1708 AND bound_row_id= * ORDER BY id ASC LIMIT 1 (fallback — works for tickets that had a * chat created via the standard binding path). */ async function findTicketConversationId(ticketId, ticketRow) { const explicit = ticketRow?.chat_conversation_id; if (explicit) { const n = Number(explicit); if (Number.isInteger(n) && n > 0) return n; } try { const row = await dbGet( `SELECT id FROM conversations WHERE bound_table_id = $1 AND bound_row_id = $2 ORDER BY id ASC LIMIT 1`, [TICKETS_TABLE_ID, ticketId] ); if (row?.id) return Number(row.id); } catch (err) { log.debug({ err: err.message, ticket_id: ticketId }, 'conversation lookup failed'); } return null; } /** * Post a system-style approval message to the ticket's chat. Sender is * 'system'/null user. Returns true if posted, false if no conversation. * * IMPORTANT: caller passes plaintext code — this function sends it once * to the chat (the only acceptable destination) and never persists or * logs it elsewhere. The redaction marker `[approval-code]` lets a future * hook scrub it from chat history if needed. */ async function postApprovalCodeToChat({ ticketId, ticketRow, code, expiresAt }) { const conversationId = await findTicketConversationId(ticketId, ticketRow); if (!conversationId) { log.warn( { ticket_id: ticketId }, 'no conversation bound to ticket — approval code cannot be posted to chat' ); return false; } const expiresMin = Math.round(APPROVAL_CONSTANTS.TTL_MS / 60_000); const content = `🔐 Approval required for run on ticket T-${ticketId}.\n\n` + `[approval-code]\`${code}\`[/approval-code]\n\n` + `Expires in ${expiresMin} min. Submit:\n` + `\`POST /api/v3/admin/agent-run-dispatcher/approve/${ticketId}\` ` + `with body \`{"code":"${code}"}\`. ` + `Expires at ${expiresAt}.`; try { await dbRun( `INSERT INTO messages (conversation_id, sender_id, sender_type, role, content, content_type, agent_id, created_at, updated_at) VALUES ($1, NULL, 'system', 'system', $2, 'markdown', NULL, NOW(), NOW())`, [conversationId, content] ); log.info( { ticket_id: ticketId, conversation_id: conversationId, code: '' }, 'approval code posted to chat' ); return true; } catch (err) { log.warn( { err: err.message, ticket_id: ticketId }, 'failed to post approval code to chat' ); return false; } } /** * Post the agent's text output as a single message into the ticket chat. * Best-effort — never throws to caller. If conversation doesn't exist, log * + skip (per Phase 4 brief: must not crash on missing chat). */ async function postRunOutputToChat({ ticketId, ticketRow, agentId, content }) { if (!content || !String(content).trim()) { log.debug({ ticket_id: ticketId }, 'no content to post to chat — skipping'); return; } const conversationId = await findTicketConversationId(ticketId, ticketRow); if (!conversationId) { log.warn({ ticket_id: ticketId }, 'no conversation bound to ticket — skipping chat post'); return; } try { await dbRun( `INSERT INTO messages (conversation_id, sender_id, sender_type, role, content, content_type, agent_id, created_at, updated_at) VALUES ($1, NULL, 'agent', 'assistant', $2, 'markdown', $3, NOW(), NOW())`, [conversationId, String(content), agentId != null ? Number(agentId) || null : null] ); log.info({ ticket_id: ticketId, conversation_id: conversationId, content_len: content.length }, 'posted run output to chat'); } catch (err) { log.warn({ err: err.message, ticket_id: ticketId }, 'failed to post run output to chat (non-blocking)'); } } /** * Phase 4 live execution path. Runs a single ticket end-to-end: * createWorkspace → preparing→running → buildPrompt → runStreamHandler → * apply terminal → post chat → destroyWorkspace. * * Always destroys workspace + decrements active map in finally so a crash * never leaks state. Returns one of: 'succeeded', 'failed', 'timeout', * 'workspace_create_failed'. */ async function processLive({ row, source, stats }) { let ws = null; let outputContent = ''; let agentId = row.assigned_to; try { // 1. Workspace. ws = await createWorkspace(row.id); stats.workspaces_created++; _activeAttempts.set(row.id, { agent_id: agentId, claimedAt: Date.now(), workspacePath: ws.path, }); log.info( { ticket_id: row.id, workspace_path: ws.path, branch: ws.branch, reused: !!ws.reused }, 'phase4: workspace materialized' ); // Persist workspace path on the ticket so observers can see it. await dbRun( `UPDATE table_rows SET data = jsonb_set(data, '{run_workspace_path}', to_jsonb($2::text), true), updated_at = NOW() WHERE table_id = $1 AND id = $3`, [TICKETS_TABLE_ID, ws.path, row.id] ); // 2. Build prompt. const { prompt, ticketRow } = await buildRunPrompt({ ticketId: row.id, agentId, }); // 2.5 Approval gate (Phase 5). Default ON in 'live' phase. Blocks here // until human approves via POST /admin/agent-run-dispatcher/approve/:id // (or hits 10-min TTL / 5 wrong attempts). Skipped entirely when // RUN_REQUIRE_APPROVAL=false (smoke tests + opt-out). if (RUN_REQUIRE_APPROVAL) { const { code, code_hash, expires_at, generated_at } = generateApprovalCode(); await persistApprovalRequest(row.id, { code_hash, expires_at, generated_at }); stats.awaiting_approval++; await appendAuditLog(row.id, { at: new Date().toISOString(), from: 'preparing', to: 'awaiting_approval', attempt: row.run_attempt, reason: 'approval_required', source, expires_at, // code/code_hash intentionally omitted — never logged. }); // Post the code to chat. The plaintext `code` lives only in this // local frame; we drop the reference immediately after. await postApprovalCodeToChat({ ticketId: row.id, ticketRow, code, expiresAt: expires_at, }); const { outcome, attempts } = await awaitApproval(row.id); if (outcome === APPROVAL_OUTCOMES.APPROVED) { stats.approval_approved++; await appendAuditLog(row.id, { at: new Date().toISOString(), from: 'awaiting_approval', to: 'preparing', attempt: row.run_attempt, reason: 'approval_granted', source, attempts, }); // resolveApproval already flipped run_state to 'preparing'. } else { // denied or expired: terminal failure, skip runStreamHandler. const terminalReason = outcome === APPROVAL_OUTCOMES.DENIED ? 'approval_denied' : 'approval_timeout'; stats[outcome === APPROVAL_OUTCOMES.DENIED ? 'approval_denied' : 'approval_expired']++; await applyRunTerminal(row.id, { finalStatus: 'failed', durationMs: 0, eventCount: 0, exitCode: null, lastError: terminalReason, }); // applyRunTerminal sets run_terminal_reason='runner_failed' for // generic failed → patch it to the approval-specific reason. await dbRun( `UPDATE table_rows SET data = jsonb_set(data, '{run_terminal_reason}', to_jsonb($2::text), true), updated_at = NOW() WHERE table_id = $1 AND id = $3`, [TICKETS_TABLE_ID, terminalReason, row.id] ); await appendAuditLog(row.id, { at: new Date().toISOString(), from: 'awaiting_approval', to: 'failed', attempt: row.run_attempt, reason: terminalReason, source, attempts, }); stats.live_failed++; return 'failed'; } } // 3. preparing → running. await transitionToRunning(row.id); await appendAuditLog(row.id, { at: new Date().toISOString(), from: 'preparing', to: 'running', attempt: row.run_attempt, reason: 'live_run_started', source, workspace_path: ws.path, }); // 4. Stream handler. ADR-150 P0 wiring: // - onEvent → capture output, audit (and FSM tracker for // observability — does not drive stall detection // anymore). // - onMeaningfulEvent → bumpLastEventAt + (if completion-intent) // bumpCompletionIntentAt. This is the freshness // signal stall detection actually reads. // - onHeartbeat → bumpHeartbeatAt (writes run_last_heartbeat_at). // When smart_liveness is OFF, ALSO bumps // run_last_event_at (legacy conflated mode). // - backstopMs → 4h smart, 30 min legacy. let lastAuditAt = 0; warnLegacyOnce(); const config = await loadConfig(); const smartLivenessEnabled = config?.smart_liveness_enabled !== false; const terminalStates = Array.isArray(config?.completion_intent_terminal_states) ? config.completion_intent_terminal_states : DEFAULT_CONFIG.completion_intent_terminal_states; const closingTools = Array.isArray(config?.completion_intent_tools) ? config.completion_intent_tools : DEFAULT_CONFIG.completion_intent_tools; const backstopMs = smartLivenessEnabled ? (Number(config?.runner_backstop_ms) || DEFAULT_CONFIG.runner_backstop_ms) : 30 * 60 * 1000; // legacy conflated mode reverts to 30-min cap (AC5) // ADR-0042 Task 6 — pass-through of the warn ratio. Out-of-range values // are sanitized inside `runStreamHandler`; we just forward the config // value verbatim and let the runner fall back to DEFAULT_BACKSTOP_WARN_RATIO. const backstopWarnRatio = Number(config?.backstop_warn_ratio) || DEFAULT_CONFIG.backstop_warn_ratio; const summary = await runStreamHandler({ ticketId: row.id, workspacePath: ws.path, prompt, agentId, backstopMs, backstopWarnRatio, onSpawn: ({ pid }) => { // Phase 6: record the PID so a future tick's stall detector can // signal it. The same _activeAttempts entry was set above; we // mutate in place so concurrent reads stay consistent. const entry = _activeAttempts.get(row.id); if (entry) entry.pid = pid; }, onEvent: (evt) => { // Capture model output content for chat posting. if (evt && evt.type === 'output' && typeof evt.content === 'string') { outputContent = evt.content; } // FSM tracker — observability only; stall path no longer reads // run_liveness_state under ADR-150 P0. Fire-and-forget. onMeaningfulEvent(row.id, evt).catch((err) => { log.debug({ err: err.message, ticket_id: row.id }, 'onMeaningfulEvent threw'); }); // Audit rate limit — at most 1 entry per AUDIT_RATE_LIMIT_MS per ticket. const now = Date.now(); if (now - lastAuditAt >= AUDIT_RATE_LIMIT_MS) { lastAuditAt = now; appendAuditLog(row.id, { at: new Date().toISOString(), event_type: evt?.type || 'unknown', event_message: typeof evt?.message === 'string' ? evt.message.slice(0, 200) : undefined, source: 'stream_event', }).catch(() => {}); } }, onMeaningfulEvent: (evt) => { // ADR-150 P0: bump the meaningful-event freshness clock. This is // the ONLY place run_last_event_at moves during a live run. bumpLastEventAt(row.id).catch(() => {}); // Completion-intent edge: stamp run_completion_intent_at so the // stall tick's grace clause can shield the closing window. if (evtIsCompletionIntent(evt, { closingTools, terminalStates })) { bumpCompletionIntentAt(row.id).catch(() => {}); } }, onHeartbeat: () => { // ADR-150 P0: heartbeat writes ONLY run_last_heartbeat_at. The // 15s timer must NOT keep run_last_event_at fresh — that was the // bug that masked real stalls. bumpHeartbeatAt(row.id).catch(() => {}); if (!smartLivenessEnabled) { // Legacy conflated mode: heartbeat ALSO bumps run_last_event_at // so the legacy threshold check has the same shape it always did. bumpLastEventAt(row.id).catch(() => {}); } }, onBackstopWarn: (warn) => { // ADR-0042 Task 6 — single audit edge ~3h before the 4h backstop. // `persistBackstopWarn` returns true only when the conditional // UPDATE actually flipped the row (DB-level idempotency); on a // resumed run where the flag is already set, we skip the audit. persistBackstopWarn(row.id, warn) .then((firstWarn) => { if (!firstWarn) return; return appendAuditLog(row.id, { at: warn.at, event_type: 'backstop_warn', elapsed_ms: warn.elapsedMs, ratio: warn.ratio, threshold_ms: warn.thresholdMs, backstop_ms: warn.backstopMs, source: 'run_stream_handler', }); }) .catch(() => {}); }, }); // 5. Terminal transition. await applyRunTerminal(row.id, summary); await appendAuditLog(row.id, { at: new Date().toISOString(), from: 'running', to: summary.finalStatus === 'success' ? 'succeeded' : 'failed', attempt: row.run_attempt, reason: summary.finalStatus === 'success' ? 'completed' : summary.finalStatus === 'timeout' ? 'timeout' : 'runner_failed', source, duration_ms: summary.durationMs, event_count: summary.eventCount, exit_code: summary.exitCode, }); // 6. Chat post (success only — failures don't get posted to user-facing // chat; they live in the audit log + run_terminal_error). if (summary.finalStatus === 'success' && outputContent) { await postRunOutputToChat({ ticketId: row.id, ticketRow, agentId, content: outputContent, }); } if (summary.finalStatus === 'success') stats.live_succeeded++; else if (summary.finalStatus === 'timeout') stats.live_timeout++; else stats.live_failed++; return summary.finalStatus; } catch (err) { stats.errors++; log.error({ err, ticket_id: row.id }, 'phase4: processLive failed — flipping to failed'); try { await applyRunTerminal(row.id, { finalStatus: 'failed', durationMs: 0, eventCount: 0, exitCode: null, lastError: err.message || String(err), }); await appendAuditLog(row.id, { at: new Date().toISOString(), from: 'preparing', to: 'failed', attempt: row.run_attempt, reason: 'live_path_exception', source, error: err.message, }); } catch (innerErr) { log.error({ err: innerErr, ticket_id: row.id }, 'phase4: failed to record terminal failure'); } stats.live_failed++; return 'failed'; } finally { // Always cleanup workspace + active map, even on failure paths. if (ws) { try { await destroyWorkspace(row.id); } catch (cleanupErr) { log.warn({ err: cleanupErr.message, ticket_id: row.id }, 'phase4: workspace cleanup failed'); } } _activeAttempts.delete(row.id); _fsmState.delete(row.id); } } /** * One full tick. Safe to call manually from admin route. */ export async function runTick({ source = 'interval' } = {}) { if (isTicking) { log.debug({ source }, 'tick already in progress; skipping'); return { skipped: true, reason: 'already_ticking' }; } isTicking = true; const startedAt = Date.now(); const stats = { picked: 0, transitioned: 0, canceled_dryrun: 0, canceled_workspace_only: 0, workspaces_created: 0, live_succeeded: 0, live_failed: 0, live_timeout: 0, awaiting_approval: 0, approval_approved: 0, approval_denied: 0, approval_expired: 0, // Phase 6 stall detection counters. stalled: 0, stall_retried: 0, stall_failed: 0, errors: 0, paused: false, }; try { const config = await loadConfig(); // Update poll interval if config changed (will take effect next tick). if (typeof config.poll_interval_ms === 'number' && config.poll_interval_ms !== currentPollIntervalMs) { log.info( { old_ms: currentPollIntervalMs, new_ms: config.poll_interval_ms }, 'poll_interval_ms changed — re-arming interval' ); currentPollIntervalMs = config.poll_interval_ms; _rearmInterval(); } if (config.paused === true) { stats.paused = true; log.debug({ source }, 'config.paused=true — skipping tick'); return { ...stats, duration_ms: Date.now() - startedAt }; } // ─── Part A: stall detection (Phase 6) ───────────────────── // Find tickets stuck in (preparing|running|streaming) past // stall_timeout_ms with no recent run_last_event_at, kill any tracked // PID, retry with exponential backoff, or terminally fail when // attempts are exhausted. awaiting_approval is excluded by design. try { await _runStallDetect({ source, stats, config }); } catch (err) { stats.errors++; log.error({ err }, 'tick: stall_detect failed'); } // ─── Part B: claim ready work ────────────────────────────── const headroom = Math.max(0, (config.max_concurrent_runs || 3) - _activeAttempts.size); if (headroom === 0) { log.debug({ active: _activeAttempts.size }, 'no headroom — skipping claim'); return { ...stats, duration_ms: Date.now() - startedAt }; } const claimed = await claimReady(headroom); stats.picked = claimed.length; for (const row of claimed) { try { // Audit: idle/queued/retry_after → preparing await appendAuditLog(row.id, { at: new Date().toISOString(), from: '', // we don't carry it back from RETURNING; cheap to omit to: 'preparing', attempt: row.run_attempt, reason: 'claimed_by_dispatcher', source, }); stats.transitioned++; if (RUN_DISPATCHER_PHASE === 'live') { // Phase 4: full pipeline — workspace + prompt + claude + chat post. // processLive owns its own audit + cleanup; we just await it here. await processLive({ row, source, stats }); continue; } if (RUN_DISPATCHER_PHASE === 'workspace_only') { // Phase 3: materialize git worktree, store path, then cancel. let ws; try { ws = await createWorkspace(row.id); stats.workspaces_created++; // Track in active map so shutdown() can clean it up. _activeAttempts.set(row.id, { agent_id: row.assigned_to, claimedAt: Date.now(), workspacePath: ws.path, }); log.info( { ticket_id: row.id, workspace_path: ws.path, branch: ws.branch, reused: !!ws.reused }, 'phase3: workspace materialized' ); } catch (wsErr) { stats.errors++; log.error({ err: wsErr, ticket_id: row.id }, 'phase3: workspace creation failed — falling back to dryrun cancel'); await dryRunCancel(row.id); await appendAuditLog(row.id, { at: new Date().toISOString(), from: 'preparing', to: 'canceled', attempt: row.run_attempt, reason: 'workspace_create_failed', source, error: wsErr.message, }); stats.canceled_dryrun++; continue; } await phase3WorkspaceOnlyCancel(row.id, ws.path); await appendAuditLog(row.id, { at: new Date().toISOString(), from: 'preparing', to: 'canceled', attempt: row.run_attempt, reason: 'phase3_workspace_only', source, workspace_path: ws.path, branch: ws.branch, }); stats.canceled_workspace_only++; // Workspace is intentionally LEFT on disk for inspection/debug. // Phase 4 will own its lifecycle (destroy after handler exits). // shutdown() still cleans up _activeAttempts entries on graceful stop. } else { // Phase 2 dry-run: immediately cancel (default). await dryRunCancel(row.id); await appendAuditLog(row.id, { at: new Date().toISOString(), from: 'preparing', to: 'canceled', attempt: row.run_attempt, reason: 'phase2_dryrun', source, }); stats.canceled_dryrun++; } } catch (err) { stats.errors++; log.error({ err, ticket_id: row.id }, 'tick: error processing claimed ticket'); } } } catch (err) { stats.errors++; log.error({ err }, 'tick: unhandled error'); } finally { isTicking = false; } const duration_ms = Date.now() - startedAt; if (stats.picked > 0 || stats.errors > 0) { log.info({ ...stats, duration_ms, source }, 'agent_run_dispatcher tick complete'); } else { log.debug({ ...stats, duration_ms, source }, 'agent_run_dispatcher tick (idle)'); } return { ...stats, duration_ms }; } function _rearmInterval() { if (intervalHandle) { clearInterval(intervalHandle); intervalHandle = setInterval(() => { runTick({ source: 'interval' }).catch((err) => log.error({ err }, 'periodic tick failed')); }, currentPollIntervalMs); intervalHandle.unref?.(); } } // ─── Module Lifecycle ────────────────────────────────────────── export async function init() { if (intervalHandle) return health(); if (process.env.AGENT_RUN_DISPATCHER_ENABLED !== 'true') { log.info( { flag: 'AGENT_RUN_DISPATCHER_ENABLED', value: process.env.AGENT_RUN_DISPATCHER_ENABLED || '' }, 'agent_run_dispatcher disabled by feature flag — not starting' ); return { ok: false, running: false, disabled: true }; } // Pre-load config so the first tick uses real poll_interval_ms. await loadConfig({ force: true }); currentPollIntervalMs = cachedConfig?.poll_interval_ms || FALLBACK_POLL_INTERVAL_MS; log.info( { intervalMs: currentPollIntervalMs, firstDelayMs: FIRST_TICK_DELAY_MS, phase: RUN_DISPATCHER_PHASE }, `starting agent_run_dispatcher (RUN_DISPATCHER_PHASE=${RUN_DISPATCHER_PHASE})` ); // ADR-150 P0: log resolved values of the four smart-liveness knobs once // at boot so operators can confirm the active configuration without // tailing per-tick output. log.info( { smart_liveness_enabled: cachedConfig?.smart_liveness_enabled !== false, meaningful_event_idle_ms: Number(cachedConfig?.meaningful_event_idle_ms) || Number(cachedConfig?.stall_timeout_ms) || DEFAULT_CONFIG.meaningful_event_idle_ms, closing_grace_ms: Number(cachedConfig?.closing_grace_ms) || DEFAULT_CONFIG.closing_grace_ms, runner_backstop_ms: Number(cachedConfig?.runner_backstop_ms) || DEFAULT_CONFIG.runner_backstop_ms, }, 'agent_run_dispatcher: ADR-150 P0 smart-liveness knobs (resolved)' ); firstTickTimer = setTimeout(() => { runTick({ source: 'first_tick' }).catch((err) => log.error({ err }, 'first tick failed')); }, FIRST_TICK_DELAY_MS); intervalHandle = setInterval(() => { runTick({ source: 'interval' }).catch((err) => log.error({ err }, 'periodic tick failed')); }, currentPollIntervalMs); intervalHandle.unref?.(); firstTickTimer.unref?.(); return health(); } export async function shutdown() { if (firstTickTimer) { clearTimeout(firstTickTimer); firstTickTimer = null; } if (intervalHandle) { clearInterval(intervalHandle); intervalHandle = null; log.info('agent_run_dispatcher stopped'); } // Phase 3: clean up any worktrees we still consider "active" so we don't // leak orphaned dirs across restarts. Use Promise.allSettled — one // failure mustn't block the rest. if (_activeAttempts.size > 0) { const ticketIds = Array.from(_activeAttempts.keys()); log.info({ count: ticketIds.length }, 'shutdown: cleaning up active workspaces'); const results = await Promise.allSettled( ticketIds.map((id) => destroyWorkspace(id)) ); let cleaned = 0; let failed = 0; results.forEach((r, idx) => { if (r.status === 'fulfilled') cleaned++; else { failed++; log.warn({ ticket_id: ticketIds[idx], err: r.reason?.message }, 'shutdown: workspace cleanup failed'); } }); _activeAttempts.clear(); log.info({ cleaned, failed }, 'shutdown: workspace cleanup complete'); } } export function health() { // In Phase 4 every entry in _activeAttempts represents a live runner // currently executing (we delete on terminal). For dryrun/workspace_only // phases the count is a no-op signal. const liveAttemptsCount = RUN_DISPATCHER_PHASE === 'live' ? _activeAttempts.size : 0; // Phase 5: count tickets currently in awaiting_approval. Best-effort: // failures fall back to undefined so health endpoint never crashes. let awaitingApprovalCount; return { ok: !!intervalHandle, running: !!intervalHandle, isTicking, intervalMs: currentPollIntervalMs, activeAttempts: _activeAttempts.size, liveAttemptsCount, awaitingApprovalCount, // Filled async via _awaitingApprovalCount when polled; sync default undefined. phase: RUN_DISPATCHER_PHASE, requireApproval: RUN_REQUIRE_APPROVAL, config: cachedConfig ? { poll_interval_ms: cachedConfig.poll_interval_ms, max_concurrent_runs: cachedConfig.max_concurrent_runs, paused: cachedConfig.paused, } : null, }; } /** * Async health flavor — adds awaitingApprovalCount. Use from admin /health * route when you want the live count. Sync `health()` stays cheap. */ export async function healthAsync() { const base = health(); try { const row = await dbGet( `SELECT COUNT(*)::int AS n FROM table_rows WHERE table_id = $1 AND data->>'run_state' = 'awaiting_approval'`, [TICKETS_TABLE_ID] ); base.awaitingApprovalCount = Number(row?.n) || 0; } catch (err) { log.debug({ err: err.message }, 'healthAsync: awaitingApprovalCount lookup failed'); base.awaitingApprovalCount = null; } return base; } // Phase 6 smoke-test affordance: expose the in-memory active-attempts map // so tests can inject a PID for the SIGTERM/SIGKILL code path. Production // code MUST NOT mutate this directly; `processLive` owns it. export function _getActiveAttemptsForTest() { return _activeAttempts; } // ─── T-148528 (WP-B) — user-initiated abort ────────────────── // // The /stop button in chat lands here (via streamController). The legacy // `cancelJob(jobId)` path only kills `agent_jobs` workers; dispatcher runs // never create that row, so without this hook the child claude-code keeps // eating tokens after a user pressed Stop. // // Contract: // - `abortRun(rowId, { reason })` is idempotent. Re-entry on an already- // dead/missing run is a no-op that resolves `{ aborted:false }`. // - Process kill uses the process-group trick (`process.kill(-pid, ...)`) // so child shells / MCP spawns get cleaned up too. Falls back to // direct-pid kill if the PGID path raises ESRCH. // - Terminal DB writes mark `run_state='canceled'`, `run_terminal_reason // ='user_stop'`, `run_finished_at=now()` — independently of whether // applyRunTerminal eventually fires from processLive's catch block. // - The conversation lookup intentionally does NOT depend on the // `_agent_runs` table (that name was floated in ADR-0030 drafts but // never landed). We walk `conversations.bound_row_id` → ticket.id. const ABORT_SIGKILL_GRACE_MS = 5_000; async function _persistRunCanceled(rowId, reason) { const nowIso = new Date().toISOString(); try { await dbRun( `UPDATE table_rows SET data = COALESCE(data, '{}'::jsonb) || jsonb_build_object( 'run_state', 'canceled'::text, 'run_terminal_reason', $2::text, 'run_finished_at', $3::text, 'run_last_event_at', $3::text ), updated_at = NOW() WHERE table_id = $1 AND id = $4`, [TICKETS_TABLE_ID, reason || 'user_stop', nowIso, rowId] ); } catch (err) { log.warn({ err: err.message, ticket_id: rowId }, 'abortRun: persist canceled state failed (non-blocking)'); } await appendAuditLog(rowId, { at: nowIso, event_type: 'aborted', reason: reason || 'user_stop', source: 'abort_run', }); } /** * Abort an active dispatcher run for a single ticket row. * * Looks up the live PID in `_activeAttempts`. Sends SIGTERM to the * process group, waits up to 5s, then escalates to SIGKILL if the child * is still alive. Always writes `run_state='canceled'` so observers * (incl. the next runTick) treat this as terminal even if the kill * raced with a natural exit. * * @param {number} rowId - ticket row id (table 1708) * @param {{reason?: string}} [opts] * @returns {Promise<{aborted: boolean, pid?: number, reason: string, escalated?: boolean}>} */ export async function abortRun(rowId, { reason = 'user_stop' } = {}) { if (!rowId || typeof rowId !== 'number') { return { aborted: false, reason: 'invalid_row_id' }; } const entry = _activeAttempts.get(rowId); const pid = entry?.pid; // Persist terminal state regardless — if a stale `_activeAttempts` entry // is missing the PID, we still flip the ticket so future ticks don't // re-pick it up. await _persistRunCanceled(rowId, reason); // Drop the active-attempts entry up front so concurrent ticks don't // treat this run as still live. _activeAttempts.delete(rowId); if (!pid || typeof pid !== 'number' || pid <= 0) { log.info({ ticket_id: rowId, reason }, 'abortRun: no live PID — terminal state persisted, nothing to signal'); return { aborted: true, reason, pid: null }; } if (!probeAlive(pid)) { log.info({ ticket_id: rowId, pid, reason }, 'abortRun: pid already gone — nothing to signal'); return { aborted: true, reason, pid, escalated: false }; } // SIGTERM the process group so child shells / MCP spawns get cleaned up. try { process.kill(-pid, 'SIGTERM'); log.info({ ticket_id: rowId, pid, reason }, 'abortRun: SIGTERM sent to process group'); } catch (err) { if (err && err.code === 'ESRCH') { log.debug({ ticket_id: rowId, pid }, 'abortRun: PGID kill ESRCH — falling back to direct PID'); try { process.kill(pid, 'SIGTERM'); } catch (innerErr) { /* already gone */ void innerErr; } } else { log.warn({ err: err.message, ticket_id: rowId, pid }, 'abortRun: SIGTERM failed (non-fatal)'); } } // SIGKILL grace timer. We do NOT await it — abortRun must return // quickly so the HTTP handler can release. The timer unref()s so it // doesn't keep the event loop alive on shutdown. const grace = setTimeout(() => { if (!probeAlive(pid)) return; try { process.kill(-pid, 'SIGKILL'); log.warn({ ticket_id: rowId, pid }, 'abortRun: SIGKILL sent (SIGTERM grace expired)'); } catch (err) { if (!err || err.code !== 'ESRCH') { log.warn({ err: err.message, ticket_id: rowId, pid }, 'abortRun: SIGKILL failed'); } } }, ABORT_SIGKILL_GRACE_MS); grace.unref?.(); return { aborted: true, reason, pid }; } /** * Abort any active dispatcher run associated with a chat conversation. * * Walks `conversations.bound_row_id` → ticket row id, then checks whether * that ticket is currently in `_activeAttempts`. If yes, delegates to * `abortRun`. Safe to call for conversations that have no bound ticket * (returns `{aborted: false}` without touching anything). * * @param {number|string} conversationId * @param {{reason?: string}} [opts] * @returns {Promise<{aborted: boolean, reason: string, pid?: number|null, ticket_id?: number|null}>} */ export async function abortRunByConversation(conversationId, { reason = 'user_stop' } = {}) { if (!conversationId) { return { aborted: false, reason: 'invalid_conversation_id' }; } let conv; try { conv = await dbGet( `SELECT bound_row_id, bound_table_id FROM conversations WHERE id = $1`, [conversationId] ); } catch (err) { log.warn({ err: err.message, conversationId }, 'abortRunByConversation: conversation lookup failed'); return { aborted: false, reason: 'lookup_failed' }; } const ticketRowId = conv?.bound_row_id ? Number(conv.bound_row_id) : null; // Tolerate both bound_table_id present (canonical) and missing (older rows). // The lookup is the authoritative signal — `_activeAttempts` keys are ticket // row ids regardless of which table they live in. if (!ticketRowId || !_activeAttempts.has(ticketRowId)) { return { aborted: false, reason: 'no_active_run', ticket_id: ticketRowId }; } const result = await abortRun(ticketRowId, { reason }); return { ...result, ticket_id: ticketRowId }; } export default { init, shutdown, health, healthAsync, runTick, loadConfig, runStallDetect, computeBackoff, abortRun, abortRunByConversation, };