Add durable state store and boot-time reconciliation (A2/A3)
Build-order step 1: the critical path everything else sits on. - StateStore: SQLite in WAL mode with synchronous=FULL, so every state transition and intent-log write is committed and fsynced before returning; a power cut cannot tear state - Durable intent log: begin_intent records a unit of work before it starts; commit/abort resolve it; pending intents survive restart - schema_version stamped from the first commit, with a sequential migration hook; a newer state file than the code supports is refused rather than misread - reconcile_on_boot (A3 first cut): pending intents are checked against external truth by registered reconcilers (observable checks, never model self-assessment); verified effects are committed, verified no-ops aborted, anything unverifiable gates to AWAIT_INPUT — never blind-resume - Lives in nisse_dev per spec 0.1: the library is extracted after the dev agent works, not designed up front - Integration test kills a subprocess mid-work with os._exit and verifies state integrity and intent visibility on reopen Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5962aaee57
commit
4319882f35
5 files changed
+516
No files matched your search
@@ -0,0 +1,53 @@
|
||||
"""Boot-time reconciliation (spec A3): turn an ambiguous crash into a safe,
|
||||
known state.
|
||||
|
||||
Every pending intent found at startup is checked against external truth by a
|
||||
registered reconciler — an observable check (query Gitea, inspect the tree),
|
||||
never model self-assessment. Anything unverifiable gates to AWAIT_INPUT;
|
||||
the agent never blind-resumes.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from nisse_dev.state import Intent, StateStore
|
||||
|
||||
# Returns True if the intent's external effect verifiably happened (do not
|
||||
# repeat it), False if it verifiably never started (safe to discard), or
|
||||
# None if it cannot be verified.
|
||||
Reconciler = Callable[[Intent], bool | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BootReport:
|
||||
action: Literal["resume", "await_input"]
|
||||
notes: list[str]
|
||||
|
||||
|
||||
def reconcile_on_boot(
|
||||
store: StateStore, reconcilers: Mapping[str, Reconciler]
|
||||
) -> BootReport:
|
||||
notes: list[str] = []
|
||||
unresolved = False
|
||||
for intent in store.pending_intents():
|
||||
label = f"intent {intent.id} ({intent.kind})"
|
||||
reconciler = reconcilers.get(intent.kind)
|
||||
if reconciler is None:
|
||||
unresolved = True
|
||||
notes.append(f"{label}: no reconciler registered")
|
||||
continue
|
||||
verdict = reconciler(intent)
|
||||
if verdict is True:
|
||||
store.commit_intent(intent.id)
|
||||
notes.append(f"{label}: external effect verified, marked committed")
|
||||
elif verdict is False:
|
||||
store.abort_intent(intent.id)
|
||||
notes.append(f"{label}: never took effect, marked aborted")
|
||||
else:
|
||||
unresolved = True
|
||||
notes.append(f"{label}: unverifiable")
|
||||
return BootReport(
|
||||
action="await_input" if unresolved else "resume",
|
||||
notes=notes,
|
||||
)
|
||||
Reference in new issue
Block a user