Anthropic の always-on エージェントガイド: ワーカーが死んだとき inbox、タイマー、outbox は何をするのか、SQLite で検証
ガイドが触れていない障害処理の大半は、1 つの手に行き着きます。すべての起動を inbox イベントにし、データベースが 2 度目の保存を拒むキーを付けることです。リトライされた Webhook、タイマーの再実行、承認の回答とその期限切れは、どれもこの 1 つの制約で扱えます。リース、リトライ、outbox にはもう少し手が要ります。
目次
Anthropic は 11 月 13 日に「Building always-on agents」を公開しました。ガイドの説明では、always-on エージェントはほとんどの時間を眠って過ごし、短い run のために目を覚まします。run の中のループは、どのエージェントも使っているのと同じループです。変わるのはその周りの仕組みで、イベント inbox、タイマー、メモリ、権限、outbox がそれにあたります。(いま取り組む理由として、ガイドはコストの数字も挙げています。名前の明かされていない長期タスク型のコーディングベンチマーク 1 つで、Opus 5.5 は解決したタスクあたりのコストが Opus 5 より 78% 安く、典型的なワークロードでは 40% 安かったというものです。ベンダーのベンチマーク 1 つの結果なので、価格ではなく方向を示す数字です。)
ガイドは部品にうまく名前を付けていますが、障害のケースの手前で止まっています。無人のエージェントが面白い夜を過ごすのは、まさにそこです。エージェントの run は高価で副作用を伴うジョブです。重複は無駄になった CPU の 1 秒ではなく、顧客への 2 通目のメールです。だから画像をリサイズするジョブキューよりも、ここでは隙間の重みが大きくなります。
その多くは 1 つの手で塞がります。タイマーの tick、承認の回答、承認の期限切れも含めて、すべての起動を inbox イベントにし、それぞれにソース由来のキーを付け、UNIQUE (source, source_id) 制約に 2 つ目のコピーを拒ませることです。残り(誰がロックを持つのか、すべてのワーカーをクラッシュさせるイベントがどうなるのか、エージェントがどのくらいの頻度で話してよいのか)には、世代番号付きのリースといくつかのカウント用カラムが要ります。以下では、そのすべてを Python と SQLite の 1 ファイルにまとめ、各部分が想定している障害に対して実際に走らせています。
Anthropic の always-on エージェントガイドは何を扱い、何を未解決のまま残しているのか
扱っているのは全体の形です。Webhook、スケジュール、バックグラウンドジョブ、ユーザーメッセージからのイベントはすべて 1 つの永続的な inbox に入り、inbox ごとに 1 つのワーカーがエージェントを走らせます。タイマーは専用のストアにあって inbox にイベントを供給し、メモリは永続ストレージに置かれ、権限が各ツール呼び出しを判定し、outbox は逐一のストリームではなく結果を届けます。未解決なのは、何かが壊れたときにそのどれもがどう持ちこたえるのか、という点です。
5 つの部品のうち 2 つは、このサイトにすでに記事があります。メモリについては、エージェントが後から調べ直せないものだけを保存するというガイドの経験則も含めて Claude Code における Anthropic のエージェントメモリの構成要素 で書きました。権限の無人運用の側面は Claude Code の denyInsteadOfPrompting にあります。この記事で扱うのは inbox、タイマー、承認、outbox です。ページを読んだ限りで、未解決の問いは次のとおりです。
- 「inbox のアクティブなワーカーは同時に 1 つだけにすべき」という原則をどう強制するのか。そして代わりのワーカーが動き出したとき、単に遅かっただけのワーカーはどうなるのか。
- リトライ。ワーカーが run の途中で死ぬと、代わりのワーカーが同じイベントを拾ってリトライしますが、上限は書かれていません。
- 触れたワーカーをすべてクラッシュさせるイベント。
- 停止中に取りこぼした実行回を、スケジューラがどう扱うのか。
- ユーザーが一度も答えなかった場合、遅延ツール呼び出し(deferred tool call)が最終的に何になるのか。
- outbox がどのくらいの頻度で話してよいのか。「最初は静かに始め、時間をかけてユーザーの注意をより多く得ていく」という直感は正しいのですが、それは数字ではありません。
always-on エージェントの inbox は、リトライされた Webhook をどうやって 1 回だけ保存するのか
すべてのイベントにソース自身の ID でキーを付け、2 つ目のコピーはデータベースに拒ませます。ガイドもはっきり書いています。「イベントは、ソースからの一意な識別子に基づいて重複排除できます」。GitHub は X-GitHub-Delivery ヘッダーに配信 ID を入れ、Stripe は各イベントに ID を付けます。制約 1 つで、繰り返しの配信が 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 == 1ガイドはハンドラーが「イベントを素早く確認応答する」ことを求め、プロンプトインジェクションのスクリーニングを含むより深いチェックは run の中に押し込んでいます。それでも安価なチェックは先に行います。ingest() の前にプロバイダーの署名を検証してください。そうしないと、エンドポイントに到達できる人なら誰でもイベントを書き込め、本物の配信が届く前にソース ID を占有できてしまいます。そのうえで素早く確認応答します。200 を待ってタイムアウトした送信側は再配信することがあるからです。Stripe は、失敗した live モードの配信を最大 3 日間リトライします。
1 回保存されることは、1 回実行されることではありません。同じ意味を持つ 2 つの別々のイベント(2 回送られた「deploy it」)はキーが 2 つになり、ガイドのバッチ処理はそれをまとめるだけです。ワーカーは保留中のイベントをすべて一度に取るので、立て続けの 3 つのメッセージは 1 回の run と 1 つの返信になります。手元の実行では、続けて取り込んだ 3 つのチャットメッセージが、3 件入りの 1 バッチとして出てきました。エージェントがデプロイを 1 回だけ行うかどうかは、依然としてエージェントとデプロイツール次第です。
always-on エージェントで、inbox ごとに 1 ワーカーをどう強制するのか
世代番号を持つリースを使います。リースの行は、誰がいつまで inbox を所有しているかを示します。乗っ取りのたびに世代が 1 つ上がり、クレームされたイベントにはそれをクレームした世代が刻まれ、run を終える書き込みは保持者、世代、イベントの状態を 1 つのトランザクションでチェックします。ガイドはユーザーごとに 1 つの inbox を推奨しています。ここではユーザーごとに SQLite ファイルが 1 つで、lease テーブルは 1 行だけを持ちます。
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 Trueリースは時計で失効しますが、単に遅いだけのワーカー(長いツール呼び出し、閉じたノートパソコンの蓋)は、自分の時間が尽きたことを知りません。保持者の名前だけをチェックしても足りません。手元のシナリオでは、ワーカー A がイベントをクレームして止まりました。120 秒のリースが切れたあとに B が乗っ取り(世代 2)、イベントを終えてメッセージをキューに入れ、アイドルになりました。A が目覚めたときには両方のリースが失効していたので、A は世代 3 として正当にリースを取り直せました。そのうえで A が世代 1 の古いクレームを終えようとすると、finish が拒否しました。イベントは done のままで、outbox のメッセージは 2 つではなく 1 つでした。名前だけのチェックなら、この遅れた finish は通ってしまいます。
メッセージはイベントの完了と同じトランザクションで outbox に入ります。そのためクラッシュが起きても、終わったイベントの返信が失われたり、もう一度走るイベントに返信がキューされたまま残ったりはしません。
このチェックで取り消せないのは、A が止まる前に起こした副作用です。A がすでに自分でメールを送っていたなら、B がもう一度送ります。乗っ取り後のリトライは at-least-once 実行で、分散システムとしてのマルチエージェントシステム で書いた境界にあたります。無人の run が呼べるツールはすべて、二度呼んでも安全であるか、受け取る側のシステムが重複排除に使うキーを渡す必要があります。リースは通常の最長の run より十分長く設定し、ステップの間に更新してください。そうしないと、長い run がすべて 2 回走ることになります。
「2 つのワーカーが同時に」のチェックは、同じタイムスタンプでの 2 回の逐次呼び出しでした(A は世代 1 を得て、B は None)。つまりルールを示しているだけで、競合状態を再現したわけではありません。1 台のホストなら、BEGIN IMMEDIATE によって SQLite が書き込みを直列化します。
ワーカーが途中で死んだとき、always-on エージェントのイベントはどうなるのか
キューに戻ります。ただし回数に上限を付け、ほかのイベントとは切り離して戻します。ガイドの回復の筋書きでは、代わりのワーカーが同じイベントを拾ってリトライします。文字どおりに受け取ると、ワーカーをクラッシュさせるイベント(コンテキストを溢れさせるペイロード、それを処理すると死ぬツール)は拾われて次のワーカーをクラッシュさせ、それが延々と続きます。その間、後ろのイベントはすべて待たされます。
claim_batch の 3 つのルールでこれに対処します。試行回数は失敗時ではなくクレーム時に数えます。クラッシュしたワーカーは失敗を記録しないからです。古い世代が刻まれた行は pending に戻します。そのワーカーはリースを失っているからです。そしてリトライするイベントは単独でクレームします。そうすれば、poison イベントが最初のバッチを共にした健全なイベントを道連れにすることはありません。
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 arbitrarypoison イベントを健全なイベントと一緒にキューに入れ、poison イベントをクレームしたワーカーはすべて finish せずに終了するようにしました。W0 は両方を取ってクラッシュしました。W1 と W2 は poison イベントだけをリトライしてクラッシュしました。そこで試行 3 回で dead になり、W3 が健全なイベントを取って終え、W4 は何も見つけませんでした。健全なイベントは生き残りましたが、3 回のクラッシュの後ろで待たされました。リトライを古い順に処理するのはレイテンシを犠牲に単純さを取る選択で、忙しい inbox なら交互に挟む手もあります。制限はほかに 2 つあります。ここで示した版は試行の間にバックオフがなく、run ごとにクレームは 1 回、それも前の run の finish の後に行うことを前提にしています。
dead の行はテーブルに残ります。エージェントの日次サマリーやアラートが読むべきなのはこの行です。誰にも気づかれない poison イベントは、ただ黙り込んだエージェントにしか見えないからです。
always-on エージェントのタイマーは、二重発火や取りこぼした日をどう扱うべきか
タイマーの更新は insert と同じトランザクションで行い、イベントにはタイマーと予定時刻でキーを付け、取りこぼした実行回は 1 つのイベントにまとめます。ガイドはタイマーを専用のストアに置き、スケジューラが「inbox にイベントを追加してワーカーを起こす」としています。タイマーは「それ自体では run を開始しない」のです。tick を inbox 経由にすることで、重複排除ができるようになります。
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,))2 つの防御は別々のものを捕まえます。同じ瞬間の 2 回目のスケジューラパスは、1 回目がすでにタイマーを進めているので、期限の来たものを何も見つけません。手元の実行では、パス 1 がタイマーを 1 つ処理し、パス 2 は 0 でした。キーのほうは、トランザクションからは見えない再実行を捕まえます。たとえば、古い期限のままスケジュールを再登録してしまうデプロイです。同じ実行回でタイマーを再登録してスケジューラを走らせると、期限の来たタイマーが 1 つ見つかり、inbox のイベントは 1 つのままでした。キーを厳密に保つため、時刻は整数の秒にしています。浮動小数点のタイムスタンプは丸めの 1 ステップ分ずれて、同じ実行回に 2 つ目のキーを生むことがあります。
毎日の実行 4 回分にかかる停止をシミュレートしたあと、スケジューラは最新の実行回のためにイベントをもう 1 つだけ出しました。10 分遅れで、ペイロードには skipped: 3 が入り、次の実行は 23.8 時間後に設定されました。まとめるかどうかはタイマーごとの選択です。日次ダイジェストはまとめるべきです。09:10 にダイジェストを 4 通受け取りたい人はいません。日ごとの請求の突き合わせはまとめるべきではなく、取りこぼした日ごとに独自のキーで 1 イベントずつ必要になります。skipped をエージェントに渡せば、何もなかったふりをせずに「これは 4 日分です」と言えます。
always-on エージェントの承認リクエストに誰も答えないとどうなるのか
ガイドは書いていません。遅延ツール呼び出しは run を終わらせ、「セッションは呼び出しの途中で保存され」、ユーザーの回答は inbox イベントとして戻ってきてセッションを再開させます。期限がなければ yes はいつまでも有効なままで、誰かがタップした時点でアクションが走ります。数日後、文脈から切り離された状態で、です。同じ隙間は MCP の elicitation にもあります。無人のプロセスは、答える人が誰もいないのに質問できてしまいます。
修正は 2 つの部分からなり、どちらもほかと同じくキーで管理します。期限切れは単発のタイマーで、承認自身のキーで expired の回答を書き込みます。そのため誰も答えないリクエストでも、セッションは拒否として再開します。そして回答の経路は、スケジューラがすでに走ったと信じるのではなく、同じトランザクションの中で自分で期限をチェックします。
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"]リクエストごとに保存される回答は 1 つだけで、関数は有効なほうの回答を返します。そのためハンドラーは、実際に何が起きたかをユーザーに伝えられます。4 時間の期限で返金の承認を 3 件シミュレートしました。期限切れタイマーが 5 時間目に発火し、6 時間目に yes が届いた場合、結果は expired でした。5 時間目に yes が届き、スケジューラがまだ走っていない場合も expired が返りました。期限をタイマーだけに持たせると間違えるのが、このケースです。1 時間目の yes はそのまま有効になり、2 時間目に 2 度目のタップで no を送ると yes が返り、あとで期限切れタイマーが発火しても何も変わりませんでした。一度も出していないリクエストへの回答は unknown を返しました。
always-on エージェントの outbox の送信ペースをどう制御するのか
メッセージをテーブルに溜め、ディスパッチャーに最小間隔で送らせ、溜まった分はまとめます。ガイドは「エージェントのメッセージのペースを整えたい場合か、エージェントがエラーから回復できるようにしたい場合にだけ、正式な outbox が必要です」と述べ、エージェントは最初は静かにすべきだとしています。間隔は、モデルの外側で静かさを強制する最小のルールです。
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)1 時間の間隔で、0 分、10 分、20 分にキューされた 3 つの更新は、2 回の送信になりました。最初の 1 つはすぐに、残りの 2 つは 61 分にまとめて送られました。送信は書き込みトランザクションの中で行われるので、2 つのディスパッチャーが同じ行を両方とも送ることはできません。それでも at-least-once です。send() の後、コミットの前にクラッシュすると、まとめたメッセージがもう一度送られます。送信前に行を送信済みにすれば at-most-once に変わります。どちらの欠落を許容するかはチャネル次第です。
間隔が抑えるのは頻度だけです。ガイドは run の終わり方を 3 つ挙げています(メッセージが出る、何も出ない、遅延ツール呼び出しがユーザーに尋ねる)。何も出さないという選択は、エージェント自身がしなければなりません。変化を見つけなかった run はメッセージなしで終わるべきで、そうなっているかを確かめる場がガイドの勧める eval です。緊急のメッセージには、間隔を迂回する明示的な優先度を行に持たせる必要があります。そうしないと、間隔をいつ適用するかをモデルが決めることになります。
単一ユーザーの always-on エージェントに、ガイドの認証情報プロキシのパターンは必要か
それはエージェントが何人のユーザーに仕えるかではなく、どんなテキストを読むかで決まります。ガイドのパターンは「エージェントの環境の外にあり、送信リクエストに認証情報を付け加えるプロキシ」です。これで得られるものは限られています。生のシークレットがエージェントから見える範囲に入らないので、トランスクリプトやメモリファイルやツール引数に紛れ込むことがなく、要約を頼まれたメールに隠された指示がそれをどこかへ送ることもできません。
持ち主のメッセージとリポジトリだけを処理するエージェントは、信頼できないテキストをほとんど読まないので、スコープを絞った短期限のトークンで足りるかもしれません。公開の受信箱、サードパーティの Webhook、Web ページを読むエージェントは、誰も見ていないところで、攻撃者が制御するテキストを定期的に読んでいます。そしてガイドは、インジェクションのスクリーニングをまさにその run の中に移しています。プロキシが想定しているのはこのケースです。スクリーニングはいずれ何かを見逃し、プロキシはその見逃しが届く範囲を限定します。ただし、すべての外向き通信がプロキシを通り、プロキシが宛先を制限し、プロキシ自身のログに認証情報が残らないことが条件です。許可されたリクエストをエージェントが悪用するのは止められません。その代わり、以後のアクセスを 1 か所で断てるようになります。
always-on エージェントのループは変わらず、保証は inbox に宿る
上の結果はすべて、末尾のドライバーを末尾のスクリプトに対して 1 回走らせて得たものです。生の出力は次のとおりです。
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']これは 1 プロセス、1 つの SQLite ファイル、シミュレートした時計での結果です。各ルールのロジックは示していますが、実際の並行処理のもとでルールがどう持ちこたえるかは示していません。1 台のホストなら BEGIN IMMEDIATE が書き込みを直列化します。複数のマシンにまたがるならサーバー型のデータベースが必要で、移植は検索と置換では済みません。Postgres には BEGIN IMMEDIATE がないので、リースのチェックと finish には明示的な行ロックが要ります。タイムスタンプには適切な整数型かタイムスタンプ型を使うべきです。そして複数コンシューマーでよく使われるクレーム方法の FOR UPDATE SKIP LOCKED は、それだけでは inbox ごとに 1 つのアクティブな run を強制しません。その版は走らせていません。
ドライバーの中のエージェントはスタブで、ここまでの内容はモデル呼び出しに一切触れていません。ループは変わらないというガイドの指摘は正しいです。作るのはその周りの帳簿付けで、その帳簿付けの大半は、結局 1 つのユニークキーでした。
スクリプト全体: 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)シナリオドライバー: 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 / ディスカッション
コメント欄はありません。議論は X で行っています。
02 / ほかの記事
セルフホスト版 Claude Managed Agents: サーバーは自分のものになり、何も報告しない失敗も自分のものになる
セルフホストにすると、トランスクリプトの管理は自分の手に移り、運用も一緒についてきます。Anthropic の差分リストには、作業が止まっている、捨てられている、あるいは別のモデルが答えているのに、サーバーが成功を返すか何も返さないケースが並んでいます。そのリストの各行こそが、デプロイの受け入れテストです。
Claude Fable 5.2 とエージェントのメモリに残った古い注意書き:Anthropic が測定したこと
メモを忠実に守るモデルでは、誰も整理しなかったメモのツケが表に出ます。Anthropic のテストでは、古い禁止事項、レビュアーが付けた条件、あいまいな承認リストがルーチン作業を止めていました。ガイドが示す対処法は、どの注意書きにも理由が書き残されていることを前提にしています。
Claude の MCP Apps:アプリツール、エントリーポイント、埋め込みサイトのうち、ユーザーが実際に使えるのはどれか
Claude の MCP Apps に加わった 3 つの新機能には、それぞれ別の条件があります。テストに使うコネクタは、ユーザーが Community リスティングからインストールしたものより多くの条件を満たします。ドキュメントによれば、機能が使えないときにユーザーにはメッセージが表示されません。