Files
ThothII/harness/tests/test_phase_reopen_order.py
marcopanandClaude Fable 5 1ab0015460 fix(harness): state-integrity pass — reopen order, atomic decision batch, bash anti-bypass
Audit findings 5.1-5.3.

5.1 `phase reopen` now appends `phase_reopened` BEFORE the artifact
teardown: a crash between the two used to leave later-phase artifacts
deleted with the ledger still at the old phase (resume entered a phase
missing its artifacts). The inverse half-state — reopened with stale later
artifacts — is benign. Order locked by tests/test_phase_reopen_order.py.

5.2 New `tht decision add-batch --doc -`: N substantive decisions in ONE
atomic ledger write (meta types and cte_approved stay on `decision add`;
strictest min-phase enforced). reviewer_schema_linking now builds the
complete curation set and persists it with a single add-batch call — a
mid-loop failure can no longer leave the audit ledger half-written, and a
retry cannot duplicate the first K decisions.

5.3 The anti-bypass hook now also blocks BASH mutations of protected
state (`echo >> review_decisions.jsonl`, `sed -i` on the manifest,
`cat > tht-gate.js`, python open('w'), mv/rm/tee/…): FORBIDDEN only
covered tht subcommands and the write/edit hook only covered pi's own
tools. Read-only access (cat/grep/tail/ls) stays allowed.

Also: knownDecisionTypes is defensive — a workflow meta declaring NO
emits at all (older tht, minimal stubs) skips pre-validation instead of
rejecting every substantive type; with emits present, unknown types are
still rejected before the widget (new L1 test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 01:51:38 +02:00

98 lines
3.4 KiB
Python

"""Il reopen scrive il ledger PRIMA del teardown (contratto crash-safety).
Un crash tra le due mutazioni deve lasciare lo stato benigno: `phase_reopened`
registrato con artefatti delle fasi successive ancora presenti (la fase foldata
vince e il workflow li sovrascrive), MAI artefatti cancellati con il ledger
fermo alla fase vecchia (resume entrerebbe in una fase senza i suoi artefatti).
"""
from typer.testing import CliRunner
from tht.cli.phase_cmd import phase_app
from tht.decisions import append_decision, list_decisions
from tht.phase import current_phase
def _walk_to_phase(session, target):
while current_phase(session) < target:
append_decision(session, type="phase_approved", subject=f"phase:{current_phase(session)}")
def _configure(monkeypatch, sessions):
import tht.cli.phase_cmd as mod
import tht.cli.session_cmd as session_mod
from tht.decisions import append_decisions
from tht.session.models import SessionManifest, SessionSnapshot
class _Repository:
def get(self, session_id):
return SessionSnapshot(
manifest=SessionManifest(
id=session_id, created_at="2026-01-01T00:00:00Z",
question="q", database="d", schema="s",
),
decisions=list_decisions(sessions / session_id),
)
def append_decisions(self, session_id, decisions):
return append_decisions(sessions / session_id, list(decisions))
repository = _Repository()
monkeypatch.setattr(mod, "_cfg", lambda: object())
monkeypatch.setattr(session_mod, "load_snapshot_or_exit", lambda _cfg, sid: repository.get(sid))
monkeypatch.setattr(session_mod, "session_repository", lambda _cfg: repository)
return repository
def _reopened_subjects(session):
return [d.subject for d in list_decisions(session) if d.type == "phase_reopened"]
def test_crash_in_teardown_still_records_phase_reopened(tmp_path, monkeypatch):
session = tmp_path / "s1"
session.mkdir()
_walk_to_phase(session, 4)
_configure(monkeypatch, tmp_path)
import tht.teardown as teardown_mod
def _boom(_repository, _snapshot, _phase):
raise RuntimeError("crash window: teardown died after the ledger append")
monkeypatch.setattr(teardown_mod, "teardown_snapshot", _boom)
result = CliRunner().invoke(phase_app, ["reopen", "--session", "s1", "--phase", "2"])
assert result.exit_code != 0
# The ledger mutation happened BEFORE the crash: the session is back at phase 2
# (with stale later artifacts, which is the benign half-state).
assert _reopened_subjects(session) == ["phase:2"]
assert current_phase(session) == 2
def test_successful_reopen_records_ledger_and_runs_teardown(tmp_path, monkeypatch):
session = tmp_path / "s1"
session.mkdir()
_walk_to_phase(session, 4)
_configure(monkeypatch, tmp_path)
import tht.teardown as teardown_mod
calls = []
class _Report:
deleted_files = []
def _spy(_repository, snapshot, phase):
# By the time teardown runs, the ledger already holds the reopen decision.
calls.append((phase, _reopened_subjects(session)))
return _Report()
monkeypatch.setattr(teardown_mod, "teardown_snapshot", _spy)
result = CliRunner().invoke(phase_app, ["reopen", "--session", "s1", "--phase", "2"])
assert result.exit_code == 0, result.output
assert calls == [(2, ["phase:2"])]
assert current_phase(session) == 2