状態: Step 1–5 実装完了 (2026-06-25)。 この設計(everything-is-an-instance + 6 external event + 3 レイヤ atomic-tx 永続化 + outbox + runs/escalations を Layer 1 から projection)は実装済み。main ブランチ(prototype)の API/CORE/FFI/ENV module + bus を、**1 actor 内の「instance kind」**に畳んだ軽量版。 読み取り(run list/get、open escalation list)は repository が Layer 1(delegations / escalations)を 直読し、facade は command 側(start/cancel/answer)のみ。 注: DB 層(Hono repo の Drizzle query)は in-memory suite では検証不能(Postgres integration test は別途 整備予定)。pure logic(projectRun / isUserFacingRequest 等)は unit test 済。
中心となる決定:
- すべては Instance(generic interface)。共通 +
engine_stateだけ kind 別(core/api=root)。- 接続点は 6 対称 external event だけ(delegate/Ack・escalate/Ack・terminate/Ack)。ユーザー操作も event。
- 永続化は 3 レイヤ + turn = 1 atomic tx(§6): Layer 1 entity(instances/delegations/escalations、各々 独立 SSoT、state machine)/ Layer 2 engine 継続(threads/scopes、entity を FK 参照)/ Layer 3 outbox。 entity と継続を分離し、整合は transactional outbox パターンが担保する。
ユーザー向けの管理面(run の start/cancel/status、user-facing escalation の list/answer)と、IR 実行(CORE engine)を、最小の結合点で繋ぐ。結合点を 6 対称 external event 一本に絞り、それ以外の特別扱い・専用 API を 無くす。
- システムは instance の木。親が
delegateで子 instance を召喚し、escalateは上へ、answer は下へ流れる。 - 接続は 6 対称 external event のみ。API ↔ CORE の結合はこれ以外に存在しない。
- kind は
engine_stateと処理方法だけを変える。routing(どの instance へ届けるか)は完全に一様で、 どの instance も — root であっても — 特別扱いしない。
Instance = {
id, project_id,
delegation_id?, // 自分を召喚した delegation (root は null)
parent, // = delegation の発行元 instance。delegation_id から導出
status, // 'running' | 'cancelling'
kind, // 'core' | 'api'
delegations, // 自分が発行した delegate (= parent として)
escalations, // 自分が発行した escalate (= child として)
engine_state, // ← kind でここだけ違う
timestamp,
}
delegations/escalations= 自分が発行した edge。- delegation の発行は親(caller が子を召喚)。
- escalation の発行は子(raiser が request を上げる)。
- routing マップ(delegation→child、escalation→raiser、pending の caller)は全部 instance の発行 edge から 導出する(warm では in-memory map、reactivate で再構築)。
- kind 別なのは
engine_stateのみ。それ以外は完全に uniform。
core |
api ( = root ) |
|
|---|---|---|
| 役割 | IR を走らせる(1 agent activation) | project の管理 top |
engine_state |
thread machine(threads / scope refs / routing / counters / 召喚 target+snapshot) | 空(管理状態は共通 fields 側) |
delegations の意味 |
sub-agent 呼び出し | run(root が走らせた agent) |
escalations の意味 |
自分が上げた request | user-facing open escalation(上げ先が無い=open) |
| 処理 | engine turn を駆動 | bookkeeping(§7、audit 書き込み) |
| 寿命 | ephemeral(terminal で self-delete) | permanent(project 毎に1つ。常駐) |
root を CORE instance にしない。engine_state が空なだけの普通の instance。synthetic thread は無い。
「IR が無いのに thread だけ召喚」という違和感は、root が api kind で thread tree を持たないことで消える。
root の特別さは「routing の特別扱い」ではなく「kind による構造の違い」に閉じる。
ユーザー操作も CORE→ユーザー通知も、すべて同じ 6 event:
startRun : (API入力) root が delegate 発行 → 新しい core instance
run 完了 : core が delegateAck → root が受領 → run を done に
escalation : core が escalate → 親へ bubble → 未処理なら root に届く → root の open escalation に
answer : (API入力) root が escalateAck 発行 → core を resume
cancel : (API入力) root が terminate 発行 → core teardown → terminateAck → run を cancelled に
→ どの core instance も「top」として特別扱いされない。root は「上げ先が無い instance」として一様に escalate を 受け、それが open escalation になるだけ。
main の API module / CORE module / bus を、1 actor 内の kind ハンドラに畳む:
actor.feed(externalEvent): // ユーザー操作も CORE 由来も同じ口
target instance へ routing
dispatch by instance.kind:
core → driveEngineTurn(instance, event) // IR(internal queue を quiescence まで)
api → handleApi(instance, event) // bookkeeping + audit/live 書き込み
turn 境界で 1 tx: { instance state + 発行 edge + outbox + (api なら audit) }
feed は serial queue に積み、その event を処理する turn が終わったら resolve する。
bus の汎用 (from→to) routing は不要 — instance tree の delegation 経由で十分。
核心原則: 「entity(= 外部 event 語彙、何であるか)」と「engine 継続(= どう再開するか)」を別レイヤにし、FK で 参照、整合は turn 単位の atomic tx が担保する。 instance / delegation / escalation は entity として独立 SSoT を 持ち、thread は継続で entity を FK 参照するだけ。
instances id, project_id, kind, status, ambient_generics?, timestamps ← generic な node interface
delegations id, project_id, issuer_instance_id, target, args, state, timestamps ← call edge
escalations id, project_id, raiser_instance_id, request, args, state, timestamps← effect edge
所有・cascade:
instances.delegation_id → FK delegations ON DELETE SET NULL // instance は召喚 delegation に所有されない
delegations.issuer_instance_id → FK instances ON DELETE CASCADE // delegation は発行元(親)が所有
escalations.raiser_instance_id → FK instances ON DELETE CASCADE // escalation は発行元(子/raiser)が所有
- delegation 行は edge だけ(thread を知らない)。escalation 行も同様。entity SSoT。
- state machine: delegation
running → done / cancelling → gone、escalationopen → answered / gone。state を残して 履歴も兼ねる(案X)。close で delete せず終端 state を残す → queryable な履歴。別 audit テーブルは作らない。 runsは別テーブルではなく projection: 「delegationswhereissuer = api root」が run 一覧。run の result / cancel reason 等の user-facing 付加情報だけrunsに持つ(= delegation の拡張属性)か、delegation 行に内包。
threads / scopes core の thread tree + scope(owner=instance, cascade)
DelegateThread { parent, parentCallId, forwardRoutes, delegationId(FK) } 「D が ack したらこの thread を再開」
AgentThread.escalations { escalationId(FK) → askId } 「esc が answer されたら askId を再開」
instances.engine_state core の counters 等(kind 別 JSONB。api は null)
- continuation(parent / askId)は entity から導出できない別 SSoT。
delegationId/escalationIdは FK 参照で あって edge の複製ではない。apiinstance は Layer 2 を持たない(thread tree 無し)。
outbox id, project_id, event(JSONB), created_at ← 未consume の external event
- internal event は instance graph 再実行で再導出できるが、external event は instance 間の committed handoff で 再導出できない。producing turn と同一 txで insert、consuming turn で delete、recovery で未consume を replay (transactional outbox)。別 DB にすると atomicity が壊れるので 同一 DB・別テーブル。
1 external event を処理する turn は、1 tx で次を書く:
1 tx = {
consume した event を outbox から delete
Layer 1 entity 遷移(delegations/escalations の insert/update、instances.status)
Layer 2 engine 継続の更新(threads/scopes、core のみ)
produce した outbound event を outbox に insert
}
これで「DelegateThread はあるが delegations 行が無い」窓が原理的に消える(前回の懸念の解決)。これは event-driven 永続化の標準パターン(transactional outbox + transactional consumer)。novel なのは Layer 2 を 載せた点だけ。
command と query で経路が違う:
command (start/cancel/answer) : HTTP → external event に翻訳 → host.feed → actor
query (list/get) : HTTP → repository で audit を直読(actor を通さない)
host が API に見せる口は1つ:
interface RuntimeHost {
feed(projectId: string, event: ExternalEvent): Promise<void>; // その event を処理した turn 完了で resolve
}service 層(抜粋):
// run.service.ts
start(projectId, input): // runId = newDelegationId(); host.feed(delegate{runId, target, args}); return {runId}
cancel(projectId, runId): // host.feed(terminate{delegation: runId})
list/get(projectId, …): // runRepository を直読(audit)
// escalation.service.ts
listOpen(projectId): // escalationRepository を直読
answer(projectId, escId, value): // host.feed(escalateAck{escalation: escId, value})→ service 層は「翻訳して feed」か「repo で直読」だけ。run の result も lifecycle promise も持たない
(durable は audit にある)。feed(delegate) は「core instance 生成 + runs(running) 書き込み」完了で resolve する
ので、startRun 直後の GET が必ず当たる。
external event は Layer 1 entity の state 遷移。turn が §6 の 1 tx で entity と継続を同時に書く:
delegate : delegations(running) insert + core instance 生成(issuer=発行 instance)
delegateAck : delegations(done, result) update + 発行 instance の DelegateThread を resume
escalate : escalations(open) insert
escalateAck : escalations(answered) update + raiser を resume
terminate(Ack): delegations(cancelling→gone) + 子の teardown cascade
- run / open escalation の list/get は Layer 1 を直読(runs =
delegations where issuer = api root、open escalation =escalations where state = open)。actor を通さない。 coreハンドラは Layer 1 の routing 用 entity を直接いじらず、engine 継続(threads)を動かすだけ。Layer 1 への 反映は turn 境界の永続化(§6 tx)が、その turn の発行 edge から行う。
delegations → delegationCaller(D→issuer)/ delegationChild(D→child)
escalations → open escalation 一覧 + raiser
instances → node 群(kind 別に Layer 2 を load)
threads/scopes → core instance の継続(DelegateThread.delegationId / AgentThread.escalations が FK)
outbox → 未consume を replay
turn = 1 atomic tx なので、どこから読んでも整合。api root は permanent(id = projectId)。
- run-root を「caller 無しの CORE instance」として特別扱いするのを廃止 → run は
apiroot の delegation、 実体は普通のcoreinstance(親 = root)。【実装済 Step 1–2】 - actor の入口を
feed(externalEvent)中心に(startRun=delegate / cancel=terminate / answer=escalateAck)。 instancesにkind、target/snapshot_idを nullable に。pendingDelegations撤廃(DelegateThread.delegationId が SSoT)。【実装済】- delegations / escalations を Layer 1 entity(live + 履歴、state を残す)に。
runsは delegation の projection。 - outbox + turn=1 atomic tx を導入(entity・継続・event を同一 tx で)。
[済] Step 1 Instance に kind + api root(permanent、id = projectId)
[済] Step 2 event 統一(feed 中心)+ actor kind dispatch
[済] Step 3a instances schema(kind + nullable target/snapshot)+ codec + migration
[済] pendingDelegations 撤廃(DelegateThread.delegationId が SSoT)
[済] Step 3b 「turn = 1 atomic tx」: Persistence を persistInstance/dropInstance → commitTurn(Layer1+Layer2)。
[済] EntityTransition で delegation/escalation 遷移を表現、turn の { instance/threads/scopes + entity
[済] 遷移 } を 1 tx で commit(DelegateThread↔delegation 行の atomicity gap を解消)。schema: outbox
[済] table + delegations/escalations を retained-history state machine 化(migration 0004)。
[済] DbPersistence は 1 drizzle tx(FK 順: delegation-open→instance→escalation-open/state updates)。
[済] Step 3c reactivate を Layer 1 から: delegations(live)→delegationCaller(run 委譲は thread が無いので table が
[済] 唯一の出所、生存 DelegateThread からも併せて再構築)。検証用 StoringPersistence + recovery test
[済] (in-flight external 復帰 / run routing を table から復元し結果を done で durable 記録)。
[半] Step 4 [済] open escalation recovery: reactivate が escalations(open) から user-facing なもの(raiser が
[半] run root = 委譲 caller が api root、かつ panic/control でない genuine request)を openEscalations
[半] に再構築。recovery test。
[半] [済] run terminal の durable 化: delegations に `failed` state + `error_message`(migration 0005)。
[半] commitTurn は `layer2: none`(api root turn — engine thread 無し)をサポート。run 失敗
[半] (panic/unhandled escape が root 到達)= handleApiEscalate が delegation-failed を api-commit
[半] + root を terminate で teardown(leak 解消)。terminal state は sticky(先に failed なら
[半] teardown の gone は no-op)。test(panic run → failed+message durable、root teardown)。
[次] [次] runs list/get を Layer 1 直読に(state/result/error を delegations から、promise でなく)。
DB 層(Hono repo)、Postgres integration test 要。
[済] Step 5 outbox = mailbox の durable backing。`TurnCommit` に `consumed: OutboxSeq|null` + `produced:
[済] OutboxMessage[]`({seq, issuer, event}、issuer は replay 時 delegate の caller 再建)。全 turn が
[済] 「consumed 行 delete + produced 行 insert」を Layer1/2 と同一 tx で commit(transactional
[済] outbox/consumer)。actor: mailbox=`{message,seq}[]`、seq を handler に明示 thread(各 path は自分の
[済] turn commit か `consumeOnly` で 1 回だけ consume)。`commit` helper が commitChain mutex で直列化+
[済] deliver。api ops(startRun/cancel/answer/run失敗の terminate)も `produce`/`commit` 経由で durable。
[済] reactivate が pendingOutbox を replay。schema: outbox.instance_id=issuer(FK 撤去・NOT NULL)、
[済] event 型 ExternalEvent(migration 0006)。
[済] **重要バグ修正**: lazy reactivate を全 commit の前に(`ensureLoaded`、loadingPromise で 1 回)。
[済] さもないと produce が commit→outbox 書込み後に reactivate が走り、その行を pendingOutbox として
[済] **二重 replay**(cascade 二重起動)。test: outbox balance(完了後 size 0) / seed→fresh actor で replay。
[済] [済] runs list/get を Layer 1 直読に。`runs` table は metadata sidecar に slim 化(id = run
[済] delegation id、name/qualified_name/snapshot/argument/cancel_reason、migration 0007 で
[済] state/result/error_message/completed_at/updated_at/instance_id を drop)。outcome は
[済] delegations を LEFT JOIN(state は delegationToRunState で gone→cancelled・failed→error
[済] に写像、result=done、errorMessage=failed、completedAt=terminal の updatedAt)。host.startRun
[済] は runId(=delegation id) を返す(runs map 廃止)。facade は promise settle を廃止(durable
[済] は delegation)、cancel は cancel_reason のみ書く。API response shape は不変。pure mapper
[済] `projectRun` を unit test(DB query は Postgres 要なので mapper のみ検証)。
注: Step 3b で delegation 行はその子の create turn が書く(caller は routing から既知、run は api root)。 これにより startRun/cancelRun は同期のまま(api root 専用 turn を作らずに済む)。caller turn の DelegateThread と delegation 行の間の一時的 gap は、recovery が 生存 DelegateThread からも caller を再構築するため埋まる。 Step 5 で in-flight event(child 完了→delegateAck が outbox にある状態で crash でも親が replay で resume)と api 発 event が durable になった。残るは runs の Layer 1 直読(DB 層、Postgres test 要)のみ。