Add control loop, gating primitives, and transport interface (A4/B1)
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>
This commit is contained in:
1 parent
78122c407e
commit
a251104a44
7 files changed
+711
No files matched your search
@@ -0,0 +1,65 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user