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 → doneon the happy path;running → pausedon a clean Stop;running → failedaftermax_attempts;paused → runningon 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-bugflow in this. - Sub-second tasks. Lease overhead dominates. Use the in-process
Tasktool; 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.
RedDB Cloud
Join the private beta.
One-click managed deploy. Free during beta. Founding pricing locked at GA.
Request access →