Anthropic's always-on agents guide: what the inbox, timers and outbox do when a worker dies, tested in SQLite
Most of the failure handling the guide leaves out comes down to one move: make every wake-up an inbox event with a key the database refuses to store twice. Retried webhooks, timer replays, approval answers and their expiries all fit through that one constraint. Leases, retries and the outbox need a little more.
Contents
Anthropic published “Building always-on agents” on November 13. An always-on agent, in the guide’s telling, sleeps most of the time and wakes for short runs, and the loop inside a run is the same loop every agent uses. What changes is the machinery around it: an event inbox, timers, memory, permissions and an outbox. (The guide’s case for doing this now includes a cost figure: on one long-horizon coding benchmark it doesn’t name, Opus 5.5 cost 78% less per solved task than Opus 5, against 40% less on typical workloads. One vendor benchmark, so a direction, not a price.)
The guide names the pieces well and stops before the failure cases, which is where an unattended agent spends its interesting nights. An agent run is an expensive, side-effecting job: a duplicate is a second email to a customer, not a wasted CPU second. So the gaps matter more here than in a job queue that resizes images.
Most of them close with one move. Make every wake-up an inbox event, timer ticks, approval answers and approval expiries included, give each a key from its source, and let a UNIQUE (source, source_id) constraint refuse the second copy. The rest (who holds the lock, what happens to an event that crashes every worker, how often the agent may speak) takes a lease with a generation number and a few counted columns. Below is all of it as one Python and SQLite file, run against the failure each part exists for.
What does Anthropic’s always-on agents guide cover, and what does it leave open?
It covers the shape: events from webhooks, schedules, background jobs and user messages all land in one durable inbox, a single worker per inbox runs the agent, timers live in their own store and feed the inbox, memory sits in durable storage, permissions decide each tool call, and the outbox delivers outcomes instead of step-by-step streams. It leaves open how any of that holds when something fails.
Two of the five pieces already have posts here. Memory, including the guide’s rule of thumb to store only what the agent can’t look up again, is in Anthropic’s components of agent memory in Claude Code. The unattended side of permissions is in Claude Code’s denyInsteadOfPrompting. This piece is about the inbox, timers, approvals and outbox. The open questions, as I read the page:
- how “an inbox should only have one active worker at a time” is enforced, and what happens to a worker that was only slow when its replacement started;
- retries: if a worker dies mid-run, a replacement picks up the same event and retries it, with no limit stated;
- an event that crashes every worker that touches it;
- what a scheduler does with occurrences it missed while it was down;
- what a deferred tool call turns into if the user never answers;
- how often the outbox may speak. “Start quiet and then earn more of the user’s attention over time” is the right instinct, and it isn’t a number.
How does an always-on agent inbox store a retried webhook once?
Key every event by its source’s own ID and let the database refuse the second copy. The guide says it directly: “events can be deduplicated based on unique identifiers from the source.” GitHub puts a delivery ID in the X-GitHub-Delivery header, Stripe gives each event an ID. One constraint turns a repeat delivery into a no-op:
CREATE TABLE IF NOT EXISTS inbox (
id INTEGER PRIMARY KEY,
source TEXT NOT NULL,
source_id TEXT NOT NULL,
payload TEXT,
status TEXT NOT NULL DEFAULT 'pending', -- pending | running | done | dead
attempts INTEGER NOT NULL DEFAULT 0,
claim_gen INTEGER, -- lease generation that claimed it
error TEXT,
UNIQUE (source, source_id)
);def ingest(db, source, source_id, payload):
"""Store an event. Call it after the webhook signature check, then ack.
Returns False for a duplicate delivery: the event is stored once.
"""
cur = db.execute(
"INSERT OR IGNORE INTO inbox (source, source_id, payload) VALUES (?, ?, ?)",
(source, source_id, json.dumps(payload)))
return cur.rowcount == 1The guide wants the handler to “acknowledge events quickly” and push deeper checks, prompt-injection screening included, into the run. Cheap checks still come first: verify the provider’s signature before ingest(), or anyone who can reach the endpoint can write events and occupy source IDs before the real deliveries arrive. Then acknowledge fast, because a sender that times out waiting for your 200 may deliver again; Stripe retries failed live-mode deliveries for up to three days.
Stored once is not run once. Two different events that mean the same thing (“deploy it” sent twice) are two keys, and the guide’s batching only groups them: the worker takes every pending event at once, so three quick messages become one run and one reply. In my run, three chat messages ingested back to back came out as one batch of three. Whether the agent deploys once is still up to the agent and the deploy tool.
How do you enforce one worker per inbox in an always-on agent?
With a lease that carries a generation number. The lease row says who owns the inbox and until when; every takeover bumps the generation; every claimed event is stamped with the generation that claimed it; and the write that finishes a run checks holder, generation and event state in one transaction. The guide recommends one inbox per user. Here that’s one SQLite file per user, and the lease table holds a single row.
def acquire(db, holder, now):
"""Take or renew the lease. Returns the generation, or None if someone else holds it."""
with tx(db):
row = db.execute("SELECT holder, gen, until FROM lease").fetchone()
if row is None:
gen = 1
elif row[0] == holder and row[2] >= now:
gen = row[1] # renewal
elif row[2] < now:
gen = row[1] + 1 # takeover of an expired lease
else:
return None # held by someone else
db.execute("INSERT OR REPLACE INTO lease VALUES (1, ?, ?, ?)",
(holder, gen, now + LEASE_TTL))
return gen
def finish(db, holder, gen, now, ids, messages=(), error=None):
"""Record a run's outcome and queue its messages in one fenced transaction."""
with tx(db):
if not _holds(db, holder, gen, now):
return False
status = "pending" if error else "done"
for i in ids:
db.execute("""UPDATE inbox SET status = ?, error = ?
WHERE id = ? AND status = 'running' AND claim_gen = ?""",
(status, error, i, gen))
if not error:
for body in messages:
db.execute("INSERT INTO outbox (body, created) VALUES (?, ?)",
(body, now))
return TrueA lease expires on a clock, and a worker that is only slow (a long tool call, a laptop lid) doesn’t know its time ran out. Checking the holder name alone isn’t enough. In my scenario, worker A claimed an event and stalled. B took over after the 120-second lease ran out (generation 2), finished the event and queued its message, then went idle. When A woke up, both leases had lapsed, so A could legitimately take the lease again, as generation 3. A then tried to finish its old claim from generation 1, and finish refused: the event stayed done with one message in the outbox, not two. With a name-only check, that late finish passes.
The message goes into the outbox in the same transaction as the event’s completion, so a crash can’t leave a finished event with a lost reply, or a queued reply on an event that will run again.
What the check can’t undo is a side effect A caused before it stalled. If A had already sent the email itself, B sends it again. Retry after a takeover is at-least-once execution, the edge I wrote about in multi-agent systems as distributed systems: every tool an unattended run can call has to be safe to call twice, or pass a key the receiving system dedupes on. Set the lease well above your longest normal run and renew it between steps, or every long run turns into two.
My “two workers at the same moment” check was two sequential calls with the same timestamp (A got generation 1, B got None), so it shows the rule, not a race. On one host, BEGIN IMMEDIATE makes SQLite serialize the writers.
What happens to an always-on agent’s event when the worker dies halfway?
It goes back to the queue, a limited number of times, and on its own. The guide’s recovery story is that a replacement worker picks up the same event and retries it. Taken literally, an event that crashes the worker (a payload that blows the context, a tool that dies on it) gets picked up, crashes the next worker, and so on, while everything behind it waits.
Three rules in claim_batch handle it. Count the attempt when the event is claimed, not when it fails: a worker that crashes never records a failure. Return rows stamped by an older generation to pending, since their worker lost the lease. And claim a retried event alone, so a poison event can’t drag the healthy events that shared its first batch down with it.
with tx(db):
if not _holds(db, holder, gen, now):
return []
db.execute("""UPDATE inbox SET status = 'pending'
WHERE status = 'running' AND claim_gen < ?""", (gen,))
db.execute("""UPDATE inbox SET status = 'dead', error = 'too many attempts'
WHERE status = 'pending' AND attempts >= ?""", (MAX_ATTEMPTS,))
retry = db.execute("""SELECT id FROM inbox WHERE status = 'pending'
AND attempts > 0 ORDER BY id LIMIT 1""").fetchone()
where = "id = ?" if retry else "status = 'pending'"
rows = db.execute(
f"""UPDATE inbox SET status = 'running', attempts = attempts + 1,
claim_gen = ? WHERE {where}
RETURNING id, source, source_id, payload""",
(gen, *retry) if retry else (gen,)).fetchall()
return sorted(rows) # RETURNING order is arbitraryI queued a poison event together with a healthy one, and every worker that claimed the poison event exited without finishing. W0 took both and crashed. W1 and W2 retried the poison event alone and crashed. Then it was dead at three attempts, W3 took the healthy event and finished it, and W4 found nothing. The healthy event survived, but it waited behind three crashes; ordering retries oldest-first trades latency for simplicity, and a busy inbox might interleave them. Two more limits: the version shown here has no backoff between attempts, and it assumes one claim per run, made after the previous run’s finish.
Dead rows stay in the table. They are what the agent’s daily summary or alerting should read, because a poison event nobody sees will just look like an agent that went quiet.
How should always-on agent timers handle a double fire or a missed day?
Advance the timer in the same transaction as the insert, key the event by timer and scheduled time, and coalesce missed occurrences into one event. The guide puts timers in their own store, with a scheduler that “adds an event to the inbox and wakes the worker”; a timer “doesn’t initiate a run on its own.” Routing the tick through the inbox is what makes it dedupable.
for timer_id, at, every, payload, ev_source, ev_id in due:
skipped = (now - at) // every if every else 0
at += skipped * (every or 0)
body = json.loads(payload)
body.update(scheduled_for=at, late_by=now - at, skipped=skipped)
db.execute(
"INSERT OR IGNORE INTO inbox (source, source_id, payload) VALUES (?, ?, ?)",
(ev_source or "timer", ev_id or f"{timer_id}@{at}", json.dumps(body)))
if every:
db.execute("UPDATE timers SET due = ? WHERE id = ?", (at + every, timer_id))
else:
db.execute("UPDATE timers SET done = 1 WHERE id = ?", (timer_id,))The two defenses catch different things. A second scheduler pass at the same moment finds nothing due, because the first pass already moved the timer: in my run, pass 1 handled one timer and pass 2 none. The key catches replays the transaction can’t see, such as a deploy that re-registers the schedule with its old due time. I re-registered the timer at the same occurrence and ran the scheduler: it found one due timer and the inbox still held one event. Times are integer seconds so the key stays exact; a float timestamp can drift by a rounding step and mint a second key for the same occurrence.
After a simulated outage that covered four daily occurrences, the scheduler emitted one more event for the latest occurrence, ten minutes late, with skipped: 3 in the payload, and set the next run 23.8 hours out. Coalescing is a per-timer choice. A daily digest should coalesce, since nobody wants four digests at 09:10. A per-day billing reconciliation shouldn’t, and would need one event per missed day under its own key. Passing skipped to the agent lets it say “this covers four days” instead of pretending nothing happened.
What happens when nobody answers an always-on agent’s approval request?
The guide doesn’t say. A deferred tool call ends the run, “the session is saved mid-call”, and the user’s answer comes back as an inbox event that resumes it. Without an expiry, a yes stays valid indefinitely and the action runs whenever someone taps it, days later and out of context. The same gap shows up in MCP elicitations: an unattended process can ask a question with no one there to answer it.
The fix has two parts, both keyed like everything else. The expiry is a one-off timer that writes an expired answer under the approval’s own key, so a request nobody answers still resumes the session, as a denial. And the answer path checks the deadline itself, in the same transaction, instead of trusting that the scheduler already ran:
def answer_approval(db, request_id, answer, now):
"""Store the user's answer unless the deadline passed. Returns the outcome
that stands: 'yes'/'no', 'expired', or 'unknown' for a request never made."""
with tx(db):
row = db.execute("SELECT due, payload FROM timers WHERE id = ?",
(f"expire:{request_id}",)).fetchone()
if row is None:
return "unknown"
due, expiry = row
payload = expiry if now >= due else json.dumps(
{"approval": request_id, "answer": answer})
db.execute("""INSERT OR IGNORE INTO inbox (source, source_id, payload)
VALUES ('approval', ?, ?)""", (request_id, payload))
stored = db.execute("""SELECT payload FROM inbox
WHERE source = 'approval' AND source_id = ?""",
(request_id,)).fetchone()[0]
return json.loads(stored)["answer"]Only one answer per request is ever stored, and the function returns the one that stands, so the handler can tell the user what actually happened. I simulated three refund approvals with a four-hour window. When the expiry timer had fired at hour five and a yes arrived at hour six, the result was expired. A yes at hour five with the scheduler not yet run also came back expired; that’s the case an expiry living only in the timer gets wrong. A yes at hour one stood, a second tap of no at hour two returned yes, and the expiry timer firing later changed nothing. An answer to a request that was never made returned unknown.
How do you pace an always-on agent’s outbox?
Hold messages in a table and let a dispatcher send them at a minimum interval, merging what piled up. The guide says “you only need a formal outbox when you want to pace your agent’s messages or allow your agent to recover from errors,” and that the agent should start quiet. A gap is the smallest rule that enforces quiet outside the model:
def dispatch(db, now, send):
"""Send the held messages as one, at most once per OUTBOX_GAP."""
with tx(db):
last = db.execute("SELECT MAX(sent_at) FROM outbox").fetchone()[0]
if last is not None and now - last < OUTBOX_GAP:
return 0
held = db.execute(
"SELECT id, body FROM outbox WHERE sent_at IS NULL ORDER BY id").fetchall()
if not held:
return 0
send("\n\n".join(body for _, body in held))
db.executemany("UPDATE outbox SET sent_at = ? WHERE id = ?",
[(now, i) for i, _ in held])
return len(held)Three updates queued at minutes 0, 10 and 20 with a one-hour gap went out as two sends: the first at once, the other two merged at minute 61. The send happens inside the write transaction, so two dispatchers can’t both send the same rows. It’s still at-least-once: a crash after send() and before the commit sends the merged message again. Marking the rows sent before sending flips that to at-most-once, and which loss you prefer depends on the channel.
The gap only caps frequency. The guide lists three ways a run can end (a message goes out, nothing goes out, or a deferred tool call asks the user), and choosing nothing has to come from the agent: a run that found no change should end without a message, and the evals the guide recommends are where you check that it does. Urgent messages need an explicit priority on the row that bypasses the gap. Otherwise the model decides when the gap applies.
Does a single-user always-on agent need the guide’s credential proxy pattern?
That depends on what text the agent reads, not on how many users it serves. The guide’s pattern is “a proxy outside the agent’s environment that adds credentials to outgoing requests.” What it buys is narrow: the raw secret never enters what the agent can see, so it can’t land in a transcript, a memory file or a tool argument, and an instruction hidden in an email the agent was asked to summarize can’t send it anywhere.
An agent that only processes its owner’s messages and repositories reads little untrusted text, and a scoped token with a short expiry may be enough. An agent that reads a public inbox, third-party webhooks or web pages is reading attacker-controlled text on a timer with nobody watching, and the guide moves injection screening into exactly that run. That’s the case the proxy is for. Screening will miss something eventually, and the proxy limits what the miss can reach, provided all outbound traffic goes through it, it restricts destinations, and its own logs leave the credential out. It doesn’t stop the agent from misusing a request it’s allowed to make; it gives you one place to cut off access from then on.
The always-on agent loop stays the same, and the guarantees live in the inbox
Every result above comes from one run of the driver at the end against the script at the end. Raw output:
1. webhook delivered twice
first: True retry: False rows: 1
2. two workers ask for the lease at the same time
A: 1 B: None
3. three quick chat messages
events in one run: 3
4. A stalls mid-run; B takes over; A wakes up
B before A's lease expires: None
B after expiry: gen 2 re-claims ['msg-3']
A renews after both leases lapsed: gen 3
A finishes its old claim (gen 1 ): False
msg-3: ('done', 2) outbox rows: 1
5. a poison event arrives together with a healthy one
W0 claimed ['poison', 'healthy'] and crashed
W1 claimed ['poison'] and crashed
W2 claimed ['poison'] and crashed
W3 claimed ['healthy'] and finished
W4 claimed [] and finished
poison: ('dead', 3) healthy: ('done', 2)
6. timers: a second scheduler pass, a replay, a three-day outage
pass 1: 1 pass 2: 0
replay of the same occurrence: 1 due, 1 event total
after the outage: events total 2 skipped 3 late by 10 min next due in 23.8 h
7. approvals with a four-hour window
881: expiry ran at 5h, yes at 6h -> expired
882: yes at 5h, scheduler not run yet -> expired
883: yes at 1h -> yes second tap no at 2h -> yes
883 after its expiry timer fired: yes
never requested -> unknown
8. outbox: three updates inside an hour
sends: ['update 0', 'update 1 | update 2']That’s one process, one SQLite file and a simulated clock. It shows the logic of each rule, not how the rules hold up under real concurrency. On one host, BEGIN IMMEDIATE serializes writers. Across machines the design needs a server database, and porting it is more than a search-and-replace: Postgres has no BEGIN IMMEDIATE, so the lease check and the finish need explicit row locks; timestamps want a proper integer or timestamp type; and the usual multi-consumer claim, FOR UPDATE SKIP LOCKED, doesn’t by itself enforce one active run per inbox. I didn’t run that version.
The agent is a stub in the driver, and nothing above touches the model call. The guide is right that the loop stays the same. What you build is the bookkeeping around it, and most of that bookkeeping turned out to be one unique key.
The full script: inbox.py (Python 3.12, SQLite 3.45)
"""Inbox, lease, timers, approvals and outbox for an always-on agent.
One SQLite file per user: the file is the user's inbox. Times are integer
seconds, and every function takes `now` so the rules can be tested without
sleeping.
"""
import json
import sqlite3
from contextlib import contextmanager
MAX_ATTEMPTS = 3 # claims before an event is parked as dead
LEASE_TTL = 120 # seconds a worker owns the inbox without renewing
OUTBOX_GAP = 3600 # at most one message per hour
SCHEMA = """
CREATE TABLE IF NOT EXISTS inbox (
id INTEGER PRIMARY KEY,
source TEXT NOT NULL,
source_id TEXT NOT NULL,
payload TEXT,
status TEXT NOT NULL DEFAULT 'pending', -- pending | running | done | dead
attempts INTEGER NOT NULL DEFAULT 0,
claim_gen INTEGER, -- lease generation that claimed it
error TEXT,
UNIQUE (source, source_id)
);
CREATE TABLE IF NOT EXISTS lease (
one INTEGER PRIMARY KEY CHECK (one = 1), -- a single row
holder TEXT NOT NULL,
gen INTEGER NOT NULL, -- +1 on every takeover
until INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS timers (
id TEXT PRIMARY KEY,
due INTEGER NOT NULL,
every INTEGER, -- NULL = one-off follow-up
payload TEXT,
ev_source TEXT, ev_id TEXT, -- fixed event key (one-offs only)
done INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS outbox (
id INTEGER PRIMARY KEY,
body TEXT NOT NULL,
created INTEGER NOT NULL,
sent_at INTEGER
);
"""
def connect(path):
db = sqlite3.connect(path, isolation_level=None) # transactions are explicit
db.execute("PRAGMA journal_mode=WAL")
db.executescript(SCHEMA)
return db
@contextmanager
def tx(db):
"""A write transaction that always ends: COMMIT on success, ROLLBACK on error."""
db.execute("BEGIN IMMEDIATE")
try:
yield
except BaseException:
db.execute("ROLLBACK")
raise
db.execute("COMMIT")
# --- inbox -----------------------------------------------------------------
def ingest(db, source, source_id, payload):
"""Store an event. Call it after the webhook signature check, then ack.
Returns False for a duplicate delivery: the event is stored once.
"""
cur = db.execute(
"INSERT OR IGNORE INTO inbox (source, source_id, payload) VALUES (?, ?, ?)",
(source, source_id, json.dumps(payload)))
return cur.rowcount == 1
# --- one worker per inbox --------------------------------------------------
def acquire(db, holder, now):
"""Take or renew the lease. Returns the generation, or None if someone else holds it.
Renewing keeps the generation; any takeover, even by the same holder
after its lease lapsed, starts a new one.
"""
with tx(db):
row = db.execute("SELECT holder, gen, until FROM lease").fetchone()
if row is None:
gen = 1
elif row[0] == holder and row[2] >= now:
gen = row[1] # renewal
elif row[2] < now:
gen = row[1] + 1 # takeover of an expired lease
else:
return None # held by someone else
db.execute("INSERT OR REPLACE INTO lease VALUES (1, ?, ?, ?)",
(holder, gen, now + LEASE_TTL))
return gen
def _holds(db, holder, gen, now):
row = db.execute("SELECT holder, gen, until FROM lease").fetchone()
return row is not None and row[:2] == (holder, gen) and row[2] >= now
def claim_batch(db, holder, gen, now):
"""Claim work for one run. Call once per run, after the previous run's finish().
Rows left 'running' by an older generation belong to a worker that lost
its lease: they go back to pending. Attempts are counted at claim time, so
an event that crashes the worker every time still runs out of attempts.
A retried event is claimed alone, so a poison event can't take healthy
events down with it.
"""
with tx(db):
if not _holds(db, holder, gen, now):
return []
db.execute("""UPDATE inbox SET status = 'pending'
WHERE status = 'running' AND claim_gen < ?""", (gen,))
db.execute("""UPDATE inbox SET status = 'dead', error = 'too many attempts'
WHERE status = 'pending' AND attempts >= ?""", (MAX_ATTEMPTS,))
retry = db.execute("""SELECT id FROM inbox WHERE status = 'pending'
AND attempts > 0 ORDER BY id LIMIT 1""").fetchone()
where = "id = ?" if retry else "status = 'pending'"
rows = db.execute(
f"""UPDATE inbox SET status = 'running', attempts = attempts + 1,
claim_gen = ? WHERE {where}
RETURNING id, source, source_id, payload""",
(gen, *retry) if retry else (gen,)).fetchall()
return sorted(rows) # RETURNING order is arbitrary
def finish(db, holder, gen, now, ids, messages=(), error=None):
"""Record a run's outcome and queue its messages in one fenced transaction.
Refused (False) unless this holder still holds the lease in the same
generation that claimed the events, and the events are still running.
"""
with tx(db):
if not _holds(db, holder, gen, now):
return False
status = "pending" if error else "done"
for i in ids:
db.execute("""UPDATE inbox SET status = ?, error = ?
WHERE id = ? AND status = 'running' AND claim_gen = ?""",
(status, error, i, gen))
if not error:
for body in messages:
db.execute("INSERT INTO outbox (body, created) VALUES (?, ?)",
(body, now))
return True
# --- timers ----------------------------------------------------------------
def add_timer(db, timer_id, due, payload, every=None, key=(None, None)):
if every is not None and every <= 0:
raise ValueError("every must be a positive number of seconds")
if every is not None and key != (None, None):
raise ValueError("a recurring timer can't use a fixed event key")
db.execute(
"""INSERT OR REPLACE INTO timers (id, due, every, payload, ev_source, ev_id)
VALUES (?, ?, ?, ?, ?, ?)""",
(timer_id, int(due), every, json.dumps(dict(payload)), *key))
def run_scheduler(db, now):
"""Turn due timers into inbox events. Never starts a run itself.
The timer advances in the same transaction as the insert, so a second pass
finds nothing due. The event key (timer id + scheduled time) is the second
line of defence: a replay of the same occurrence is a duplicate. Missed
occurrences coalesce into one event for the latest of them.
"""
with tx(db):
due = db.execute(
"""SELECT id, due, every, payload, ev_source, ev_id FROM timers
WHERE done = 0 AND due <= ?""", (now,)).fetchall()
for timer_id, at, every, payload, ev_source, ev_id in due:
skipped = (now - at) // every if every else 0
at += skipped * (every or 0)
body = json.loads(payload)
body.update(scheduled_for=at, late_by=now - at, skipped=skipped)
db.execute(
"INSERT OR IGNORE INTO inbox (source, source_id, payload) VALUES (?, ?, ?)",
(ev_source or "timer", ev_id or f"{timer_id}@{at}", json.dumps(body)))
if every:
db.execute("UPDATE timers SET due = ? WHERE id = ?", (at + every, timer_id))
else:
db.execute("UPDATE timers SET done = 1 WHERE id = ?", (timer_id,))
return len(due)
# --- deferred approvals ----------------------------------------------------
def request_approval(db, request_id, now, expires_in, action):
"""The run ends here. The answer, or its expiry, comes back as an inbox event
under the same key ('approval', request_id): only one of them is ever stored.
The timer resumes the session when nobody answers."""
add_timer(db, f"expire:{request_id}", now + expires_in,
{"approval": request_id, "answer": "expired", "action": action},
key=("approval", request_id))
def answer_approval(db, request_id, answer, now):
"""Store the user's answer unless the deadline passed. Returns the outcome
that stands: 'yes'/'no', 'expired', or 'unknown' for a request never made."""
with tx(db):
row = db.execute("SELECT due, payload FROM timers WHERE id = ?",
(f"expire:{request_id}",)).fetchone()
if row is None:
return "unknown"
due, expiry = row
payload = expiry if now >= due else json.dumps(
{"approval": request_id, "answer": answer})
db.execute("""INSERT OR IGNORE INTO inbox (source, source_id, payload)
VALUES ('approval', ?, ?)""", (request_id, payload))
stored = db.execute("""SELECT payload FROM inbox
WHERE source = 'approval' AND source_id = ?""",
(request_id,)).fetchone()[0]
return json.loads(stored)["answer"]
# --- outbox ----------------------------------------------------------------
def dispatch(db, now, send):
"""Send the held messages as one, at most once per OUTBOX_GAP.
Runs inside the write transaction, so two dispatchers can't both send the
same rows. A crash after send() and before COMMIT sends them again on the
next pass: delivery is at-least-once, so the channel should dedupe or the
duplicate has to be harmless.
"""
with tx(db):
last = db.execute("SELECT MAX(sent_at) FROM outbox").fetchone()[0]
if last is not None and now - last < OUTBOX_GAP:
return 0
held = db.execute(
"SELECT id, body FROM outbox WHERE sent_at IS NULL ORDER BY id").fetchall()
if not held:
return 0
send("\n\n".join(body for _, body in held))
db.executemany("UPDATE outbox SET sent_at = ? WHERE id = ?",
[(now, i) for i, _ in held])
return len(held)The scenario driver: demo.py
"""Failure scenarios for inbox.py. Simulated clock, one process; a 'crash' is a
worker that claims and never calls finish()."""
import json
import os
import tempfile
import inbox as ib
db = ib.connect(os.path.join(tempfile.mkdtemp(), "agent.db"))
t = 1_000_000
DAY = 86400
def run(holder, now, messages=()):
"""One run: take the lease, claim, finish."""
gen = ib.acquire(db, holder, now)
batch = ib.claim_batch(db, holder, gen, now)
ib.finish(db, holder, gen, now, [r[0] for r in batch], messages)
return batch
def status(source_id):
return db.execute("SELECT status, attempts FROM inbox WHERE source_id = ?",
(source_id,)).fetchone()
print("1. webhook delivered twice")
print(" first:", ib.ingest(db, "github", "delivery-7f3a", {"pr": 12}),
" retry:", ib.ingest(db, "github", "delivery-7f3a", {"pr": 12}),
" rows:", db.execute("SELECT COUNT(*) FROM inbox").fetchone()[0])
print("2. two workers ask for the lease at the same time")
print(" A:", ib.acquire(db, "A", t), " B:", ib.acquire(db, "B", t))
run("A", t)
print("3. three quick chat messages")
for n in range(3):
ib.ingest(db, "chat", f"msg-{n}", {"text": f"message {n}"})
print(" events in one run:", len(run("A", t + 5)))
print("4. A stalls mid-run; B takes over; A wakes up")
ib.ingest(db, "chat", "msg-3", {"text": "send the report"})
gen_a = ib.acquire(db, "A", t + 10)
batch_a = ib.claim_batch(db, "A", gen_a, t + 10) # A stalls here
print(" B before A's lease expires:", ib.acquire(db, "B", t + 60))
t2 = t + 10 + ib.LEASE_TTL + 1
gen_b = ib.acquire(db, "B", t2)
batch_b = ib.claim_batch(db, "B", gen_b, t2)
print(" B after expiry: gen", gen_b, " re-claims", [r[2] for r in batch_b])
ib.finish(db, "B", gen_b, t2, [r[0] for r in batch_b], ["report sent"])
t3 = t2 + ib.LEASE_TTL + 1 # B's lease lapses too
print(" A renews after both leases lapsed: gen", ib.acquire(db, "A", t3))
print(" A finishes its old claim (gen", gen_a, "):",
ib.finish(db, "A", gen_a, t3, [r[0] for r in batch_a], ["report sent"]))
print(" msg-3:", status("msg-3"), " outbox rows:",
db.execute("SELECT COUNT(*) FROM outbox").fetchone()[0])
print("5. a poison event arrives together with a healthy one")
ib.ingest(db, "chat", "poison", {"text": "x"})
ib.ingest(db, "chat", "healthy", {"text": "ok"})
now = t3 + ib.LEASE_TTL + 1
for n in range(5):
gen = ib.acquire(db, f"W{n}", now)
got = ib.claim_batch(db, f"W{n}", gen, now)
ids = [r[2] for r in got]
if "poison" in ids:
print(f" W{n} claimed {ids} and crashed") # no finish()
else:
ib.finish(db, f"W{n}", gen, now, [r[0] for r in got])
print(f" W{n} claimed {ids} and finished")
now += ib.LEASE_TTL + 1
print(" poison:", status("poison"), " healthy:", status("healthy"))
print("6. timers: a second scheduler pass, a replay, a three-day outage")
ib.add_timer(db, "digest", now, {"task": "daily digest"}, every=DAY)
print(" pass 1:", ib.run_scheduler(db, now), " pass 2:", ib.run_scheduler(db, now))
ib.add_timer(db, "digest", now, {"task": "daily digest"}, every=DAY) # replay
print(" replay of the same occurrence:", ib.run_scheduler(db, now), "due,",
db.execute("SELECT COUNT(*) FROM inbox WHERE source_id LIKE 'digest@%'")
.fetchone()[0], "event total")
later = now + DAY + 3 * DAY + 600 # next due + 3 days
ib.run_scheduler(db, later)
ev = json.loads(db.execute("""SELECT payload FROM inbox WHERE source_id LIKE 'digest@%'
ORDER BY id DESC LIMIT 1""").fetchone()[0])
print(" after the outage: events total",
db.execute("SELECT COUNT(*) FROM inbox WHERE source_id LIKE 'digest@%'")
.fetchone()[0], " skipped", ev["skipped"], " late by", ev["late_by"] // 60, "min",
" next due in", round((db.execute("SELECT due FROM timers WHERE id = 'digest'")
.fetchone()[0] - later) / 3600, 1), "h")
print("7. approvals with a four-hour window")
h = 3600
ib.request_approval(db, "refund-881", later, 4 * h, "refund order 881")
ib.run_scheduler(db, later + 5 * h)
print(" 881: expiry ran at 5h, yes at 6h ->", ib.answer_approval(db, "refund-881", "yes", later + 6 * h))
ib.request_approval(db, "refund-882", later, 4 * h, "refund order 882")
print(" 882: yes at 5h, scheduler not run yet ->", ib.answer_approval(db, "refund-882", "yes", later + 5 * h))
ib.request_approval(db, "refund-883", later, 4 * h, "refund order 883")
print(" 883: yes at 1h ->", ib.answer_approval(db, "refund-883", "yes", later + h),
" second tap no at 2h ->", ib.answer_approval(db, "refund-883", "no", later + 2 * h))
ib.run_scheduler(db, later + 5 * h)
print(" 883 after its expiry timer fired:", json.loads(db.execute(
"SELECT payload FROM inbox WHERE source = 'approval' AND source_id = 'refund-883'")
.fetchone()[0])["answer"])
print(" never requested ->", ib.answer_approval(db, "refund-999", "yes", later))
print("8. outbox: three updates inside an hour")
sent = []
m = later + 6 * h
db.execute("UPDATE outbox SET sent_at = ? WHERE sent_at IS NULL", (m - 2 * h,))
for n, minute in enumerate((0, 10, 20)):
db.execute("INSERT INTO outbox (body, created) VALUES (?, ?)", (f"update {n}", m + minute * 60))
ib.dispatch(db, m + minute * 60, sent.append)
ib.dispatch(db, m + 61 * 60, sent.append)
print(" sends:", [s.replace("\n\n", " | ") for s in sent])01 / Discussion
No comment section here — all discussions happen on X.
02 / More articles
Self-hosted Claude Managed Agents: the server is yours, and so are the failures that report nothing
Self-hosting moves custody of the transcripts to you, and the operations come with it. Anthropic's list of differences names the cases where your server says success, or nothing, while the work is stuck, dropped or answered by another model. Those rows are the acceptance test for a deployment.
Claude Fable 5.2 and old cautions in agent memory: what Anthropic measured
A model that follows notes closely pays for the notes nobody pruned. Anthropic's tests show stale bans, reviewer conditions and loose approval lists holding back routine work, and its remedy assumes every caution still carries the reason it was written for.
MCP Apps in Claude: app tools, entry points and embedded sites, and which of them your users actually get
Three new MCP Apps features in Claude, each behind its own set of gates. The connector you test with clears more of them than the install your users get from a Community listing, and per the docs the user sees no message when a feature is withheld.