Files
nisse/nisse-dev/tests/test_no_stranding.py
T
dongarandClaude Opus 5.5 83804fe02a
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m14s
Put work items in the state store, and bind the log to them (M1.1)
Development Plan M1.1, as discussed.

- Schema v4: a work_items table (id, scope, title, status, outcome,
  position, data) and an agent row whose active_item points at one
  (NULL: idle). Canonical events and intents carry their work item, and
  events their actor. Older stores are refused with a clear message:
  there was no production data, so no migration (the mechanism stays).
- The log is reached through a Transcript bound to one work item and
  one actor: store.transcript(item, actor) reads only that item and
  writes with both. The planner, executor, delegation and expert desk
  get one per call from the orchestrator instead of holding the store,
  so a component can neither read another task's events (the planner
  read every earlier task's, #78) nor write without attribution.
  store.all_events() is kept for readers that span items (evaluation
  capture, dumps). Intents are begun through the transcript or with
  work_item=, so each belongs to its item.
- The orchestrator opens a work item on 'use', closes it with its
  outcome (completed or abandoned), and carries what the operator said
  while idle into the new item as copies with their origin, so a task
  sent before 'use' is still planned from (D1). control.py keeps its
  state machine through get_state()/transition(), now over the active
  work item, until M2.6 retires it.
- nisse_dev.dump shows work items and each event's item and actor.

Tests: test_work_items.py (scoping, attribution, intent ownership,
restart, outcomes, carried messages, a second task never seeing the
first); the suites updated for the per-call transcript.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-01 23:20:29 +00:00

239 lines
7.9 KiB
Python

"""The orchestrator's resting-state invariant, checked under injected faults.
Working states (planning, executing) are only right while work runs. After
any message is handled the agent must be somewhere it can wait for the
operator, and some reply must lead on from there. The first live run found
four ways to break this one at a time; this drives a whole project with a
fault at each step instead.
"""
import io
from pathlib import Path
from typing import Any
import pytest
from nisse_dev.control import (
AWAIT_ARCH_SIGNOFF,
AWAIT_INPUT,
AWAIT_MILESTONE_SIGNOFF,
EXECUTING,
IDLE,
MILESTONE_PLANNING,
PLANNING_ARCH,
PR_OPEN,
AskHuman,
Orchestrator,
PlanningError,
SubmitPlan,
)
from nisse_dev.executor import ExecutorOutcome
from nisse_dev.gitea import GiteaError, PullRequest
from nisse_dev.oplog import OpLog
from nisse_dev.publish import PublishResult
from nisse_dev.state import StateStore
from nisse_dev.transport import InMemoryTransport
WORKING = {PLANNING_ARCH, MILESTONE_PLANNING, EXECUTING}
class Faults:
"""One fault, fired once, at the nth call to one site."""
def __init__(self, site: str, nth: int, fault: Any) -> None:
self._site, self._nth, self._fault = site, nth, fault
self._calls: dict[str, int] = {}
self.fired = False
def check(self, site: str) -> Any:
"""Raise or return the fault if this call is the one; else None."""
self._calls[site] = self._calls.get(site, 0) + 1
if self.fired or site != self._site or self._calls[site] != self._nth:
return None
self.fired = True
if isinstance(self._fault, Exception):
raise self._fault
return self._fault
class Planner:
def __init__(self, faults: Faults) -> None:
self._faults = faults
def plan(
self,
transcript: object,
gate: str,
project: str,
feedback: str | None = None,
answer: str | None = None,
) -> SubmitPlan | AskHuman:
return self._faults.check("planner") or SubmitPlan(f"{gate} plan")
class Executor:
def __init__(self, faults: Faults) -> None:
self._faults = faults
def run(self, transcript: object, milestone: int) -> ExecutorOutcome:
return self._faults.check("executor") or ExecutorOutcome("completed", "done")
class Publisher:
def __init__(self, faults: Faults) -> None:
self._faults = faults
self._opened = 0
def reconcilers(self) -> dict[str, Any]:
return {}
def publish(self, **kwargs: Any) -> PublishResult:
self._faults.check("publisher")
self._opened += 1
pr = PullRequest(number=self._opened, state="open", head="h", url="u")
return PublishResult(pr, f"opened PR #{self._opened}")
def next_reply(store: StateStore, used_project: bool) -> str:
"""What an operator following the prompts would say next."""
state = store.get_state()
assert state is not None
data = state.data
match state.state:
case "IDLE":
return "status" if used_project else "use demo"
case "AWAIT_ARCH_SIGNOFF":
return "approve"
case "AWAIT_MILESTONE_SIGNOFF":
if data["gate"]["approved"]:
return f"start milestone {data['milestone']}"
return "approve"
case "PR_OPEN":
return "next milestone" if data["milestone"] < 2 else "project complete"
case "AWAIT_INPUT":
return "continue"
raise AssertionError(f"stranded in {state.state}")
FAULTS = [
("planner", 1, RuntimeError("bug in planning")),
("planner", 1, PlanningError("planner returned 400")),
("planner", 1, AskHuman("which language?")),
("planner", 2, RuntimeError("bug in planning")),
("planner", 3, OSError("disk full")),
("executor", 1, RuntimeError("bug in the executor")),
("executor", 1, ExecutorOutcome("escalate", "stuck")),
("executor", 1, ExecutorOutcome("capped", "max turns")),
("executor", 2, ExecutorOutcome("stopped", "operator")),
("publisher", 1, RuntimeError("bug in publishing")),
("publisher", 1, GiteaError("gitea is down")),
("publisher", 2, RuntimeError("bug in publishing")),
]
@pytest.mark.parametrize(
("site", "nth", "fault"), FAULTS, ids=[f"{s}{n}-{f!r}" for s, n, f in FAULTS]
)
def test_a_fault_anywhere_never_strands_the_agent(
tmp_path: Path, site: str, nth: int, fault: Any
) -> None:
faults = Faults(site, nth, fault)
store = StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
transport = InMemoryTransport()
Orchestrator(
store,
transport,
Planner(faults),
operators={"don"},
executor=Executor(faults),
publisher=Publisher(faults),
).start()
used_project = False
for _ in range(40):
reply = next_reply(store, used_project)
used_project = used_project or reply.startswith("use ")
transport.receive("don", reply)
state = store.get_state()
assert state is not None
assert state.state not in WORKING, (
f"after {reply!r}: stranded in {state.state}; "
f"last said: {transport.outbound[-1].text!r}"
)
if reply == "project complete":
break
else:
raise AssertionError("never reached project complete")
assert faults.fired
assert store.get_state().state == IDLE
def test_a_signoff_state_without_its_gate_offers_a_way_out(tmp_path: Path) -> None:
faults = Faults("none", 0, None)
store = StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
transport = InMemoryTransport()
orch = Orchestrator(store, transport, Planner(faults), operators={"don"})
orch.start()
transport.receive("don", "use demo")
state = store.get_state()
store.transition(
AWAIT_ARCH_SIGNOFF,
cause="test",
project="demo",
data={**state.data, "gate": None},
)
orch._data["gate"] = None # as a restart would load it
transport.receive("don", "approve")
assert store.get_state().state == AWAIT_INPUT
transport.receive("don", "continue")
assert store.get_state().state == AWAIT_ARCH_SIGNOFF
class RaisingReviewExecutor:
def run(self, transcript: object, milestone: int) -> ExecutorOutcome:
raise RuntimeError("bug while addressing review")
def test_a_failing_review_response_keeps_pr_open_and_says_so(
tmp_path: Path,
) -> None:
faults = Faults("none", 0, None)
store = StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
transport = InMemoryTransport()
orch = Orchestrator(
store,
transport,
Planner(faults),
operators={"don"},
gitea_operators={"don"},
executor=Executor(faults),
publisher=Publisher(faults),
)
orch.start()
for reply in ("use demo", "approve", "approve", "start milestone 1"):
transport.receive("don", reply)
assert store.get_state().state == PR_OPEN
orch._executor = RaisingReviewExecutor()
class Comment:
id, author, body = 1, "don", "add a test"
orch.handle_review_comment(Comment())
assert store.get_state().state == PR_OPEN
assert "could not handle a review comment" in transport.outbound[-1].text
transport.receive("don", "project complete")
assert store.get_state().state == IDLE
def test_the_stub_configuration_reaches_every_state(tmp_path: Path) -> None:
store = StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
transport = InMemoryTransport()
Orchestrator(
store, transport, Planner(Faults("none", 0, None)), operators={"don"}
).start()
for reply in ("use demo", "approve", "approve", "start milestone 1"):
transport.receive("don", reply)
assert store.get_state().state == PR_OPEN
transport.receive("don", "next milestone")
assert store.get_state().state == AWAIT_MILESTONE_SIGNOFF