CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m14s
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
185 lines
6.7 KiB
Python
185 lines
6.7 KiB
Python
"""Reacting to PR review comments while in PR_OPEN (architecture D5)."""
|
|
|
|
import io
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from nisse_dev.canonical import CanonicalEvent, StoredEvent, ToolCall
|
|
from nisse_dev.control import AWAIT_INPUT, PR_OPEN, Orchestrator
|
|
from nisse_dev.executor import ExecutorOutcome
|
|
from nisse_dev.gitea import Comment, 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="x", planner="y", helper="y", expert="y")
|
|
|
|
|
|
class ScriptedClient:
|
|
def __init__(self) -> None:
|
|
self.calls = 0
|
|
|
|
def complete(self, events, tools, model, system=None) -> list[CanonicalEvent]:
|
|
self.calls += 1
|
|
return [
|
|
ToolCall(
|
|
call_id=f"c{self.calls}",
|
|
tool="submit_plan",
|
|
arguments={"type": "x", "content": f"plan {self.calls}"},
|
|
)
|
|
]
|
|
|
|
|
|
class RecordingExecutor:
|
|
def __init__(self, outcomes: list[str] | None = None) -> None:
|
|
self.runs: list[int] = []
|
|
self.seen_events: list[list[StoredEvent]] = []
|
|
# One entry per run, so a test can let the milestone succeed and
|
|
# only the review response fail.
|
|
self._outcomes = outcomes or []
|
|
self.store: StateStore | None = None
|
|
|
|
def run(self, transcript: Any, milestone: int) -> ExecutorOutcome:
|
|
self.runs.append(milestone)
|
|
# What it is given: the work item's transcript, nothing wider.
|
|
self.seen_events.append(transcript.read())
|
|
status = self._outcomes.pop(0) if self._outcomes else "completed"
|
|
return ExecutorOutcome(status, f"executor said {status}")
|
|
|
|
|
|
class RecordingPublisher:
|
|
def __init__(self, *, sha: str | None = "abc12345", error: Exception | None = None):
|
|
self._sha = sha
|
|
self._error = error
|
|
self.followups: list[dict[str, Any]] = []
|
|
self.comments: list[tuple[int, str]] = []
|
|
|
|
def reconcilers(self) -> dict[str, Any]:
|
|
return {}
|
|
|
|
def publish(self, **kwargs: Any) -> PublishResult:
|
|
return PublishResult(
|
|
PullRequest(number=42, state="open", head="h", url="u"), "opened PR #42: u"
|
|
)
|
|
|
|
def push_followup(self, **kwargs: Any) -> str | None:
|
|
self.followups.append(kwargs)
|
|
if self._error is not None:
|
|
raise self._error
|
|
return self._sha
|
|
|
|
def comment(self, index: int, body: str) -> None:
|
|
self.comments.append((index, body))
|
|
|
|
|
|
def build(
|
|
tmp_path: Path,
|
|
executor: Any,
|
|
publisher: Any,
|
|
gitea_operators: set[str] | None = None,
|
|
) -> tuple[Orchestrator, StateStore, InMemoryTransport]:
|
|
store = StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
|
if hasattr(executor, "store"):
|
|
executor.store = store
|
|
transport = InMemoryTransport()
|
|
orch = Orchestrator(
|
|
store,
|
|
transport,
|
|
LLMPlanningAgent(ScriptedClient(), ROLES),
|
|
operators={"don"},
|
|
roles=ROLES,
|
|
executor=executor,
|
|
publisher=publisher,
|
|
gitea_operators=gitea_operators if gitea_operators is not None else {"don"},
|
|
)
|
|
orch.start()
|
|
transport.receive("don", "use demo")
|
|
transport.receive("don", "approve")
|
|
transport.receive("don", "approve")
|
|
transport.receive("don", "start milestone 1")
|
|
return orch, store, transport
|
|
|
|
|
|
def review(
|
|
cid: int = 5, author: str = "don", body: str = "rename the module"
|
|
) -> Comment:
|
|
return Comment(id=cid, author=author, body=body, created_at="t")
|
|
|
|
|
|
def test_review_comment_triggers_a_fix_and_a_push(tmp_path: Path) -> None:
|
|
executor, publisher = RecordingExecutor(), RecordingPublisher()
|
|
orch, store, transport = build(tmp_path, executor, publisher)
|
|
assert store.get_state().state == PR_OPEN
|
|
|
|
orch.handle_review_comment(review())
|
|
|
|
assert executor.runs == [1, 1] # milestone run, then the review response
|
|
assert publisher.followups[0]["milestone"] == 1
|
|
assert "abc12345" in transport.outbound[-1].text
|
|
assert publisher.comments[0][0] == 42 # replied on the PR
|
|
|
|
|
|
def test_comment_enters_the_transcript_before_the_executor_runs(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
executor, publisher = RecordingExecutor(), RecordingPublisher()
|
|
orch, _, _ = build(tmp_path, executor, publisher)
|
|
orch.handle_review_comment(review(body="please add a test"))
|
|
|
|
# The executor must see the feedback as conversation, not as an
|
|
# out-of-band instruction it was never shown.
|
|
texts = [getattr(e.event, "text", "") for e in executor.seen_events[-1]]
|
|
assert any("please add a test" in t for t in texts)
|
|
|
|
|
|
def test_non_operator_comments_are_ignored(tmp_path: Path) -> None:
|
|
executor, publisher = RecordingExecutor(), RecordingPublisher()
|
|
orch, store, _ = build(tmp_path, executor, publisher)
|
|
orch.handle_review_comment(review(author="drive-by"))
|
|
|
|
assert executor.runs == [1] # only the original milestone run
|
|
assert publisher.followups == []
|
|
assert store.get_state().state == PR_OPEN
|
|
|
|
|
|
def test_comments_outside_pr_open_are_ignored(tmp_path: Path) -> None:
|
|
executor, publisher = RecordingExecutor(), RecordingPublisher()
|
|
orch, _store, transport = build(tmp_path, executor, publisher)
|
|
transport.receive("don", "project complete") # back to IDLE
|
|
|
|
orch.handle_review_comment(review())
|
|
assert executor.runs == [1]
|
|
|
|
|
|
def test_failed_review_response_gates_to_the_operator(tmp_path: Path) -> None:
|
|
# Milestone succeeds; the response to the review comment does not.
|
|
executor = RecordingExecutor(outcomes=["completed", "escalate"])
|
|
publisher = RecordingPublisher()
|
|
orch, store, transport = build(tmp_path, executor, publisher)
|
|
orch.handle_review_comment(review())
|
|
|
|
assert store.get_state().state == AWAIT_INPUT
|
|
assert "could not address the review comment" in transport.outbound[-1].text
|
|
assert publisher.followups == []
|
|
|
|
|
|
def test_no_code_change_needed_is_reported(tmp_path: Path) -> None:
|
|
executor, publisher = RecordingExecutor(), RecordingPublisher(sha=None)
|
|
orch, _, transport = build(tmp_path, executor, publisher)
|
|
orch.handle_review_comment(review(body="just curious, why sqlite?"))
|
|
|
|
assert "no code change was needed" in transport.outbound[-1].text
|
|
|
|
|
|
def test_push_failure_is_reported_without_losing_pr_open(tmp_path: Path) -> None:
|
|
executor = RecordingExecutor()
|
|
publisher = RecordingPublisher(error=GiteaError("push rejected"))
|
|
orch, store, transport = build(tmp_path, executor, publisher)
|
|
orch.handle_review_comment(review())
|
|
|
|
assert "could not push the fix" in transport.outbound[-1].text
|
|
assert store.get_state().state == PR_OPEN # still reviewable, can retry
|