Step-2 PR A: the approval loop end to end with a stub agent, no LLM and no network. - transport.py: Transport interface per A1 (send_message, send_approval_request, ask_question, on_message) with an InMemoryTransport; no platform SDK outside an implementation - gates.py: PlanRevision with monotonic rev-N tags; Gate.evaluate enforces revision binding (stale tags refused with a notice), operator allowlisting, and approve-word-only semantics; any other reply is rejection feedback. Channel seam ready for Gitea/Vikunja approval sources - control.py: B1 state machine subset with structural gating — the agent's only actions are SubmitPlan (ends its turn) and AskHuman; no proceed tool exists. Gate state, revision counters, task_id, and pending questions checkpoint through StateStore, and boot resumes an open gate or gates to AWAIT_INPUT, never blind-resumes - gate.open/approve/reject A9 events complete the log taxonomy - CLAUDE.md: record the plan-first working convention - Tests: 15 unit (gates + orchestrator flows), 2 integration (mid-gate restart with revisions intact; unresolved intents gate the whole boot) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
66 lines
2.2 KiB
Python
66 lines
2.2 KiB
Python
"""Integration: a restart mid-gate resumes the open gate with its revision
|
|
history intact; unresolved intents gate the whole boot to AWAIT_INPUT
|
|
(spec A2/A3/A4 together)."""
|
|
|
|
import io
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from nisse_dev.control import (
|
|
AWAIT_ARCH_SIGNOFF,
|
|
AWAIT_INPUT,
|
|
AWAIT_MILESTONE_SIGNOFF,
|
|
Orchestrator,
|
|
StubAgent,
|
|
)
|
|
from nisse_dev.oplog import OpLog
|
|
from nisse_dev.state import StateStore
|
|
from nisse_dev.transport import InMemoryTransport
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
def boot(db: Path) -> tuple[Orchestrator, StateStore, InMemoryTransport]:
|
|
store = StateStore(db, oplog=OpLog("nisse-dev", io.StringIO()))
|
|
transport = InMemoryTransport()
|
|
orch = Orchestrator(store, transport, StubAgent(), operators={"don"})
|
|
orch.start()
|
|
return orch, store, transport
|
|
|
|
|
|
def test_restart_mid_gate_resumes_with_revisions_intact(tmp_path: Path) -> None:
|
|
db = tmp_path / "state.db"
|
|
_, store, transport = boot(db)
|
|
transport.receive("don", "use demo")
|
|
transport.receive("don", "needs work") # arch gate now at rev-2
|
|
task_id = store.oplog.task_id
|
|
store.close()
|
|
|
|
_, store2, transport2 = boot(db)
|
|
state = store2.get_state()
|
|
assert state is not None
|
|
assert state.state == AWAIT_ARCH_SIGNOFF
|
|
assert store2.oplog.task_id == task_id # correlation survives restart
|
|
notices = [m.text for m in transport2.outbound]
|
|
assert any("rev-2" in n and "still open" in n for n in notices)
|
|
|
|
transport2.receive("don", "approve rev-1") # stale across the restart too
|
|
assert store2.get_state().state == AWAIT_ARCH_SIGNOFF
|
|
transport2.receive("don", "approve rev-2")
|
|
assert store2.get_state().state == AWAIT_MILESTONE_SIGNOFF
|
|
|
|
|
|
def test_restart_with_unresolved_intent_gates_boot(tmp_path: Path) -> None:
|
|
db = tmp_path / "state.db"
|
|
_, store, transport = boot(db)
|
|
transport.receive("don", "use demo")
|
|
store.begin_intent("push_branch", {"branch": "m1"}, cause="test")
|
|
store.close()
|
|
|
|
_, store2, transport2 = boot(db)
|
|
assert store2.get_state().state == AWAIT_INPUT
|
|
question = transport2.outbound[-1]
|
|
assert question.kind == "question"
|
|
assert "unresolved work" in question.text
|