Implement A9 state-change logging in the state store
CI / checks (push) Successful in 9s
CI / checks (pull_request) Successful in 8s

Retrofit for the conventions merged in #4 (issue #3):

- OpLog: JSON-lines emitter with the fixed envelope (ts, agent,
  event, task_id, cause); task_id set by the orchestrator at
  unit-of-work start
- StateStore owns emission: transition, intent begin/commit/abort,
  and schema migrations each emit their event right after the
  durable write commits; callers pass the cause
- reconcile_on_boot emits boot.reconcile with action and verdicts,
  and resolves intents with reconciliation-verdict causes
- Failed resolutions emit nothing (a line exists iff the write
  committed)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dongarandClaude Fable 5 committed 2026-09-05 21:13:36 +00:00
1 parent ba848281ec
commit 18d4fcd701
7 files changed
+216 -32

No files matched your search

+39 -10
View File
@@ -18,6 +18,8 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Self
from nisse_dev.oplog import OpLog
CURRENT_SCHEMA_VERSION = 1
Migration = Callable[[sqlite3.Connection], None]
@@ -83,7 +85,11 @@ class StateStore:
*,
schema_version: int = CURRENT_SCHEMA_VERSION,
migrations: Mapping[int, Migration] = MIGRATIONS,
oplog: OpLog | None = None,
) -> None:
# Log emission is owned by the store (A9): the same code path that
# performs a durable write emits its log line, right after commit.
self.oplog = oplog if oplog is not None else OpLog("nisse-dev")
self._conn = sqlite3.connect(path, isolation_level=None)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
@@ -115,6 +121,12 @@ class StateStore:
f"no migration registered from schema version {version}"
)
self._in_transaction(lambda m=migrate, v=version: self._apply(m, v))
self.oplog.emit(
"schema.migrate",
cause="startup",
from_version=version,
to_version=version + 1,
)
def _create_schema(self) -> None:
for statement in _SCHEMA:
@@ -162,11 +174,13 @@ class StateStore:
self,
state: str,
*,
cause: str,
project: str | None = None,
data: dict[str, Any] | None = None,
) -> None:
"""Record the new state-machine position; the full position is passed
every time (no partial updates), committed and fsynced before return."""
previous = self.get_state()
self._conn.execute(
"""
INSERT INTO agent_state (id, state, project, data, updated_at)
@@ -179,30 +193,45 @@ class StateStore:
""",
(state, project, json.dumps(data or {}), _now()),
)
self.oplog.emit(
"state.transition",
cause=cause,
**{
"from": previous.state if previous else None,
"to": state,
"project": project,
},
)
def begin_intent(self, kind: str, payload: dict[str, Any]) -> int:
def begin_intent(self, kind: str, payload: dict[str, Any], *, cause: str) -> int:
"""Durably record that a unit of work is about to start."""
cursor = self._conn.execute(
"INSERT INTO intent_log (kind, payload, created_at) VALUES (?, ?, ?)",
(kind, json.dumps(payload), _now()),
)
assert cursor.lastrowid is not None
self.oplog.emit(
"intent.begin", cause=cause, intent_id=cursor.lastrowid, kind=kind
)
return cursor.lastrowid
def commit_intent(self, intent_id: int) -> None:
self._resolve_intent(intent_id, "committed")
def commit_intent(self, intent_id: int, *, cause: str) -> None:
self._resolve_intent(intent_id, "committed", "intent.commit", cause)
def abort_intent(self, intent_id: int) -> None:
self._resolve_intent(intent_id, "aborted")
def abort_intent(self, intent_id: int, *, cause: str) -> None:
self._resolve_intent(intent_id, "aborted", "intent.abort", cause)
def _resolve_intent(self, intent_id: int, status: str) -> None:
cursor = self._conn.execute(
def _resolve_intent(
self, intent_id: int, status: str, event: str, cause: str
) -> None:
row = self._conn.execute(
"UPDATE intent_log SET status = ?, resolved_at = ? "
"WHERE id = ? AND status = 'pending'",
"WHERE id = ? AND status = 'pending' RETURNING kind",
(status, _now(), intent_id),
)
if cursor.rowcount != 1:
).fetchone()
if row is None:
raise KeyError(f"no pending intent with id {intent_id}")
self.oplog.emit(event, cause=cause, intent_id=intent_id, kind=row["kind"])
def pending_intents(self) -> list[Intent]:
rows = self._conn.execute(