State Machine (Reference)¶
Audience. Engineers debugging execution flow, writing engine tests, or implementing alternative drivers. This page enumerates every action type, every state transition, and every persistence touchpoint that the engine performs. For why the design is shaped this way, see ../architecture.md. For the driving CLI loop, see ../engine-and-runtime.md.
1. The action enum¶
ActionType is defined at
agent_baton/models/execution.py:60.
It is an enum.Enum; values are lowercase strings used in JSON and on
the wire.
| Action | String | Meaning |
|---|---|---|
DISPATCH |
"dispatch" |
Spawn a subagent (or run an automation step). Carries agent_name, agent_model, step_id, delegation_prompt. |
GATE |
"gate" |
Run a QA gate (test/build/lint/spec/review/ci). Carries gate_type, gate_command, phase_id. |
APPROVAL |
"approval" |
Pause for human approval. Carries phase_id, approval_context, approval_options. |
FEEDBACK |
"feedback" |
Present multiple-choice questions; the chosen option dispatches a follow-up step. Carries phase_id, feedback_context, feedback_questions. |
INTERACT |
"interact" |
Multi-turn agent dialogue: the agent asked a clarifying question; pause for human reply. Carries interact_step_id, interact_agent_name, interact_turn, interact_max_turns, interact_prompt. |
WAIT |
"wait" |
Parallel steps still in flight; caller should poll next_action() again. |
COMPLETE |
"complete" |
Execution finished successfully. Carries summary. |
FAILED |
"failed" |
Execution cannot continue. Carries summary. |
CHECKPOINT |
"checkpoint" |
Save state and suggest a fresh session to prevent context rot. |
The enum is consumed by _print_action() in
cli/commands/execution/execute.py:568
and by TaskWorker in
core/runtime/worker.py.
2. Step and phase status enums¶
Two more enums in the same module track per-step and per-phase progress
inside ExecutionState. Their values appear in step_results and the
phase progression of current_phase.
StepStatus (models/execution.py:37):
| Value | When set |
|---|---|
pending |
Default; step has not yet been dispatched |
running |
Dispatched; agent is in flight |
complete |
record_step_result(status="complete") was called |
failed |
record_step_result(status="failed") was called |
skipped |
Step intentionally bypassed (e.g. amendment) |
interrupted |
Worker shutdown caught a signal mid-step |
interacting |
Agent emitted INTERACT; awaiting human reply |
interact_dispatched |
Human replied; agent re-dispatched for next turn |
PhaseStatus (models/execution.py:50):
| Value | When set |
|---|---|
pending |
Phase not yet started |
running |
At least one step dispatched |
gate_pending |
All steps complete; gate not yet run |
complete |
Gate passed (or no gate); phase advanced |
failed |
Gate failed past retry cap |
ExecutionState.status is a free-form string with the canonical values
running, gate_pending, approval_pending, feedback_pending,
complete, failed, cancelled, paused-takeover, budget_exceeded
(see models/execution.py:1323).
3. Transition table¶
Each row describes a single engine call; the action returned is what the
caller does next. Transitions occur in
core/engine/executor.py.
| Caller invokes | Engine method | Inspects | Returns / next action |
|---|---|---|---|
baton execute start |
start(plan) (executor.py:1301) |
New plan | DISPATCH for first ready step, or COMPLETE if plan is empty |
baton execute next |
next_action() (executor.py:1503) |
Phase, deps, in-flight set | DISPATCH / GATE / APPROVAL / FEEDBACK / INTERACT / WAIT / COMPLETE / FAILED |
| (parallel poll) | next_actions() (executor.py:1539) |
All ready steps | list[DISPATCH] (one per dispatchable step) |
baton execute dispatched |
mark_dispatched(step_id, agent_name) |
Step exists | (none — state mutation only) |
baton execute record |
record_step_result(...) (executor.py:1730) |
Outcome text | (none — parses KNOWLEDGE_GAP, BEAD_*, fires events) |
baton execute gate |
record_gate_result(phase_id, passed, output) (executor.py:2454) |
Pass/fail | (none — advances phase or sets failed) |
baton execute approve |
record_approval_result(phase_id, result, feedback) (protocols.py:132) |
approve / reject / approve-with-feedback |
(none — approve resumes; reject fails; feedback inserts amendment) |
baton execute feedback |
record_feedback_result(phase_id, question_id, chosen_index) |
Chosen option | (none — inserts dispatch step via amendment) |
baton execute amend |
amend_plan(...) (protocols.py:170) |
New phases/steps | Returns PlanAmendment |
baton execute team-record |
record_team_member_result(...) (protocols.py:204) |
Member outcome | (none — when last member done, parent step completes) |
baton execute interact |
provide_interact_input(...) / complete_interaction(...) (protocols.py:261, 282) |
Human input | (none — flips status to interact_dispatched or complete) |
baton execute resume |
resume() (executor.py:3440) |
On-disk state | The action that was pending when the session crashed |
baton execute complete |
complete() (executor.py:3121) |
Final state | Returns summary string; writes trace, usage, retro |
baton execute status |
status() |
State | Returns dict (task_id, status, progress, gates) |
The full method list lives on ExecutionDriver
(core/engine/protocols.py:22)
— 15 methods. TaskWorker.__init__ accepts engine: ExecutionDriver,
not the concrete ExecutionEngine, so any protocol-conforming object can
drive a worker (ADR-03).
4. Phase progression¶
A plan is a list of PlanPhase. The engine advances phases sequentially:
phase 0
step 1.1 -> step 1.2 -> ... -> all complete
(optional) APPROVAL
(optional) GATE
passed? yes -> advance to phase 1
no -> retry (up to _max_gate_retries=3) or FAIL
phase 1
...
phase N
complete() -> writes trace + usage + retro -> auto-sync
Within a phase, steps may run in parallel. next_actions() returns every
step whose depends_on is satisfied and that is not already dispatched,
complete, or failed. The caller can launch all of them concurrently;
StepScheduler (core/runtime/scheduler.py)
caps simultaneous launches at max_concurrent (default 3).
When a phase has an approval_required: true flag and a gate, the
engine emits APPROVAL first, then GATE. Either failing fails the
phase.
Gate command extension¶
The planned gate.command is the baseline. Before emitting the GATE
action, the engine inspects the files the agent created or modified
during the phase via
ArtifactValidator.
Recognised runnable artifacts — .github/workflows/*.yml (run:
steps), gate-worthy package.json scripts (test, test:*, lint,
typecheck, audit), playwright.config.*, Makefile targets
(test, lint, typecheck, check, audit, ci), and
.pre-commit-config.yaml — contribute extra shell commands that are
appended to the planned command, chained with &&. The phase only
passes when both the planned gate and every derived command exit zero;
the GATE action's message field carries bracket suffixes
([+artifact checks: ...] / [+agent additions: ...]) so the
orchestrator can attribute a failure to the offending artifact or
agent-declared check. Disable artifact derivation by setting
BATON_ARTIFACT_VALIDATION=0 (see the env-vars table in
the root CLAUDE.md).
GATE_ADDITION: signal. An agent may declare additional gate
commands by including lines of the form GATE_ADDITION: <command> in
its step outcome. The engine collects these across all steps in the
phase (via _phase_step_extensions in executor.py) and appends them
to gate_command after artifact-derived commands, also chained with
&&. Up to 8 additions per step are accepted; excess, unsafe (shell
metacharacters), and destructive-pattern commands are silently dropped
by the command-safety layer in
_command_safety.py.
Maximum command length is 256 characters.
Structured provenance fields. The GATE action carries two optional
fields — derived_commands (list of {command, source_file, rationale}
dicts) and agent_additions (list of strings) — that expose structured
attribution without requiring callers to re-parse gate_command. These
same fields are persisted on GateResult (recorded at
record_gate_result() time) and included in the compliance audit entry
(derived_commands_count, agent_additions_count) so regulated
investigations can query which gates ran with extensions without parsing
the concatenated command.
5. Persistence touchpoints¶
State is written to disk after every mutation. A single record_*
call may trigger several writes; failures are logged but do not raise
through to the caller (graceful degradation, rule 5).
| Mutation point | What gets written | Where |
|---|---|---|
start() |
ExecutionState (full) |
SQLite executions + JSON execution-state.json |
mark_dispatched() |
step_results row, status=dispatched |
SQLite step_results + JSON |
record_step_result() |
step_results row, status=final |
SQLite step_results + JSON |
record_step_result() (side) |
parsed beads → BdBeadStore.write() |
external bd workspace (.beads/) |
record_step_result() (side) |
parsed knowledge gap → pending_gaps |
JSON state |
record_step_result() (side) |
event publish | EventBus subscribers (incl. EventPersistence JSONL) |
record_gate_result() |
gate_results row |
SQLite gate_results + JSON |
record_approval_result() |
approval_results row |
SQLite approval_results + JSON |
record_feedback_result() |
feedback_results + PlanAmendment |
SQLite feedback_results, amendments + JSON |
amend_plan() |
PlanAmendment, mutated plan |
SQLite amendments, plans + JSON |
complete() |
trace, usage, retro, consolidation_result, completed_at |
SQLite + traces/ + usage-log.jsonl + retrospectives/ |
Atomic-write contract:
- JSON files use tmp+rename
(
persistence.py:83). On Windows,Path.replace()retries up to 5× with 50 ms backoff to tolerate antivirus / search-indexer holds. - SQLite uses WAL mode with busy timeout
(
core/storage/connection.py). - Auto-sync to
central.dbruns aftercomplete()returns, wrapped intry/except. Sync failure never blocks completion.
The legacy flat path is <context_root>/execution-state.json. The
namespaced path is
<context_root>/executions/<task-id>/execution-state.json.
StatePersistence.set_task_id() (persistence.py:70) recomputes the
state path atomically — direct mutation of _task_id would leave the
path stale.
6. Crash recovery¶
On any crash mid-execution, the next CLI call is baton execute resume,
which runs engine.resume() (executor.py:3440). The engine:
- Loads
ExecutionStatefrom disk viaStatePersistence.load(). - Re-attaches the in-memory trace (zero-events; all prior events are in
the
events.jsonllog, replayable viaEventPersistence). - Calls
_determine_action()to recompute the action that was pending. - Returns it.
There is no "in-progress" step state to repair: a step is either
dispatched (return DISPATCH again — the agent will re-run; idempotency
is the agent's responsibility, enforced by the worktree-isolation
contract in core/engine/worktree_manager.py)
or recorded as terminal (engine moves on).
For human-in-the-loop recovery (takeover) and automated gate-retry,
see ../engine-and-runtime.md §5.
7. Where to find each rule¶
| Rule | Code |
|---|---|
| Action types are an enum, not strings | models/execution.py:60 |
Engine returns ExecutionAction, not raw dicts |
models/execution.py:1486 |
Driver contract is a Protocol |
core/engine/protocols.py:22 |
| State writes are atomic | core/engine/persistence.py:83 |
| WAL mode for SQLite | core/storage/connection.py |
| Auto-sync is best-effort | auto_sync_current_project() in core/storage/sync.py |
_print_action() is the wire format |
cli/commands/execution/execute.py:568 |
8. Goal-driven execution (G1)¶
When a plan is created with a completion_condition (baton plan --goal or
baton goal), the engine evaluates the goal at every gate-pass boundary
before advancing to the next phase. The evaluation is internal to
record_gate_result — no new ActionType is emitted and the
_print_action() wire format is unchanged.
gate passes
└─→ _evaluate_goal_after_gate(state, passed_phase_id, last_gate_passed=True)
├─ select_evaluator() (stub | Haiku | Opus, env-gated)
├─ evaluator.evaluate(state, plan, last_gate_passed=True)
├─ safety rail: if met=True but last_gate_passed=False → force met=False
├─ append GoalCheck to state.goal_checks
└─ branch:
met → goal_status = "met"
not_met, cycles_used < max, suggestions → inline amend_plan
cycles_used += 1
goal_status = "active"
not_met, cycles_used >= max → goal_status = "exhausted"
└─→ advance_phase
Termination is reached on goal_status == "met" (natural completion),
goal_status == "exhausted" (FAILED with reason), or
BATON_RUN_TOKEN_CEILING hit.
9. Team dispatch hooks (A2)¶
When _team_dispatch_action emits the parallel DISPATCH actions for a
team step, it also writes mailbox events to
.claude/team-context/mailbox/team-{step_id}.jsonl:
| When | Event |
|---|---|
| First dispatch of each member | task_created |
record_team_member_result(complete) |
task_completed |
record_team_member_result(failed) |
task_failed |
| Parent step finalises | teammate_idle per member |
request_team_member_plan_approval() |
plan_approval_requested |
decide_team_member_plan_approval() |
plan_approval_decided |
The mailbox is an append-only JSONL file retained past team teardown
(audit requirement). See
agent_baton/core/engine/mailbox.py.