State machine
Everything ferrus does is modeled as an explicit state machine. There is
no hidden context — the current status of every task lives as a row in
ferrus.db (SQLite), and agents are stateless between spawns.
The old single global .ferrus/STATE.json is gone. Every SQLite task row
still follows the same Supervisor–Executor lifecycle, just per task instead
of per project — with max_parallel_tasks = 1 the runtime behaves exactly
like the original single-task workflow, only DB-backed. With a higher
max_parallel_tasks, multiple task rows advance through this same machine
independently and concurrently.
States and transitions
SQLite stores status as a lowercase string (pending, executing, …); this
guide capitalizes them for readability.
pending
└─► executing ← wait_for_task claim
├─► addressing ← reject (Supervisor) → work loop
├─► consultation ← consult (Executor)
│ └─► (restore paused status) ← wait_for_consult
├─► awaiting_human ← ask_human
│ └─► (restore paused status) ← wait_for_answer
├─► reviewing ← submit (final check passed)
│ ├─► addressing ← reject → work loop
│ └─► complete ← approve
└─► failed ← check / submit / reject hits retry or cycle limit
Every transition is explicit and gated by a tool call from the right
role — supervisors can't submit, executors can't approve.
State catalog
| Status | Meaning |
|---|---|
pending | Task queued (via enqueue_task from /task or /run) but not yet claimed by an executor. |
executing | Executor is implementing the task. |
addressing | Reviewer rejected — executor is fixing. |
consultation | Executor paused to ask the supervisor a question via consult. |
awaiting_human | Agent paused to ask a human a question via ask_human. |
reviewing | Executor submitted work; supervisor is reading the submission. |
complete | Supervisor approved the submission. Terminal — not resettable. |
failed | Retries, review cycles, or executor-dispatch budget exhausted. /reset required to clear. |
reset | Task was force-reset via /reset; its scoped artifacts were cleared. |
Consultation and human-answer flows store their paused status and requester
metadata directly in SQLite, with the request/response artifacts scoped
under .ferrus/runs/<task-id>/.
The SQLite scheduler
HQ runs a background scheduler tick (every 2 seconds) that claims pending
task rows and dispatches executors for them, bounded by max_parallel_tasks
concurrent sessions. /task queues one task and lets the scheduler pick it
up; /run [--limit N] queues every ready milestone in the selected spec (or
up to --limit) in one shot so the scheduler can work through several tasks
in parallel.
Pinned repository views
If the optional repository graph is enabled, each task also carries a repository view reference: the immutable baseline snapshot matching the task's pinned Ferrus baseline Git tree, plus an overlay derived from that worktree's changed, added, and deleted files.
This keeps structural context as isolated as the worktree itself. A newer canonical snapshot does not make a running task's baseline stale; only that task's own edits invalidate its overlay. On submission the view is frozen alongside its Git tree, so the reviewer reads exactly what was submitted.
Indexing is never a correctness dependency of this state machine. If a view is missing, stale, or failed, the tools say so and the agent falls back to reading source — dispatch, checks, submission, review, and approval all proceed regardless.
Retry and review budgets
Three counters guard the loop, all configured in ferrus.toml under
[limits] (see Configuration):
max_check_retries— consecutive check failures. Reset to 0 on each successful pass and on each rejected submission. Exhausting this budget moves the task toFailed.max_review_cycles— number of reject → fix round trips. Exhausting this budget also moves the task toFailed.max_executor_dispatches— how many times HQ will (re)spawn an executor for one task within a single work phase before giving up. Bounds the respawn loop when a session hits its turn limit and exits without submitting. Resets on each fresh rejection back toAddressing.
Crash safety
Task rows, leases, and counters are written to SQLite transactionally, so a crash mid-write never leaves runtime state corrupt.
If an agent process dies, the lease attached to its task row expires
after lease.ttl_secs. On HQ startup — and on demand via ferrus recover
— dead running rows whose PIDs are gone are marked interrupted, leases
still backed by a live run are preserved, other expired leases are
released, and paused (consultation / human-answer) flows are reconciled.
You can then /resume the executor headlessly and the loop picks up
exactly where it left off.
Leases
Only one executor works on a given task at a time. When an executor claims a
task, it writes its identity (executor:codex:1) into the task's
claimed_by column and starts calling heartbeat every
heartbeat_interval_secs. If heartbeats stop for longer than ttl_secs,
the claim is considered dead and can be taken by a new process.
This is the mechanism that makes ferrus restart-safe: crashes, terminal closes, or power losses don't leave a task stuck.
Why a state machine, not a chat?
Chat-based agents are stateful by accident — context accumulates in a conversation window and any crash or restart loses it. ferrus flips this:
- State lives in SQLite, queryable and transactional.
- Agents are stateless between runs; each spawn is handed a short prompt that points to the runtime context and files it needs.
- Lifecycle is explicit:
ferrus tasks list,ferrus runs list, andferrus events list(or/tasks,/runs,/eventsfrom HQ) show exactly what's happening, for every task at once.
This is what "deterministic orchestration" means in practice — not that the agents are deterministic, but that ferrus is.