Agents · 2026-06-07 · By RedDB team · 4 min read

Cross-session task queues — AFK agents with durable resume

An AFK agent's to-do list is the worst place to keep ephemeral state. Move it to a RedDB queue with checkpoints, idempotency keys, and a watchdog and a Stop becomes a pause, a crash becomes a retry, and a machine swap becomes a noop.

The AFK promise that breaks at 3am

You hand the agent a 40-item backlog before bed. Eight hours later you wake up to find it crashed on item 12, the context window got compacted past the point of useful, and the remaining 28 items live only inside a transcript nobody will read. Or worse — it finished items 1–11, started 12, crashed, restarted, and ran 12 again (writing duplicate rows, double-posting the PR comment, double-charging the API).

The Ralph loop and every other AFK pattern share the same failure mode: the task list lives in the agent’s head. Stop the agent and the list vanishes. Restart it and there’s no idempotency on what was already done.

The fix is unromantic: model the task list as rows in RedDB. State machine on each row. Idempotency key on each side-effect. Watchdog that reclaims expired leases. Sibling to D12’s sub-agent dispatch queue but pointed inward — the agent’s own work, not its fan-out.

The schema: state, checkpoint, idem_key

CREATE TYPE afk_state AS ENUM ('todo', 'running', 'paused', 'done', 'failed');

CREATE TABLE afk_task (
  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  agent_id      text NOT NULL,                      -- which AFK runner owns this list
  ordinal       int  NOT NULL,                      -- stable order in the backlog
  title         text NOT NULL,
  spec          jsonb NOT NULL,                     -- prompt + acceptance criteria
  state         afk_state NOT NULL DEFAULT 'todo',
  checkpoint    jsonb,                              -- mid-task progress: files touched, sub-results
  idem_key      text UNIQUE,                        -- one row per (agent, logical-task)
  lease_until   timestamptz,                        -- null when not running
  attempts      int NOT NULL DEFAULT 0,
  max_attempts  int NOT NULL DEFAULT 5,
  last_error    text,
  created_at    timestamptz NOT NULL DEFAULT now(),
  updated_at    timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX afk_task_next_idx
  ON afk_task (agent_id, ordinal)
  WHERE state IN ('todo','paused');

CREATE INDEX afk_task_lease_idx
  ON afk_task (lease_until)
  WHERE state = 'running';

Three columns do the heavy lifting:

  • state — the row’s place in the lifecycle. todo → running → done on the happy path; running → paused on a clean Stop; running → failed after max_attempts; paused → running on resume.
  • checkpoint — every non-trivial step writes its progress here. Files modified, sub-prompts completed, partial scores. The agent that picks the task up after a crash reads this before deciding what to do next.
  • lease_until — set when a worker claims the row, refreshed on each checkpoint. The watchdog reclaims rows whose lease expired (worker crashed without releasing).

Enqueue: idempotent and ordered

The dispatcher is whatever produces the backlog — a /plan command, a to-issues skill output, a CI job. It writes rows with ON CONFLICT DO NOTHING on idem_key:

import { Client } from 'pg'

export async function enqueue(
  pg: Client,
  agentId: string,
  tasks: { title: string; spec: object; idemKey: string }[],
) {
  await pg.query('BEGIN')
  try {
    const startOrdinal = (
      await pg.query(
        `SELECT COALESCE(MAX(ordinal), 0) AS m FROM afk_task WHERE agent_id = $1`,
        [agentId],
      )
    ).rows[0].m

    for (const [i, t] of tasks.entries()) {
      await pg.query(
        `INSERT INTO afk_task (agent_id, ordinal, title, spec, idem_key)
         VALUES ($1, $2, $3, $4, $5)
         ON CONFLICT (idem_key) DO NOTHING`,
        [agentId, startOrdinal + i + 1, t.title, t.spec, t.idemKey],
      )
    }
    await pg.query('COMMIT')
  } catch (e) {
    await pg.query('ROLLBACK')
    throw e
  }
}

idem_key is the contract. "afk:ralph:issue-145:v1" survives every restart and every duplicate dispatch. Re-running the same /plan is a noop.

Claim: SKIP LOCKED + lease

The agent’s run loop claims one row at a time:

const CLAIM = `
  WITH next AS (
    SELECT id FROM afk_task
     WHERE agent_id = $1
       AND state IN ('todo','paused')
     ORDER BY ordinal
     FOR UPDATE SKIP LOCKED
     LIMIT 1
  )
  UPDATE afk_task t
     SET state       = 'running',
         lease_until = now() + interval '15 minutes',
         attempts    = t.attempts + 1,
         updated_at  = now()
    FROM next
   WHERE t.id = next.id
  RETURNING t.*;
`

export async function claimNext(pg: Client, agentId: string) {
  const r = await pg.query(CLAIM, [agentId])
  return r.rows[0] ?? null
}

FOR UPDATE SKIP LOCKED is what makes this safe under multiple parallel runners on the same agent_id — two machines running the same AFK loop will never grab the same row. 15-minute lease is a starting point; tune to the longest reasonable task duration.

Checkpoint: write often, write cheap

Every meaningful step inside the task body writes a checkpoint. The frequency is the recovery-cost knob — too sparse and a crash redoes hours of work; too dense and you bloat the row.

export async function checkpoint(
  pg: Client,
  taskId: string,
  patch: object,
) {
  await pg.query(
    `UPDATE afk_task
        SET checkpoint = COALESCE(checkpoint, '{}'::jsonb) || $2::jsonb,
            lease_until = now() + interval '15 minutes',
            updated_at  = now()
      WHERE id = $1`,
    [taskId, patch],
  )
}

The jsonb || jsonb merge keeps it additive — no read-modify-write race between the worker and a stop-handler. Inside a Ralph iteration the natural checkpoint boundaries are: after exploration, after first edit, after pnpm test passes, after commit. Four writes per task is plenty.

Resume: read the checkpoint, don’t redo it

On startup the agent reads its own state before claiming a new row:

const r = await pg.query(
  `SELECT * FROM afk_task
    WHERE agent_id = $1 AND state = 'running'
    ORDER BY ordinal LIMIT 1`,
  [agentId],
)
if (r.rows[0]) {
  // crashed mid-flight; the row's lease will expire and the watchdog
  // will flip it to 'paused' for us. Pick it up explicitly:
  return resumeFromCheckpoint(r.rows[0])
}

resumeFromCheckpoint is the per-task-kind logic — for a Ralph iteration that means re-reading the issue file plus the checkpoint’s files_touched list, skipping the parts already done, picking up at the next unmet acceptance criterion. The checkpoint isn’t a magic resume — it’s a structured note from past-self to future-self.

Watchdog: reclaim, retry, give up

A separate process (cron, systemd timer, or a Stop hook wired into the agent itself) runs every minute:

-- 1. Reclaim expired leases
UPDATE afk_task
   SET state = CASE
                 WHEN attempts >= max_attempts THEN 'failed'
                 ELSE 'paused'
               END,
       lease_until = NULL,
       last_error  = COALESCE(last_error, 'lease expired'),
       updated_at  = now()
 WHERE state = 'running'
   AND lease_until < now();

-- 2. Surface poison pills
SELECT id, agent_id, title, attempts, last_error
  FROM afk_task
 WHERE state = 'failed'
   AND updated_at > now() - interval '1 hour';

The first statement is the resume mechanism — a crashed worker’s row gets flipped back to paused so the next claim picks it up. The second is the dead-letter view that the human (or an orchestrator) checks in the morning.

Stop handler: graceful pause, not a crash

Claude Code emits a Stop event when the user cancels mid-flight. Wiring a hook to it flips the row cleanly:

{
  "hooks": {
    "Stop": [
      {
        "matcher": ".*",
        "command": "psql $RDB_URL -c \"UPDATE afk_task SET state='paused', lease_until=NULL, updated_at=now() WHERE agent_id='$AFK_AGENT_ID' AND state='running'\""
      }
    ]
  }
}

The difference between paused and a watchdog-reclaimed running is intent — a paused row was put down deliberately; a running row past its lease was abandoned. The watchdog treats them identically on resume, but the audit trail keeps the distinction.

When to skip this

Three cases where adding a queue is overkill:

  • Single-task agents. If the agent does one thing and exits, the durability surface is the commit, not the queue. Don’t wrap a /fix-one-bug flow in this.
  • Sub-second tasks. Lease overhead dominates. Use the in-process Task tool; the queue exists for tasks measured in minutes, not seconds.
  • Tasks with no idempotency story. If you can’t write a stable idem_key, you can’t safely retry. Either fix the side-effect to be idempotent or accept the risk and document it.

Tying it together

The Ralph loop and every AFK pattern in this series — /remember commands, eval replay, sub-agent fan-out — all become noticeably more boring once their state machine is a table you can SELECT from. The observability queries from D9 join straight against afk_task so “what’s my agent doing right now?” stops being a transcript-grep problem and starts being a SQL problem. Boring is the goal.

© 2026 RedDB.io. AGPL-3.0 self-host · Managed Cloud.