Files
gbrain/src/core/minions/queue.ts
T
b313938e86 fix(queue): dead/cancelled jobs no longer block idempotency re-submission (#2253) (#3306)
queue.add() with an idempotency_key returns any existing row regardless
of status. This means dead jobs (exhausted retries from a transient
provider outage) permanently block re-submission of the same work —
even after the underlying issue is fixed.

Fix: when the existing row is dead or cancelled, NULL its
idempotency_key (preserving the row for audit) and fall through to the
INSERT path so a fresh job can be created.

Affects dream synthesize children that died during provider migrations
(429 rate-limit on old Anthropic proxy, tool-results-missing on old
OpenRouter). 45 dead children were blocking re-synthesis of transcripts
in production.

Includes 4 new tests covering dead, cancelled, completed, and active
status interactions with idempotency dedup.

Co-authored-by: Rafael Reis <57492577+rafaelreis-r@users.noreply.github.com>
Co-authored-by: Rafael Reis <rafael.reis@contabilizei.com.br>
2026-07-23 17:43:11 -07:00

1514 lines
63 KiB
TypeScript

/**
* MinionQueue — Postgres-native job queue inspired by BullMQ.
*
* Usage:
* const queue = new MinionQueue(engine);
* const job = await queue.add('sync', { full: true });
* const status = await queue.getJob(job.id);
* await queue.prune({ olderThan: new Date(Date.now() - 30 * 86400000) });
*/
import type { BrainEngine } from '../engine.ts';
import type {
MinionJob, MinionJobInput, MinionJobStatus, InboxMessage, TokenUpdate,
MinionQueueOpts, ChildDoneMessage, Attachment, AttachmentInput,
} from './types.ts';
import { rowToMinionJob, rowToInboxMessage, rowToAttachment } from './types.ts';
import { validateAttachment } from './attachments.ts';
import { isProtectedJobName } from './protected-names.ts';
import { defaultTimeoutMsFor } from './handler-timeouts.ts';
import {
withRetry, BULK_RETRY_OPTS, resolveBulkRetryOpts, computeNextDelay,
isRetryableConnError,
} from '../retry.ts';
import {
logBatchRetry as auditLogBatchRetry,
logBatchExhausted as auditLogBatchExhausted,
} from '../audit/batch-retry-audit.ts';
/** Options for opting into protected-job-name submission. Passed as a separate
* 4th arg to `MinionQueue.add()` (NOT folded into `opts`) so user-spread
* `{...userOpts}` payloads can't accidentally carry the trust flag. */
export interface TrustedSubmitOpts {
/** When true, allow submission of names in PROTECTED_JOB_NAMES (currently 'shell').
* Set only by the CLI path and by `submit_job` when `ctx.remote === false`. */
allowProtectedSubmit?: boolean;
}
const MIGRATION_VERSION = 7;
const DEFAULT_MAX_SPAWN_DEPTH = 5;
const DEFAULT_MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024; // 5 MiB
const TERMINAL_STATUSES = ['completed', 'failed', 'dead', 'cancelled'] as const;
export class MinionQueue {
readonly maxSpawnDepth: number;
readonly maxAttachmentBytes: number;
constructor(private engine: BrainEngine, opts: MinionQueueOpts = {}) {
this.maxSpawnDepth = opts.maxSpawnDepth ?? DEFAULT_MAX_SPAWN_DEPTH;
this.maxAttachmentBytes = opts.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES;
}
/** Verify minion_jobs table exists (migration v5+). Call before first operation. */
async ensureSchema(): Promise<void> {
const ver = await this.engine.getConfig('version');
const current = parseInt(ver || '1', 10);
if (current < MIGRATION_VERSION) {
throw new Error(
`minion_jobs table not found (schema version ${current}, need ${MIGRATION_VERSION}). Run 'gbrain init' to apply migrations.`
);
}
}
/**
* Submit a new job.
*
* Wrapped in engine.transaction(): when parent_job_id is set, takes
* SELECT ... FOR UPDATE on the parent so concurrent submissions serialize
* on the cap check. Without this, two concurrent submissions could both
* see count = N-1 and both insert, blowing max_children.
*
* Child status is 'waiting' (or 'delayed') — claimable. Parent is flipped
* to 'waiting-children' atomically. Idempotency_key dedups via PG unique
* partial index; same key returns the existing row (no second insert).
*/
async add(
name: string,
data?: Record<string, unknown>,
opts?: Partial<MinionJobInput>,
trusted?: TrustedSubmitOpts,
): Promise<MinionJob> {
// Normalize first so the protected-name check and the insert use the same
// canonical form. Without the trim-before-check, `queue.add(' shell ', ...)`
// would evade the guard and insert a job literally named 'shell'.
const jobName = (name || '').trim();
if (jobName.length === 0) {
throw new Error('Job name cannot be empty');
}
if (isProtectedJobName(jobName) && !trusted?.allowProtectedSubmit) {
throw new Error(
`protected job name '${jobName}' requires CLI or operation-local submitter ` +
`(pass {allowProtectedSubmit: true} as the 4th arg to MinionQueue.add)`,
);
}
// v0.38 (S1.7 + D6) — capability-based gate replaces the v0.31.12 Anthropic
// pin. The subagent loop now routes through `gateway.toolLoop()` so any
// provider with native tool calling works. Only refuse-at-submit when
// the requested model literally cannot run a tool loop. The handler
// (`subagent.ts`) does a defense-in-depth check at dispatch time too.
if (jobName === 'subagent' && data && typeof data === 'object') {
const submittedModel = (data as { model?: unknown }).model;
if (typeof submittedModel === 'string' && submittedModel.length > 0) {
const { classifyCapabilities } = await import('../ai/capabilities.ts');
const verdict = classifyCapabilities(submittedModel);
if (verdict === 'unusable:no_tools') {
throw new Error(
`subagent job rejected: data.model "${submittedModel}" lacks native tool calling. ` +
`The subagent loop dispatches brain ops via tool calls — without tool support the loop has no way to run. ` +
`Pick a provider that supports tools (anthropic, openai, google, openrouter, litellm-proxy, deepseek, groq, together, azure-openai).`,
);
}
if (verdict === 'unknown') {
throw new Error(
`subagent job rejected: data.model "${submittedModel}" references an unknown provider. ` +
`Use format provider:model where provider matches a recipe in src/core/ai/recipes/. ` +
`Known providers: anthropic, openai, google, openrouter, litellm-proxy, ollama, llama-server, ` +
`together, azure-openai, deepseek, groq, dashscope, minimax, zhipu, voyage, zeroentropyai.`,
);
}
// 'degraded:no_caching' and 'degraded:no_parallel' pass through — the
// gateway prints a once-per-(source, model) cost warning at first
// dispatch. 'ok' passes through silently.
}
}
await this.ensureSchema();
const childStatus: MinionJobStatus = opts?.delay ? 'delayed' : 'waiting';
const delayUntil = opts?.delay ? new Date(Date.now() + opts.delay) : null;
const maxSpawnDepth = opts?.max_spawn_depth ?? this.maxSpawnDepth;
return this.engine.transaction(async (tx) => {
// 1. Idempotency fast path — if a row already exists for this key, return it
// without doing any other work. The unique partial index guarantees
// no second row can be inserted with the same non-null key.
//
// Dead/cancelled jobs represent permanently-failed work whose
// idempotency slot must be freed so a fresh attempt can be inserted.
// We NULL the key (preserving the row for audit) and fall through
// to the INSERT path below.
if (opts?.idempotency_key) {
const existing = await tx.executeRaw<Record<string, unknown>>(
`SELECT * FROM minion_jobs WHERE idempotency_key = $1`,
[opts.idempotency_key]
);
if (existing.length > 0) {
const existingJob = rowToMinionJob(existing[0]);
if (existingJob.status === 'dead' || existingJob.status === 'cancelled') {
await tx.executeRaw(
`UPDATE minion_jobs SET idempotency_key = NULL WHERE id = $1`,
[existingJob.id]
);
} else {
return existingJob;
}
}
}
// 1b. Submission-time backpressure for high-frequency named jobs.
// If waiting jobs for this (name, queue) already hit maxWaiting, return
// the most-recent waiting row instead of inserting another slot.
//
// Correctness: two concurrent submitters could both see waitingCount <
// maxWaiting and both insert, violating the cap. `pg_advisory_xact_lock`
// keyed on (name, queue) serializes concurrent count+insert decisions
// for the SAME key while leaving different keys fully parallel. The
// lock releases on txn commit/rollback automatically — no cleanup path
// to leak. Cost: one no-op SELECT on the hot path per coalesce-guarded
// submission; trivial compared to the protection.
//
// Queue scope: the filter includes `queue=$2` so a waiting
// 'autopilot-cycle' in queue 'default' does NOT suppress submissions
// to queue 'shell' with the same name. Pre-D2 code filtered on `name`
// alone — a real cross-queue bleed that sequential tests missed.
//
// Engine compatibility: PGLite (WASM Postgres 17) supports
// pg_advisory_xact_lock, so this works on both engines without branching.
if (opts?.maxWaiting !== undefined) {
const maxWaiting = Math.max(1, Math.floor(opts.maxWaiting));
const backpressureQueue = opts?.queue ?? 'default';
// Multi-source scope: jobs of the same (name, queue) but different
// data.sourceId are independent workstreams (per-source sync/cycle).
// Counting them together made a waiting default-source sync swallow
// every other source's freshness sync — a secondary source sat 29h stale
// while dispatch logs showed its syncs "dispatched" (coalesced into
// the default row). Key the lock and the count on sourceId when the
// submission carries one; NULL keeps legacy single-scope behavior.
const bpSourceId = typeof (data as Record<string, unknown> | undefined)?.sourceId === 'string'
? (data as Record<string, unknown>).sourceId as string
: null;
await tx.executeRaw(
`SELECT pg_advisory_xact_lock(hashtext('minion_maxwaiting:' || $1 || ':' || $2 || ':' || coalesce($3, '')))`,
[jobName, backpressureQueue, bpSourceId]
);
const waitingCountRows = await tx.executeRaw<{ count: string }>(
`SELECT count(*)::text AS count
FROM minion_jobs
WHERE name = $1 AND queue = $2 AND status = 'waiting'
AND ($3::text IS NULL OR data->>'sourceId' IS NOT DISTINCT FROM $3)`,
[jobName, backpressureQueue, bpSourceId]
);
const waitingCount = parseInt(waitingCountRows[0]?.count ?? '0', 10);
if (waitingCount >= maxWaiting) {
const existingWaiting = await tx.executeRaw<Record<string, unknown>>(
`SELECT * FROM minion_jobs
WHERE name = $1 AND queue = $2 AND status = 'waiting'
AND ($3::text IS NULL OR data->>'sourceId' IS NOT DISTINCT FROM $3)
ORDER BY created_at DESC, id DESC
LIMIT 1`,
[jobName, backpressureQueue, bpSourceId]
);
if (existingWaiting.length > 0) {
const coalesced = rowToMinionJob(existingWaiting[0]);
try {
const { logBackpressureCoalesce } = await import('./backpressure-audit.ts');
logBackpressureCoalesce({
queue: backpressureQueue,
name: jobName,
waiting_count: waitingCount,
max_waiting: maxWaiting,
returned_job_id: coalesced.id,
});
} catch { /* audit failures never block submission */ }
return coalesced;
}
}
}
// 2. Parent lock + depth/cap validation
let depth = 0;
if (opts?.parent_job_id) {
const parentRows = await tx.executeRaw<Record<string, unknown>>(
`SELECT * FROM minion_jobs WHERE id = $1 FOR UPDATE`,
[opts.parent_job_id]
);
if (parentRows.length === 0) {
throw new Error(`parent_job_id ${opts.parent_job_id} not found`);
}
const parent = rowToMinionJob(parentRows[0]);
depth = parent.depth + 1;
if (depth > maxSpawnDepth) {
throw new Error(`spawn depth ${depth} exceeds maxSpawnDepth ${maxSpawnDepth}`);
}
if (parent.max_children !== null) {
const countRows = await tx.executeRaw<{ count: string }>(
`SELECT count(*)::text as count FROM minion_jobs
WHERE parent_job_id = $1 AND status NOT IN ('completed','failed','dead','cancelled')`,
[opts.parent_job_id]
);
const live = parseInt(countRows[0]?.count ?? '0', 10);
if (live >= parent.max_children) {
throw new Error(`parent ${opts.parent_job_id} already has ${live} live children (max_children=${parent.max_children})`);
}
}
}
// 3. Insert child. Use ON CONFLICT for idempotency; if a concurrent submit
// raced past the fast-path SELECT, the unique index catches it here.
// quiet_hours + stagger_key always present (null fallback; schema
// stores NULL). max_stalled is conditional: provided values get
// clamped to [1, 100] and included in the INSERT; omitted values
// skip the column so the schema DEFAULT (5 as of v0.14.1) kicks in.
// Keeps the app layer from hardcoding the schema default constant.
//
// Footgun note (codex iter 3): threading max_stalled on INSERT only is
// deliberate. An idempotency-key hit returns the EXISTING row via the
// fast-path SELECT above — we do NOT UPDATE max_stalled on a re-submit,
// because letting a second submitter mutate the first submitter's
// durability semantics is a nasty surprise.
const hasMaxStalled = opts?.max_stalled !== undefined && opts.max_stalled !== null;
const clampedMaxStalled = hasMaxStalled
? Math.max(1, Math.min(100, Math.floor(opts!.max_stalled as number)))
: null;
const baseCols = `name, queue, status, priority, data, max_attempts, backoff_type,
backoff_delay, backoff_jitter, delay_until, parent_job_id, on_child_fail,
depth, max_children, timeout_ms, remove_on_complete, remove_on_fail, idempotency_key,
quiet_hours, stagger_key`;
const baseVals = `$1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19::jsonb, $20`;
const cols = hasMaxStalled ? `${baseCols}, max_stalled` : baseCols;
const vals = hasMaxStalled ? `${baseVals}, $21` : baseVals;
const insertSql = opts?.idempotency_key
? `INSERT INTO minion_jobs (${cols})
VALUES (${vals})
ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING
RETURNING *`
: `INSERT INTO minion_jobs (${cols})
VALUES (${vals})
RETURNING *`;
const params: unknown[] = [
jobName,
opts?.queue ?? 'default',
childStatus,
opts?.priority ?? 0,
data ?? {},
opts?.max_attempts ?? 3,
opts?.backoff_type ?? 'exponential',
opts?.backoff_delay ?? 1000,
opts?.backoff_jitter ?? 0.2,
delayUntil?.toISOString() ?? null,
opts?.parent_job_id ?? null,
opts?.on_child_fail ?? 'fail_parent',
depth,
opts?.max_children ?? null,
// #1737: long handlers (subagent, embed-backfill, autopilot-cycle) get a
// sane long wall-clock default stamped at submit when the caller didn't
// pass one, so they aren't killed mid-progress by the short null-default.
opts?.timeout_ms ?? defaultTimeoutMsFor(jobName),
opts?.remove_on_complete ?? false,
opts?.remove_on_fail ?? false,
opts?.idempotency_key ?? null,
opts?.quiet_hours ?? null,
opts?.stagger_key ?? null,
];
if (hasMaxStalled) params.push(clampedMaxStalled);
const inserted = await tx.executeRaw<Record<string, unknown>>(insertSql, params);
// ON CONFLICT DO NOTHING returns 0 rows — fall back to SELECT to fetch the
// existing row that won the race.
if (inserted.length === 0 && opts?.idempotency_key) {
const existing = await tx.executeRaw<Record<string, unknown>>(
`SELECT * FROM minion_jobs WHERE idempotency_key = $1`,
[opts.idempotency_key]
);
if (existing.length === 0) {
throw new Error(`idempotency_key ${opts.idempotency_key} insert returned no row and no existing row found`);
}
return rowToMinionJob(existing[0]);
}
const child = rowToMinionJob(inserted[0]);
// 4. Flip parent to waiting-children if this is a fresh child insert.
// Only transition from non-terminal, non-already-waiting-children states.
if (opts?.parent_job_id) {
await tx.executeRaw(
`UPDATE minion_jobs SET status = 'waiting-children', updated_at = now()
WHERE id = $1 AND status IN ('waiting','active','delayed')`,
[opts.parent_job_id]
);
}
return child;
});
}
/** Get a job by ID. Returns null if not found. */
async getJob(id: number): Promise<MinionJob | null> {
const rows = await this.engine.executeRaw<Record<string, unknown>>(
'SELECT * FROM minion_jobs WHERE id = $1',
[id]
);
return rows.length > 0 ? rowToMinionJob(rows[0]) : null;
}
/** List jobs with optional filters. */
async getJobs(opts?: {
status?: MinionJobStatus;
queue?: string;
name?: string;
limit?: number;
offset?: number;
}): Promise<MinionJob[]> {
const conditions: string[] = [];
const params: unknown[] = [];
let idx = 1;
if (opts?.status) {
conditions.push(`status = $${idx++}`);
params.push(opts.status);
}
if (opts?.queue) {
conditions.push(`queue = $${idx++}`);
params.push(opts.queue);
}
if (opts?.name) {
conditions.push(`name = $${idx++}`);
params.push(opts.name);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const limit = opts?.limit ?? 50;
const offset = opts?.offset ?? 0;
const rows = await this.engine.executeRaw<Record<string, unknown>>(
`SELECT * FROM minion_jobs ${where} ORDER BY created_at DESC LIMIT $${idx++} OFFSET $${idx}`,
[...params, limit, offset]
);
return rows.map(rowToMinionJob);
}
/** Remove a job. Only terminal statuses can be removed. */
async removeJob(id: number): Promise<boolean> {
const rows = await this.engine.executeRaw<Record<string, unknown>>(
`DELETE FROM minion_jobs WHERE id = $1 AND status IN ('completed', 'dead', 'cancelled', 'failed') RETURNING id`,
[id]
);
return rows.length > 0;
}
/**
* Cancel a job and cascade-kill all descendants in one statement.
*
* Honest scope: this is BullMQ-style best-effort cancel. The recursive CTE
* snapshots the parent_job_id chain at statement start. A descendant
* re-parented BEFORE the cancel call is excluded; one re-parented DURING
* the call may still get cancelled (cancel wins if seen in the snapshot).
* Re-parented descendants whose parent_job_id is NULL'd by
* removeChildDependency naturally fall out of the recursive walk.
*
* Active descendants get lock_token = NULL — same path pause uses, so the
* worker's renewLock will fail next tick and AbortController fires.
*
* Returns the *root* (the job matching id), not an arbitrary descendant.
*/
async cancelJob(id: number): Promise<MinionJob | null> {
return this.engine.transaction(async (tx) => {
const rows = await tx.executeRaw<Record<string, unknown>>(
`WITH RECURSIVE descendants AS (
SELECT id, 0 AS d FROM minion_jobs WHERE id = $1
UNION ALL
SELECT m.id, descendants.d + 1
FROM minion_jobs m
JOIN descendants ON m.parent_job_id = descendants.id
WHERE descendants.d < 100
)
UPDATE minion_jobs SET
status = 'cancelled',
lock_token = NULL,
lock_until = NULL,
finished_at = now(),
updated_at = now()
WHERE id IN (SELECT id FROM descendants)
AND status IN ('waiting','active','delayed','waiting-children','paused')
RETURNING *`,
[id]
);
if (rows.length === 0) return null;
// v0.15: emit child_done(outcome='cancelled') for every cancelled row
// that had a parent. Without this, an aggregator waiting for N
// child_done messages hangs forever when a child is cancelled (codex
// iteration 3). Also unblock any aggregator parents whose last
// non-terminal child we just cancelled.
const parentIds = new Set<number>();
for (const r of rows) {
const childId = r.id as number;
const parentJobId = r.parent_job_id as number | null;
const name = r.name as string;
// Skip the root if it's the caller's cancel target AND has no parent.
// Descendants whose parent got cancelled in the same sweep still
// benefit from the inbox message — their parent exits waiting-children
// via the resolve sweep below even though the parent is itself
// cancelled (EXISTS guard on inbox INSERT handles it).
if (parentJobId == null) continue;
parentIds.add(parentJobId);
const childDone: ChildDoneMessage = {
type: 'child_done',
child_id: childId,
job_name: name,
result: null,
outcome: 'cancelled',
error: 'cancelled',
};
await tx.executeRaw(
`INSERT INTO minion_inbox (job_id, sender, payload)
SELECT $1, 'minions', $2::jsonb
WHERE EXISTS (
SELECT 1 FROM minion_jobs
WHERE id = $1 AND status NOT IN ('completed','failed','dead','cancelled')
)`,
[parentJobId, childDone]
);
}
// Resolve any non-cancelled aggregator parents sitting on
// waiting-children whose last open child we just cancelled.
for (const parentId of parentIds) {
await tx.executeRaw(
`UPDATE minion_jobs SET status = 'waiting', updated_at = now()
WHERE id = $1 AND status = 'waiting-children'
AND NOT EXISTS (
SELECT 1 FROM minion_jobs
WHERE parent_job_id = $1
AND status NOT IN ('completed', 'failed', 'dead', 'cancelled')
)`,
[parentId]
);
}
const root = rows.find(r => (r.id as number) === id);
return root ? rowToMinionJob(root) : null;
});
}
/**
* Re-queue a failed or dead job for retry.
*
* #2783: an explicit `jobs retry` is an operator asserting "run this
* fresh" — so it clears `started_at` (re-stamped on re-claim via
* `claim()`'s `COALESCE(started_at, now())`, `queue.ts:620`) and resets
* `attempts_made`/`attempts_started` to 0. Without this, `started_at`
* kept the ORIGINAL first-claim time, so `handleWallClockTimeouts()`
* (anchored on `now() - started_at`, `queue.ts:729-749`) could measure
* from long before the retry — a retry issued more than `timeout_ms * 2`
* after the original claim was dead-lettered again in under a second,
* with `attempts_made` already past `max_attempts`. This made retry
* useless for exactly the case it exists for: recovering work after an
* outage that outlasted the job's timeout.
*
* Also resets `stalled_counter` (Codex review): `handleStalled()`
* dead-letters once `stalled_counter + 1 >= max_stalled` (`queue.ts:1190`).
* A job dead-lettered BY stall exhaustion, left un-reset, would hit that
* same threshold on its very first lock expiry after retry — a job
* killed by 3 stalls doesn't get a fresh stall budget, contradicting
* "run this fresh" the same way the unreset attempt counters did.
*/
async retryJob(id: number): Promise<MinionJob | null> {
const rows = await this.engine.executeRaw<Record<string, unknown>>(
`UPDATE minion_jobs SET status = 'waiting', error_text = NULL,
lock_token = NULL, lock_until = NULL, delay_until = NULL,
finished_at = NULL, started_at = NULL, attempts_made = 0,
attempts_started = 0, stalled_counter = 0, updated_at = now()
WHERE id = $1 AND status IN ('failed', 'dead')
RETURNING *`,
[id]
);
return rows.length > 0 ? rowToMinionJob(rows[0]) : null;
}
/** Prune old jobs in terminal statuses. Returns count of deleted rows. */
async prune(opts?: { olderThan?: Date; status?: MinionJobStatus[] }): Promise<number> {
const statuses = opts?.status ?? ['completed', 'dead', 'cancelled'];
const olderThan = opts?.olderThan ?? new Date(Date.now() - 30 * 86400000);
const rows = await this.engine.executeRaw<{ count: string }>(
`WITH pruned AS (
DELETE FROM minion_jobs
WHERE status = ANY($1) AND updated_at < $2
RETURNING id
)
SELECT count(*)::text as count FROM pruned`,
[statuses, olderThan.toISOString()]
);
return parseInt(rows[0]?.count ?? '0', 10);
}
/** Get job statistics. */
async getStats(opts?: { since?: Date; queue?: string }): Promise<{
by_status: Record<string, number>;
by_type: Array<{ name: string; total: number; completed: number; failed: number; dead: number; avg_duration_ms: number | null }>;
queue_health: { waiting: number; active: number; stalled: number };
/**
* issue #1801 — QUEUE-SCOPED wedge signature for the `jobs stats` WEDGED
* line. by_status/by_type/queue_health above stay GLOBAL (dashboard
* overview); this block is scoped to one queue (default 'default') because
* a wedge is per-queue — a healthy worker on one queue must not mask a
* wedged one (Codex #14/#15). `active_healthy` counts only live-lock active
* rows so an expired-lock row (worker died mid-job) does NOT mask the wedge.
*/
wedge: {
queue: string;
active_healthy: number;
waiting: number;
last_completed_at: string | null;
minutes_since_completion: number | null;
};
}> {
const since = opts?.since ?? new Date(Date.now() - 86400000);
const wedgeQueue = opts?.queue ?? 'default';
// Status counts
const statusRows = await this.engine.executeRaw<{ status: string; count: string }>(
`SELECT status, count(*)::text as count FROM minion_jobs GROUP BY status`
);
const by_status: Record<string, number> = {};
for (const r of statusRows) by_status[r.status] = parseInt(r.count, 10);
// Type breakdown (within time window)
const typeRows = await this.engine.executeRaw<Record<string, unknown>>(
`SELECT name,
count(*)::text as total,
count(*) FILTER (WHERE status = 'completed')::text as completed,
count(*) FILTER (WHERE status = 'failed')::text as failed,
count(*) FILTER (WHERE status = 'dead')::text as dead,
avg(EXTRACT(EPOCH FROM (finished_at - started_at)) * 1000) FILTER (WHERE finished_at IS NOT NULL AND started_at IS NOT NULL) as avg_duration_ms
FROM minion_jobs WHERE created_at >= $1
GROUP BY name ORDER BY total DESC`,
[since.toISOString()]
);
const by_type = typeRows.map(r => ({
name: r.name as string,
total: parseInt(r.total as string, 10),
completed: parseInt(r.completed as string, 10),
failed: parseInt(r.failed as string, 10),
dead: parseInt(r.dead as string, 10),
avg_duration_ms: r.avg_duration_ms != null ? Math.round(r.avg_duration_ms as number) : null,
}));
// Queue health: stalled = active with expired lock
const stalledRows = await this.engine.executeRaw<{ count: string }>(
`SELECT count(*)::text as count FROM minion_jobs WHERE status = 'active' AND lock_until < now()`
);
const stalled = parseInt(stalledRows[0]?.count ?? '0', 10);
// issue #1801 — queue-scoped wedge signature (one query, one queue).
const wedgeRows = await this.engine.executeRaw<{
active_healthy: string;
waiting: string;
last_completed: string | null;
}>(
`SELECT
count(*) FILTER (WHERE status = 'active' AND lock_until > now())::text AS active_healthy,
count(*) FILTER (WHERE status = 'waiting')::text AS waiting,
max(updated_at) FILTER (WHERE status = 'completed')::text AS last_completed
FROM minion_jobs
WHERE queue = $1`,
[wedgeQueue],
);
const wr = wedgeRows[0] ?? { active_healthy: '0', waiting: '0', last_completed: null };
const wedgeLastCompleted = wr.last_completed ? new Date(wr.last_completed) : null;
return {
by_status,
by_type,
queue_health: {
waiting: by_status['waiting'] ?? 0,
active: by_status['active'] ?? 0,
stalled,
},
wedge: {
queue: wedgeQueue,
active_healthy: parseInt(wr.active_healthy ?? '0', 10),
waiting: parseInt(wr.waiting ?? '0', 10),
last_completed_at: wr.last_completed,
minutes_since_completion: wedgeLastCompleted
? Math.round((Date.now() - wedgeLastCompleted.getTime()) / 60_000)
: null,
},
};
}
/**
* Claim the next waiting job for a worker. Token-fenced, filters by registered names.
*
* Sets timeout_at = now() + timeout_ms when the job has a per-job deadline,
* so handleTimeouts() can dead-letter expired jobs without rereading timeout_ms.
*/
async claim(lockToken: string, lockDurationMs: number, queue: string, registeredNames: string[]): Promise<MinionJob | null> {
if (registeredNames.length === 0) return null;
// Direct (session-mode) pool: claim opens the lock that renewLock then
// heartbeats. Both must live on a connection the transaction-mode pooler
// won't recycle mid-hold, or the lock orphans and the worker wedges.
const rows = await this.engine.executeRawDirect<Record<string, unknown>>(
`UPDATE minion_jobs SET
status = 'active',
lock_token = $1,
lock_until = now() + ($2::double precision * interval '1 millisecond'),
timeout_at = CASE WHEN timeout_ms IS NOT NULL
THEN now() + (timeout_ms::double precision * interval '1 millisecond')
ELSE NULL END,
attempts_started = attempts_started + 1,
started_at = COALESCE(started_at, now()),
updated_at = now()
WHERE id = (
SELECT id FROM minion_jobs
WHERE queue = $3 AND status = 'waiting' AND name = ANY($4)
ORDER BY priority ASC, created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *`,
[lockToken, lockDurationMs, queue, registeredNames]
);
return rows.length > 0 ? rowToMinionJob(rows[0]) : null;
}
/**
* Dead-letter active jobs whose timeout_at has passed.
*
* The lock_until > now() guard is critical: a stalled job (lock_until < now)
* is being requeued by handleStalled, NOT timed out terminally. Stall →
* retry, timeout → dead. Order in worker loop: handleStalled() before
* handleTimeouts() to give stall recovery first crack.
*
* Honest scope: 1-tick TOCTOU window remains. A job whose lock_until
* expires between handleStalled and handleTimeouts may miss this tick
* but will be caught the next one (after re-claim). Never double-handled.
*/
async handleTimeouts(): Promise<MinionJob[]> {
return this.engine.transaction(async (tx) => {
const rows = await tx.executeRaw<Record<string, unknown>>(
// #1737: count the timed-out run as a spent attempt (terminal, no retry),
// mirroring handleWallClockTimeouts + handleStalled. handleTimeouts is the
// FIRST killer to fire for the long-lane handlers (timeout_ms stamped at
// submit), so without this the job reads `attempts: 0/N (started: N)`.
// Safe against double-count: the worker sweep runs handleStalled ->
// handleTimeouts -> handleWallClockTimeouts sequentially and awaited, and
// each guards on `status = 'active'`, so the first to set status='dead'
// excludes the row from the later sweeps.
`UPDATE minion_jobs SET
status = 'dead',
error_text = 'timeout exceeded',
attempts_made = attempts_made + 1,
lock_token = NULL,
lock_until = NULL,
finished_at = now(),
updated_at = now()
WHERE status = 'active'
AND timeout_at IS NOT NULL
AND timeout_at < now()
AND lock_until > now()
RETURNING *`
);
// v0.15: emit child_done(outcome='timeout') for every timed-out job that
// had a parent. Without this, an aggregator waiting for N child_done
// messages hangs forever when a child times out (codex iteration 3).
// Outcome 'timeout' is distinct from 'dead' so consumers can distinguish
// "timed out during run" from "died via max-stall".
const parentIds = new Set<number>();
for (const r of rows) {
const parentJobId = r.parent_job_id as number | null;
if (parentJobId == null) continue;
parentIds.add(parentJobId);
const childDone: ChildDoneMessage = {
type: 'child_done',
child_id: r.id as number,
job_name: r.name as string,
result: null,
outcome: 'timeout',
error: 'timeout exceeded',
};
await tx.executeRaw(
`INSERT INTO minion_inbox (job_id, sender, payload)
SELECT $1, 'minions', $2::jsonb
WHERE EXISTS (
SELECT 1 FROM minion_jobs
WHERE id = $1 AND status NOT IN ('completed','failed','dead','cancelled')
)`,
[parentJobId, childDone]
);
}
// Unblock any aggregator parents whose last open child we just killed.
for (const parentId of parentIds) {
await tx.executeRaw(
`UPDATE minion_jobs SET status = 'waiting', updated_at = now()
WHERE id = $1 AND status = 'waiting-children'
AND NOT EXISTS (
SELECT 1 FROM minion_jobs
WHERE parent_job_id = $1
AND status NOT IN ('completed', 'failed', 'dead', 'cancelled')
)`,
[parentId]
);
}
return rows.map(rowToMinionJob);
});
}
/**
* Dead-letter active jobs that exceed a wall-clock runtime threshold,
* regardless of lock state. This catches jobs stuck while still holding
* DB resources (e.g. blocked on file locks) where stall sweeps skip rows.
*
* Threshold (ms):
* timeout_ms set -> timeout_ms * 2
* timeout_ms null -> 2 * lockDurationMs * max_stalled
*/
async handleWallClockTimeouts(lockDurationMs: number): Promise<MinionJob[]> {
return this.engine.transaction(async (tx) => {
const rows = await tx.executeRaw<Record<string, unknown>>(
`UPDATE minion_jobs SET
status = 'dead',
error_text = 'wall-clock timeout exceeded',
attempts_made = attempts_made + 1,
lock_token = NULL,
lock_until = NULL,
finished_at = now(),
updated_at = now()
WHERE status = 'active'
AND started_at IS NOT NULL
AND EXTRACT(EPOCH FROM (now() - started_at)) * 1000 >
CASE
WHEN timeout_ms IS NOT NULL THEN timeout_ms * 2
ELSE $1::double precision * 2 * GREATEST(max_stalled, 1)
END
RETURNING *`,
[lockDurationMs]
);
const parentIds = new Set<number>();
for (const r of rows) {
const parentJobId = r.parent_job_id as number | null;
if (parentJobId == null) continue;
parentIds.add(parentJobId);
const childDone: ChildDoneMessage = {
type: 'child_done',
child_id: r.id as number,
job_name: r.name as string,
result: null,
outcome: 'timeout',
error: 'wall-clock timeout exceeded',
};
await tx.executeRaw(
`INSERT INTO minion_inbox (job_id, sender, payload)
SELECT $1, 'minions', $2::jsonb
WHERE EXISTS (
SELECT 1 FROM minion_jobs
WHERE id = $1 AND status NOT IN ('completed','failed','dead','cancelled')
)`,
[parentJobId, childDone]
);
}
for (const parentId of parentIds) {
await tx.executeRaw(
`UPDATE minion_jobs SET status = 'waiting', updated_at = now()
WHERE id = $1 AND status = 'waiting-children'
AND NOT EXISTS (
SELECT 1 FROM minion_jobs
WHERE parent_job_id = $1
AND status NOT IN ('completed', 'failed', 'dead', 'cancelled')
)`,
[parentId]
);
}
return rows.map(rowToMinionJob);
});
}
/**
* Complete a job (token-fenced). All side effects atomic in one transaction:
* 1. UPDATE child to 'completed' with result
* 2. Roll up token counts to parent (skipped if parent is terminal)
* 3. Insert child_done message into parent's inbox (skipped if parent terminal)
* 4. Resolve parent (flip waiting-children → waiting if all kids done)
* 5. If remove_on_complete, DELETE the child row (cascades inbox + attachments)
*
* Returns the completed job (the in-memory snapshot before any delete), or
* null if the lock_token mismatched (e.g., reclaimed mid-completion).
*
* The fold-in of resolveParent eliminates the crash window where a process
* died between completeJob and worker's prior post-call resolveParent,
* stranding the parent in waiting-children forever.
*/
async completeJob(id: number, lockToken: string, result?: Record<string, unknown>): Promise<MinionJob | null> {
return this.engine.transaction(async (tx) => {
// Peek at parent_job_id before the UPDATE so we can lock the parent row
// FIRST. Without this SELECT FOR UPDATE, two siblings completing
// concurrently each see the other as still active (pre-commit snapshot
// under read-committed), neither flips the parent, and the parent is
// stuck in waiting-children forever.
const peek = await tx.executeRaw<{ parent_job_id: number | null }>(
`SELECT parent_job_id FROM minion_jobs WHERE id = $1`,
[id]
);
const parentId = peek[0]?.parent_job_id ?? null;
if (parentId) {
await tx.executeRaw(
`SELECT id FROM minion_jobs WHERE id = $1 FOR UPDATE`,
[parentId]
);
}
const rows = await tx.executeRaw<Record<string, unknown>>(
`UPDATE minion_jobs SET status = 'completed', result = $1::jsonb,
finished_at = now(), lock_token = NULL, lock_until = NULL, updated_at = now()
WHERE id = $2 AND status = 'active' AND lock_token = $3
RETURNING *`,
[result ?? null, id, lockToken]
);
if (rows.length === 0) return null;
const completed = rowToMinionJob(rows[0]);
if (completed.parent_job_id) {
// Roll up token counts. Guarded against parent already being terminal.
if (completed.tokens_input > 0 || completed.tokens_output > 0 || completed.tokens_cache_read > 0) {
await tx.executeRaw(
`UPDATE minion_jobs SET
tokens_input = tokens_input + $1,
tokens_output = tokens_output + $2,
tokens_cache_read = tokens_cache_read + $3,
updated_at = now()
WHERE id = $4 AND status NOT IN ('completed', 'failed', 'dead', 'cancelled')`,
[completed.tokens_input, completed.tokens_output, completed.tokens_cache_read, completed.parent_job_id]
);
}
// Auto-post child_done into parent's inbox. EXISTS guard skips if parent
// was deleted or hit a terminal state mid-flight (no FK violation, no
// contradiction with the token rollup guard).
const childDone: ChildDoneMessage = {
type: 'child_done',
child_id: completed.id,
job_name: completed.name,
result: result ?? null,
outcome: 'complete',
};
await tx.executeRaw(
`INSERT INTO minion_inbox (job_id, sender, payload)
SELECT $1, 'minions', $2::jsonb
WHERE EXISTS (
SELECT 1 FROM minion_jobs
WHERE id = $1 AND status NOT IN ('completed','failed','dead','cancelled')
)`,
[completed.parent_job_id, childDone]
);
// Fold-in resolveParent: flip parent to waiting once all children are
// in ANY terminal state. Terminal set includes 'failed' so a failed
// child with on_child_fail='continue'/'ignore' doesn't strand the
// parent in waiting-children forever (v0.15 aggregator fix).
await tx.executeRaw(
`UPDATE minion_jobs SET status = 'waiting', updated_at = now()
WHERE id = $1 AND status = 'waiting-children'
AND NOT EXISTS (
SELECT 1 FROM minion_jobs
WHERE parent_job_id = $1
AND status NOT IN ('completed', 'failed', 'dead', 'cancelled')
)`,
[completed.parent_job_id]
);
}
// remove_on_complete cleanup AFTER all parent-side bookkeeping.
// The child_done we just inserted lives in the *parent's* inbox row,
// so it survives the child cascade-delete.
if (completed.remove_on_complete) {
await tx.executeRaw(
`DELETE FROM minion_jobs WHERE id = $1`,
[completed.id]
);
}
return completed;
});
}
/**
* Fail a job (token-fenced). All side effects atomic in one transaction:
* 1. UPDATE child to 'delayed' (retry) | 'failed' | 'dead'
* 2. If terminal AND parent_job_id, run on_child_fail policy:
* - 'fail_parent' → mark parent 'failed' (via failParent SQL)
* - 'remove_dep' → null out parent_job_id (via removeChildDependency SQL)
* - 'ignore' / 'continue' → no parent action
* 3. If remove_on_fail AND terminal, DELETE the child row (parent hook
* already ran in this txn using in-memory state, so child deletion is safe)
*
* Folding the parent hook into this transaction eliminates the crash window
* where a process died between failJob and worker's prior post-call hook,
* leaving the parent stuck in waiting-children.
*/
async failJob(
id: number,
lockToken: string,
errorText: string,
newStatus: 'delayed' | 'failed' | 'dead',
backoffMs?: number
): Promise<MinionJob | null> {
return this.engine.transaction(async (tx) => {
// Lock the parent row first so concurrent sibling completions/failures
// serialize on the parent — same race fix as completeJob.
const peek = await tx.executeRaw<{ parent_job_id: number | null }>(
`SELECT parent_job_id FROM minion_jobs WHERE id = $1`,
[id]
);
const parentId = peek[0]?.parent_job_id ?? null;
if (parentId) {
await tx.executeRaw(
`SELECT id FROM minion_jobs WHERE id = $1 FOR UPDATE`,
[parentId]
);
}
const rows = await tx.executeRaw<Record<string, unknown>>(
`UPDATE minion_jobs SET
status = $1, error_text = $2, attempts_made = attempts_made + 1,
stacktrace = COALESCE(stacktrace, '[]'::jsonb) || to_jsonb($3::text),
delay_until = CASE WHEN $1 = 'delayed' THEN now() + ($4::double precision * interval '1 millisecond') ELSE NULL END,
finished_at = CASE WHEN $1 IN ('failed', 'dead') THEN now() ELSE NULL END,
lock_token = NULL, lock_until = NULL, updated_at = now()
WHERE id = $5 AND status = 'active' AND lock_token = $6
RETURNING *`,
[newStatus, errorText, errorText, backoffMs ?? 0, id, lockToken]
);
if (rows.length === 0) return null;
const failed = rowToMinionJob(rows[0]);
const terminal = newStatus === 'failed' || newStatus === 'dead';
// Parent hook on terminal failure.
if (terminal && failed.parent_job_id) {
// v0.15: emit child_done(outcome='failed') BEFORE any parent-terminal
// update. Insertion order matters because `completeJob`'s inbox-write
// EXISTS guard skips writes once the parent is 'failed' — if we let
// the fail_parent UPDATE run first, this inbox row would be dropped
// for aggregator-style parents that still want to count it (codex).
const childDone: ChildDoneMessage = {
type: 'child_done',
child_id: failed.id,
job_name: failed.name,
result: null,
outcome: newStatus === 'dead' ? 'dead' : 'failed',
error: errorText,
};
await tx.executeRaw(
`INSERT INTO minion_inbox (job_id, sender, payload)
SELECT $1, 'minions', $2::jsonb
WHERE EXISTS (
SELECT 1 FROM minion_jobs
WHERE id = $1 AND status NOT IN ('completed','failed','dead','cancelled')
)`,
[failed.parent_job_id, childDone]
);
if (failed.on_child_fail === 'fail_parent') {
await tx.executeRaw(
`UPDATE minion_jobs SET status = 'failed',
error_text = $1, finished_at = now(), updated_at = now()
WHERE id = $2 AND status = 'waiting-children'`,
[`child job ${failed.id} failed: ${errorText}`, failed.parent_job_id]
);
} else if (failed.on_child_fail === 'remove_dep') {
await tx.executeRaw(
`UPDATE minion_jobs SET parent_job_id = NULL, updated_at = now() WHERE id = $1`,
[failed.id]
);
// After dropping the dep, try to resolve the parent if all OTHER
// kids are terminal. Terminal set includes 'failed' (v0.15).
await tx.executeRaw(
`UPDATE minion_jobs SET status = 'waiting', updated_at = now()
WHERE id = $1 AND status = 'waiting-children'
AND NOT EXISTS (
SELECT 1 FROM minion_jobs
WHERE parent_job_id = $1
AND status NOT IN ('completed', 'failed', 'dead', 'cancelled')
)`,
[failed.parent_job_id]
);
} else {
// 'ignore' / 'continue': parent stays in waiting-children waiting on
// siblings. With v0.15 terminal-set expansion + child_done emission
// above, an aggregator sibling-count model now works: all N children
// reach terminal → completeJob on a sibling (or the LAST terminal
// transition here) flips parent → waiting once no non-terminal kids
// remain. Run the resolve check here so the last child transitioning
// via THIS code path still unblocks the parent.
await tx.executeRaw(
`UPDATE minion_jobs SET status = 'waiting', updated_at = now()
WHERE id = $1 AND status = 'waiting-children'
AND NOT EXISTS (
SELECT 1 FROM minion_jobs
WHERE parent_job_id = $1
AND status NOT IN ('completed', 'failed', 'dead', 'cancelled')
)`,
[failed.parent_job_id]
);
}
}
// remove_on_fail cleanup AFTER parent hook.
if (terminal && failed.remove_on_fail) {
await tx.executeRaw(
`DELETE FROM minion_jobs WHERE id = $1`,
[failed.id]
);
}
return failed;
});
}
/**
* v0.41 Bug 2 — release a job back to `delayed` after a
* `RateLeaseUnavailableError` bounce, WITHOUT incrementing `attempts_made`.
*
* The field-report bug: pre-v0.41, lease-full bounces routed through
* `failJob` which bumps `attempts_made`. After 3 bounces the job hit
* `max_attempts` (default 3) and dead-lettered with message
* `rate lease "anthropic:messages" full (8/8)`. Operators saw a dead
* job and assumed a real failure.
*
* This method is the workhorse fix: status → `delayed`, jittered backoff
* via `delay_until`, `attempts_made` UNCHANGED. The handler comment at
* `src/core/minions/handlers/subagent.ts:425` ("treat as renewable
* error so the worker re-claims") is now actually true.
*
* Audit row write to `minion_lease_pressure_log` is the caller's
* responsibility (the worker has the model/queue context); this method
* stays focused on the state-machine flip. Same `lock_token + status='active'`
* idempotency guard as `failJob` so a racing stall sweep / cancel still
* wins. Returns `null` on lock_token mismatch.
*
* Returns the updated `MinionJob` row on success so the caller can stamp
* the audit row with provenance from the SAME row that just flipped.
*/
async releaseLeaseFullJob(
id: number,
lockToken: string,
errorText: string,
backoffMs: number,
): Promise<MinionJob | null> {
const rows = await this.engine.executeRaw<Record<string, unknown>>(
`UPDATE minion_jobs SET
status = 'delayed',
error_text = $1,
stacktrace = COALESCE(stacktrace, '[]'::jsonb) || to_jsonb($1::text),
delay_until = now() + ($2::double precision * interval '1 millisecond'),
lock_token = NULL, lock_until = NULL, updated_at = now()
WHERE id = $3 AND status = 'active' AND lock_token = $4
RETURNING *`,
[errorText, backoffMs, id, lockToken],
);
if (rows.length === 0) return null;
return rowToMinionJob(rows[0]);
}
/** Update job progress (token-fenced). */
async updateProgress(id: number, lockToken: string, progress: unknown): Promise<boolean> {
const rows = await this.engine.executeRaw<Record<string, unknown>>(
`UPDATE minion_jobs SET progress = $1::jsonb, updated_at = now()
WHERE id = $2 AND status = 'active' AND lock_token = $3
RETURNING id`,
[progress, id, lockToken]
);
return rows.length > 0;
}
/** Renew lock (token-fenced). Returns false if token mismatch (job was reclaimed). */
async renewLock(id: number, lockToken: string, lockDurationMs: number): Promise<boolean> {
// Direct (session-mode) pool — see claim(). The heartbeat that keeps a job
// alive for minutes cannot run on the transaction pooler without periodic
// CONNECTION_ENDED drops that look like lock-expiry and orphan the job.
const rows = await this.engine.executeRawDirect<Record<string, unknown>>(
`UPDATE minion_jobs SET lock_until = now() + ($1::double precision * interval '1 millisecond'), updated_at = now()
WHERE id = $2 AND lock_token = $3 AND status = 'active'
RETURNING id`,
[lockDurationMs, id, lockToken]
);
return rows.length > 0;
}
/**
* issue #1678 — self-healing retry for the Minion hot-path lock SQL.
* ONLY promoteDelayed routes through this: it's idempotent (re-running the
* same UPDATE on already-promoted rows is a no-op), so a retry after a
* reaped pooler socket can't cause double-work. `claim` and `renewLock`
* deliberately do NOT use this — see their call sites for why (Codex #1/#2):
* blind-retrying claim can double-claim a job, and retrying renewLock races
* the renewal-tick's own timeout. The reconnect callback rebuilds the
* instance pool between attempts when the engine supports it (Postgres);
* PGLite has no pooler reaping so reconnect is absent and the retry is a
* cheap pass-through.
*/
private async lockRetry<T>(fn: () => Promise<T>): Promise<T> {
const reconnect = (this.engine as { reconnect?: () => Promise<void> }).reconnect;
const opts = resolveBulkRetryOpts();
let prevDelay = 0;
try {
return await withRetry(fn, {
maxRetries: opts.maxRetries,
delayMs: opts.delayMs,
delayMaxMs: opts.delayMaxMs,
jitter: BULK_RETRY_OPTS.jitter,
auditSite: 'minion-lock',
onRetry: (attempt, err) => {
const delay = computeNextDelay(attempt - 1, prevDelay, opts.delayMs, opts.delayMaxMs, BULK_RETRY_OPTS.jitter);
prevDelay = delay;
auditLogBatchRetry('minion-lock', 1, attempt, delay, err);
},
reconnect: reconnect ? () => reconnect.call(this.engine) : undefined,
});
} catch (err) {
if (err instanceof Error && err.name === 'RetryAbortError') throw err;
if (isRetryableConnError(err)) auditLogBatchExhausted('minion-lock', 1, opts.maxRetries + 1, err);
throw err;
}
}
/** Promote delayed jobs whose delay_until has passed. Returns promoted jobs. */
async promoteDelayed(): Promise<MinionJob[]> {
const rows = await this.lockRetry(() => this.engine.executeRaw<Record<string, unknown>>(
`UPDATE minion_jobs SET status = 'waiting', delay_until = NULL,
lock_token = NULL, lock_until = NULL, updated_at = now()
WHERE status = 'delayed' AND delay_until <= now()
RETURNING *`
));
return rows.map(rowToMinionJob);
}
/** Detect and handle stalled jobs. Single CTE, no off-by-one. Returns affected jobs. */
async handleStalled(): Promise<{ requeued: MinionJob[]; dead: MinionJob[] }> {
const rows = await this.engine.executeRaw<Record<string, unknown> & { action: string }>(
`WITH stalled AS (
SELECT id, stalled_counter, max_stalled
FROM minion_jobs
WHERE status = 'active' AND lock_until < now()
FOR UPDATE SKIP LOCKED
),
requeued AS (
UPDATE minion_jobs SET
status = 'waiting', stalled_counter = stalled_counter + 1,
lock_token = NULL, lock_until = NULL, updated_at = now()
WHERE id IN (SELECT id FROM stalled WHERE stalled_counter + 1 < max_stalled)
RETURNING *, 'requeued' as action
),
dead_lettered AS (
UPDATE minion_jobs SET
status = 'dead', stalled_counter = stalled_counter + 1,
attempts_made = attempts_made + 1,
error_text = 'max stalled count exceeded',
lock_token = NULL, lock_until = NULL, finished_at = now(), updated_at = now()
WHERE id IN (SELECT id FROM stalled WHERE stalled_counter + 1 >= max_stalled)
RETURNING *, 'dead' as action
)
SELECT * FROM requeued UNION ALL SELECT * FROM dead_lettered`
);
const requeued: MinionJob[] = [];
const dead: MinionJob[] = [];
for (const r of rows) {
const job = rowToMinionJob(r);
if (r.action === 'requeued') requeued.push(job);
else dead.push(job);
}
return { requeued, dead };
}
/**
* Check if all children of a parent are in ANY terminal state. If so,
* unblock parent (flip waiting-children → waiting).
*
* v0.15: terminal set includes 'failed' so a child failing with
* on_child_fail='continue'/'ignore' doesn't strand the parent.
*/
async resolveParent(parentId: number): Promise<MinionJob | null> {
const rows = await this.engine.executeRaw<Record<string, unknown>>(
`UPDATE minion_jobs SET status = 'waiting', updated_at = now()
WHERE id = $1 AND status = 'waiting-children'
AND NOT EXISTS (
SELECT 1 FROM minion_jobs
WHERE parent_job_id = $1
AND status NOT IN ('completed', 'failed', 'dead', 'cancelled')
)
RETURNING *`,
[parentId]
);
return rows.length > 0 ? rowToMinionJob(rows[0]) : null;
}
/** Fail the parent when a child fails with fail_parent policy. */
async failParent(parentId: number, childId: number, errorText: string): Promise<MinionJob | null> {
const rows = await this.engine.executeRaw<Record<string, unknown>>(
`UPDATE minion_jobs SET status = 'failed',
error_text = $1, finished_at = now(), updated_at = now()
WHERE id = $2 AND status = 'waiting-children'
RETURNING *`,
[`child job ${childId} failed: ${errorText}`, parentId]
);
return rows.length > 0 ? rowToMinionJob(rows[0]) : null;
}
/** Pause a waiting or active job. For active jobs, clears the lock so the worker's
* AbortController fires and the handler stops gracefully. */
async pauseJob(id: number): Promise<MinionJob | null> {
const rows = await this.engine.executeRaw<Record<string, unknown>>(
`UPDATE minion_jobs SET status = 'paused',
lock_token = NULL, lock_until = NULL, updated_at = now()
WHERE id = $1 AND status IN ('waiting', 'active', 'delayed')
RETURNING *`,
[id]
);
return rows.length > 0 ? rowToMinionJob(rows[0]) : null;
}
/** Resume a paused job back to waiting. */
async resumeJob(id: number): Promise<MinionJob | null> {
const rows = await this.engine.executeRaw<Record<string, unknown>>(
`UPDATE minion_jobs SET status = 'waiting',
lock_token = NULL, lock_until = NULL, updated_at = now()
WHERE id = $1 AND status = 'paused'
RETURNING *`,
[id]
);
return rows.length > 0 ? rowToMinionJob(rows[0]) : null;
}
/** Send a message to a job's inbox. Sender must be the parent job or 'admin'. */
async sendMessage(jobId: number, payload: unknown, sender: string): Promise<InboxMessage | null> {
// Validate job exists and is in a messageable state
const job = await this.getJob(jobId);
if (!job) return null;
if (['completed', 'dead', 'cancelled', 'failed'].includes(job.status)) return null;
// Sender validation: must be parent job ID or 'admin'
if (sender !== 'admin' && sender !== String(job.parent_job_id)) {
return null;
}
const rows = await this.engine.executeRaw<Record<string, unknown>>(
`INSERT INTO minion_inbox (job_id, sender, payload)
VALUES ($1, $2, $3)
RETURNING *`,
[jobId, sender, payload]
);
return rows.length > 0 ? rowToInboxMessage(rows[0]) : null;
}
/** Read unread inbox messages for a job. Token-fenced. Marks messages as read. */
async readInbox(jobId: number, lockToken: string): Promise<InboxMessage[]> {
// Verify lock ownership
const lockCheck = await this.engine.executeRaw<{ id: number }>(
`SELECT id FROM minion_jobs WHERE id = $1 AND lock_token = $2 AND status = 'active'`,
[jobId, lockToken]
);
if (lockCheck.length === 0) return [];
const rows = await this.engine.executeRaw<Record<string, unknown>>(
`UPDATE minion_inbox SET read_at = now()
WHERE job_id = $1 AND read_at IS NULL
RETURNING *`,
[jobId]
);
return rows.map(rowToInboxMessage);
}
/** Update token counts for a job. Accumulates (adds to existing). Token-fenced. */
async updateTokens(id: number, lockToken: string, tokens: TokenUpdate): Promise<boolean> {
const rows = await this.engine.executeRaw<Record<string, unknown>>(
`UPDATE minion_jobs SET
tokens_input = tokens_input + $1,
tokens_output = tokens_output + $2,
tokens_cache_read = tokens_cache_read + $3,
updated_at = now()
WHERE id = $4 AND status = 'active' AND lock_token = $5
RETURNING id`,
[tokens.input ?? 0, tokens.output ?? 0, tokens.cache_read ?? 0, id, lockToken]
);
return rows.length > 0;
}
/** Replay a completed/failed/dead job with optional data overrides. Creates a new job. */
async replayJob(id: number, dataOverrides?: Record<string, unknown>): Promise<MinionJob | null> {
const source = await this.getJob(id);
if (!source) return null;
if (!['completed', 'failed', 'dead'].includes(source.status)) return null;
const data = dataOverrides
? { ...source.data, ...dataOverrides }
: source.data;
return this.add(source.name, data, {
queue: source.queue,
priority: source.priority,
max_attempts: source.max_attempts,
backoff_type: source.backoff_type,
backoff_delay: source.backoff_delay,
backoff_jitter: source.backoff_jitter,
});
}
/** Remove a child's dependency on its parent. */
async removeChildDependency(childId: number): Promise<void> {
await this.engine.executeRaw(
`UPDATE minion_jobs SET parent_job_id = NULL, updated_at = now() WHERE id = $1`,
[childId]
);
}
/**
* Read child_done messages from a parent's inbox. Token-fenced (the parent
* job must currently hold lockToken — same fence as readInbox to prevent a
* stale process polling completions for jobs it no longer owns).
*
* Does NOT mark messages read (parent may want to poll repeatedly with a
* cursor). Use `since` to fetch only newer entries.
*/
async readChildCompletions(
parentId: number,
lockToken: string,
opts?: { since?: Date }
): Promise<ChildDoneMessage[]> {
// Verify the caller holds the parent's lock.
const lockCheck = await this.engine.executeRaw<{ id: number }>(
`SELECT id FROM minion_jobs WHERE id = $1 AND lock_token = $2 AND status = 'active'`,
[parentId, lockToken]
);
if (lockCheck.length === 0) return [];
const params: unknown[] = [parentId];
let sinceClause = '';
if (opts?.since) {
sinceClause = ` AND sent_at > $2::timestamptz`;
params.push(opts.since.toISOString());
}
const rows = await this.engine.executeRaw<Record<string, unknown>>(
`SELECT payload FROM minion_inbox
WHERE job_id = $1 AND (payload->>'type') = 'child_done'${sinceClause}
ORDER BY sent_at ASC`,
params
);
return rows.map(r => {
const p = typeof r.payload === 'string' ? JSON.parse(r.payload) : r.payload;
return p as ChildDoneMessage;
});
}
/**
* Attach a file to a job. Validates size, base64, filename safety, and
* duplicate filename. Returns the persisted attachment metadata (not the
* bytes — use getAttachment to fetch).
*
* The DB UNIQUE (job_id, filename) constraint is the authoritative duplicate
* fence; the in-memory check just gives a faster error.
*/
async addAttachment(jobId: number, input: AttachmentInput): Promise<Attachment> {
await this.ensureSchema();
// Verify job exists (FK guarantees this on insert too, but explicit error is clearer)
const exists = await this.engine.executeRaw<{ id: number }>(
`SELECT id FROM minion_jobs WHERE id = $1`,
[jobId]
);
if (exists.length === 0) {
throw new Error(`job ${jobId} not found`);
}
const existingRows = await this.engine.executeRaw<{ filename: string }>(
`SELECT filename FROM minion_attachments WHERE job_id = $1`,
[jobId]
);
const existingFilenames = new Set(existingRows.map(r => r.filename));
const result = validateAttachment(input, {
maxBytes: this.maxAttachmentBytes,
existingFilenames,
});
if (!result.ok) {
throw new Error(`attachment validation failed: ${result.error}`);
}
const { filename, content_type, bytes, size_bytes, sha256 } = result.normalized;
const rows = await this.engine.executeRaw<Record<string, unknown>>(
`INSERT INTO minion_attachments (job_id, filename, content_type, content, size_bytes, sha256)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, job_id, filename, content_type, storage_uri, size_bytes, sha256, created_at`,
[jobId, filename, content_type, bytes, size_bytes, sha256]
);
return rowToAttachment(rows[0]);
}
/** List attachments for a job (metadata only, no bytes). */
async listAttachments(jobId: number): Promise<Attachment[]> {
const rows = await this.engine.executeRaw<Record<string, unknown>>(
`SELECT id, job_id, filename, content_type, storage_uri, size_bytes, sha256, created_at
FROM minion_attachments
WHERE job_id = $1
ORDER BY created_at ASC, id ASC`,
[jobId]
);
return rows.map(rowToAttachment);
}
/**
* Fetch a single attachment with bytes. Returns null if not found.
* The bytes are returned as a Buffer (Uint8Array under the hood).
*/
async getAttachment(jobId: number, filename: string): Promise<{ meta: Attachment; bytes: Buffer } | null> {
const rows = await this.engine.executeRaw<Record<string, unknown>>(
`SELECT id, job_id, filename, content_type, storage_uri, size_bytes, sha256, created_at, content
FROM minion_attachments
WHERE job_id = $1 AND filename = $2`,
[jobId, filename]
);
if (rows.length === 0) return null;
const row = rows[0];
const meta = rowToAttachment(row);
const raw = row.content;
let bytes: Buffer;
if (raw == null) {
bytes = Buffer.alloc(0);
} else if (Buffer.isBuffer(raw)) {
bytes = raw;
} else if (raw instanceof Uint8Array) {
bytes = Buffer.from(raw);
} else {
bytes = Buffer.from(raw as ArrayBuffer);
}
return { meta, bytes };
}
/** Delete an attachment by job + filename. Returns true if a row was removed. */
async deleteAttachment(jobId: number, filename: string): Promise<boolean> {
const rows = await this.engine.executeRaw<{ id: number }>(
`DELETE FROM minion_attachments WHERE job_id = $1 AND filename = $2 RETURNING id`,
[jobId, filename]
);
return rows.length > 0;
}
}