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
148 lines
4.7 KiB
Python
148 lines
4.7 KiB
Python
"""Approvals and feedback bind to the plan revision they were about (L3)."""
|
|
|
|
import io
|
|
from pathlib import Path
|
|
|
|
from nisse_dev.canonical import UserMessage
|
|
from nisse_dev.control import (
|
|
AWAIT_MILESTONE_SIGNOFF,
|
|
Orchestrator,
|
|
StubAgent,
|
|
)
|
|
from nisse_dev.gitea import Comment
|
|
from nisse_dev.oplog import OpLog
|
|
from nisse_dev.state import StateStore
|
|
from nisse_dev.transport import InMemoryTransport
|
|
|
|
|
|
class Held:
|
|
"""A worker that queues jobs until released, like one under load."""
|
|
|
|
def __init__(self) -> None:
|
|
self.jobs: list = []
|
|
self.busy = False
|
|
|
|
def submit(self, job) -> None:
|
|
self.jobs.append(job)
|
|
|
|
def call(self, job, timeout=None):
|
|
return job()
|
|
|
|
def run(self) -> None:
|
|
while self.jobs:
|
|
self.jobs.pop(0)()
|
|
|
|
|
|
class Board:
|
|
def __init__(self) -> None:
|
|
self.next_id = 100
|
|
|
|
def open_project_issue(self, project: str, task_id: str) -> int:
|
|
return 10
|
|
|
|
def post_plan(self, issue: int, gate: str, rev: int, plan: str):
|
|
self.next_id += 1
|
|
|
|
class Posted:
|
|
comment_id = self.next_id
|
|
|
|
return Posted()
|
|
|
|
def echo(self, issue: int, text: str) -> None:
|
|
pass
|
|
|
|
|
|
def build(tmp_path: Path, worker: Held | None = None, board: Board | None = None):
|
|
store = StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
|
transport = InMemoryTransport()
|
|
orch = Orchestrator(
|
|
store,
|
|
transport,
|
|
StubAgent(),
|
|
operators={"don"},
|
|
gitea_operators={"don"},
|
|
board=board,
|
|
**({"worker": worker} if worker else {}),
|
|
)
|
|
orch.start()
|
|
return store, transport, orch
|
|
|
|
|
|
def gate(store: StateStore) -> dict:
|
|
return store.get_state().data.get("gate") or {}
|
|
|
|
|
|
def test_revisions_run_across_the_task(tmp_path: Path) -> None:
|
|
store, transport, _ = build(tmp_path)
|
|
transport.receive("don", "use demo")
|
|
assert gate(store)["rev"] == 1
|
|
transport.receive("don", "approve")
|
|
assert gate(store) == {**gate(store), "name": "milestone-1", "rev": 2}
|
|
assert "milestone-1 rev-2" in transport.outbound[-1].text
|
|
|
|
|
|
def test_a_stale_approval_is_answered_and_a_fresh_one_works(tmp_path: Path) -> None:
|
|
worker = Held()
|
|
store, transport, _ = build(tmp_path, worker)
|
|
transport.receive("don", "use demo")
|
|
worker.run()
|
|
transport.receive("don", "approve")
|
|
transport.receive("don", "approve") # both while arch rev-1 was up
|
|
worker.run()
|
|
assert store.get_state().state == AWAIT_MILESTONE_SIGNOFF
|
|
assert not gate(store)["approved"]
|
|
assert "arrived while rev-1 was up" in transport.outbound[-1].text
|
|
transport.receive("don", "approve") # now about milestone-1 rev-2
|
|
worker.run()
|
|
assert gate(store)["approved"]
|
|
|
|
|
|
def test_stale_feedback_is_recorded_but_rejects_nothing(tmp_path: Path) -> None:
|
|
worker = Held()
|
|
store, transport, _ = build(tmp_path, worker)
|
|
transport.receive("don", "use demo")
|
|
worker.run()
|
|
transport.receive("don", "approve")
|
|
transport.receive("don", "also, keep it in one file")
|
|
worker.run()
|
|
assert store.get_state().state == AWAIT_MILESTONE_SIGNOFF
|
|
assert gate(store)["rev"] == 2
|
|
assert "not taken as feedback" in transport.outbound[-1].text
|
|
said = [
|
|
e.event.text for e in store.read_events() if isinstance(e.event, UserMessage)
|
|
]
|
|
assert "also, keep it in one file" in said # the planner still hears it
|
|
|
|
|
|
def test_a_gitea_reply_binds_to_the_latest_plan_posted_before_it(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
board = Board()
|
|
store, transport, orch = build(tmp_path, board=board)
|
|
transport.receive("don", "use demo") # arch rev-1 posted as comment 101
|
|
orch._handle_plan_comment_now(
|
|
Comment(id=102, author="don", body="approve", created_at="t")
|
|
)
|
|
# Approved arch rev-1; milestone-1 rev-2 is now posted as comment 103.
|
|
assert gate(store) == {**gate(store), "name": "milestone-1", "approved": False}
|
|
# A reply older than that post was about arch rev-1: not applied.
|
|
orch._handle_plan_comment_now(
|
|
Comment(id=102, author="don", body="approve", created_at="t")
|
|
)
|
|
assert not gate(store)["approved"]
|
|
# One after it is about milestone-1 rev-2.
|
|
orch._handle_plan_comment_now(
|
|
Comment(id=104, author="don", body="approve", created_at="t")
|
|
)
|
|
assert gate(store)["approved"]
|
|
|
|
|
|
def test_the_plan_up_survives_a_restart(tmp_path: Path) -> None:
|
|
_, transport, _ = build(tmp_path)
|
|
transport.receive("don", "use demo") # arch rev-1 up; no board, so unsaved
|
|
store, transport, _ = build(tmp_path) # restart at the open gate
|
|
transport.receive("don", "approve") # stamped with the restored rev-1
|
|
assert store.get_state().state == AWAIT_MILESTONE_SIGNOFF
|
|
assert gate(store)["name"] == "milestone-1"
|
|
assert gate(store)["rev"] == 2
|