cccc console
bluebut#63stalled
c1a7db48-be78-466b-9993-5e832a611fec · pbox · Jul 25, 01:14:39 PM → 4h ago
- team
- solo
- turns
- —
- tools
- 10
- tokens
- —
- spend
- —
- took
- 7m 13s
- model
- —
- errors
- 0
claude --resume c1a7db48-be78-466b-9993-5e832a611feclead agent
model not reported
GOAL: fix GitLab issue #63 in devdashco/bluebut in an isolated worktree, push, LAND IT ON main, and close.
SUBAGENTS: spawn AT MOST 4 parallel subagent(s) (Task tool) at a time. NEVER fan heavy commands (installs, full test suites, builds) out across subagents in parallel — that is what melts the box.
You are in the devdashco/bluebut repository (you are already assigned to this issue). This repo
lives on our self-hosted GitLab (gitlab.hostbun.cc) — use the `glab` CLI for all issue /
MR work (it is installed and a…
Jul 25, 01:14:39 PM
Bash{"command": "PROG='python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 63'; $PROG read start \"checking existing state\"; pwd; git branch --show-current; git fetch origin -q; echo \"--- commits ahead of main ---\"; git log --oneline origin/main..fix/issue-63 2>/dev/null; echo \"--- MR list ---\"; glab mr list --repo devdashco/bluebut --source-branch fix/issue-63 2>/dev/null", "description": "Check existing branch/MR state"}
/home/philip/Documents/GitHub/bluebut-issue-63 fix/issue-63 --- commits ahead of main --- --- MR list --- No open merge requests match your search in devdashco/bluebut.
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-63; git log --oneline -8; echo \"---\"; grep -rl \"started_at\\|claimed_at\\|MAX_CONCURRENT\\|resolve-aliases\" --include=*.ts --include=*.js --include=*.sql -i . | grep -v node_modules | head -40", "description": "Find queue worker files"}
8f8f2a3d chore(runs): DELETE the last four pre-Skyvern table names + fix the schema.ts drift 7a94903f refactor(queue): REMOVE the claim state — queued → running in one atomic transition 34256bd0 fix(runs): a fleet rule's run history was showing 1 entry instead of 50 304abc9c fix(resolvers): land the adapter rewire — 3123995f deleted the seam but kept the imports 3123995f refactor(resolvers): DELETE the claim/lease state machine — lease-free target queue 4fc13f7c chore: working tree — outreach source_channel view (0059) + skyvern-workflows skill dd9d9e8e chore(mcp): DELETE every bluebut MCP — code, .mcp.json wiring and both Coolify apps 9e19ad05 chore(skills): DELETE the job-architecture skill --- server/http/routes/cloakbox.ts server/runs/enqueue-broadcast.ts db/migrations/entity/0056_claim_job_restore_pacing.sql db/migrations/entity/0060_dequeue_run_drop_claim_state.sql adapters/_lib/flow-health.ts adapters/_lib/workflow/workflow.ts adapters/_lib/passive/wiretap-feed.ts ontology/ontology.ts server/runs/verbs/leads.ts db/0002_claim_job.sql server/runs/worker.ts db/migrations/cloak/0001_cloakbox_profile_store.sql server/http/routes/accounts-mint.ts adapters/_lib/coolify.ts ui/src/api/workflow-runs.ts server/http/routes/bridge.ts server/store/workflows.ts server/http/routes/capture.ts server/store/engine.ts db/0012_lively_mystique.sql db/0001_job_queue.sql db/migrations/entity/0045_homii_resolve_claim.sql db/schema.ts db/migrations/entity/0054_drop_run_ok.sql db/migrations/entity/0011_taxonomy_guardrails.sql db/0000_init_baseline.sql server/contract/job.ts server/runs/queue.ts adapters/_lib/cloak-client-core.ts db/migrations/entity/0052_drop_run_compat_columns.sql adapters/_lib/cloak-client-warehouse.ts adapters/_lib/e2ee-event-connect.ts adapters/_lib/post-login-orchestrator.ts adapters/_lib/login-wire.ts adapters/_lib/schema-cache.ts adapters/facebook/resolve-aliases.js adapters/facebook/infra-health-check.js adapters/facebook/flow-stall-check.js ui/src/api/instances.ts shared/contract/index.ts
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-63; echo \"===== worker.ts =====\"; wc -l server/runs/worker.ts server/runs/queue.ts; echo \"===== 0060 migration =====\"; cat db/migrations/entity/0060_dequeue_run_drop_claim_state.sql", "description": "Inspect worker/queue and latest migration"}
===== worker.ts =====
277 server/runs/worker.ts
324 server/runs/queue.ts
601 total
===== 0060 migration =====
-- 0060_dequeue_run_drop_claim_state — REMOVE the claim/"occupied" state from the run queue.
--
-- WHAT GOES. The `claimed` status, `claimed_at` and `worker_id` were bookkeeping layered on
-- top of a dequeue that was ALREADY atomic: claim_job's selection is `FOR UPDATE SKIP
-- LOCKED`, so the lock — not the status write — is what makes the take exclusive. The extra
-- persisted state bought nothing and cost plenty:
-- • a row could rest in `claimed` indefinitely if a worker died before writing `running`,
-- holding its account lane until a reaper swept it → the "queue wedge" class
-- • it needed TWO reapers, because claimed_at IS NULL on inline-spawned rows meant
-- reapStale's `claimed_at=lt.<cut>` filter could not see them (#57 follow-up 3)
-- • `claimed` inflated the status vocabulary the single-writer funnel is trying to collapse
-- (inv #26)
--
-- WHAT REPLACES IT. `dequeue_run(p_worker)` — the same selection (due-time gate, per-account
-- lane, priority order, FOR UPDATE SKIP LOCKED) but it transitions queued → **running**
-- directly and stamps `started_at`. One state, one transition, nothing to expire or release.
-- Crash recovery time-boxes off `started_at` (a real fact about the run) instead of
-- `claimed_at` (a fact about the bookkeeping), which collapses the two reapers into one.
--
-- WHAT IS DELIBERATELY KEPT. The atomic take and the per-account lane are NOT claim
-- bookkeeping: without atomicity the same run is handed to N workers (the same landlord DMed
-- twice — outreach-send.js:331 "Can't un-send"), and without the account lane two runs drive
-- ONE cloak profile concurrently, which the engine forbids outright (one opener per profile,
-- inv #1) and which trips FB's "too many sessions" (inv #8). `p_worker` is accepted and
-- ignored so the signature stays stable for callers that still pass it.
--
-- EXPAND/CONTRACT (inv #21). This migration is ADDITIVE and backward-compatible:
-- • `claim_job` is LEFT IN PLACE — the old container is still serving during the deploy
-- window and calls it. It is dropped in a follow-up, after this code is everywhere.
-- • `claimed_at` / `worker_id` columns are NOT dropped, for the same reason.
-- • dequeue_run's lane check still counts `claimed` as occupying a lane, so it respects
-- rows the OLD claim_job creates mid-window. The follow-up narrows it to `running`.
CREATE OR REPLACE FUNCTION public.dequeue_run(p_worker text DEFAULT NULL)
RETURNS SETOF public.run
LANGUAGE plpgsql AS $$
DECLARE
v_run public.run;
BEGIN
SELECT j.* INTO v_run
FROM public.run j
WHERE j.status = 'queued'
AND (j.not_before IS NULL OR j.not_before <= now()) -- due-time gate (pacing, #217)
AND (
j.account IS NULL
OR NOT EXISTS (
-- per-account lane. 'claimed' stays in this list for the deploy window only, so a
-- row taken by the OLD claim_job still blocks its account. → narrow to ('running')
-- in the follow-up that drops claim_job.
SELECT 1 FROM public.run b
WHERE b.account = j.account
AND b.status IN ('claimed', 'running')
)
)
ORDER BY j.priority DESC NULLS LAST, j.created_at ASC -- priority order (#217)
FOR UPDATE OF j SKIP LOCKED
LIMIT 1;
IF NOT FOUND THEN
RETURN;
END IF;
-- queued → running in ONE transition. No intermediate `claimed`, no lease timestamp.
UPDATE public.run
SET status = 'running', started_at = now(), updated_at = now()
WHERE id = v_run.id;
v_run.status := 'running';
RETURN NEXT v_run;
END;
$$;
-- Serve the crash-recovery sweep off started_at (it used to key on claimed_at).
CREATE INDEX IF NOT EXISTS run_running_started_idx
ON public.run (started_at)
WHERE status = 'running';
NOTIFY pgrst, 'reload schema';Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-63; cat server/runs/worker.ts", "description": "Read worker.ts"}
// worker — claims queued jobs and runs them through the EXISTING runner.
//
// This is the industry-standard replacement for the backend's spawn-per-request +
// in-memory ACCOUNT_LOCK Map. The worker owns no job state: it polls claim() (the
// atomic RPC), spawns scripts/bluebut-adapter.ts for the claimed verb, and the
// runner writes the durable ledger row. A dead worker leaves rows queued/claimed
// to be reclaimed by the periodic reaper (reapStale, wired below), no lock Map,
// no bridge process required.
//
// Concurrency is bounded by MAX_CONCURRENT; per-account exclusion is enforced in
// SQL (claim_job won't hand out a second job for a busy account), so the worker
// never has to reason about it.
import { spawn } from 'node:child_process'
import { join, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { getVerb } from './catalog.ts'
import { claim, markFailed, reapStale, type RunRow } from './queue.ts'
import { publishLine, endStream } from './step-stream.ts'
import { numEnv } from '../../adapters/_lib/num-env.ts'
import { adapterPath } from '../http/runtime/spawn-env.mjs'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = join(HERE, '..', '..')
const RUNNER = process.env.BLUEBUT_ADAPTER_BIN || join(REPO, 'scripts', 'bluebut-adapter.ts')
const NODE = process.execPath
const POLL_MS = numEnv('BLUEBUT_QUEUE_POLL_MS', 1500)
const MAX_CONCURRENT = numEnv('BLUEBUT_QUEUE_CONCURRENCY', 3)
// Hard wall-clock cap on one claimed child. runJob otherwise waits for the child's
// `exit`/`error` event with NO timeout, so a child that HANGS (never exits — a stuck
// DB read, a wedged reharvest-history rebuilding a huge HAR, a socket with no timeout)
// leaves its runJob promise pending forever → tick's `.finally(() => running -= 1)`
// never runs → the concurrency slot leaks. Three hung children pin `running` at
// MAX_CONCURRENT and the worker STOPS CLAIMING entirely (observed 2026-07-25: worker
// w_58 wedged at 06:06 on 3 hung reharvest-history children → every outreach-send/
// send-message starved for hours, no DMs sent). The reaper (reapStale) marks the DB
// row failed at REAP_STALE_MS but can neither kill the zombie child nor un-leak the
// in-memory counter — only this watchdog can. Aligned to REAP_STALE_MS by default so
// the worker's kill and the ledger's stale-reap converge on the same bound.
const JOB_TIMEOUT_MS = numEnv('BLUEBUT_QUEUE_JOB_TIMEOUT_MS', 15 * 60 * 1000)
// How many trailing stderr lines to keep per child for the failure post-mortem (see `tail`
// in runJob). Small on purpose: this rides in aborted_reason, which the UI renders inline.
const STDERR_TAIL = 6
// The reaper runs on its own slower cadence (not every claim poll) — sweeping stale
// claimed/running rows is a cleanup, not a hot path, and a per-poll DB scan across the
// fleet would be wasteful. reapStale itself is time-boxed (BLUEBUT_QUEUE_REAP_STALE_MS).
const REAP_POLL_MS = numEnv('BLUEBUT_QUEUE_REAP_POLL_MS', 60_000)
const DRY_RUN = /^(1|true|yes)$/i.test(process.env.BLUEBUT_QUEUE_DRYRUN || '')
// Graceful-drain window for a STANDALONE worker (worker.ts run directly). When the
// worker is imported by server/http/index.mjs, that barrel owns SIGTERM/SIGINT and
// calls our stop fn — the entry-point guard below only self-registers signals when
// this module IS the process entry, so handlers are never double-installed.
const SHUTDOWN_GRACE_MS = numEnv('BLUEBUT_SHUTDOWN_GRACE_MS', 8000)
// Live claimed children, keyed by run id (#295). The ONE handle on a running child, so
// the operator Stop path (cancelRun → killRun) can reach a process the worker spawned.
// Populated in runJob, cleared in its single-settle. Same process as the HTTP server, so
// an in-memory Map is sufficient — it is a supervisor handle, never run state (the ledger
// row is the state; this Map dying with the process just means the reaper sweeps instead).
const LIVE_CHILDREN = new Map<string, ReturnType<typeof spawn>>()
/** Resolve one job's wall-clock budget: the producer-supplied `input._timeout_ms`
* (the workflow/broadcast lanes' per-cmd budget) else the global JOB_TIMEOUT_MS. */
export function jobTimeoutMs(job: RunRow): number {
const raw = (job.input as Record<string, unknown> | null | undefined)?._timeout_ms
const n = Number(raw)
return Number.isFinite(n) && n > 0 ? n : JOB_TIMEOUT_MS
}
/** SIGKILL a claimed child by run id. Returns false when this worker isn't running it
* (already finished, or never claimed here) — the caller still marks the row canceled,
* so a queued-but-unclaimed run cancels correctly without a process to kill. */
export function killRun(job_id: string): boolean {
const child = LIVE_CHILDREN.get(job_id)
if (!child) return false
try { child.kill('SIGKILL') } catch { /* already gone */ }
return true
}
let running = 0
let stopped = false
/**
* PURE: resolve a claimed job to its runner adapter + argv. THROWS on a malformed
* row — an unknown/empty verb (getVerb → ValidationError) or bad input (toArgv).
* runJob MUST call this inside a try: a synchronous throw here would skip tick's
* `.finally(() => running -= 1)` and leak a concurrency slot forever (3 malformed
* jobs wedge the worker), and leave the row `claimed` (account lane blocked until
* the reaper). Exported so that guard is unit-tested without spawning a child.
*/
export function jobSpawnArgs(job: RunRow): { name: string; adapter: string; argv: string[] } {
const input = (job.input ?? {}) as Record<string, unknown>
// epic #6 cutover: RAW jobs (enqueueRaw — the UI's argv-speaking /exec path). They
// carry `input._raw` with the (site, cmd, argv) verbatim instead of a typed verb, so
// resolve the adapter by PATH and pass argv through EXACTLY like the legacy /exec
// dispatcher — same adapterPath + same --format/json strip. A missing adapter throws,
// which runJob catches → markFailed (the row never wedges the account lane).
if (input._raw) {
const site = String(input.site || '')
const cmd = String(input.cmd || '')
const adapter = adapterPath(site, cmd)
if (!adapter) throw new Error(`no adapter at adapters/${site}/${cmd}.js (raw job ${job.job_id})`)
const rawArgv = Array.isArray(input.argv) ? (input.argv as unknown[]) : []
const argv = rawArgv.filter((a) => a != null && a !== '--format' && a !== 'json').map(String)
return { name: `${site}/${cmd}`, adapter, argv }
}
const verb = getVerb(String(job.verb || job.cmd || ''))
return { name: verb.name, adapter: verb.adapter, argv: verb.toArgv(job.input ?? {}) }
}
// Run one claimed job to completion. Resolves when the child exits (any code) —
// the runner owns the terminal ledger write, so the worker only frees its slot.
// NEVER throws synchronously (see jobSpawnArgs) — a malformed job resolves after
// being marked failed, so tick's slot-release `.finally` always runs.
function runJob(job: RunRow): Promise<void> {
let name: string, adapter: string, argv: string[]
try {
({ name, adapter, argv } = jobSpawnArgs(job))
} catch (err) {
void markFailed(job.job_id, `worker setup failed: ${(err as Error).message}`).catch(() => {})
return Promise.resolve()
}
const env: NodeJS.ProcessEnv = { ...process.env, BLUEBUT_JOB_ID: job.job_id }
if (job.batch_id) env.BLUEBUT_BATCH_ID = String(job.batch_id)
if (DRY_RUN) {
console.log(`[worker] DRY_RUN ${job.job_id} ${name} ${argv.join(' ')}`)
return Promise.resolve()
}
// Per-job timeout budget (#295). The workflow/broadcast lanes used to enforce their own
// per-cmd timeout around their own spawn; now they enqueue and the WORKER is the one
// killer, so the budget rides the row (`input._timeout_ms`) and the watchdog honours it.
// Absent → the global JOB_TIMEOUT_MS. Two killers for one child is exactly the drift the
// one-executor rule exists to prevent.
const TIMEOUT_MS = jobTimeoutMs(job)
return new Promise<void>((resolve) => {
// Capture the runner's stderr (its step-log lines) into the in-process tap so a live
// HTTP tailer — the /api/queue/jobs/:id/events SSE, same process — can stream them to
// the dashboard (epic #6: the worker spawns out-of-band, so the request handler has no
// child to tee; the tap bridges it). stdout is discarded — the runner writes the
// durable `run` row itself, so the terminal RESULT is read from the ledger, not here.
const child = spawn(NODE, [RUNNER, adapter, ...argv], { env, cwd: REPO, stdio: ['ignore', 'ignore', 'pipe'] })
// Live-child registry (#295) — the operator Stop path. The lanes used to hold their own
// child handle to SIGKILL on cancel; with the worker owning every spawn, cancelRun()
// needs a way to reach the process. Same Node process as the backend, so a Map is enough.
LIVE_CHILDREN.set(job.job_id, child)
// Single-settle guard: the watchdog, `exit`, and `error` paths can each fire; only the
// FIRST may resolve (freeing the slot) + clear the timer. Without this a late `exit`
// after a watchdog-kill would resolve twice (double slot-decrement in tick's finally).
let settled = false
const settle = (): void => {
if (settled) return
settled = true
clearTimeout(watchdog)
LIVE_CHILDREN.delete(job.job_id)
endStream(job.job_id)
resolve()
}
// WATCHDOG — kill a child that overruns JOB_TIMEOUT_MS and free the slot. This is the
// guarantee that a HUNG child (never emits exit/error) can no longer wedge the worker
// (see JOB_TIMEOUT_MS). SIGKILL (not SIGTERM) because the failure mode is a child stuck
// in an unkillable-by-TERM state (blocking syscall / ignored handler); markFailed sweeps
// the ledger row so its account lane frees too. The child's own `exit` then fires with a
// signal code, but `settled` already true → a harmless no-op.
const watchdog = setTimeout(() => {
console.warn(`[worker] ${job.job_id} ${name} exceeded ${TIMEOUT_MS}ms — SIGKILL + free slot`)
void markFailed(job.job_id, `worker watchdog: killed after ${TIMEOUT_MS}ms (hung child)`).catch(() => {})
try { child.kill('SIGKILL') } catch { /* already gone */ }
settle()
}, TIMEOUT_MS)
watchdog.unref?.()
let ebuf = ''
// Rolling tail of the child's stderr. The SSE tap is LIVE-only — once the stream ends
// the lines are gone, so a child that dies before the runner writes a terminal row left
// `child exited 1` as the entire post-mortem (resolve-homii-pfbid burned a week that way:
// a real 42703 was invisible because the reason carried no detail). Keep the last few
// lines so the ledger's aborted_reason names the ACTUAL error.
const tail: string[] = []
child.stderr?.on('data', (d: Buffer) => {
ebuf += d
let i: number
while ((i = ebuf.indexOf('\n')) >= 0) {
const line = ebuf.slice(0, i).replace(/\x1b\[[0-9;]*m/g, '')
ebuf = ebuf.slice(i + 1)
if (line.trim()) { publishLine(job.job_id, line); tail.push(line); if (tail.length > STDERR_TAIL) tail.shift() }
}
})
child.on('error', (err: Error) => {
void markFailed(job.job_id, `worker spawn failed: ${err.message}`).catch(() => {})
settle()
})
child.on('exit', (code) => {
if (settled) return // watchdog already killed + freed this slot — nothing to do
if (ebuf.trim()) publishLine(job.job_id, ebuf.replace(/\x1b\[[0-9;]*m/g, '')) // flush trailing partial line
if (code !== 0) {
// The runner OWNS the terminal write on a clean run, but a child that dies
// before writing one (crash, OOM, SIGKILL) would leave the row stuck in
// `claimed`/`running` — and claim_job treats that as an active job, so the
// account is blocked forever. markFailed is status-scoped (queued/claimed/
// running only), so it's a no-op when the runner already wrote a terminal row.
console.warn(`[worker] ${job.job_id} exited ${code} — sweeping any non-terminal row to failed`)
// Prefer a NON-JSONL tail line: the runner's own step-log is JSONL on stderr, so a
// raw line is the interesting one (an import/DB crash before any step ran — exactly
// the resolve-homii-pfbid case). Fall back to the last line when everything is JSONL.
const raw = tail.filter((l) => !l.trimStart().startsWith('{'))
const why = (raw.length ? raw : tail).slice(-2).join(' | ').slice(0, 300)
void markFailed(job.job_id, `child exited ${code}${why ? ` — ${why}` : ''}`).catch(() => {})
}
settle()
})
})
}
async function tick(): Promise<void> {
if (stopped) return
while (running < MAX_CONCURRENT) {
let job: RunRow | null
try {
job = await claim(WORKER_ID)
} catch (err) {
console.warn(`[worker] claim error: ${(err as Error).message}`)
break
}
if (!job) break
running += 1
void runJob(job).finally(() => { running -= 1 })
}
}
export const WORKER_ID = `w_${process.pid}`
export function startWorker(): () => void {
console.log(`[worker] ${WORKER_ID} up — poll ${POLL_MS}ms, concurrency ${MAX_CONCURRENT}${DRY_RUN ? ' (DRY_RUN)' : ''}`)
const timer = setInterval(() => { void tick().catch((e) => console.warn(`[worker] tick: ${(e as Error).message}`)) }, POLL_MS)
timer.unref?.()
// Reclaim rows orphaned in claimed/running by a worker that died before its
// child.on('exit') could markFailed them (queue.ts reapStale) — the account lane
// stays blocked until this sweeps. Runs regardless of claim activity.
// ONE sweep. It keys on `started_at`, so it covers queue-dequeued AND inline-spawned rows
// alike; the second `reapOrphans` pass existed only because `claimed_at` was NULL on inline
// rows and is gone with the claim state (migration 0060).
const reaper = setInterval(() => {
void reapStale()
.then((n) => { if (n) console.warn(`[worker] reaped ${n} dead running row(s)`) })
.catch((e) => console.warn(`[worker] reap: ${(e as Error).message}`))
}, REAP_POLL_MS)
reaper.unref?.()
return () => { stopped = true; clearInterval(timer); clearInterval(reaper) }
}
// Standalone entry: when worker.ts is the process (not imported by index.mjs), it
// must catch SIGTERM/SIGINT itself — container stop/redeploy sends SIGTERM, and a
// bare process.on listener would otherwise OVERRIDE Node's default-exit, wedging the
// container. So we only self-register when this module IS the entry point; under
// index.mjs the barrel's own handlers drive our stop fn instead (no double-install).
const isEntry = !!process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (isEntry) {
const stop = startWorker()
let shuttingDown = false
const shutdown = (sig: string): void => {
if (shuttingDown) return
shuttingDown = true
console.log(`[worker] ${sig} — draining ${running} in-flight (grace ${SHUTDOWN_GRACE_MS}ms)`)
stop() // idempotent: flips `stopped`, halts new claims + reaper
const hard = setTimeout(() => { console.warn('[worker] grace elapsed — exiting'); process.exit(0) }, SHUTDOWN_GRACE_MS)
hard.unref?.()
const poll = setInterval(() => {
if (running === 0) { clearTimeout(hard); clearInterval(poll); process.exit(0) }
}, 100)
poll.unref?.()
}
process.once('SIGTERM', () => shutdown('SIGTERM'))
process.once('SIGINT', () => shutdown('SIGINT'))
}Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-63; cat server/runs/queue.ts", "description": "Read queue.ts"}
// queue — the job queue, built on the `job` ledger table via the db.js seam ONLY.
//
// enqueue() inserts a `queued` row; claim() calls the claim_job RPC (atomic
// FOR UPDATE SKIP LOCKED + per-account unique-index lock, migration 0001). No new
// DB client, no in-memory Map: the table IS the queue and the job identity, so it
// survives any process restart. The runner (spawned by the worker) merge-upserts
// the same job_id to running→terminal, so a claimed row upgrades in place.
// db.ts is the untyped legacy _lib seam — treat `rest` as loose until it's typed on touch (invariant #20)
import { rest as _rest } from '../../adapters/_lib/db/db.ts'
const rest = _rest as (method: string, path: string, body?: unknown, opts?: Record<string, unknown>) => Promise<any>
import { getVerb } from './catalog.ts'
import { mintJobId } from './run-id.ts'
import { numEnv } from '../../adapters/_lib/num-env.ts'
// How long a `running` row may sit without a terminal status before crash recovery
// reclaims it (the runner died without reaching the child.on('exit') handler that normally
// sweeps it via markFailed — see reapStale below). Keyed on `started_at`, so ONE window
// covers both queue-dequeued and inline-spawned rows; the separate ORPHAN window that used
// to exist only because `claimed_at` was NULL on inline rows is gone with the claim state
// (migration 0060). Generous default so a legitimately long run is never clobbered mid-run.
// Env-tunable, matching this repo's numEnv convention (adapters/_lib/num-env.ts).
const REAP_STALE_MS = numEnv('BLUEBUT_QUEUE_REAP_STALE_MS', 30 * 60 * 1000)
// The durable `job` ledger's status vocabulary — the ONE source for "is this run
// still going / done?" across every ledger reader (SSE guard, reaper, worker).
// (Distinct from taxonomy.ts RunStatus, the Skyvern vocab the ledger has not yet
// migrated onto — see CLAUDE.md inv #26. Until that lands, this is
// the single writer/reader of the ledger's own terms — no re-hardcoding per site.)
//
// TERMINAL must list EVERY status the runner banks on a finished row, or a reader
// treats a done job as still-running. `adapter-finalize.jobLifecycleStatus` writes
// SUCCESS as 'ok' (not 'done') and the benign terminals 'skipped'/'partial'/
// 'content_unavailable' — omitting them made `/jobs/:id/events` poll a succeeded
// job for the full 600s instead of closing. 'done' is kept for the in-mem bridge
// map; the taxonomy terminals are included so a future write of them still reads
// terminal. (LEDGER_ACTIVE also scopes markFailed's non-terminal sweep, below.)
export const LEDGER_ACTIVE = ['queued', 'claimed', 'running'] as const
export const LEDGER_TERMINAL = [
'ok', 'done', 'completed', 'failed', 'blocked', 'skipped', 'partial',
'content_unavailable', 'terminated', 'timed_out', 'canceled',
] as const
export const isLedgerTerminal = (status: string): boolean =>
(LEDGER_TERMINAL as readonly string[]).includes(status)
// A job row as PostgREST returns it (loosely typed — the DB is the schema).
export interface RunRow {
id?: string // the `run` PK (epic #200 rename of job_id); normalized → job_id below
job_id: string
verb?: string | null
cmd?: string | null
account?: string | null
status: string
input?: unknown
batch_id?: string | null
// issue #52: `ok` column dropped — derive it from `status` via okForStatus, never read it off the row.
[k: string]: unknown
}
// epic #200 renamed the ledger table job→run: the PK column is now `id` (was `job_id`)
// and the batch FK is `workflow_run_id` (was `batch_id`). The queue's public RunRow shape
// keeps job_id/batch_id, so EVERY row read back from `run` is normalized HERE at the seam
// — worker.ts, the /api/queue routes, and the tests are untouched by the column rename.
function normalizeRun(row: Record<string, unknown>): RunRow {
return {
...row,
job_id: String(row.id ?? row.job_id ?? ''),
batch_id: (row.workflow_run_id ?? row.batch_id ?? null) as string | null,
} as RunRow
}
/** Optional ledger-level knobs an enqueue can set (issue #217): a batch grouping, the
* `not_before` due-time (pacing — the row is invisible to claim_job_v2 until due), and
* `priority` (claim order). All optional; omitting them = legacy FIFO immediate-claim. */
export interface EnqueueOpts {
batch_id?: string | null
not_before?: string | null // ISO timestamptz; a queued row won't be claimed before this
priority?: number // higher = claimed first (default 0)
}
/** enqueue — validate input against the verb, insert a queued ledger row. */
export async function enqueue(
verbName: string,
rawInput: unknown,
opts: EnqueueOpts = {},
): Promise<{ job_id: string; status: 'queued' }> {
const verb = getVerb(verbName) // unknown verb → ValidationError → 400 (app.ts onError)
// Bad args throw a ZodError, which the app.ts onError maps to 422 'invalid_input'
// with structured `issues` — richer than a flattened string, and the design's
// intended split (unknown verb → 400, bad args → 422). Do NOT wrap it.
const input = verb.input.parse(rawInput) as Record<string, unknown>
const tgt = verb.target(input)
const job_id = mintJobId()
await rest('POST', 'run', {
id: job_id, // `run` PK (epic #200 rename of job_id)
verb: verb.name,
cmd: verb.name, // keep `cmd` populated for existing ledger consumers
site: verb.site,
account: input.account ?? null,
target_type: tgt.type,
target_id: tgt.id,
status: 'queued',
input,
workflow_run_id: opts.batch_id ?? null, // batch FK (epic #200 rename of batch_id)
not_before: opts.not_before ?? null,
priority: opts.priority ?? 0,
created_at: new Date().toISOString(),
}, { headers: { Prefer: 'return=minimal' }, label: 'queue' })
return { job_id, status: 'queued' }
}
// Pull --account (or the send-path aliases) out of a raw argv so a raw job still
// takes the per-account claim lane, exactly like the typed path's input.account.
function accountFromArgv(argv: string[]): string | null {
for (const f of ['--account', '--from-account', '--to-account']) {
const i = argv.indexOf(f)
if (i >= 0 && argv[i + 1]) return argv[i + 1]
}
return null
}
/** enqueueRaw — enqueue an arbitrary (site, cmd, argv) job WITHOUT the typed-verb
* catalog. The UI's runCli() path speaks argv, not typed input; this carries the raw
* argv on the run row (input._raw) so the worker spawns the runner IDENTICALLY to the
* legacy /exec dispatcher (same adapterPath + argv). It is the durable-queue equivalent
* of a POST /exec/stream call — the missing capability the UI cutover needs, so the
* queue can run ANY adapter the dashboard fires, not only the 64 cataloged verbs. (#295)
* A `job_id` may be supplied so the UI's own job identity threads through unchanged. */
export async function enqueueRaw(
site: string,
cmd: string,
argv: string[],
opts: EnqueueOpts & { job_id?: string; account?: string | null; timeout_ms?: number } = {},
): Promise<{ job_id: string; status: 'queued' }> {
const job_id = opts.job_id || mintJobId()
await rest('POST', 'run', {
id: job_id, // `run` PK (epic #200 rename of job_id)
verb: cmd,
cmd,
site,
account: opts.account ?? accountFromArgv(argv),
status: 'queued',
// `_timeout_ms` is the producer's per-cmd wall-clock budget (#295). The workflow/
// broadcast lanes carry per-cmd timeouts; now that the worker owns every spawn, the
// budget rides the row and the worker's watchdog is the ONE killer that enforces it.
input: { _raw: true, site, cmd, argv, ...(opts.timeout_ms ? { _timeout_ms: opts.timeout_ms } : {}) },
run_type: 'job',
workflow_run_id: opts.batch_id ?? null, // batch FK (epic #200 rename of batch_id)
not_before: opts.not_before ?? null,
priority: opts.priority ?? 0,
created_at: new Date().toISOString(),
}, { headers: { Prefer: 'return=minimal' }, label: 'queue-raw' })
return { job_id, status: 'queued' }
}
/** dequeue — atomically take the next runnable queued run, queued → **running** in ONE
* transition (rpc/dequeue_run, migration 0060). There is no `claimed` state, no
* `claimed_at` lease and no `worker_id`: exclusivity comes from the RPC's
* `FOR UPDATE SKIP LOCKED`, which is what always made the take atomic — the status write
* was only bookkeeping, and a row resting in `claimed` after a worker died was the queue
* wedge. `workerId` is accepted for call-site compatibility and ignored. */
export async function claim(workerId: string): Promise<RunRow | null> {
const rows = await rest('POST', 'rpc/dequeue_run', { p_worker: workerId }, { silent: true })
return Array.isArray(rows) && rows.length ? normalizeRun(rows[0]) : null
}
/** Fetch one job row by id (for GET /jobs/:id). */
export async function getJob(job_id: string): Promise<RunRow | null> {
const rows = await rest('GET', `run?id=eq.${encodeURIComponent(job_id)}&limit=1`, null, { silent: true })
return Array.isArray(rows) && rows.length ? normalizeRun(rows[0]) : null
}
/** Most-recent jobs (for GET /jobs). */
export async function listJobs({ limit = 50 }: { limit?: number } = {}): Promise<RunRow[]> {
const rows = await rest('GET', `run?order=created_at.desc&limit=${Number(limit)}`, null, { silent: true })
return Array.isArray(rows) ? rows.map(normalizeRun) : []
}
// ── The single status-writer funnel (issue #19 Phase 1) ───────────────────────
// updateRunStatus is the ONE place a `run` row's status transition is written in
// the queue path: it writes `status` alone, stamps ended_at on a terminal status, and
// scopes the PATCH to non-terminal rows so a banked terminal is never clobbered.
//
// CONTRACT DONE (issue #52): the legacy `ok` column is dropped (migration
// entity/0054_drop_run_ok). No writer writes it anymore — this funnel, the live runner-
// finalize (scripts/_lib/adapter-finalize.ts updateJobLedger), and every other path
// write `status` only. Every reader rolls `ok` up FROM `status` via okForStatus, so the
// boolean is derived on read (server/routes/jobs.ts SSE frame, taxonomy batchRollup).
// Statuses that mean the run SUCCEEDED (ok=true). A benign terminal
// (skipped/partial/content_unavailable) is NOT a success — the runner banks it
// with ok=false — so okForStatus returns false for it, matching the ledger's data.
const LEDGER_SUCCESS = ['ok', 'done', 'completed'] as const
/** Derive the legacy `ok` boolean a ledger status implies: true for a success
* terminal, false for any other terminal (failure or benign), null while active. */
export function okForStatus(status: string): boolean | null {
if ((LEDGER_SUCCESS as readonly string[]).includes(status)) return true
if (isLedgerTerminal(status)) return false
return null // queued / claimed / running — undecided
}
/** Build the PATCH body for a status transition (pure — no I/O; unit-tested).
* issue #52: `ok` is NO LONGER written — the column is dropped (migration
* entity/0054). Every reader now rolls `ok` up FROM `status` via okForStatus (the SSE
* result frame in server/routes/jobs.ts, taxonomy batchRollup), so the funnel writes
* status alone and the derived boolean is computed on read. okForStatus is kept for
* exactly that read-side derivation. */
export function statusPatch(status: string, reason?: unknown): Record<string, unknown> {
const now = new Date().toISOString()
const body: Record<string, unknown> = { status, updated_at: now }
if (isLedgerTerminal(status)) body.ended_at = now
if (reason != null) body.aborted_reason = String(reason).slice(0, 500)
return body
}
/** THE single writer for a `run` row's status transition (queue path). Scoped to
* non-terminal rows so a banked terminal is NEVER clobbered — the PATCH matches no
* row in that case (a safe no-op). Callers pass a status; `ok` is derived here. */
export async function updateRunStatus(job_id: string, status: string, reason?: unknown): Promise<void> {
await rest('PATCH', `run?id=eq.${encodeURIComponent(job_id)}&status=in.(${LEDGER_ACTIVE.join(',')})`,
statusPatch(status, reason), { headers: { Prefer: 'return=minimal' }, silent: true })
}
/** Mark a claimed/running job failed when the worker itself can't finish it (spawn
* error, child died before writing a terminal row). Routes through the single
* updateRunStatus funnel; the non-terminal scope means a runner that already wrote
* a terminal row is NEVER clobbered. Guards against a job stuck in `claimed` forever
* (which permanently blocks its account via claim_job). */
export async function markFailed(job_id: string, reason: unknown): Promise<void> {
await updateRunStatus(job_id, 'failed', reason)
}
// ── Producer-side helpers: submit async, poll to terminal (#295, Skyvern pattern 3) ──
// The workflow/broadcast lanes used to SPAWN their target runs inline — a second
// dispatcher with its own in-memory account lock, its own timeout/SIGKILL, its own
// orphan finalizer, and a SQL cross-check bolted on to reconcile its lock with
// claim_job's. That whole apparatus is the worker's job. The lanes now ENQUEUE and
// await the ledger, exactly as Skyvern's clients poll GET /runs/{id} until is_final().
// The lane keeps only what is genuinely its own: pacing, jitter, and partitioning.
/** The terminal outcome of a run, read back off the ledger (the source of truth). */
export interface RunOutcome {
ok: boolean
status: string
aborted_reason: string | null
summary: Record<string, unknown> | null
}
/** Await a run's terminal status by polling the ledger. Resolves as soon as the row
* reads terminal. `ceilingMs` is a LAST-RESORT bound on the WAIT, not on the job —
* the worker's watchdog owns killing the child (jobTimeoutMs), so this ceiling sits
* deliberately ABOVE the job budget and only trips if the row never settles at all
* (worker died mid-flight; the reaper will sweep the row shortly after). */
export async function awaitTerminal(
job_id: string,
ceilingMs: number,
{ pollMs = 2000 }: { pollMs?: number } = {},
): Promise<RunOutcome> {
const deadline = Date.now() + ceilingMs
for (;;) {
const row = await getJob(job_id).catch(() => null)
if (row && isLedgerTerminal(row.status)) {
return {
ok: okForStatus(row.status) === true,
status: row.status,
aborted_reason: (row.aborted_reason as string | null) ?? null,
summary: (row.summary as Record<string, unknown> | null) ?? null,
}
}
if (Date.now() >= deadline) {
return {
ok: false,
status: row?.status ?? 'unknown',
aborted_reason: `run did not reach a terminal status within ${Math.round(ceilingMs / 1000)}s (last seen: ${row?.status ?? 'no row'})`,
summary: null,
}
}
await new Promise((r) => setTimeout(r, pollMs))
}
}
/** Operator Stop for a queued or running run. Writes `canceled` through the single
* funnel (non-terminal-scoped, so a run that just finished is never clobbered) and
* SIGKILLs the child if THIS process's worker is running it. A queued-but-unclaimed
* run has no child — the status write alone is the cancel, and claim_job will never
* pick it up because the funnel moved it out of LEDGER_ACTIVE. */
export async function cancelRun(job_id: string, reason = 'canceled by operator'): Promise<void> {
await updateRunStatus(job_id, 'canceled', reason)
const { killRun } = await import('./worker.ts')
killRun(job_id)
}
/** CRASH RECOVERY — sweep `running` rows whose runner died without ever writing a terminal
* status (the worker itself was SIGKILLed, the container restarted mid-run, an inline spawn
* vanished). Such a row has no other exit: LEDGER_ACTIVE scopes it active forever and it
* holds its account's lane, so nothing else can drive that account.
*
* ONE sweep, time-boxed off `started_at` — a fact about the RUN. This replaces the two it
* used to take (one on `claimed_at`, one on `created_at`): those existed
* only because `claimed_at` was NULL on inline-spawned rows, so the first sweep's
* `claimed_at=lt.<cut>` filter could not see them (#57 follow-up 3) and a second sweep had to
* cover the gap. With the claim state gone (migration 0060) there is one timestamp and one
* case. `created_at` is the fallback for a row that somehow never got `started_at`, and
* `claimed` stays in the status list for the deploy window so rows taken by the OLD claim_job
* are still recovered.
*
* Reclaims via the same markFailed funnel, which is scoped to non-terminal rows — so a run
* that just finished legitimately is never clobbered. Safe to call repeatedly/concurrently.
* Returns the count reaped. */
export async function reapStale(staleMs: number = REAP_STALE_MS): Promise<number> {
const cutoff = encodeURIComponent(new Date(Date.now() - staleMs).toISOString())
const rows = await rest(
'GET',
`run?status=in.(claimed,running)&or=(started_at.lt.${cutoff},and(started_at.is.null,created_at.lt.${cutoff}))&select=id`,
null,
{ silent: true },
)
if (!Array.isArray(rows) || !rows.length) return 0
await Promise.all(rows.map((row: Record<string, unknown>) => {
const job_id = String(row.id ?? row.job_id ?? '')
return job_id ? markFailed(job_id, 'reaped: runner died without a terminal status').catch(() => {}) : Promise.resolve()
}))
return rows.length
}Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-63; git log --format='%h %ci %s' -6; echo \"--- who still calls claim_job ---\"; grep -rn \"claim_job\\|rpc/claim\" --include=*.ts --include=*.js --include=*.mjs . | grep -v node_modules | grep -v \"\\.sql\"", "description": "Commit times and claim_job callers"}
8f8f2a3d 2026-07-25 15:13:10 +0200 chore(runs): DELETE the last four pre-Skyvern table names + fix the schema.ts drift
7a94903f 2026-07-25 15:06:47 +0200 refactor(queue): REMOVE the claim state — queued → running in one atomic transition
34256bd0 2026-07-25 15:05:17 +0200 fix(runs): a fleet rule's run history was showing 1 entry instead of 50
304abc9c 2026-07-25 14:59:56 +0200 fix(resolvers): land the adapter rewire — 3123995f deleted the seam but kept the imports
3123995f 2026-07-25 14:53:26 +0200 refactor(resolvers): DELETE the claim/lease state machine — lease-free target queue
4fc13f7c 2026-07-25 14:47:28 +0200 chore: working tree — outreach source_channel view (0059) + skyvern-workflows skill
--- who still calls claim_job ---
server/runs/worker.ts:11:// SQL (claim_job won't hand out a second job for a busy account), so the worker
server/runs/worker.ts:201: // `claimed`/`running` — and claim_job treats that as an active job, so the
server/http/runtime/health.mjs:244: // NULL, so a status=running-only sweep never matches it and claim_job's
scripts/e2e.test.mjs:179:// that claim_job_v2 relies on: at most ONE claimed/running `run` per account.
scripts/e2e.test.mjs:181:// anti-spam jitter into the ledger (now + i*jitter), which claim_job_v2's due-gate
scripts/e2e.test.mjs:220: // partial-unique index — this IS claim_job_v2's per-account exclusion guarantee.
scripts/migrate.ts:10: * pings PostgREST to reload its schema cache so new columns + RPCs (claim_job) show.
server/runs/queue.ts:3:// enqueue() inserts a `queued` row; claim() calls the claim_job RPC (atomic
server/runs/queue.ts:72: * `not_before` due-time (pacing — the row is invisible to claim_job_v2 until due), and
server/runs/queue.ts:228: * (which permanently blocks its account via claim_job). */
server/runs/queue.ts:237:// claim_job's. That whole apparatus is the worker's job. The lanes now ENQUEUE and
server/runs/queue.ts:285: * run has no child — the status write alone is the cancel, and claim_job will never
server/runs/queue.ts:304: * `claimed` stays in the status list for the deploy window so rows taken by the OLD claim_job
server/runs/enqueue-broadcast.ts:10:// drains it via `claim_job_v2`, whose per-account partial-unique lock GUARANTEES an
server/runs/enqueue-broadcast.ts:17:// `not_before` (now + i*jitter for that account's i-th post), so claim_job_v2's due-time
server/runs/enqueue-broadcast.ts:101: // independent streams (they run in parallel via SKIP LOCKED, gated only by claim_job's
server/runs/enqueue-broadcast.ts:117: // created_at → claim_job hands it out first) with the same not_before, and the
server/http/runtime/run-core.mjs:72:// Fan-out runs claim BELOW everything else. claim_job orders `priority DESC, created_at
server/http/runtime/run-core.mjs:95:// child, the per-account lock (claim_job), the timeout kill, and the terminal status
server/http/runtime/run-core.mjs:100:// • the in-memory ACCOUNT_LOCK — claim_job's run_one_active_per_account IS the lock
server/http/runtime/run-core.mjs:119: // funnel moves the row out of the set claim_job selects from.
server/http/index.mjs:417: // Durable-queue worker (drains claim_job → runner). It is THE executor (#295) — no
server/http/runtime/broadcast.mjs:281:// worker's per-account claim_job lock serialize the fleet (no in-process pace-loops, no
adapters/_lib/target-queue.ts:23:// Pure: map an rpc/claim_stale_targets response (SETOF text → ["id",…]; tolerate row
adapters/_lib/target-queue.ts:68: const rows = await rest('POST', 'rpc/claim_stale_targets', {
ontology/ontology.ts:139: { pattern: 'one executor seam', skyvern: 'AsyncExecutorFactory — routes enqueue + return queued; poll GET /runs/:id; swap backend without touching routes', have: 'server/jobs/ worker + queue + catalog (dormant, off in prod, ~12 of 66 verbs) beside inline /exec spawn + detached _activeBatches loops + setInterval scheduler', target: 'server/jobs/ worker is the ONE dispatcher; /exec + /workflow + /broadcast + /schedule enqueue; recurring = claim_job, never a web-process timer (inv #23)', doc: 'SKYVERN-PATTERNS.md' },
adapters/facebook/get-group-feed.js:377: const rows = await rest('POST', 'rpc/claim_stale_groups',
db/schema.ts:865: // (claim_job_v2 adds `AND (not_before IS NULL OR not_before <= now())`); `priority`
db/schema.ts:866: // lets an urgent job jump the FIFO order (claim_job_v2 orders priority DESC, then
db/schema.ts:876: // Serves claim_job_v2's ORDER BY priority DESC, created_at ASC over the queued slice (issue #217).Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-63; sed -n '220,270p' server/http/runtime/health.mjs", "description": "Read health.mjs reaper section"}
kvJson(res, 200, { ok: true, ...r, swept_at: _lastHealthSweep });
} catch (e) { kvJson(res, 502, { ok: false, error: `poll-now: ${e.message || e}` }); }
}
// Periodic keyvault reconcile — droidfleet farm (df-sync) → bluebut's view, every
// KEYVAULT_SYNC_MS (default 60s). On-change write-through (recordSessionLoad /
// saveSession / edits) already keeps OUR namespace current; this tick is the
// import direction the manual Sync button used to be the only trigger for, so the
// farm's fresh logins flow in without an operator click. Best-effort + guarded
// (skip while one is in flight); set KEYVAULT_SYNC_MS=0 to disable.
// Reap orphaned job-ledger rows. The runner writes status=running on start and
// PATCHes a terminal state in its `finally` — but a HARD kill (SIGKILL / OOM /
// box CDP timeout / bridge restart mid-run) skips `finally`, so the row stays
// status=running forever: shows "Running" in the dock and skews every status
// rollup. Nothing else ever closes it (the dead runner can't). Time-based: any
// running row older than JOB_REAP_AFTER_MS is declared dead. Threshold is
// generous (45 min) so a genuinely long scrape is never reaped early.
const JOB_REAP_AFTER_MS = numEnv('JOB_REAP_AFTER_MS', 45 * 60_000);
async function reapStuckJobs() {
const cutoff = new Date(Date.now() - JOB_REAP_AFTER_MS).toISOString();
try {
// Cover BOTH orphan shapes (issue #43.F1): a row stuck `running` past its
// started_at, AND a row stuck `claimed` past its claimed_at — a worker that
// claimed then died before writing its first `running` row leaves started_at
// NULL, so a status=running-only sweep never matches it and claim_job's
// per-account guard leaves that lane permanently unclaimable.
const rows = await dbRest("PATCH",
`run?or=(and(status.eq.running,started_at.lt.${encodeURIComponent(cutoff)}),and(status.eq.claimed,claimed_at.lt.${encodeURIComponent(cutoff)}))`,
{ status: "failed", aborted_reason: "runner_died (no terminal update — reaped)", // issue #52: run.ok dropped (derived from status)
ended_at: new Date().toISOString(), updated_at: new Date().toISOString() },
{ silent: true, headers: { Prefer: "return=representation" } });
const n = Array.isArray(rows) ? rows.length : 0;
if (n) {
console.log(`[backend] job reaper: closed ${n} orphaned running row(s) older than ${Math.round(JOB_REAP_AFTER_MS / 60000)}m`);
// A reaped job died WITHOUT reporting — the exact silent-failure the operator
// must hear about. Name the first few so the alert points at what stalled.
const names = rows.slice(0, 5).map((r) => `${r.cmd || "job"}${r.account ? `·${r.account}` : ""} (${r.id})`).join("\n");
notifyTelegram(`🪦 Job reaper · ${n} stuck job(s) killed (runner died, no terminal update)\n${names}`).catch(() => {});
// A job whose process died before finalizeJob files nothing on its own — the reaper
// is its ONLY closer. Ship an ERROR per reaped job through the funnel (category=BUG),
// so it reaches Sentry → GitHub issue like every other defect. Deduped by
// exception_hash on the cmd so one silently-dying cmd = one issue, not one per run.
for (const r of rows) {
shipLog("bluebut", "ERROR", `[reaper] stuck job killed: ${r.cmd || "job"}${r.account ? `·${r.account}` : ""} (${r.id})`,
{ category: "BUG", cmd: r.cmd, account: r.account, job_id: r.id, batch_id: r.workflow_run_id,
aborted_reason: r.aborted_reason, exception_hash: `reaper:${r.cmd || "job"}` }).catch(() => {});
}
// Auto-file a GitHub issue per reaped job (deduped by signature → one issue per
// cmd that keeps dying silently). The reaper is the ONLY closer for jobs whose
// process died before finalizeJob, so without this those failures file nothing.
for (const r of rows) {Continue from where you left off.
Jul 25, 01:17:19 PM
No response requested.
Jul 25, 01:17:19 PM
Your previous run was interrupted. Reassess where you left off (git status/log, your progress notes) and CONTINUE to completion — do not start over or redo finished work.
Jul 25, 01:17:19 PM
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-63; PROG='python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 63'; $PROG read running \"0060 fixed worker path post-issue; hunting residual claimed-row path\"; echo \"=== migrations touching dequeue/claim ===\"; ls db/migrations/entity/ | tail -20; echo \"=== does dequeue_run exist only in 0060? grep claim_job definition ===\"; grep -rln \"CREATE OR REPLACE FUNCTION.*claim_job\\|FUNCTION public.claim_job\\|rpc/claim_job\" db/ | head", "description": "Survey migrations and claim_job definitions"}
=== migrations touching dequeue/claim === 0043_broadcast_config_closest_km.sql 0044_job_error_issue.sql 0045_homii_resolve_claim.sql 0046_resync_workflow_campaign.sql 0047_rename_run_workflow_run.sql 0048_outreach_geo_source.sql 0049_run_status_check.sql 0050_drop_legacy_run_views.sql 0051_rename_run_columns_expand.sql 0052_drop_run_compat_columns.sql 0053_schedule_compat_and_run_grants.sql 0054_drop_run_ok.sql 0055_lead_sent_no_skip.sql 0056_claim_job_restore_pacing.sql 0057_schedule_last_run_id_expand.sql 0058_drop_schedule_campaign.sql 0059_outreach_source_channel.sql 0060_dequeue_run_drop_claim_state.sql 0062_drop_pre_skyvern_compat_views.sql pending === does dequeue_run exist only in 0060? grep claim_job definition === db/0012_lively_mystique.sql db/0002_claim_job.sql db/migrations/entity/0056_claim_job_restore_pacing.sql db/migrations/entity/0052_drop_run_compat_columns.sql
Continue from where you left off.
Jul 25, 01:19:17 PM
No response requested.
Jul 25, 01:19:17 PM
Your previous run was interrupted. Reassess where you left off (git status/log, your progress notes) and CONTINUE to completion — do not start over or redo finished work.
Jul 25, 01:19:17 PM
Continue from where you left off.
Jul 25, 01:21:18 PM
No response requested.
Jul 25, 01:21:18 PM
Your previous run was interrupted. Reassess where you left off (git status/log, your progress notes) and CONTINUE to completion — do not start over or redo finished work.
Jul 25, 01:21:18 PM
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-63; git status --short; git branch --show-current; PROG='python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 63'; $PROG read done \"0060 made worker path atomic AFTER issue, but a stray 'claimed' row (old claim_job / 0056) still wedges: reapStale only sweeps claimed-NULL-started via the 30min created_at window, not a tight claimed_at lease\"; $PROG worktree done \"in fix/issue-63 worktree, clean\"", "description": "Confirm clean worktree, mark read/worktree done"}
fix/issue-63
Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-63; PROG='python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 63'; $PROG fix start \"add tight claimed-lease sweep to reapStale\"", "description": "Mark fix start"}
no result shipped
Solo run
The lead agent did the work itself — no workers were dispatched.
drag to pan · ⌘/ctrl + scroll to zoom · double-click to fit
100%
Goes into the live fixer's pane (or resumes its session if it has none) — the same path a Telegram reply takes.