CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m22s
Development Plan M0.1. Every section reference in code, tests, specs/, the example config, the tuning guide, and the CLAUDE.md and README files now cites docs/Architecture.md (P, L, D, O, X, Q) instead of the old spec's A-H, and docs/Architecture-Build-Spec.md is deleted (git keeps it). The plan's old-to-new table goes with it. Mostly mechanical, with a per-site choice where one old section split: A4 became L2 where it meant stop/abandon/the loop and L3 where it meant approvals; A10 became L6 in the context-budget code and L7 elsewhere; B2 became D4 for outbound Gitea and publishing and D5 for review intake; 'A3 step N' became the matching step of L8's boot sequence. Comments describing behaviour the architecture drops (planning always escalating, thresholds tuned at an old build step) now describe the current code and point at the Development Plan. docs/Architecture.md gains one line the rewrite missed: intake outages delay but never fail work (old B2), now in L5, where the poller and Matrix code cite it. Comments and docs only; no behaviour change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
203 lines
6.8 KiB
Python
203 lines
6.8 KiB
Python
"""The loop past EXECUTING (D4, D5): publish, review gate, then next milestone or
|
|
project completion."""
|
|
|
|
import io
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from nisse_dev.canonical import AssistantText, CanonicalEvent, StoredEvent, ToolCall
|
|
from nisse_dev.control import (
|
|
AWAIT_INPUT,
|
|
AWAIT_MILESTONE_SIGNOFF,
|
|
IDLE,
|
|
PR_OPEN,
|
|
Orchestrator,
|
|
)
|
|
from nisse_dev.executor import ExecutorOutcome
|
|
from nisse_dev.gitea import GiteaError, PullRequest
|
|
from nisse_dev.llm import RoleMap
|
|
from nisse_dev.oplog import OpLog
|
|
from nisse_dev.planner import LLMPlanningAgent
|
|
from nisse_dev.publish import PublishResult
|
|
from nisse_dev.state import StateStore
|
|
from nisse_dev.transport import InMemoryTransport
|
|
|
|
ROLES = RoleMap(
|
|
executor="llama3.2",
|
|
planner="haiku",
|
|
delegate="haiku",
|
|
consultant="haiku",
|
|
)
|
|
|
|
|
|
class ScriptedClient:
|
|
def __init__(self, plans: list[str]) -> None:
|
|
self._plans = plans
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
def complete(
|
|
self,
|
|
events: list[StoredEvent],
|
|
tools: list[dict[str, Any]] | None,
|
|
model: str,
|
|
system: str | None = None,
|
|
) -> list[CanonicalEvent]:
|
|
self.calls.append({"system": system})
|
|
if not self._plans:
|
|
return [AssistantText(text="no more plans", model=model)]
|
|
return [
|
|
ToolCall(
|
|
call_id=f"c{len(self.calls)}",
|
|
tool="submit_plan",
|
|
arguments={"type": "x", "content": self._plans.pop(0)},
|
|
)
|
|
]
|
|
|
|
|
|
class DoneExecutor:
|
|
def run(self, milestone: int) -> ExecutorOutcome:
|
|
return ExecutorOutcome("completed", f"milestone {milestone} done")
|
|
|
|
|
|
class FakePublisher:
|
|
def __init__(
|
|
self, *, result: PublishResult | None = None, error: Exception | None = None
|
|
) -> None:
|
|
self._result = result or PublishResult(
|
|
PullRequest(number=7, state="open", head="h", url="https://g/pulls/7"),
|
|
"opened PR #7: https://g/pulls/7",
|
|
)
|
|
self._error = error
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
def reconcilers(self) -> dict[str, Any]:
|
|
return {"open_pr": lambda intent: True}
|
|
|
|
def publish(self, **kwargs: Any) -> PublishResult:
|
|
self.calls.append(kwargs)
|
|
if self._error is not None:
|
|
raise self._error
|
|
return self._result
|
|
|
|
|
|
def build(
|
|
tmp_path: Path, publisher: Any, plans: list[str] | None = None
|
|
) -> tuple[StateStore, InMemoryTransport]:
|
|
store = StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
|
transport = InMemoryTransport()
|
|
client = ScriptedClient(plans or ["arch plan", "m1 plan", "m2 plan"])
|
|
orch = Orchestrator(
|
|
store,
|
|
transport,
|
|
LLMPlanningAgent(client, store, ROLES),
|
|
operators={"don"},
|
|
roles=ROLES,
|
|
executor=DoneExecutor(),
|
|
publisher=publisher,
|
|
)
|
|
orch.start()
|
|
return store, transport
|
|
|
|
|
|
def run_to_pr(tmp_path: Path, publisher: Any) -> tuple[StateStore, InMemoryTransport]:
|
|
store, transport = build(tmp_path, publisher)
|
|
transport.receive("don", "use demo")
|
|
transport.receive("don", "approve") # arch
|
|
transport.receive("don", "approve") # milestone plan
|
|
transport.receive("don", "start milestone 1")
|
|
return store, transport
|
|
|
|
|
|
def test_completed_milestone_publishes_and_enters_pr_open(tmp_path: Path) -> None:
|
|
publisher = FakePublisher()
|
|
store, transport = run_to_pr(tmp_path, publisher)
|
|
|
|
assert store.get_state().state == PR_OPEN
|
|
assert publisher.calls[0]["milestone"] == 1
|
|
assert publisher.calls[0]["project"] == "demo"
|
|
assert "m1 plan" in publisher.calls[0]["plan"] # the approved plan travels
|
|
assert "PR #7" in transport.outbound[-1].text
|
|
|
|
|
|
def test_pr_open_records_the_pull_request(tmp_path: Path) -> None:
|
|
store, _ = run_to_pr(tmp_path, FakePublisher())
|
|
assert store.get_state().data["pr"]["number"] == 7
|
|
|
|
|
|
def test_next_milestone_replans_and_bumps_the_counter(tmp_path: Path) -> None:
|
|
store, transport = run_to_pr(tmp_path, FakePublisher())
|
|
transport.receive("don", "next milestone")
|
|
|
|
state = store.get_state()
|
|
assert state.state != PR_OPEN
|
|
assert state.data["milestone"] == 2
|
|
assert "m2 plan" in transport.outbound[-1].text
|
|
|
|
|
|
def test_project_complete_returns_to_idle(tmp_path: Path) -> None:
|
|
store, transport = run_to_pr(tmp_path, FakePublisher())
|
|
transport.receive("don", "project complete")
|
|
|
|
state = store.get_state()
|
|
assert state.state == IDLE
|
|
assert state.project is None
|
|
assert "project closed" in transport.outbound[-1].text
|
|
|
|
|
|
def test_unknown_command_in_pr_open_explains_the_options(tmp_path: Path) -> None:
|
|
store, transport = run_to_pr(tmp_path, FakePublisher())
|
|
transport.receive("don", "looks good!")
|
|
assert store.get_state().state == PR_OPEN
|
|
assert "next milestone" in transport.outbound[-1].text
|
|
|
|
|
|
def test_no_changes_gates_to_the_operator_without_entering_pr_open(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
publisher = FakePublisher(result=PublishResult(None, "no changes to publish"))
|
|
store, transport = run_to_pr(tmp_path, publisher)
|
|
# Not left in EXECUTING, where every message is answered "busy".
|
|
assert store.get_state().state == AWAIT_INPUT
|
|
assert "no changes to publish" in transport.outbound[-1].text
|
|
transport.receive("don", "ok")
|
|
assert store.get_state().state == AWAIT_MILESTONE_SIGNOFF
|
|
assert "start milestone 1" in transport.outbound[-1].text
|
|
|
|
|
|
def test_publish_failure_gates_to_the_operator(tmp_path: Path) -> None:
|
|
publisher = FakePublisher(error=GiteaError("gitea is down"))
|
|
store, transport = run_to_pr(tmp_path, publisher)
|
|
assert store.get_state().state == AWAIT_INPUT
|
|
assert "could not publish" in transport.outbound[-1].text
|
|
# And the operator can still get back out (the deadlock regression).
|
|
transport.receive("don", "gitea is back")
|
|
assert store.get_state().state != AWAIT_INPUT
|
|
|
|
|
|
def test_publisher_reconciler_is_registered_at_boot(tmp_path: Path) -> None:
|
|
store = StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
|
store.begin_intent("open_pr", {"branch": "b"}, cause="crash")
|
|
transport = InMemoryTransport()
|
|
orch = Orchestrator(
|
|
store,
|
|
transport,
|
|
LLMPlanningAgent(ScriptedClient([]), store, ROLES),
|
|
operators={"don"},
|
|
roles=ROLES,
|
|
publisher=FakePublisher(),
|
|
)
|
|
orch.start()
|
|
# The publisher's reconciler answered "already opened", so boot resumed
|
|
# instead of gating on an unknown intent kind.
|
|
assert store.pending_intents() == []
|
|
assert store.get_state().state != AWAIT_INPUT
|
|
|
|
|
|
@pytest.mark.parametrize("command", ["next milestone", "project complete"])
|
|
def test_pr_open_commands_are_operator_only(tmp_path: Path, command: str) -> None:
|
|
store, transport = run_to_pr(tmp_path, FakePublisher())
|
|
transport.receive("mallory", command)
|
|
assert store.get_state().state == PR_OPEN
|