cccc console

bluebut#63stalled

c1a7db48-be78-466b-9993-5e832a611fec · pbox · Jul 25, 01:14:39 PM4h ago

team
solo
turns
tools
10
tokens
spend
took
7m 13s
model
errors
0
claude --resume c1a7db48-be78-466b-9993-5e832a611fec

lead agent

model not reported

stalled
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
38 messages · 10 tool calls

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.