Module 25 — Durable Orchestration & Real Agent Patterns¶
Time: 7–10 days · Depends on: 12, 19, 21, 22 · Next: Orchestrators in production
Fictional teaching scenario.
A “codebase investigator” is supposed to find why billing double-charges. It chats for forty minutes, holds the hypothesis in free-form CoT, writes the user’s tree directly, dies on a laptop sleep, and comes back with no memory. A junior re-runs it; it files a PR that fails tests; merge is a Slack thumbs-up. Durable orchestration is the opposite design: coordinator with a log, tree of claims with scores, photocopy worktree, tests + approval before merge, HITL as a state, not a hope.
Case question: What must be persisted, isolated, tested, and approved so the investigation can resume after interruption without corrupting the user tree?
Learning objectives¶
- Run a long-running coordinator that persists phases and can pause/resume
- Grow a hypothesis tree and back-propagate evidence to parent claims
- Execute writes in an isolated worktree, then pass a merge gate
- Keep durable state (append-only log) and human-in-the-loop as first-class states
- Eval the whole graph under cost and latency caps (Module 22), not only the last message
Modules 18–19 gave leaf patterns and workflow shape. This module is the end-to-end machine: something that can survive a restart and refuse to merge junk.
Intuition lock
Sticky picture: The coordinator is a job queue with named phases. The hypothesis tree is a bug tracker: children prove or kill the parent. A worktree is a scratch branch. The merge gate is CI + CODEOWNERS. The journal is a receipt book: a finished line records one decision; an unfinished last line is not a receipt. HITL is a paused coroutine, not a print statement.
Kill this idea: “Long-running means a bigger context window and a while loop.” → Replace with: Persist events, isolate side effects, gate merges, pause for humans, resume from the log.
Mental model¶
flowchart TB
Goal --> Coord[Coordinator + DurableStore]
Coord --> H[HypothesisTree]
H -->|frontier| W[WorktreeExecutor]
W --> Tests[run_tests in copy]
Tests --> Gate[MergeGate]
Gate -->|allow| Apply[User tree / PR]
Gate -->|ask_human| HITL[paused event]
HITL -->|human payload| Coord
Coord --> Eval[Module 22 dashboard]
Invariant: replay preserves each complete, committed decision. An unfinished final record is discarded; corruption in a complete record fails closed. A worker interrupted before completion may run again, so its side effects need stable idempotency keys. The journal has one writer; it is not a multi-worker database.
Core tutorial¶
1. The crash between “yes” and “done”¶
A human clicks Deny. You write “approval resolved” to disk. Before you write “workflow aborted,” the process dies. On restart, the program sees no pending approval and no abort. Which fact should it believe?
The problem is that one decision was split into two writes. Coordinator.resume
now records a single hitl_resolved event containing the boolean decision, phase,
and structured result. Replay derives the next state from that event. There is no
second write it must hope survived.
from pathlib import Path
from src.durable import DurableStore
store = DurableStore(Path("events.jsonl"))
store.append("hitl", {"phase": "review", "result": {"facts": ["duplicate charge"]}})
store.append("hitl_resolved", {"phase": "review", "approved": False})
# Restart: the denial itself is sufficient to recover the aborted state.
store = DurableStore(Path("events.jsonl"))
append writes a complete newline-terminated record, flushes it, and calls fsync
before acknowledging success. In-memory state advances only after that succeeds.
On restart, an unfinished final line is removed before another append; a malformed
complete line is an error, not something the loader quietly skips. The loader reports
recovered_tail_bytes so a torn write is visible.
A failure during fsync is an uncertain commit: the caller cannot assume the
record is absent. Stop and reopen the journal to recover its state. Do not keep
appending from stale memory. The directory should already be on persistent storage;
this example does not establish guarantees for network filesystems or failing disks.
Only one process may own a journal at a time. Use a transactional store and leases
when workers can race.
Predict: what if the worker already sent the refund, but crashed before recording
phase_done? The journal cannot infer whether the recipient received it. We will
make that awkward moment happen in the lab below.
2. Coordinator: run until a gate¶
def research(ctx):
return {"facts": [...], "ask_human": "approve write?"}
def write(ctx):
return {"ok": True}
c = Coordinator(store, ["research", "write"], {"research": research, "write": write})
paused = c.run_until_gate({})
assert paused["status"] == "paused"
resumed = c.resume({}, {"approved": True}) # next phase runs
# c.resume({}, {"approved": False}) → status "denied"; write never runs
ask_human does not complete the phase. One hitl_resolved event records the decision: approval completes that phase; denial aborts. Only a real boolean is accepted—"false" is a nonempty string, not permission. Replayed results are available in context["phase_results"]. An application must schedule approval expiry and submit a denial; this coordinator has no background timeout scheduler.
stateDiagram-v2
[*] --> running
running --> paused: phase returns ask_human (HITL event persisted)
paused --> running: one approved decision event, next phase starts
paused --> denied: resume(approved=False)
paused --> denied: application records expiry as denial
denied --> [*]
running --> done: last phase completes
done --> [*]
Once the pause event has committed, a restart recovers the pending question. Once the decision event has committed, it recovers that decision. Killing the process between those states leaves it paused; killing it after the denial leaves it denied. The tests terminate a real child process to check this boundary.
3. Hypothesis trees and insight backprop¶
from src.durable import Hypothesis, HypothesisTree
tree = HypothesisTree()
tree.add(Hypothesis(id="root", claim="double charge is a webhook retry"))
tree.add(Hypothesis(id="c1", claim="idempotency key missing", parent_id="root"))
tree.record_evidence("c1", "logs: two POSTs same invoice", delta=0.4)
# child score high → parent score rises (backpropagate)
Use this for research/investigation agents:
- Manager proposes 2–5 competing hypotheses (cap the fan-out).
- Workers gather evidence in isolation (Module 18 subroutine).
- Evidence updates the child; a fraction back-propagates to the parent.
frontier()is what you work next — do not DFS the whole tree.
This is not gradient descent. It is a scored AND/OR tree so the coordinator does not forget why it is reading file 40.
Explainer
Backpropagation of insight means: a leaf finding should change the parent’s priority, or you will keep exploring a refuted story. If the child is refuted (score <= 0.2), the parent should drop too (negative delta). Cap depth; cap total nodes; each node has a token/step budget (Module 20/24). Unbounded trees are runaway loops with prettier names.
flowchart TB
Root["root: 'double charge is a webhook retry'<br/>score: rising"]
C1["c1: 'idempotency key missing'<br/>evidence: two POSTs same invoice<br/>delta +0.4"]
C2["c2: 'race in payment worker'<br/>evidence: refuted by logs<br/>delta -0.3"]
Root --> C1
Root --> C2
C1 -.->|score rise backpropagates| Root
C2 -.->|score drop backpropagates| Root
record_evidence moves the child's own score by delta, then backpropagates half that delta to the parent — one level up, not recursively to the grandparent. So c1's +0.4 raises root by +0.2, and c2's -0.3 lowers root by -0.15; the parent's score is a damped blend of what its children are finding. frontier(min_score=0.4) only returns leaf nodes still open and above the floor — so once c2 drops to refuted, work shifts to c1 and any new children of it, not back to the root.
4. Isolated executor + merge gate¶
from src.sandbox import WorktreeExecutor
from src.durable import MergeGate
with WorktreeExecutor(source) as wt:
wt.write_file("src/foo.py", new_src)
tests_ok = run_pytest(wt.path)
decision = MergeGate().review(
tests_passed=tests_ok,
diff_files=["src/foo.py"],
approved=human_said_yes,
)
# original tree unchanged unless you apply after decision["allow"]
| Gate check | Why |
|---|---|
| Tests passed | Don’t merge red |
| Human approved | Dual control on writes |
| Diff size cap | Stop “rewrite the monorepo” |
Production: this is a PR, not shutil.copy back. The teaching gate is the policy, GitHub/GitLab is the transport.
5. Eval the graph, not the soloist¶
Long coordinators fail on cost and latency even when the answer is right:
- Per-phase
CostEvent(Module 26) - Trajectory per worker + composite (Module 22)
- Wall-clock SLO: pause and HITL rather than spin
A coordinator that takes 25 minutes and $12 to find a one-line fix is a failed design unless you documented that trade.
Think about it
Question: The coordinator persists phases, but each worker still dumps its full scratchpad into the next phase’s context. What went wrong?
Reveal a strong answer
You persisted **the wrong artifact**. Durable events should carry **compressed results** (`facts[]`, hypothesis scores, diff paths) — Module 19 planner rule. Scratchpads stay inside the worker. Otherwise restart is durable *and* you still drown the window (Module 05). Pair store payloads with schemas; reject oversized events.Failure modes¶
| Symptom | Cause | Fix |
|---|---|---|
| Restart replays side effects | Non-idempotent workers | Idempotency keys; worktree until merge |
| Tree explodes | No cap on children | Max nodes; min_score frontier |
| Merge thumbs-up in Slack | No MergeGate |
Tests + approval object |
| HITL deadlock | No timeout | Deny by default (Module 21) |
| Cheap demo, $ prod | No per-phase budget | Modules 22 + 26 |
Case checkpoint
Opening failure: A long investigation lost its hypothesis on restart and wrote an untested patch directly into the user's tree.
What this lab demonstrates: The durable log, hypothesis frontier, pause/resume path, denial assertion, and merge gate prove state and side effects survive interruption safely.
What it does not prove: Persistence can faithfully preserve a wrong hypothesis, so evidence scoring and human review still matter.
Lab¶
HypothesisTree: child evidence raises parent score;frontier()returns leaves.DurableStoreon a temp JSONL; new instance sees the committed event. Append half a final record and restart: recovery reports discarded bytes. Corrupt a complete record: recovery must refuse it.- Coordinator pauses on
ask_human; denial does not run the next phase; approval then resumes. MergeGate: fail tests →allowfalse; tests + approval → true.- Stretch: run a worktree write + gate in one script (no live model).
Crash experiment — the receipt arrived, the notebook did not¶
Before running, choose a prediction: zero refunds, one refund, or two?
The first process commits a simulated effect at the receiver, then exits before
writing phase_done. The next process replays the workflow and runs the worker
again. There are two attempts but one effect: the receiver stores the stable
operation ID ticket-7:refund under a unique constraint in the same transaction
as its effect. A fresh UUID on every retry would defeat that protection.
sequenceDiagram
participant W as Worker
participant R as Receiver
participant J as Journal
W->>R: refund(ticket-7:refund)
R-->>W: committed once
Note over W,J: Worker dies before phase_done
W->>J: replay after restart
J-->>W: refund phase incomplete
W->>R: refund(ticket-7:refund) again
R-->>W: existing receipt; no second effect
W->>J: phase_done
Change the receiver's idempotency handling in a scratch copy and repeat. Explain why a durable coordinator alone does not buy exactly-once effects. For a remote payment API, the receiver must honor the key; if it cannot, you need reconciliation or a human decision for uncertain outcomes.
Prove: terminate immediately after approval or denial commits; restart and verify
the correct phase. Also interrupt between the simulated effect and completion.
tests/test_durable.py and the experiment cover different crash windows.
Quizzes¶
Quiz · +25 XP
What should happen when a phase returns ask_human?
Quiz · +25 XP
A worktree patch is ready. What does MergeGate require before allow=True?
Open source materials¶
| Resource | Use it for |
|---|---|
src/durable.py, src/sandbox.py |
Coordinator, tree, gate, worktree |
| LangGraph checkpoints / HITL | Industry analog after you can name the states |
| 12-factor agents | Owned control flow |
| Module 22 / 26 | Eval and $ attribution on the graph |
Checkpoint¶
- Restart does not lose phase
- Hypotheses are data with scores, not a paragraph
- Writes happen off the user’s original tree until a gate
- HITL is a state in the log
- You can say what the run cost and how long it took
Mark complete when you can pause a coordinator, resume it from JSONL, and refuse a red merge.
Exercise¶
- Catalog: EX-25 — Durable graph
- Prove: Pause/resume from JSONL; a denied HITL does not run the next phase; merge gate blocks failed tests.
- Test:
pytest tests/test_durable.py -v
Return to the case: An append-only log, isolated worktree, tests, and a human merge state let the investigation survive interruption without writing directly to the user tree. Durability preserves state; it does not validate the hypothesis by itself.