I knew the scheduler still had a problem when every stage could resume safely, but I still had to copy the output from one session into the next. After a writer returned, I pasted the result into a controller chat. After review, I opened another session and asked it to continue. When the next task became ready, the system gave me another message to paste.
The task graph and individual commands could already survive a restart. What was missing was a process which joined those operations together, so I was still doing that part by hand. Closing a chat, restarting a terminal or stepping away for the evening meant remembering which controller should resume and what it was allowed to do next.
This is the third article in the series. The first article built the coding-agent scheduler around explicit goals, ownership, bounded workers and one integration queue. The second added Beads as durable workflow state, along with pull-based workers, deterministic CI observation, checkpoints and tighter safety controls.
Since then I have been working on one question: what would it take for a single controller process to keep traversing an approved Beads root without relying on me to carry state between sessions? It turned out to involve much more than putting a loop around the existing commands.
Resumable commands still left me operating the scheduler
The earlier workflow could recover any individual stage. In practice, a longer job still looked like this:
run controller preflight
-> launch a writer
-> copy its summary back to the controller
-> launch independent review
-> copy those findings back
-> ask the controller to resume
-> repeat for CI and integration
Each pause was an opportunity to resume the wrong session, use stale task state or miss that another provider process was still editing the same workspace. The controller also had no durable proof that it was the only controller acting on the root.
Agentflow now runs one persistent root controller. It acquires authority for one approved Beads root, builds a root-wide preflight snapshot, claims one exact ready task, launches or observes the assigned provider, validates the returned evidence and repeats. It stops only when the goal is complete or a person needs to make a decision.
acquire or reattach controller lease
-> preflight the complete root
-> claim one exact ready task
-> launch or observe its provider session
-> validate the returned evidence
-> record the disposition in Beads
-> repeat until GOAL_COMPLETE or USER_ACTION_REQUIRED
The private CLI exposes that lifecycle through start and resume:
agentflow controller start \
--root "$PWD" \
--workflow-root <approved-root> \
--controller agentflow-controller \
--json
agentflow controller resume \
--root "$PWD" \
--workflow-root <approved-root> \
--controller agentflow-controller \
--json
These commands show the interface rather than install a public Agentflow release. A diagnostic --once mode performs one transition, but normal operation continues internally. Another chat message is not required merely because a task moved from writing to review.
That sounds like a small change. In day-to-day use, it was the point where the scheduler finally stopped handing me its clipboard after every stage.
Beads remains the system of record
The persistent process does not own the plan. Beads remains authoritative for the approved root, its child tasks, dependencies, assignments, acceptance evidence and review decisions.
That boundary matters when the controller restarts. It reconstructs the workflow from durable records rather than its previous conversation. It may advance an existing approved graph; it cannot quietly add a new goal, widen a writer’s file ownership or treat an untested acceptance row as complete.
The authority split is now:
| Component | Authority |
|---|---|
| Human | Goal approval, exceptions, material decisions, hosted actions and merge authorisation |
| Beads | Approved work, dependencies, exact task state, evidence and dispositions |
| Agentflow controller | Lease, claims, preflight, routing, result validation and deterministic transitions |
| Codex, Claude Code and Copilot | One bounded execution or review assignment |
| GitHub | Pull requests, hosted checks and integration state |
This also gives the controller a useful recovery rule: a chat transcript may help explain how somebody reached a decision, but the decision must be present in Beads before it can authorise later work.
One root gets one controller lease
A persistent controller is dangerous if two copies can act at the same time. Agentflow therefore gives one controller a lease over one workflow root and binds every state-changing action to that lease.
The first implementation treated the public lease identity too much like a resume secret. A value such as root/controller/epoch is useful for logs and fencing, but it is guessable. Resume credentials now live in controller-owned storage outside worker workspaces and do not appear in provider arguments, provider environment variables, handoffs or normal status output.
Authenticated reattachment rotates the resume proof and the public lease epoch. A process holding the older epoch is fenced from:
- claiming another task;
- writing the controller checkpoint;
- launching a provider;
- consuming a provider result; and
- recording a workflow disposition.
The controller also records a continuity identity. It distinguishes the same owner recovering after a crash from a different owner taking over a reusable controller name. A legitimate recovery can continue its existing result channels. A takeover changes the continuity identity, so results created for the earlier incarnation are rejected as stale.
After a resume or takeover, I expect only the current controller instance to be able to change the root. Every older instance must be locked out.
A valid task is not enough to authorise a launch
Worker preflight used to answer a narrow question: can this agent read its handoff, tools and required skill? After using the workflow across a number of tasks and areas of work, I found that the complete root could still be unsafe to advance.
I found other provider processes left running in the same workspace, and I also hit a controller profile which resolved to a generic model name the authenticated provider would not accept. Launching around either condition would have created more work without resolving the underlying problem.
The root controller now creates one typed snapshot which binds the conditions required for dispatch:
| Area | What the snapshot records |
|---|---|
| Workflow | Exact root, exact task, current lease, continuity identity and actor-bound claim |
| Repository | Expected base, worktree state and writable boundary |
| Concurrency | Live processes and any overlapping writer ownership |
| Context | Required tools, canonical project skills and complete package digests |
| Handoff | Content, manifest and preflight digests |
| Evidence | Root and task acceptance identifiers and their current states |
| Route | Provider, role, exact model, effort, budget and policy version |
| Permissions | Local writes, push, merge, hosted tests and hosted-state mutation |
I only let the controller launch when the complete snapshot is current. Changed files, a new live process, a stale claim or an updated skill package requires a new preflight. The controller does not repair those conditions as a side effect of launch because that would mix diagnosis, policy changes and execution in one action.
Model selection became a closed policy
Prompts such as “use Opus” or “choose a fast coding model” leave too much room for aliases, provider defaults and changing catalogues. Agentflow now validates an exact provider, role, model, effort and policy version before launch.
For my current setup, the routing table is:
| Work | Approved route |
|---|---|
| Controller, final judgement and high-risk review in Codex | gpt-5.6-sol at high or xhigh effort |
| Codex coding and exploration | gpt-5.6-luna at medium or high effort |
| Claude Code controller or judgement | claude-opus-4-8 |
| Claude Code coding or exploration | claude-sonnet-5 |
| Copilot controller or judgement | claude-opus-4.8 |
| Copilot coding or exploration | claude-sonnet-4.6 |
Terra, Haiku, Auto, generic names such as gpt-5.6, short aliases such as opus or sonnet, lookalike strings and unresolved profiles fail closed. An approved model used for the wrong role also fails.
These exact names describe my versioned routing contract at the time of writing. I am not treating the table as a universal provider ranking. A model name which worked yesterday can stop the run today, which is slightly annoying but much easier to diagnose than discovering that the provider silently chose something else.
Herdr became part of the launch lifecycle
I still prefer native provider subagents for short, bounded work. An external Herdr session is useful when a task is long-running, crosses providers, owns a separate worktree or is likely to need direct human attention.
At first I treated Herdr mainly as a visible terminal surface. Agentflow now records its launch and session identity as part of the task contract: root, task, actor, claim, controller incarnation, handoff digests, provider, exact model, effort, policy and pane identity.
That makes monitoring useful without asking the pane to prove something it cannot. An active pane is an attention signal, and an exited pane tells the controller to inspect the result channel. Acceptance still comes from the task’s evidence.
The launch path is controller-owned. A public launch command can show a dry-run plan, but a live provider spawn requires current controller authority and the materialised, preflighted handoff. This prevents another local process from bypassing the controller by calling the session manager directly with a similar-looking prompt.
A worker return is untrusted input
The result path needed the same care. A provider can write a structured result, but it cannot be allowed to turn its own output into accepted workflow state.
For each external launch, the controller creates a single-use return contract bound to:
- the root, task, actor and exact claim;
- the controller lease and continuity identity;
- the launch and Herdr session;
- the handoff, manifest and preflight digests; and
- the acceptance identifiers which the result must address.
The provider submits bounded JSON to an inbox it can write. The inbox contains no controller resume credential, signing authority or state path. The current controller consumes the submission once, rechecks the contract and acceptance evidence, then records its disposition in Beads.
A single-use capability means that only the first valid submission can be consumed. A second submission using the spent capability fails. Results from an older controller incarnation, an unrelated task or a changed contract also fail. Unknown acceptance IDs, missing evidence, contradictory rows, embedded secrets and finalised invalid submissions become explicit blockers rather than being retried until a generic timeout.
Waivers use the same trust model. A task title containing “approved” does not prove that an exception was authorised. A valid waiver is typed durable data tied to the exact root, task, acceptance row, decision, reference, non-worker approver and timestamp. The provider which needs the waiver cannot approve it for itself.
Reading DONE from a terminal would be simpler, but it is not enough here. A provider result can change the durable graph and release downstream work, so the controller needs evidence that the return belongs to the task it is about to advance.
Cross-provider skills needed package-level identity
The repository remains the owner of domain knowledge. One canonical skill under .agents/skills holds the actual workflow, references and validation rules. Thin provider adapters make that skill discoverable from Claude Code or Copilot without maintaining three independent copies.
provider adapter
-> canonical SKILL.md
-> references, scripts and metadata
-> another local skill package when explicitly required
Pinning only the adapter turned out to be insufficient. The adapter can remain byte-for-byte identical while the canonical instructions or one of their supporting files changes.
Agentflow now records the selected entrypoint hash and a versioned aggregate digest over every regular file in the selected package. It follows explicit local skill references transitively and includes those packages as well. Missing, escaping, cyclic, symlinked or unreadable references stop preflight.
The digest shows that the package launched is the package which was reviewed. Correctness and safety still need review, acceptance evidence and the imported-asset controls described in the previous article.
The controller also needed better engineering habits
Reliable orchestration can deliver a poorly diagnosed fix or a shallow review with great consistency. Three small skills now define how particular tasks should be handled inside the control plane.
Diagnose before editing
The diagnosing-bugs skill requires one focused, repeatable command which can reproduce the reported symptom before cause theories are ranked. Diagnosis-only work remains read-only. When a fix is authorised, the worker creates a red regression seam, makes the smallest correction, runs it green and reruns the original reproduction.
This addresses a pattern I had seen in agent debugging: several plausible causes could be discussed before any command reliably distinguished failure from success.
Map decisions before creating implementation tickets
The wayfinder skill handles work where the destination is understood but route-changing decisions remain. It creates a small Beads graph of decisions and bounded investigations, works only the ready frontier and stops when the result can be expressed as an approvable goal or plan.
This keeps premature implementation tasks out of the graph. If an architectural choice would invalidate half the tickets, that choice belongs in the decision map first.
Review standards and specification independently
The code-review skill uses two read-only passes from one fixed base:
standards axis -> correctness, security, repository rules and tests
specification axis -> approved outcomes, constraints, scope and acceptance evidence
The two passes do not share findings until both finish. The controller then reproduces material findings, records a disposition and routes accepted corrections to the original writer. This prevents a review which likes the implementation style from overlooking that the wrong thing was built, or a specification review from missing an ordinary correctness defect.
These skills were adapted from ideas in the MIT-licensed mattpocock/skills project, while retaining Beads as the decision and evidence graph.
What the real failures changed
The reliability work was driven by failures and near misses rather than a planned feature list:
| What happened | What changed |
|---|---|
| I copied controller output between sessions after every stage | One persistent process now traverses the approved root |
| A restart could look like a second controller | Resume credentials, lease rotation, continuity identity and takeover fencing separate recovery from replacement |
| Other provider processes overlapped the workspace | Root-wide preflight inspects live process and ownership overlap before launch |
| A generic model profile reached provider rejection | The exact model policy now rejects unresolved, generic and unapproved routes earlier |
| A pane could exit without trustworthy evidence | Herdr sessions now use controller-bound, single-use result contracts |
| A provider-visible result file could be rewritten | The inbox is untrusted and only the current controller can ingest it |
| An adapter hash missed changes below the entrypoint | Transitive package digests cover the selected instruction surface |
| Bug, discovery and review tasks lacked a consistent method | Focused diagnosis, decision-first Wayfinder and two-axis review are first-class skills |
Once I wrote the failures down, most had the same cause: I was treating a useful signal as authority. A task claim, terminal pane, model alias or result file can tell the controller something, but each needs a separate check before it can release more work in the graph.
What is verified, and what remains unproven?
There are two test counts in my notes, and they refer to different points in the work.
- The Agentflow 0.9.0 reliability checkpoint passed 406 local tests. That state included the persistent controller, exact claims, root-wide preflight, model policy, Herdr lifecycle, authenticated results, replay and takeover fencing, typed waivers and transitive skill pins.
- The three engineering skills merged afterwards. The current suite passes 410 local tests, including the additional checks for diagnosis, Wayfinder and two-axis review.
I reran the current 410-test suite while preparing this article and it passed. The suite covers the local implementation; provider environments and hosted workflows have separate validation lanes.
During project work I also observed the controller reach a terminal local-success state while leaving hosted-only acceptance lanes untested. Root preflight stopped on overlapping provider processes and an incompatible generic model route instead of launching around them. Those observations verify the intended stop behaviour in those cases; they do not establish a general reliability rate.
Hosted GitHub checks were unavailable for the reliability release. Repeated recovery after machine sleep, provider interruption and controller takeover still needs a larger live sample. The same is true for historical-search quality, attention accuracy, checkpoint recovery across repeated compaction, concurrent-worktree success and any claim that the new engineering skills reduce rework.
I do not yet have enough comparable runs to say that Beads, Herdr or the persistent controller improved speed, quality or cost. The mechanisms and test harnesses exist, and the longitudinal results are still being collected.
Build a small version yourself
You do not need Agentflow to test the design. Start with the public Beads setup from the durable scheduler article and add one small controller program around it.
I would keep the tracked policy separate from the runtime state:
project/
├── AGENTS.md
├── .agents/
│ └── skills/
│ └── project-domain/
│ └── SKILL.md
├── controller/
│ ├── policy.json # tracked model and permission policy
│ ├── schemas/
│ │ ├── preflight.schema.json
│ │ └── result.schema.json
│ └── examples/
│ ├── preflight.json
│ └── result.json
├── scripts/
│ └── controller.py
└── .controller-state/ # ignored; lease, checkpoints and inbox
The controller/ directory tells a reviewer what the system will accept. Validate the two example records against their schemas when the controller starts and immediately before launch or result ingestion. The ignored .controller-state/ directory holds current process state and must never contain provider transcripts or source files. On a real implementation, keep the resume or signing credential outside the project as well, where a worker cannot read it.
For a first local-only version, an operating-system file lock is enough to prevent two controller processes from owning the same state directory:
from pathlib import Path
import fcntl
import os
def acquire_local_lease(state_dir: Path) -> int:
state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
lease_fd = os.open(
state_dir / "controller.lock",
os.O_CREAT | os.O_RDWR,
0o600,
)
try:
fcntl.flock(lease_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
os.close(lease_fd)
raise SystemExit("USER_ACTION_REQUIRED: controller already running")
return lease_fd # keep this descriptor open until the controller exits
This lock is suitable for two processes on one macOS or Linux machine. A process crash releases it automatically. It is not a distributed lease and it does not fence a result produced under an older controller epoch. Add an incrementing epoch to the checkpoint and result contract before introducing takeover or running controllers on more than one machine.
Claim work atomically
The easiest race to avoid is selecting a ready task and claiming it in two separate commands. Beads can do both together:
root_id="<approved-root>"
BD_ACTOR=writer-a bd ready \
--parent "$root_id" \
--unassigned \
--claim \
--json
The useful fields in the response look like this:
[
{
"id": "<task-id>",
"title": "Implement the retry fix",
"status": "in_progress",
"assignee": "writer-a",
"parent": "<approved-root>"
}
]
Persist the returned task ID and claim evidence in the controller checkpoint before launching a worker. If the command returns an empty list, inspect the root rather than treating “nothing ready” as success; blocked or incomplete descendants may still exist.
The same claim can be called without shell=True from controller.py:
import json
import os
import subprocess
def claim_ready_task(root_id: str, actor: str) -> dict | None:
environment = os.environ.copy()
environment["BD_ACTOR"] = actor
completed = subprocess.run(
[
"bd", "ready",
"--parent", root_id,
"--unassigned",
"--claim",
"--json",
],
check=True,
capture_output=True,
text=True,
env=environment,
)
tasks = json.loads(completed.stdout)
return tasks[0] if tasks else None
Keep the actor stable for the task, verify that no more than one item was returned, and write the task ID into the checkpoint before starting the provider. The controller should never pick a task from one command and claim it in another.
Put the route and permissions in a file
A prompt is a poor place to enforce model and side-effect policy. A starter controller/policy.json can be reviewed and versioned:
{
"schema": "coding-agent-controller-policy/v1",
"terminalStates": ["GOAL_COMPLETE", "USER_ACTION_REQUIRED"],
"routes": {
"controller": {"provider": "codex", "model": "gpt-5.6-sol", "effort": "high"},
"coding": {"provider": "codex", "model": "gpt-5.6-luna", "effort": "medium"},
"claudeJudgment": {"provider": "claude", "model": "claude-opus-4-8", "effort": "high"},
"claudeCoding": {"provider": "claude", "model": "claude-sonnet-5", "effort": "medium"},
"copilotJudgment": {"provider": "copilot", "model": "claude-opus-4.8", "effort": "high"},
"copilotCoding": {"provider": "copilot", "model": "claude-sonnet-4.6", "effort": "medium"}
},
"onUnresolvedModel": "halt",
"permissions": {
"localWrite": true,
"push": false,
"merge": false,
"hostedTests": false,
"hostedMutation": false
}
}
Keep only the routes you actually use, then validate the exact strings instead of accepting prefixes or aliases. The important field is onUnresolvedModel: a missing route stops the controller and produces one useful request for the user.
Build one preflight record
Before launch, materialise the facts which jointly authorise the worker:
{
"schema": "coding-agent-root-preflight/v1",
"root": "<approved-root>",
"task": "<task-id>",
"leaseEpoch": 7,
"controllerContinuity": "<non-secret-incarnation-id>",
"claimDigest": "sha256:<digest>",
"base": "main@<revision>",
"worktreeClean": true,
"overlappingWriters": [],
"skillPackageDigest": "sha256:<digest>",
"handoffDigest": "sha256:<digest>",
"acceptanceIds": ["A1", "A2"],
"route": {"provider": "codex", "model": "gpt-5.6-luna", "effort": "medium"},
"permissions": ["local-write"]
}
Recreate this record whenever the root, task, base, live processes, skill package, handoff, acceptance matrix, model route or permissions change. Do not put lease credentials or return capabilities in the record; workers only need the non-secret bindings required to produce a result for their task.
Require a bounded result
A worker result should address the acceptance rows directly:
{
"schema": "coding-agent-worker-result/v1",
"task": "<task-id>",
"contractDigest": "sha256:<digest>",
"outcome": "completed",
"acceptance": [
{
"id": "A1",
"status": "passed",
"evidence": "Focused regression test passed twice"
},
{
"id": "A2",
"status": "passed",
"evidence": "Repository test suite passed"
}
],
"changedFiles": ["src/retry.py", "tests/test_retry.py"],
"remainingRisks": []
}
Write this into a worker-writable inbox, then have the controller validate and move it to a consumed state in one locked operation. Reject a second consumption attempt, unknown acceptance IDs, a changed contract digest, negative or missing evidence and any result bound to an older lease epoch.
At this point the controller loop is ordinary code:
while current lease remains valid:
snapshot = preflight complete root
if snapshot has blocker:
stop USER_ACTION_REQUIRED
task = atomically claim one exact ready task
if no task and every acceptance row is satisfied:
stop GOAL_COMPLETE
if no task:
stop USER_ACTION_REQUIRED
launch or observe the task-bound worker
consume one validated result
record the disposition in Beads
Test this with two controller processes, a killed controller, a replayed result and a changed skill file before allowing it to run unattended. Those four tests reveal most of the assumptions which prompted the Agentflow reliability work.
Add the reliability controls in stages
If you already built the Beads workflow from the previous article, add persistent control in this order:
- Define terminal states. The loop must stop at success or a precise request for user action.
- Give one controller authority over one root. Use an expiring lease and bind every mutation to the current epoch.
- Keep resume proof outside worker reach. Rotate it on authenticated reattachment and fence older incarnations.
- Claim one exact ready task. Do not select one task and claim another in a later operation.
- Preflight the whole root. Bind repository state, process overlap, skills, evidence, route and permissions in one typed snapshot.
- Treat the model route as policy. Use exact names and roles, with no automatic substitution on rejection.
- Make external sessions return through an untrusted inbox. Let only the current controller validate and consume a task-bound, single-use result.
- Represent waivers as data. Bind the approver and decision to one acceptance row rather than parsing approval from prose.
- Hash the complete instruction surface. Include canonical skill files, supporting resources and explicit transitive packages.
- Add task-method skills only when they change behaviour you can inspect. Diagnosis, decision mapping and review are useful because each has an observable return contract.
Start with a controller which invokes one provider synchronously and waits for its structured result. Add Herdr when long or cross-provider sessions justify a visible control surface. Add persistent background operation only after the lease, claim, preflight and result boundaries work under forced restart and replay tests.
Where I still stop the controller
I have not handed goal approval, exceptions, hosted actions or integration to the controller. Its job is to carry durable state between transitions which the system can already evaluate.
The controller can continue while its lease, exact claim, preflight snapshot, model route, permissions and result authority remain valid. It stops when a new goal, wider write boundary, hosted action, waiver, unresolved conflict or merge decision needs approval.
The longer runtime is safe because ownership and evidence are concrete enough for the controller to handle the hand-offs I had been doing myself. It has no wider scope to make judgement calls. Beads records the approved work, provider sessions carry out bounded assignments, and the controller only advances the graph while it can prove that it still owns the next transition.
That has been the useful change for me: I can close the chat or step away without becoming part of the workflow state. When the controller cannot prove its next transition, it stops and tells me exactly what needs a decision.
Agentflow is currently private. The CLI examples describe the tested interface rather than a public installer. Model catalogues and command behaviour can change, so inspect the current policy and help output before adapting the design.