Status: Implemented
Author: Mohamed Ameen
Date: 2026-04-17
Last Updated: 2026-04-17
Reviewers: --
Package: src/state/
Entry Point: Library-only (consumed by orchestrator/, tournament/, and CLI commands)
The Durable State Layer provides crash-safe, append-only persistence for all AutoDev runtime state. It stores the execution plan, task status transitions, evidence bundles, and audit history in a tamper-evident ledger with CAS (Content-Addressable Storage) hash chaining. Every mutation is atomic and every state can be reconstructed by replaying the ledger from genesis or from the latest snapshot.
In scope:
- Append-only JSONL ledger with SHA-256 hash chaining (
prev_hash -> self_hash) - Atomic writes via
tempfile + os.replacepattern - Async-safe file locking via
filelockwiththread_local=False - Ledger replay for plan state reconstruction with snapshot shortcutting
PlanManagerfacade for all plan CRUD operations- Evidence bundle I/O (discriminated union, atomic writes)
- Centralized
.autodev/filesystem layout constants - Pydantic v2 schemas with
extra="forbid"for all boundary data
Out of scope:
- Knowledge store (Phase 9 -- lives in
state/knowledge.pybut is a separate design) - Session event streams (
state/sessions/is path-defined but session management is separate) - Config file loading (lives in
src/config/)
The state layer is the persistence backbone of AutoDev:
orchestrator -> state/plan_manager -> state/ledger -> .autodev/plan-ledger.jsonl
-> state/evidence -> .autodev/evidence/
-> state/lockfile -> .autodev/.lock
All orchestrator components that read or mutate plan state do so through PlanManager, which acquires the file lock and appends to the ledger atomically. The ledger is the single source of truth -- plan.json is a convenience snapshot that can be reconstructed from ledger replay at any time.
- FR-1: All plan state mutations must be recorded as append-only ledger entries.
- FR-2: Ledger entries must form a hash chain (
entry[n].prev_hash == entry[n-1].self_hash) that is verifiable during replay. - FR-3: Ledger replay must reconstruct the current plan state from the full entry history.
- FR-4: Snapshot entries must allow replay to short-circuit from the latest snapshot rather than replaying from genesis.
- FR-5: Evidence bundles must be persisted as individually addressable JSON files keyed by
(task_id, kind). - FR-6: All state writes must be atomic -- a crash at any point must not corrupt existing state.
- FR-7: Concurrent access (multiple orchestrator instances or asyncio tasks) must be serialized via file locking.
- Crash-safety: Every write uses the
tempfile + os.replacepattern. The ledger append copies the entire existing file to a temp file, appends the new line, then atomically replaces. No partial writes are ever visible. - Asyncio concurrency:
plan_lockwrapsfilelock.FileLock.acquire()/.release()inasyncio.to_thread()so the event loop is never blocked.thread_local=Falseensures concurrent asyncio tasks compete on the on-disk lock correctly. - Pydantic v2 strict validation: All schemas use
ConfigDict(extra="forbid"). Every ledger entry is validated throughLedgerEntry.model_validate()on read. Evidence bundles useTypeAdapter(Evidence)for discriminated union deserialization. - Tamper evidence: A broken
prev_hashorself_hashraisesLedgerCorruptErrorduring replay, making it impossible to silently drop or reorder entries. - Maintainability: All disk paths are defined in
state/paths.py. All logging usesstructlog.
- Must run on Python 3.11+ with no compiled extensions.
- Must work within a single-machine, single-user context (no distributed locking).
- The lock file (
.autodev/.lock) is safe to leave between runs;filelockhandles stale locks. - Ledger file can grow unbounded in theory; snapshots mitigate replay cost.
flowchart TB
subgraph "Public API (PlanManager)"
PM_load["load()"]
PM_init["init_plan()"]
PM_save["save()"]
PM_status["update_task_status()"]
PM_retry["mark_task_retry()"]
PM_esc["mark_escalated()"]
PM_next["next_pending_task()"]
end
subgraph "Lock Layer"
LOCK["plan_lock(cwd)"]
end
subgraph "Ledger Layer"
AE["append_entry()"]
RE["read_entries()"]
RP["replay_ledger()"]
SP["snapshot_plan()"]
end
subgraph "Disk"
JSONL[".autodev/plan-ledger.jsonl"]
PLAN[".autodev/plan.json"]
EVID[".autodev/evidence/*.json"]
end
PM_load --> LOCK --> RE --> JSONL
PM_init --> LOCK --> AE --> JSONL
PM_init --> SP --> PLAN
PM_save --> LOCK --> AE
PM_save --> SP
PM_status --> LOCK --> AE
PM_status --> SP
| File | Purpose |
|---|---|
state/paths.py |
Centralized .autodev/ layout constants and path builder functions |
state/schemas.py |
Pydantic v2 models: Plan, Phase, Task, AcceptanceCriterion, TaskStatus, Evidence discriminated union |
state/lockfile.py |
plan_lock() async context manager wrapping filelock.FileLock |
state/ledger.py |
LedgerEntry model, append_entry(), read_entries(), replay_ledger(), snapshot_plan(), compute_hash() |
state/plan_manager.py |
PlanManager facade: load(), init_plan(), save(), update_task_status(), next_pending_task(), etc. |
state/evidence.py |
write_evidence(), read_evidence(), list_evidence(), write_patch() |
state/__init__.py |
Re-exports all public symbols |
Ledger Entry:
LedgerOp = Literal[
"init_plan",
"update_plan",
"update_task_status",
"append_evidence",
"mark_blocked",
"mark_complete",
"snapshot",
"plan_tournament_complete",
"impl_tournament_complete",
]
class LedgerEntry(BaseModel):
model_config = ConfigDict(extra="forbid")
seq: int # monotonically increasing, starts at 1
timestamp: str # ISO 8601, UTC
session_id: str
op: LedgerOp
payload: dict[str, Any] # op-specific data
prev_hash: str # self_hash of entry[n-1]; "" for genesis
self_hash: str # SHA-256 prefix (16 hex chars) of all other fieldsPlan/Phase/Task:
TaskStatus = Literal[
"pending", "in_progress", "coded", "auto_gated", "reviewed",
"tested", "tournamented", "complete", "blocked", "skipped",
]
class AcceptanceCriterion(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
description: str
met: bool = False
class Task(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str # "1.1", "1.2", "2.1"
phase_id: str # "1", "2", "3"
title: str
description: str
status: TaskStatus = "pending"
files: list[str] = []
acceptance: list[AcceptanceCriterion] = []
depends_on: list[str] = []
retry_count: int = 0
escalated: bool = False
assigned_agent: str | None = None
evidence_bundle: str | None = None
blocked_reason: str | None = None
metadata: dict[str, Any] = {}
class Phase(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str # "1", "2", "3"
title: str
description: str = ""
tasks: list[Task]
class Plan(BaseModel):
model_config = ConfigDict(extra="forbid")
plan_id: str
spec_hash: str
phases: list[Phase]
metadata: dict[str, Any] = {}
created_at: str
updated_at: str
content_hash: str = ""Evidence Discriminated Union:
Evidence = Annotated[
Union[
CoderEvidence, # kind="developer"
ReviewEvidence, # kind="review"
TestEvidence, # kind="test"
ExploreEvidence, # kind="explore"
SMEEvidence, # kind="domain_expert"
CriticEvidence, # kind="critic"
TournamentEvidence, # kind="tournament"
],
Field(discriminator="kind"),
]Each variant carries task_id and a kind literal discriminator. Notable fields:
| Variant | Key Fields |
|---|---|
CoderEvidence |
diff, files_changed, output_text, duration_s, success |
ReviewEvidence |
verdict (APPROVED/NEEDS_CHANGES/REJECTED), issues |
TestEvidence |
passed, failed, total, coverage_pct |
ExploreEvidence |
findings, files_referenced |
SMEEvidence |
topic, findings, confidence (HIGH/MEDIUM/LOW) |
CriticEvidence |
verdict (APPROVED/NEEDS_REVISION/REJECTED), issues |
TournamentEvidence |
tournament_id, phase, passes, winner, converged, history |
The Task FSM captures the lifecycle of each task through the execute phase:
stateDiagram-v2
[*] --> pending
pending --> in_progress : execute_phase starts task
in_progress --> coded : developer succeeds
in_progress --> blocked : guardrail exceeded / escalated
coded --> auto_gated : QA gates pass
coded --> in_progress : QA gates fail (retry)
auto_gated --> reviewed : reviewer APPROVED
auto_gated --> in_progress : reviewer NEEDS_CHANGES (retry)
reviewed --> tested : tests pass
reviewed --> in_progress : tests fail (retry)
tested --> tournamented : impl tournament complete (or skipped)
tournamented --> complete : mark complete
in_progress --> blocked : retry exhausted + escalated
pending --> skipped : task skipped
The state layer does not define runtime-checkable protocols. It exposes concrete classes and functions consumed by the orchestrator and tournament engine.
PlanManager:
| Method | Description |
|---|---|
async load() -> Plan | None |
Load plan via snapshot fast-path or full replay |
async init_plan(plan) -> Plan |
Initialize a new plan (fails if one exists) |
async save(plan) -> Plan |
Overwrite plan wholesale (appends update_plan + snapshot) |
async get_task(task_id) -> Task | None |
Retrieve a single task by ID |
async next_pending_task() -> Task | None |
Return first task with status=pending (phase-major order) |
async update_task_status(task_id, status, meta) -> Task |
Transition task status with validation |
async mark_task_retry(task_id) -> int |
Increment retry count, return new count |
async mark_escalated(task_id) -> None |
Flag task as escalated |
async read_ledger() -> list[LedgerEntry] |
Read all validated entries (for debugging/CLI) |
async ledger_append(op, payload) -> LedgerEntry |
Append an arbitrary audit-only entry |
Ledger functions:
| Function | Description |
|---|---|
async append_entry(cwd, op, payload, session_id) |
Append one entry, computing seq and hashes |
read_entries(cwd) -> list[LedgerEntry] |
Read + validate entire chain |
replay_ledger(cwd) -> (Plan | None, list[LedgerEntry]) |
Full replay from genesis |
async snapshot_plan(cwd, plan, session_id) |
Atomic write of plan.json + snapshot ledger entry |
compute_hash(entry_dict) -> str |
16-char SHA-256 prefix of canonical JSON |
Evidence functions:
| Function | Description |
|---|---|
async write_evidence(cwd, task_id, evidence) -> Path |
Atomic write to evidence/{task_id}-{kind}.json |
async read_evidence(cwd, task_id, kind) -> Evidence | None |
Read and validate |
async list_evidence(cwd, task_id) -> list[Evidence] |
All evidence for a task |
async write_patch(cwd, task_id, diff) -> Path |
Atomic write to evidence/{task_id}.patch |
| Decision | Rationale | Alternatives Considered |
|---|---|---|
| Append-only JSONL with CAS hash chaining | Provides tamper evidence, crash safety (partial writes detectable), and full auditability. Replay enables time-travel debugging. | SQLite, single JSON file with versioning |
| 16-char SHA-256 prefix for hashes | Sufficient collision resistance for local single-user use (2^64 space). Shorter hashes are more readable in logs and diffs. | Full 64-char SHA-256, MD5, UUID-based |
| Atomic append via full-file copy + replace | Ensures either the entire new ledger is visible or nothing changes. Resilient to kill -9 mid-write. | Direct fh.write() + fsync (risk of torn writes), journaling |
filelock with thread_local=False |
Concurrent asyncio tasks each run lock.acquire() in a to_thread worker. Without thread_local=False, filelock suppresses the OS lock call when the same thread has already acquired, breaking in-process concurrency. |
asyncio.Lock (only in-process, not cross-process), fcntl.flock (lower-level) |
| Snapshot ops for replay shortcutting | Full replay from genesis is O(N) in ledger size. Snapshots embed the current plan state so replay only needs to process entries after the latest snapshot. | Periodic compaction (destructive), separate checkpoint files |
Discriminated union for evidence (kind field) |
Single TypeAdapter(Evidence) dispatches to the correct subclass. Using kind instead of type avoids shadowing Python's type builtin. |
Separate files per evidence type, generic dict with schema version |
extra="forbid" on all boundary models |
Rejects unknown fields at deserialization time, catching schema drift and typos immediately. | extra="ignore" (silent data loss), extra="allow" (untyped passthrough) |
- Full-file copy on append: The atomic append strategy copies the entire existing ledger to a temp file for every append. This is O(N) in ledger size. For typical runs (hundreds of entries), this is negligible (~milliseconds). For very long-running projects with thousands of entries, snapshot compaction would help, but is not yet implemented.
- No concurrent writers:
plan_lockserializes all access. This is correct for single-machine use but would not scale to distributed orchestration. - Snapshot + ledger redundancy: Both
plan.jsonand the ledger contain the plan state. This is intentional:plan.jsonis a fast-load convenience; the ledger is the authoritative source. A crash between writingplan.jsonand the snapshot ledger entry is harmless because the next successful snapshot reconciles them.
Hash chain computation:
def compute_hash(entry_dict_without_hash: dict[str, Any]) -> str:
canon = json.dumps(entry_dict_without_hash, sort_keys=True)
return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]The entry is serialized to canonical JSON (sort_keys=True) with the self_hash field excluded. The resulting 16-char hex prefix is stored in self_hash and becomes the prev_hash for the next entry.
Chain validation during read_entries:
- For each entry, verify
seq == prev_seq + 1(monotonic). - Verify
entry.prev_hash == previous_entry.self_hash(chain link). - Recompute
compute_hash(entry without self_hash)and verify it matchesentry.self_hash(integrity). - Any violation raises
LedgerCorruptErrorwith the line number and expected vs. actual values.
Ledger replay (replay_ledger):
Applies ops in order. Key op handlers:
init_plan/update_plan/snapshot: Replace the in-memory plan wholesale frompayload.plan.update_task_status: Find the task by ID and mutate itsstatus,blocked_reason,retry_count,escalated,evidence_bundle.mark_blocked/mark_complete: Terminal status transitions.append_evidence: Record the evidence path on the task.plan_tournament_complete/impl_tournament_complete: Audit-only, no plan mutation.
Snapshot fast-path in PlanManager.load:
- Walk entries backwards to find the latest
snapshotop. - Deserialize the plan from the snapshot's payload.
- Apply only the entries after the snapshot index.
- This is O(M) where M is entries since last snapshot, typically 0-10.
@contextlib.asynccontextmanager
async def plan_lock(cwd: Path, timeout_s: float = 30.0) -> AsyncIterator[None]:
ensure_autodev_dir(cwd)
lock = FileLock(str(lock_path(cwd)), timeout=timeout_s, thread_local=False)
try:
await asyncio.to_thread(lock.acquire)
except Timeout as exc:
raise PlanLockTimeoutError(
f"could not acquire .autodev/.lock within {timeout_s}s"
) from exc
try:
yield
finally:
await asyncio.to_thread(lock.release)Key design points:
asyncio.to_threadoffloads the blockinglock.acquire()to a thread pool worker, keeping the event loop responsive.thread_local=Falseis critical: without it, if two asyncio tasks happen to run theirto_threadworkers on the same thread pool thread,filelockwould skip the actual OS lock for the second caller, creating a data race.- Timeout defaults to 30 seconds.
PlanLockTimeoutErroris raised on timeout so the caller can surface a clear error.
All state writes follow this pattern:
def _atomic_append(path: Path, line: str) -> None:
parent = path.parent
parent.mkdir(parents=True, exist_ok=True)
existing = path.read_bytes() if path.exists() else b""
fd, tmp_path = tempfile.mkstemp(prefix=".ledger.", suffix=".tmp", dir=str(parent))
try:
with os.fdopen(fd, "wb") as fh:
fh.write(existing)
fh.write(line.encode("utf-8"))
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_path, path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raiseThe os.fsync call ensures data is flushed to disk before os.replace. The temp file is always in the same directory as the target so os.replace is atomic on POSIX (same filesystem).
Evidence writes use the same pattern:
def _atomic_write(path: Path, data: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".evidence.", suffix=".tmp", dir=str(path.parent))
try:
with os.fdopen(fd, "wb") as fh:
fh.write(data)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raiseException hierarchy:
LedgerCorruptError(AutodevError)-- integrity violation during replay (broken chain, bad JSON, missing fields, unknown op).PlanConcurrentModificationError(AutodevError)-- plan mutation with stale state (e.g.,init_planwhen plan already exists,update_task_statusfor unknown task).PlanLockTimeoutError(AutodevError)-- could not acquire.autodev/.lockwithin timeout.
Recovery from corruption:
The error messages include specific guidance:
- Malformed last line: "Manual recovery required -- inspect the trailing line of {path} and either remove it or restore from a snapshot."
- Chain break: Includes expected vs. actual hash values and line number.
- filelock: Cross-process file locking via
FileLock. - pydantic: All schema models,
model_validate,model_dump,TypeAdapterfor evidence union. - structlog: Structured logging throughout.
- Internal:
src/errorsfor exception types,src/autologgingfor logger factory.
The state layer itself has no configuration -- it operates on the .autodev/ directory relative to the provided cwd. The PlanManager accepts a lock_timeout_s parameter (default 30s).
Related config in .autodev/config.json:
qa_retry_limit: int = 3-- governs how many times a task can be retried (used by the orchestrator, reflected in ledger entries).
| Component | Dependency |
|---|---|
src/errors.py |
LedgerCorruptError, PlanConcurrentModificationError, AutodevError |
src/autologging.py |
get_logger() factory |
src/orchestrator/task_state.py |
assert_transition() for FSM validation (imported lazily in update_task_status) |
The state layer does not depend on any adapter. It is a pure persistence layer.
All events emitted to the ledger:
| Op | Payload Keys | Mutates Plan? |
|---|---|---|
init_plan |
plan (full Plan dict) |
Yes (sets initial) |
update_plan |
plan (full Plan dict) |
Yes (replaces) |
snapshot |
plan (full Plan dict) |
Yes (replaces) |
update_task_status |
task_id, status, optional blocked_reason, retry_count, escalated, evidence_bundle |
Yes (mutates task) |
mark_blocked |
task_id, optional reason |
Yes (sets blocked) |
mark_complete |
task_id |
Yes (sets complete) |
append_evidence |
task_id, path |
Yes (sets evidence_bundle) |
plan_tournament_complete |
varies | No (audit-only) |
impl_tournament_complete |
varies | No (audit-only) |
| Consumer | Usage |
|---|---|
orchestrator/plan_phase.py |
PlanManager.init_plan(), .save() |
orchestrator/execute_phase.py |
PlanManager.update_task_status(), .next_pending_task(), .mark_task_retry(), .mark_escalated() |
orchestrator/impl_tournament_runner.py |
PlanManager.ledger_append() for audit entries |
tournament/state.py |
Separate artifact store; does not use ledger directly |
CLI status command |
PlanManager.load(), .read_ledger() |
- Filesystem: All state lives under
.autodev/in the repository root. - No network dependencies.
LedgerEntryround-trip serialization (model_validate -> model_dump -> model_validate).compute_hashdeterminism (same input -> same hash, different input -> different hash).read_entrieschain validation (corrupt prev_hash, corrupt self_hash, non-monotonic seq, malformed JSON).replay_ledgerwith each op type.PlanManager.loadsnapshot fast-path vs. full replay.PlanManager.update_task_statusFSM validation (invalid transitions rejected).- Evidence round-trip via
TypeAdapter(Evidence). plan_locktimeout behavior.
- Full lifecycle:
init_plan-> multipleupdate_task_status->snapshot-> crash-simulate ->loadrecovers correct state. - Concurrent
PlanManagerinstances competing for the lock. - Evidence
write_evidence->read_evidence->list_evidenceround-trip. - Ledger corruption recovery: truncate last line, verify
read_entriesraisesLedgerCorruptError.
- Hypothesis strategy for
TaskStatus: any valid transition sequence produces a valid final state. - Hypothesis strategy for ledger entries: any sequence of valid ops, when replayed, produces a plan consistent with the ops applied.
- Fuzz
compute_hashinput to verify no collisions in reasonable sample sizes.
- Fixture
Planwith multiple phases and tasks. - Fixture ledger JSONL files with known hash chains (for replay tests).
- Corrupt ledger fixtures (bad JSON, broken chain, missing fields).
- Tamper evidence: The hash chain detects any modification to historical entries. This is not cryptographic security (no signing), but it catches accidental corruption and naive tampering.
- File permissions:
.autodev/inherits the repository's file permissions. The lock file is safe to leave between runs. - No secrets in state: Evidence bundles may contain diff text but should never contain API keys or credentials (the secret scan QA gate runs before evidence is persisted).
- Ledger append: O(N) in ledger size due to full-file copy. Acceptable for typical use (hundreds of entries). Mitigated by snapshot shortcutting during load.
- Lock contention: Default 30s timeout is generous for single-user use. Multi-process contention (multiple
autodevinstances on the same repo) is serialized correctly but may cause wait times. - Replay cost: Full replay from genesis is O(N). With snapshots, load is O(M) where M = entries since last snapshot.
PlanManager.save()andupdate_task_status()both emit snapshots, keeping M small. - Evidence I/O: Each evidence bundle is a small JSON file (~1-10KB). No batching needed.
The state layer is an internal library package under src/state/. It has no standalone CLI entry points.
State is accessed indirectly through:
autodev status # reads plan via PlanManager.load(), displays task statuses
autodev run # orchestrator uses PlanManager throughout execution.autodev/
config.json # project configuration
spec.md # project specification
plan.json # convenience snapshot of current plan
plan-ledger.jsonl # append-only ledger (source of truth)
.lock # filelock sentinel
evidence/
1.1-developer.json # CoderEvidence for task 1.1
1.1-review.json # ReviewEvidence for task 1.1
1.1-test.json # TestEvidence for task 1.1
1.1.patch # raw unified diff for task 1.1
1.1-tournament.json # TournamentEvidence for task 1.1
tournaments/
{tournament_id}/ # per-tournament artifact store
sessions/
{session_id}/
events.jsonl
snapshot.json
delegations/ # inline adapter delegation files
responses/ # inline adapter response files
knowledge.jsonl # project-level learnings
rejected_lessons.jsonl # rejected knowledge entries
| Event | Key Fields | Description |
|---|---|---|
ledger.append |
op, seq, session_id |
Every ledger write |
plan.initialized |
plan_id |
First plan created |
plan.saved |
plan_id |
Plan overwritten |
task.status_updated |
task_id, status, retry, escalated |
Task FSM transition |
evidence.write |
task_id, kind, path |
Evidence persisted |
evidence.patch_written |
task_id, path |
Diff patch persisted |
The ledger itself is the primary audit artifact. Each JSONL line contains:
- Sequence number and timestamp for ordering.
- Session ID for attribution.
- Op type and full payload for reconstruction.
- Hash chain for integrity verification.
Evidence bundles under .autodev/evidence/ provide per-task audit trails: what the developer produced, what the reviewer said, test results, tournament outcomes.
autodev status displays:
- Plan ID and spec hash.
- Phase/task tree with current statuses.
- Retry counts and escalation flags.
- Ledger entry count and last op timestamp.
The state layer itself has zero LLM cost. It is a pure persistence layer. LLM costs are incurred by the orchestrator and tournament engine that write to the state layer.
| Operation | LLM Calls | Notes |
|---|---|---|
| Any state operation | 0 | Pure I/O |
- Ledger compaction: periodically rewrite the ledger as a single snapshot entry, discarding historical entries (with optional archive).
- Staleness detection: compare
plan.jsoncontent hash against ledger tail to detect external modifications. - Plan diff rendering: derive
plan.mdfrom Plan JSON for human-readable diffs. - Auto-migration: version the ledger format and migrate old entries on replay.
- Distributed locking: replace
filelockwith a coordination service for multi-machine orchestration.
- Should the ledger support compaction (rewriting as a single snapshot) to bound file size?
- Should evidence bundles be embedded in ledger entries rather than stored as separate files?
- Is the 30s lock timeout appropriate for all deployment scenarios?
- ADR-002: Append-only JSONL with CAS hash chaining
| Date | Author | Changes |
|---|---|---|
| 2026-04-17 | Mohamed Ameen | Initial draft |