Files
nisse/nisse-dev/tests/test_pr_open_handling.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

251 lines
7.9 KiB
Python

"""Gitea threads across restarts and interruptions (#58, #59).
Uses the real CommentPoller against a fake Gitea, with its thread replaced
by explicit polls, so the cursor logic under test is the real one.
"""
import io
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from nisse_dev.control import (
AWAIT_ARCH_SIGNOFF,
AWAIT_INPUT,
AWAIT_MILESTONE_SIGNOFF,
MILESTONE_PLANNING,
PR_OPEN,
Orchestrator,
StubAgent,
)
from nisse_dev.executor import ExecutorOutcome
from nisse_dev.gitea import Comment, PullRequest
from nisse_dev.oplog import OpLog
from nisse_dev.poller import CommentPoller
from nisse_dev.publish import PublishResult
from nisse_dev.state import StateStore
from nisse_dev.transport import InMemoryTransport
AGENT = "nisse-bot"
PLAN_ISSUE = 10
PR = 20
class FakeGitea:
"""Comment threads by issue number; IDs grow across all threads."""
def __init__(self) -> None:
self.threads: dict[int, list[Comment]] = {}
self._next_id = 100
def add(self, index: int, author: str, body: str) -> int:
self._next_id += 1
self.threads.setdefault(index, []).append(
Comment(id=self._next_id, author=author, body=body, created_at="t")
)
return self._next_id
def list_comments(self, repo: str, index: int, since: str | None = None) -> list:
return list(self.threads.get(index, []))
@dataclass
class Posted:
comment_id: int
class Board:
def __init__(self, gitea: FakeGitea) -> None:
self._gitea = gitea
def open_project_issue(self, project: str, task_id: str) -> int:
return PLAN_ISSUE
def post_plan(self, issue: int, gate: str, rev: int, plan: str) -> Posted:
return Posted(self._gitea.add(issue, AGENT, f"{gate} rev-{rev}: {plan}"))
def echo(self, issue: int, text: str) -> None:
self._gitea.add(issue, AGENT, text)
class Executor:
def __init__(self) -> None:
self.runs = 0
self.outcome = ExecutorOutcome("completed", "done")
def run(self, transcript: object, milestone: int) -> ExecutorOutcome:
self.runs += 1
return self.outcome
class Publisher:
def __init__(self) -> None:
self.followups = 0
def reconcilers(self) -> dict[str, Any]:
return {}
def publish(self, **kwargs: Any) -> PublishResult:
return PublishResult(
PullRequest(number=PR, state="open", head="h", url="u"), f"opened PR #{PR}"
)
def push_followup(self, **kwargs: Any) -> str | None:
self.followups += 1
return "abcdef1234"
def comment(self, index: int, body: str) -> None:
pass
class Agent:
"""One orchestrator process: a restart builds a new one on the same
store and the same Gitea."""
def __init__(self, tmp_path: Path, gitea: FakeGitea, executor: Executor) -> None:
self.store = StateStore(
tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO())
)
self.transport = InMemoryTransport()
self.pollers: dict[int, CommentPoller] = {}
self.publisher = Publisher()
def factory(handler: Any) -> CommentPoller:
poller = CommentPoller(gitea, "o/r", handler, ignore_authors=[AGENT])
agent = self
def start(index: int) -> None: # no thread: tests poll explicitly
agent.pollers[index] = poller
poller.start = start # type: ignore[method-assign]
poller.stop = lambda: None # type: ignore[method-assign]
return poller
self.orch = Orchestrator(
self.store,
self.transport,
StubAgent(),
operators={"don"},
gitea_operators={"don"},
board=Board(gitea),
executor=executor,
publisher=self.publisher,
poller_factory=factory,
)
self.orch.start()
@property
def state(self) -> str:
state = self.store.get_state()
assert state is not None
return state.state
def poll(self, index: int) -> list[Comment]:
return self.pollers[index].poll_once(index)
def to_pr_open(agent: Agent) -> None:
for reply in ("use demo", "approve", "approve", "start milestone 1"):
agent.transport.receive("don", reply)
assert agent.state == PR_OPEN
def test_an_approval_posted_while_down_is_applied_after_a_restart(
tmp_path: Path,
) -> None:
gitea = FakeGitea()
agent = Agent(tmp_path, gitea, Executor())
agent.transport.receive("don", "use demo")
assert agent.state == AWAIT_ARCH_SIGNOFF
gitea.add(PLAN_ISSUE, "don", "approve") # while the agent is down
agent = Agent(tmp_path, gitea, Executor())
[delivered] = agent.poll(PLAN_ISSUE)
assert delivered.body == "approve"
assert agent.state == AWAIT_MILESTONE_SIGNOFF
def test_a_handled_approval_is_not_replayed_after_a_restart(tmp_path: Path) -> None:
gitea = FakeGitea()
agent = Agent(tmp_path, gitea, Executor())
agent.transport.receive("don", "use demo")
gitea.add(PLAN_ISSUE, "don", "approve")
agent.poll(PLAN_ISSUE) # arch approved; the milestone plan is posted
assert agent.state == AWAIT_MILESTONE_SIGNOFF
agent = Agent(tmp_path, gitea, Executor())
assert agent.poll(PLAN_ISSUE) == []
assert agent.state == AWAIT_MILESTONE_SIGNOFF
def test_a_new_revision_ignores_an_approval_posted_before_it(tmp_path: Path) -> None:
gitea = FakeGitea()
agent = Agent(tmp_path, gitea, Executor())
agent.transport.receive("don", "use demo")
agent.transport.receive("don", "needs tests") # rejects: rev-2 is posted
assert agent.state == AWAIT_ARCH_SIGNOFF
stale = gitea.add(PLAN_ISSUE, "don", "approve")
agent.transport.receive("don", "and docs") # rev-3 posted after it
assert all(c.id != stale for c in agent.poll(PLAN_ISSUE))
assert agent.state == AWAIT_ARCH_SIGNOFF
def test_data_from_before_cursors_skips_old_comments_on_restart(
tmp_path: Path,
) -> None:
gitea = FakeGitea()
agent = Agent(tmp_path, gitea, Executor())
agent.transport.receive("don", "use demo")
gitea.add(PLAN_ISSUE, "don", "approve") # an old approval, say of rev-1
state = agent.store.get_state()
state.data["board"].pop("last_comment") # as saved before this change
agent.store.transition(
state.state, cause="test", project=state.project, data=state.data
)
agent = Agent(tmp_path, gitea, Executor())
assert agent.poll(PLAN_ISSUE) == []
assert agent.state == AWAIT_ARCH_SIGNOFF
def test_a_restart_in_pr_open_resumes_and_catches_up_on_review(
tmp_path: Path,
) -> None:
gitea = FakeGitea()
agent = Agent(tmp_path, gitea, Executor())
to_pr_open(agent)
gitea.add(PR, "don", "please add a test") # while the agent is down
agent = Agent(tmp_path, gitea, Executor())
assert agent.state == PR_OPEN # no restart question
assert "restarted with PR #20 open" in agent.transport.outbound[-1].text
[delivered] = agent.poll(PR)
assert delivered.body == "please add a test"
assert agent.publisher.followups == 1
assert agent.state == PR_OPEN
def test_a_failed_review_response_returns_to_the_open_pr(tmp_path: Path) -> None:
gitea = FakeGitea()
executor = Executor()
agent = Agent(tmp_path, gitea, executor)
to_pr_open(agent)
executor.outcome = ExecutorOutcome("escalate", "stuck")
gitea.add(PR, "don", "please add a test")
agent.poll(PR)
assert agent.state == AWAIT_INPUT
# A comment while it waits is not lost: back in PR_OPEN it is delivered.
executor.outcome = ExecutorOutcome("completed", "done")
gitea.add(PR, "don", "try again")
agent.poll(PR)
assert agent.state == AWAIT_INPUT
agent.transport.receive("don", "ok")
assert agent.state == PR_OPEN
[delivered] = agent.poll(PR)
assert delivered.body == "try again"
assert agent.publisher.followups == 1
agent.transport.receive("don", "next milestone")
assert agent.state in (MILESTONE_PLANNING, AWAIT_MILESTONE_SIGNOFF)