"""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.canonical import GateOpened, PlanSubmitted, UserMessage from nisse_dev.gates import Approved, Gate, Ignored, PlanRevision, Rejected from nisse_dev.llm import RoleMap 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 class PlanningError(RuntimeError): """A planning turn produced nothing usable. Part of the PlanningAgent contract: the orchestrator gates to AWAIT_INPUT rather than inventing a plan or retrying forever.""" @dataclass(frozen=True) class SubmitPlan: content: str @dataclass(frozen=True) class AskHuman: question: str class MilestoneExecutor(Protocol): """What the orchestrator needs from an executor loop; the concrete one lives in nisse_dev.executor (imported there, not here, to keep the dependency pointing one way).""" def run(self, milestone: int) -> Any: ... 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, "roles": None, # A11 mapping, pinned at task start } class Orchestrator: def __init__( self, store: StateStore, transport: Transport, agent: PlanningAgent, operators: Iterable[str], roles: RoleMap | None = None, executor: "MilestoneExecutor | None" = None, ) -> None: self._store = store self._transport = transport self._agent = agent self._roles = roles self._executor = executor 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 # Every operator message enters the canonical transcript, so planning # turns see feedback and answers as conversation rather than as # out-of-band parameters (A10). self._store.append_event( UserMessage(text=msg.text.strip(), source=f"transport:{msg.sender}") ) 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 '") 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]}" # Pin the role mapping now (A11): a mid-task config change must not # split one task across two planners. pinned = self._roles.pinned() if self._roles is not None else None self._data = {**_fresh_data(), "task_id": task_id, "roles": pinned} 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 try: action = self._agent.plan( gate_name, self._project, feedback=feedback, answer=answer ) except PlanningError as exc: self._transition(AWAIT_INPUT, cause=f"planning failed: {exc}") self._transport.ask_question( f"planning for {gate_name} failed ({exc}); retry or re-plan?" ) return 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 ) roles = self._data.get("roles") or {} self._store.append_event( PlanSubmitted(plan=action.content, model=roles.get("planner", "stub")) ) self._store.append_event( GateOpened(gate=gate_name, reason=f"plan submitted (rev-{rev})") ) 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._execute_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 _execute_milestone(self, milestone: int) -> None: if self._executor is None: self._transport.send_message(f"[stub] executing milestone {milestone}") return self._transport.send_message(f"executing milestone {milestone}") outcome = self._executor.run(milestone) if outcome.status == "completed": # PR_OPEN and the review loop arrive with the Gitea layer (B2); # until then a finished milestone simply reports and waits. self._transport.send_message(f"milestone {milestone} done: {outcome.note}") return self._transition(AWAIT_INPUT, cause=f"milestone {milestone} {outcome.status}") self._transport.ask_question( f"milestone {milestone} stopped ({outcome.status}): {outcome.note}" ) 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 )