Add control loop, gating primitives, and transport interface (A4/B1)
CI / checks (push) Successful in 9s
CI / checks (pull_request) Successful in 9s

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:
dongarandClaude Fable 5 committed 2026-09-05 21:28:07 +00:00
1 parent 78122c407e
commit a251104a44
7 files changed
+711

No files matched your search

+309
View File
@@ -0,0 +1,309 @@
"""Control loop and state machine (spec A4, B1 subset).
Gating is structural, not advisory: the agent's only actions are ``SubmitPlan``
(posts the plan for approval and ends the agent's turn) and ``AskHuman``
(pause, ask, resume on answer). There is deliberately no proceed/start action —
advancement past a gate happens only here, when an operator approval or command
arrives over transport. Every position change is checkpointed through the state
store before the loop continues, so a restart lands mid-gate, not mid-air.
"""
import uuid
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from typing import Any, Protocol
from nisse_dev.gates import Approved, Gate, Ignored, PlanRevision, Rejected
from nisse_dev.reconcile import Reconciler, reconcile_on_boot
from nisse_dev.state import StateStore
from nisse_dev.transport import InboundMessage, Transport
IDLE = "IDLE"
PLANNING_ARCH = "PLANNING_ARCH"
AWAIT_ARCH_SIGNOFF = "AWAIT_ARCH_SIGNOFF"
MILESTONE_PLANNING = "MILESTONE_PLANNING"
AWAIT_MILESTONE_SIGNOFF = "AWAIT_MILESTONE_SIGNOFF"
EXECUTING = "EXECUTING"
AWAIT_INPUT = "AWAIT_INPUT"
TRANSITIONS = {
(IDLE, PLANNING_ARCH),
(PLANNING_ARCH, AWAIT_ARCH_SIGNOFF),
(AWAIT_ARCH_SIGNOFF, PLANNING_ARCH), # rejected: replan with feedback
(AWAIT_ARCH_SIGNOFF, MILESTONE_PLANNING),
(MILESTONE_PLANNING, AWAIT_MILESTONE_SIGNOFF),
(AWAIT_MILESTONE_SIGNOFF, MILESTONE_PLANNING),
(AWAIT_MILESTONE_SIGNOFF, EXECUTING),
}
class TransitionError(RuntimeError):
pass
@dataclass(frozen=True)
class SubmitPlan:
content: str
@dataclass(frozen=True)
class AskHuman:
question: str
class PlanningAgent(Protocol):
def plan(
self,
gate: str,
project: str,
feedback: str | None = None,
answer: str | None = None,
) -> SubmitPlan | AskHuman: ...
class StubAgent:
"""Echoes canned plans so the approval loop is exercisable with no LLM."""
def plan(
self,
gate: str,
project: str,
feedback: str | None = None,
answer: str | None = None,
) -> SubmitPlan | AskHuman:
notes = ""
if feedback:
notes += f" (revised after feedback: {feedback})"
if answer:
notes += f" (operator answered: {answer})"
return SubmitPlan(f"[stub] {gate} plan for {project}{notes}")
def _fresh_data() -> dict[str, Any]:
return {
"revs": {},
"gate": None,
"question": None,
"milestone": 1,
"task_id": None,
}
class Orchestrator:
def __init__(
self,
store: StateStore,
transport: Transport,
agent: PlanningAgent,
operators: Iterable[str],
) -> None:
self._store = store
self._transport = transport
self._agent = agent
self._operators = set(operators)
self._data = _fresh_data()
self._project: str | None = None
transport.on_message(self._handle)
def start(self, reconcilers: Mapping[str, Reconciler] | None = None) -> None:
report = reconcile_on_boot(self._store, reconcilers or {})
state = self._store.get_state()
if state is None:
self._transition(IDLE, cause="boot: fresh state")
self._transport.send_message("nisse-dev ready (idle)")
return
self._data = {**_fresh_data(), **state.data}
self._project = state.project
self._store.oplog.task_id = self._data.get("task_id")
gate = self._data.get("gate")
if report.action == "await_input":
self._transition(AWAIT_INPUT, cause="boot: unresolved intents")
self._transport.ask_question(
"restart found unresolved work: "
+ "; ".join(report.notes)
+ " — how should I proceed?"
)
elif state.state == IDLE or (
state.state in (AWAIT_ARCH_SIGNOFF, AWAIT_MILESTONE_SIGNOFF) and gate
):
# A gate is a safe place to have been interrupted: nothing was in
# flight, the plan and revision are durable. Announce and resume.
if gate:
self._transport.send_message(
f"restarted while awaiting sign-off on {gate['name']} "
f"rev-{gate['rev']}; the gate is still open"
)
else:
# Interrupted mid-turn (planning, executing, unanswered question):
# never blind-resume (A3.6).
self._transition(AWAIT_INPUT, cause=f"boot: restarted in {state.state}")
self._transport.ask_question(
f"restarted mid-{state.state}; continue or re-plan?"
)
def _handle(self, msg: InboundMessage) -> None:
if msg.sender not in self._operators:
return
state = self._store.get_state()
current = state.state if state else IDLE
text = msg.text.strip()
if text == "status":
self._send_status(current)
elif current == IDLE:
if text.startswith("use "):
self._begin_project(text[4:].strip())
else:
self._transport.send_message("no active project; say 'use <project>'")
elif current in (AWAIT_ARCH_SIGNOFF, AWAIT_MILESTONE_SIGNOFF):
self._handle_gate_reply(msg, text)
elif current == AWAIT_INPUT:
self._handle_answer(text)
else:
self._transport.send_message(f"busy ({current}); 'status' for details")
def _send_status(self, current: str) -> None:
gate = self._data.get("gate")
gate_note = (
f", awaiting sign-off on {gate['name']} rev-{gate['rev']}" if gate else ""
)
self._transport.send_message(
f"state={current} project={self._project}{gate_note}"
)
def _begin_project(self, project: str) -> None:
task_id = f"{project}-{uuid.uuid4().hex[:8]}"
self._data = {**_fresh_data(), "task_id": task_id}
self._project = project
self._store.oplog.task_id = task_id
self._transition(PLANNING_ARCH, cause=f"operator command: use {project}")
self._run_planning_turn("arch")
def _run_planning_turn(
self, gate_name: str, feedback: str | None = None, answer: str | None = None
) -> None:
assert self._project is not None
action = self._agent.plan(
gate_name, self._project, feedback=feedback, answer=answer
)
if isinstance(action, AskHuman):
self._data["question"] = {"text": action.question, "gate": gate_name}
self._transition(
AWAIT_INPUT, cause=f"agent question during {gate_name} planning"
)
self._transport.ask_question(action.question)
return
rev = self._data["revs"].get(gate_name, 0) + 1
self._data["revs"][gate_name] = rev
self._data["gate"] = {
"name": gate_name,
"rev": rev,
"plan": action.content,
"approved": False,
}
await_state = (
AWAIT_ARCH_SIGNOFF if gate_name == "arch" else AWAIT_MILESTONE_SIGNOFF
)
self._transition(await_state, cause=f"plan submitted ({gate_name} rev-{rev})")
self._transport.send_approval_request(
f"{action.content}\n\nreply 'approve' to approve rev-{rev}; "
"any other reply is rejection feedback"
)
self._store.oplog.emit(
"gate.open",
cause=f"plan submitted (rev-{rev})",
gate=gate_name,
rev=f"rev-{rev}",
channel="transport",
)
def _handle_gate_reply(self, msg: InboundMessage, text: str) -> None:
gate_data = self._data["gate"]
name, rev = gate_data["name"], gate_data["rev"]
milestone = self._data["milestone"]
if gate_data["approved"]:
if text == f"start milestone {milestone}":
self._transition(
EXECUTING, cause=f"operator command: start milestone {milestone}"
)
self._transport.send_message(f"[stub] executing milestone {milestone}")
else:
self._transport.send_message(
f"plan approved; say 'start milestone {milestone}' to begin"
)
return
revision = PlanRevision(gate=name, rev=rev, content=gate_data["plan"])
result = Gate(revision, self._operators).evaluate(
msg.sender, text, channel="transport"
)
if isinstance(result, Ignored):
if result.notice:
self._transport.send_message(result.notice)
return
if isinstance(result, Rejected):
self._store.oplog.emit(
"gate.reject",
cause=f"rejected by {result.approver}",
gate=name,
rev=f"rev-{rev}",
channel=result.channel,
)
self._data["gate"] = None
back = PLANNING_ARCH if name == "arch" else MILESTONE_PLANNING
self._transition(back, cause=f"plan rejected ({name} rev-{rev})")
self._run_planning_turn(name, feedback=result.feedback)
return
assert isinstance(result, Approved)
self._store.oplog.emit(
"gate.approve",
cause=f"approved by {result.approver}",
gate=name,
rev=f"rev-{rev}",
channel=result.channel,
)
if name == "arch":
self._data["gate"] = None
self._transition(
MILESTONE_PLANNING, cause=f"arch plan approved (rev-{rev})"
)
self._transport.send_message(
f"architecture approved; planning milestone {milestone}"
)
self._run_planning_turn(f"milestone-{milestone}")
else:
gate_data["approved"] = True
self._transition(
AWAIT_MILESTONE_SIGNOFF,
cause=f"milestone plan approved (rev-{rev})",
)
self._transport.send_message(
f"milestone plan approved; say 'start milestone {milestone}' to begin"
)
def _handle_answer(self, text: str) -> None:
question = self._data.get("question")
if question is None:
self._transport.send_message(
"awaiting operator resolution; no pending question to answer"
)
return
self._data["question"] = None
gate_name = question["gate"]
back = PLANNING_ARCH if gate_name == "arch" else MILESTONE_PLANNING
self._transition(back, cause="operator answered question")
self._run_planning_turn(gate_name, answer=text)
def _transition(self, new_state: str, *, cause: str) -> None:
state = self._store.get_state()
current = state.state if state else None
allowed = (
current is None
or current == new_state
or new_state == AWAIT_INPUT
or current == AWAIT_INPUT
or (current, new_state) in TRANSITIONS
)
if not allowed:
raise TransitionError(f"illegal transition {current} -> {new_state}")
self._store.transition(
new_state, cause=cause, project=self._project, data=self._data
)