Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions skills/loop-engineering/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ immediately in the same invocation. Do not yield an intermediate result or ask
again. Yield only for a terminal outcome, user interruption, or real runtime
boundary.

If later evidence contradicts a recorded fact, use `annotate --evidence
"<correction>"`. Preserve the audit trail; do not reopen or hand-edit terminal
state.
If later evidence contradicts a recorded fact, capture `fingerprint --state
<state-file>`, then use `annotate --expect-sha256 <fingerprint> --evidence
"<correction>"`. Preserve the audit trail; do not reopen terminal state.

Use `finish` for `blocked`, `needs_human`, `cancelled`, or
`continue_scheduled`. Never translate those states or `budget_exhausted` into
Expand Down
4 changes: 4 additions & 0 deletions skills/loop-engineering/references/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,12 @@ If a fresh check later contradicts evidence in a saved terminal run, append the
correction and revalidate the state:

```bash
python3 <skill-dir>/scripts/loop_state.py fingerprint \
--state <state-file>
# Pass the exact printed value from the expected snapshot.
python3 <skill-dir>/scripts/loop_state.py annotate \
--state <state-file> \
--expect-sha256 <printed-sha256> \
--evidence "Correction: isolated rerun reproduced the timing failure"
python3 <skill-dir>/scripts/loop_state.py validate --state <state-file>
python3 <skill-dir>/scripts/loop_state.py show --state <state-file>
Expand Down
9 changes: 6 additions & 3 deletions skills/loop-engineering/references/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ the JSON.
declared units. Resumable outcomes require `--next-action`; `complete` and
`cancelled` reject that flag and clear the prior running action. Every
executed cycle must be accounted exactly once by `advance` or `finish`.
- `annotate` appends corrected evidence without reopening terminal state or
changing the consumed budget. It cannot add a next action to `complete` or
`cancelled`.
- `fingerprint` validates a state and prints the SHA-256 of its exact bytes.
- `annotate --expect-sha256 <fingerprint>` appends corrected evidence without
reopening terminal state or changing the consumed budget. Capture the
fingerprint after the last successful transition. A mismatch rejects the
write; verify state ownership instead of refreshing an unexpected mismatch.
Annotation cannot add a next action to `complete` or `cancelled`.
- `validate` checks the schema and transition invariants.
- `show` prints the five-field contract; `--json` returns the full history.

Expand Down
55 changes: 48 additions & 7 deletions skills/loop-engineering/scripts/loop_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,29 @@ def now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")


def read_state(path: pathlib.Path) -> dict[str, Any]:
def read_state_snapshot(path: pathlib.Path) -> tuple[dict[str, Any], bytes]:
try:
state = json.loads(path.read_text())
raw_state = path.read_bytes()
except FileNotFoundError as exc:
raise StateError(f"state file does not exist: {path}") from exc
try:
state = json.loads(raw_state)
except json.JSONDecodeError as exc:
raise StateError(f"state file is not valid JSON: {path}: {exc}") from exc
validate_state(state)
return state
return state, raw_state


def read_state(path: pathlib.Path) -> dict[str, Any]:
return read_state_snapshot(path)[0]


def write_state(path: pathlib.Path, state: dict[str, Any]) -> None:
def write_state(
path: pathlib.Path,
state: dict[str, Any],
*,
expect_sha256: str | None = None,
) -> None:
validate_state(state)
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary_name = tempfile.mkstemp(
Expand All @@ -67,6 +78,15 @@ def write_state(path: pathlib.Path, state: dict[str, Any]) -> None:
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
if expect_sha256 is not None:
try:
actual_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
except FileNotFoundError as exc:
raise StateError("state disappeared before guarded write") from exc
if actual_sha256 != expect_sha256:
raise StateError(
"state fingerprint changed during annotation; refusing write"
)
os.replace(temporary_path, path)
finally:
temporary_path.unlink(missing_ok=True)
Expand Down Expand Up @@ -264,7 +284,13 @@ def command_finish(args: argparse.Namespace) -> dict[str, Any]:


def command_annotate(args: argparse.Namespace) -> dict[str, Any]:
state = read_state(args.state)
state, raw_state = read_state_snapshot(args.state)
actual_sha256 = hashlib.sha256(raw_state).hexdigest()
if args.expect_sha256 != actual_sha256:
raise StateError(
"state fingerprint changed; refusing annotation "
f"(expected {args.expect_sha256}, actual {actual_sha256})"
)
if (
state["terminal_status"] in NON_RESUMABLE_STATUSES
and args.next_action is not None
Expand All @@ -274,10 +300,15 @@ def command_annotate(args: argparse.Namespace) -> dict[str, Any]:
if args.next_action is not None:
state["next_action"] = args.next_action
append_history(state, "annotated", args.evidence, state["next_action"])
write_state(args.state, state)
write_state(args.state, state, expect_sha256=actual_sha256)
return state


def command_fingerprint(args: argparse.Namespace) -> str:
_, raw_state = read_state_snapshot(args.state)
return hashlib.sha256(raw_state).hexdigest()


def summary(state: dict[str, Any]) -> str:
budget = state["budget"]
return "\n".join(
Expand Down Expand Up @@ -348,10 +379,18 @@ def build_parser() -> argparse.ArgumentParser:
help="append corrected evidence without changing status or budget",
)
add_state_argument(annotate)
annotate.add_argument("--expect-sha256", required=True)
annotate.add_argument("--evidence", action="append", required=True)
annotate.add_argument("--next-action")
annotate.set_defaults(handler=command_annotate)

fingerprint = subparsers.add_parser(
"fingerprint",
help="print the SHA-256 of a validated state snapshot",
)
add_state_argument(fingerprint)
fingerprint.set_defaults(handler=command_fingerprint)

validate = subparsers.add_parser("validate", help="validate an existing state")
add_state_argument(validate)
validate.set_defaults(handler=lambda args: read_state(args.state))
Expand All @@ -377,7 +416,9 @@ def main() -> int:
except StateError as exc:
print(f"loop-state: {exc}", file=sys.stderr)
return 3
if args.command == "show" and not args.json:
if args.command == "fingerprint":
print(state)
elif args.command == "show" and not args.json:
print(summary(state))
else:
print(json.dumps(state, indent=2, sort_keys=True))
Expand Down
49 changes: 49 additions & 0 deletions skills/loop-engineering/tests/test_loop_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ def initialize(self, limit: int = 2) -> dict[str, object]:
)
return json.loads(result.stdout)

def fingerprint(self) -> str:
return self.run_cli(
"fingerprint",
"--state",
str(self.state),
).stdout.strip()

def test_init_and_validate(self) -> None:
state = self.initialize()
self.assertEqual(state["terminal_status"], "running")
Expand Down Expand Up @@ -353,11 +360,14 @@ def test_annotate_rejects_next_action_for_non_resumable_terminal(self) -> None:
"--evidence",
"operator cancelled the run",
)
expected_sha256 = self.fingerprint()
before = self.state.read_text()
result = self.run_cli(
"annotate",
"--state",
str(self.state),
"--expect-sha256",
expected_sha256,
"--evidence",
"correction: cancellation was explicit",
"--next-action",
Expand Down Expand Up @@ -416,11 +426,14 @@ def test_annotate_corrects_terminal_evidence_without_reopening(self) -> None:
"obtain target repository",
).stdout
)
expected_sha256 = self.fingerprint()
corrected = json.loads(
self.run_cli(
"annotate",
"--state",
str(self.state),
"--expect-sha256",
expected_sha256,
"--evidence",
"correction: cwd has .git but is not the target repository",
).stdout
Expand All @@ -429,6 +442,42 @@ def test_annotate_corrects_terminal_evidence_without_reopening(self) -> None:
self.assertEqual(corrected["budget"], exhausted["budget"])
self.assertEqual(corrected["history"][-1]["event"], "annotated")

def test_fingerprint_matches_exact_state_bytes(self) -> None:
self.initialize()

self.assertEqual(
self.fingerprint(),
hashlib.sha256(self.state.read_bytes()).hexdigest(),
)

def test_annotate_rejects_stale_fingerprint_atomically(self) -> None:
self.initialize()
stale_sha256 = self.fingerprint()
self.run_cli(
"advance",
"--state",
str(self.state),
"--evidence",
"another session advanced the run",
"--next-action",
"continue from the newer snapshot",
)
before = self.state.read_text()

result = self.run_cli(
"annotate",
"--state",
str(self.state),
"--expect-sha256",
stale_sha256,
"--evidence",
"stale session correction",
expected_returncode=3,
)

self.assertIn("state fingerprint changed", result.stderr)
self.assertEqual(self.state.read_text(), before)


if __name__ == "__main__":
unittest.main()
6 changes: 6 additions & 0 deletions tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ checks = {
and "divergent-copy" in install_script
),
"atomic state write": "os.replace" in state_script,
"fingerprint-guarded correction": (
"fingerprint --state" in root
and "--expect-sha256" in root
and "state fingerprint changed" in state_script
and "verify state ownership" in protocol
),
"host differences deferred": references == {"examples.md", "hosts.md", "protocol.md"},
"cross-host continuation": (
hosts.count("while state is `running`") >= 3
Expand Down
Loading