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
1258 lines
52 KiB
Python
1258 lines
52 KiB
Python
"""Control loop and state machine (architecture L2, L3; retired by M2.6).
|
|
|
|
Gating is structural, not advisory: the agent's only actions are ``SubmitPlan``
|
|
(posts the plan for approval and ends the agent's turn) and ``AskHuman``
|
|
(pause, ask, resume on answer). There is deliberately no proceed/start action —
|
|
advancement past a gate happens only here, when an operator approval or command
|
|
arrives over transport. Every position change is checkpointed through the state
|
|
store before the loop continues, so a restart lands mid-gate, not mid-air.
|
|
|
|
All of it runs on one worker (nisse_dev.worker), never on the thread that
|
|
delivered a message. The one exception is `stop` (L2), which has to reach
|
|
work that is already running: the transport's thread sets the cancel flag
|
|
directly, and the worker records the stop once the work has returned.
|
|
"""
|
|
|
|
import sys
|
|
import traceback
|
|
import uuid
|
|
from collections.abc import Callable, Iterable, Iterator, Mapping
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from typing import Any, Protocol
|
|
|
|
from nisse_dev.cancel import Cancel
|
|
from nisse_dev.canonical import GateOpened, PlanSubmitted, UserMessage
|
|
from nisse_dev.gates import (
|
|
Approved,
|
|
Gate,
|
|
Ignored,
|
|
PlanRevision,
|
|
Rejected,
|
|
command_form,
|
|
)
|
|
from nisse_dev.git import GitError
|
|
from nisse_dev.gitea import GiteaError
|
|
from nisse_dev.llm import RoleMap
|
|
from nisse_dev.reconcile import Reconciler, reconcile_on_boot
|
|
from nisse_dev.state import StateStore
|
|
from nisse_dev.transport import InboundMessage, Transport
|
|
from nisse_dev.worker import InlineWorker, Worker
|
|
|
|
IDLE = "IDLE"
|
|
PLANNING_ARCH = "PLANNING_ARCH"
|
|
AWAIT_ARCH_SIGNOFF = "AWAIT_ARCH_SIGNOFF"
|
|
MILESTONE_PLANNING = "MILESTONE_PLANNING"
|
|
AWAIT_MILESTONE_SIGNOFF = "AWAIT_MILESTONE_SIGNOFF"
|
|
EXECUTING = "EXECUTING"
|
|
PR_OPEN = "PR_OPEN"
|
|
AWAIT_INPUT = "AWAIT_INPUT"
|
|
|
|
TRANSITIONS = {
|
|
(IDLE, PLANNING_ARCH),
|
|
(PLANNING_ARCH, AWAIT_ARCH_SIGNOFF),
|
|
(AWAIT_ARCH_SIGNOFF, PLANNING_ARCH), # rejected: replan with feedback
|
|
(AWAIT_ARCH_SIGNOFF, MILESTONE_PLANNING),
|
|
(MILESTONE_PLANNING, AWAIT_MILESTONE_SIGNOFF),
|
|
(AWAIT_MILESTONE_SIGNOFF, MILESTONE_PLANNING),
|
|
(AWAIT_MILESTONE_SIGNOFF, EXECUTING),
|
|
(EXECUTING, PR_OPEN),
|
|
# PR acceptance is the milestone's final gate (D5): a merged PR is the
|
|
# durable record, so completion returns to planning or to idle rather
|
|
# than passing through a separate FINISHED state.
|
|
(PR_OPEN, MILESTONE_PLANNING),
|
|
(PR_OPEN, IDLE),
|
|
# abandon (L2): the operator may end the task from any waiting state.
|
|
# (AWAIT_INPUT may go anywhere already.)
|
|
(AWAIT_ARCH_SIGNOFF, IDLE),
|
|
(AWAIT_MILESTONE_SIGNOFF, IDLE),
|
|
}
|
|
|
|
|
|
# States that are only right while work is running. Once a job ends the agent
|
|
# must be somewhere it can wait for the operator, or every message would be
|
|
# answered "busy" until a restart.
|
|
_WORKING = (PLANNING_ARCH, MILESTONE_PLANNING, EXECUTING)
|
|
|
|
_BOOT_CHOICES = (
|
|
"reply 'continue' to pick up where the restart cut in, "
|
|
"'re-plan' to plan the current step again, or 'abandon' to drop the task"
|
|
)
|
|
|
|
|
|
class TransitionError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class PlanningError(RuntimeError):
|
|
"""A planning turn produced nothing usable. Part of the PlanningAgent
|
|
contract: the orchestrator gates to AWAIT_INPUT rather than inventing a
|
|
plan or retrying forever."""
|
|
|
|
|
|
class PlanningStopped(RuntimeError):
|
|
"""The operator said `stop` (L2) before a planning turn could run."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SubmitPlan:
|
|
content: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AskHuman:
|
|
question: str
|
|
|
|
|
|
class MilestoneExecutor(Protocol):
|
|
"""What the orchestrator needs from an executor loop; the concrete one
|
|
lives in nisse_dev.executor (imported there, not here, to keep the
|
|
dependency pointing one way)."""
|
|
|
|
def run(self, milestone: int) -> Any: ...
|
|
|
|
|
|
class MilestonePublisher(Protocol):
|
|
"""What the orchestrator needs to publish a milestone; the concrete one
|
|
lives in nisse_dev.publish."""
|
|
|
|
def reconcilers(self) -> Mapping[str, Reconciler]: ...
|
|
|
|
def publish(
|
|
self, *, task_id: str, milestone: int, project: str, plan: str
|
|
) -> Any: ...
|
|
|
|
def push_followup(
|
|
self, *, task_id: str, milestone: int, message: str
|
|
) -> str | None: ...
|
|
|
|
def comment(self, index: int, body: str) -> None: ...
|
|
|
|
def close_pull_request(self, index: int, note: str) -> None: ...
|
|
|
|
def published_pull_request(self, task_id: str, milestone: int) -> Any: ...
|
|
|
|
|
|
class PlanBoard(Protocol):
|
|
"""The Gitea side of plan gating; the concrete one is in
|
|
nisse_dev.approvals."""
|
|
|
|
def open_project_issue(self, project: str, task_id: str) -> int: ...
|
|
|
|
def post_plan(self, issue: int, gate: str, rev: int, plan: str) -> Any: ...
|
|
|
|
def echo(self, issue: int, text: str) -> None: ...
|
|
|
|
def close(self, issue: int, note: str) -> None: ...
|
|
|
|
|
|
class PlanningAgent(Protocol):
|
|
def plan(
|
|
self,
|
|
gate: str,
|
|
project: str,
|
|
feedback: str | None = None,
|
|
answer: str | None = None,
|
|
) -> SubmitPlan | AskHuman: ...
|
|
|
|
|
|
class StubAgent:
|
|
"""Echoes canned plans so the approval loop is exercisable with no LLM."""
|
|
|
|
def plan(
|
|
self,
|
|
gate: str,
|
|
project: str,
|
|
feedback: str | None = None,
|
|
answer: str | None = None,
|
|
) -> SubmitPlan | AskHuman:
|
|
notes = ""
|
|
if feedback:
|
|
notes += f" (revised after feedback: {feedback})"
|
|
if answer:
|
|
notes += f" (operator answered: {answer})"
|
|
return SubmitPlan(f"[stub] {gate} plan for {project}{notes}")
|
|
|
|
|
|
def _rev_before(board_state: dict[str, Any] | None, comment_id: int) -> int | None:
|
|
"""The revision a Gitea comment was about: the latest plan posted before
|
|
it. None for a record kept before plan posts were."""
|
|
posts = (board_state or {}).get("posts")
|
|
if posts is None:
|
|
return None
|
|
earlier = [int(rev) for cid, rev in posts if int(cid) < comment_id]
|
|
return max(earlier) if earlier else 0
|
|
|
|
|
|
def _project_name(text: str) -> str:
|
|
"""The name in 'use <name>', as written: only the command word is
|
|
case-insensitive."""
|
|
return text.strip().rstrip(".!")[len("use") :].strip()
|
|
|
|
|
|
def _fresh_data() -> dict[str, Any]:
|
|
return {
|
|
# Latest revision per gate; revision numbers themselves run across
|
|
# the whole work item (last_rev), so rev-N names one plan (L3).
|
|
"revs": {},
|
|
"last_rev": 0,
|
|
# The revision last put up for sign-off: a reply that names none
|
|
# binds to the one up when it arrived.
|
|
"shown_rev": 0,
|
|
"gate": None,
|
|
# What an operator reply in AWAIT_INPUT should resume. Every path into
|
|
# AWAIT_INPUT must set this, or the reply has nowhere to go.
|
|
"pending": None,
|
|
"milestone": 1,
|
|
"task_id": None,
|
|
"roles": None, # L9 mapping, pinned at task start
|
|
# What each pinned alias resolved to, discovered from LiteLLM. An
|
|
# alias is free to point somewhere else tomorrow, so the transcript
|
|
# has to say what actually answered (L9).
|
|
"models": None,
|
|
"pr": None, # the open PR for the current milestone, once published
|
|
# Pointer to the Gitea plan record (D2): the state store holds
|
|
# only this, so gate state can be re-derived from Gitea after a
|
|
# state-volume loss.
|
|
"board": None,
|
|
}
|
|
|
|
|
|
class Orchestrator:
|
|
def __init__(
|
|
self,
|
|
store: StateStore,
|
|
transport: Transport,
|
|
agent: PlanningAgent,
|
|
operators: Iterable[str],
|
|
roles: RoleMap | None = None,
|
|
executor: "MilestoneExecutor | None" = None,
|
|
publisher: "MilestonePublisher | None" = None,
|
|
gitea_operators: Iterable[str] = (),
|
|
board: "PlanBoard | None" = None,
|
|
delegate: Any = None,
|
|
poller_factory: Any = None,
|
|
catalog: Any = None,
|
|
worker: Worker | None = None,
|
|
cancel: Cancel | None = None,
|
|
) -> None:
|
|
self._store = store
|
|
self._transport = transport
|
|
self._agent = agent
|
|
self._roles = roles
|
|
self._catalog = catalog
|
|
self._executor = executor
|
|
self._publisher = publisher
|
|
self._operators = set(operators)
|
|
# Per-channel allowlists (L3): a Matrix MXID and a Gitea login
|
|
# are different names for the same person, and neither implies
|
|
# the other.
|
|
self._gitea_operators = set(gitea_operators)
|
|
self._board = board
|
|
self._delegate = delegate
|
|
# Builds a poller bound to a comment handler; the orchestrator
|
|
# owns *when* to watch, which is a state-machine concern, while
|
|
# the factory owns how to build one.
|
|
self._poller_factory = poller_factory
|
|
self._pollers: dict[str, Any] = {}
|
|
# Transport and the comment poller deliver on their own threads, and
|
|
# every handler runs on the worker, one at a time, so state
|
|
# transitions never interleave.
|
|
self._worker = worker or InlineWorker()
|
|
# Shared with the executor, delegation and planner, which check it
|
|
# at each tool boundary.
|
|
self._cancel = cancel or Cancel()
|
|
# Read from the transport's thread, so plain values only: whether
|
|
# long work is running, and the state name for a "busy" reply.
|
|
self._working = False
|
|
self._state_name = IDLE
|
|
# The revision up for sign-off, stamped on each message as it arrives.
|
|
self._shown_rev = 0
|
|
# Set by whichever path halted because of a stop, so the stop's own
|
|
# job knows whether it stopped anything.
|
|
self._halted_by_stop = False
|
|
self._data = _fresh_data()
|
|
self._project: str | None = None
|
|
transport.on_message(self._handle)
|
|
|
|
def start(self, reconcilers: Mapping[str, Reconciler] | None = None) -> None:
|
|
# L8 boot step 5 runs before the generic sweep: an interrupted delegation is
|
|
# resumed or discarded by inspecting the branch and the log, and only a
|
|
# genuinely unclean cut is left for the sweep below to gate on.
|
|
if self._delegate is not None:
|
|
with self._long_work():
|
|
self._delegate.recover(self._transport.send_message)
|
|
# The publisher owns the external-truth query for its own intents, so
|
|
# a configured publisher always contributes its reconciler (L8).
|
|
registered: dict[str, Reconciler] = dict(reconcilers or {})
|
|
if self._publisher is not None:
|
|
registered = {**self._publisher.reconcilers(), **registered}
|
|
report = reconcile_on_boot(self._store, registered)
|
|
state = self._store.get_state()
|
|
if state is not None:
|
|
self._state_name = state.state
|
|
if state is None:
|
|
self._transition(IDLE, cause="boot: fresh state")
|
|
self._transport.send_message("nisse-dev ready (idle)")
|
|
return
|
|
self._data = {**_fresh_data(), **state.data}
|
|
self._project = state.project
|
|
self._store.oplog.task_id = self._data.get("task_id")
|
|
gate = self._data.get("gate")
|
|
# An open gate is announced again below, so its plan counts as up.
|
|
self._shown_rev = max(
|
|
int(self._data.get("shown_rev") or 0),
|
|
int(gate["rev"])
|
|
if gate and state.state in (AWAIT_ARCH_SIGNOFF, AWAIT_MILESTONE_SIGNOFF)
|
|
else 0,
|
|
)
|
|
if report.action == "await_input":
|
|
self._await_input(
|
|
cause="boot: unresolved intents",
|
|
prompt=(
|
|
"restart found unresolved work: "
|
|
+ "; ".join(report.notes)
|
|
+ f" — {_BOOT_CHOICES}"
|
|
),
|
|
pending={"kind": "boot", "interrupted": state.state},
|
|
)
|
|
elif state.state == AWAIT_INPUT and self._data.get("pending"):
|
|
# Waiting on the operator is a committed state (L8 boot step 4):
|
|
# nothing was in flight, and the question and what answering it
|
|
# resumes are durable. Ask it again rather than replacing it.
|
|
pending = self._data["pending"]
|
|
self._transport.ask_question(
|
|
"restarted while waiting on you: "
|
|
+ pending.get("prompt", "my last question still stands")
|
|
)
|
|
elif state.state == EXECUTING and (published := self._published_pr()):
|
|
# L8 boot step 3: Gitea shows this milestone's PR open, so it was
|
|
# published and only PR_OPEN went unrecorded. Adopt the PR rather
|
|
# than ask, and never publish it again. Nothing watched it yet,
|
|
# so every comment on it is new.
|
|
milestone = int(self._data.get("milestone", 1))
|
|
self._data["pr"] = {
|
|
"number": published.number,
|
|
"url": published.url,
|
|
"milestone": milestone,
|
|
"last_comment": self._watch(
|
|
"review", published.number, self.handle_review_comment, after=0
|
|
),
|
|
}
|
|
self._unwatch("plan")
|
|
self._transition(PR_OPEN, cause=f"boot: milestone {milestone} published")
|
|
self._transport.send_message(
|
|
f"restarted after milestone {milestone} was published: PR "
|
|
f"#{published.number} is open ({published.url}); watching it for "
|
|
"review comments"
|
|
)
|
|
elif state.state == PR_OPEN:
|
|
# Waiting on review is a committed state like a gate (L8 boot step
|
|
# 8): resume, and watch the PR again from its cursor.
|
|
pr = self._data.get("pr") or {}
|
|
self._watch_pr(pr)
|
|
if pr.get("reviewing"):
|
|
self._transport.send_message(
|
|
f"restarted while answering review comment {pr['reviewing']} "
|
|
f"on PR #{pr.get('number')}; answering it again"
|
|
)
|
|
else:
|
|
self._transport.send_message(
|
|
f"restarted with PR #{pr.get('number')} open; still watching "
|
|
"it for review comments"
|
|
)
|
|
elif state.state == IDLE or (
|
|
state.state in (AWAIT_ARCH_SIGNOFF, AWAIT_MILESTONE_SIGNOFF) and gate
|
|
):
|
|
# A gate is a safe place to have been interrupted: nothing was in
|
|
# flight, the plan and revision are durable. Announce and resume,
|
|
# watching the plan issue again from its cursor.
|
|
if gate:
|
|
board_state = self._data.get("board")
|
|
if board_state and board_state.get("comment_id"):
|
|
self._watch(
|
|
"plan",
|
|
board_state["issue"],
|
|
self.handle_plan_comment,
|
|
# No cursor means data from before cursors were
|
|
# kept: skip what is there, as a fresh watch does,
|
|
# so an old approval cannot approve this revision.
|
|
after=board_state.get("last_comment") or None,
|
|
)
|
|
self._transport.send_message(
|
|
f"restarted while awaiting sign-off on {gate['name']} "
|
|
f"rev-{gate['rev']}; the gate is still open"
|
|
)
|
|
else:
|
|
# Interrupted mid-turn (planning, executing, unanswered question):
|
|
# never blind-resume (L8 boot step 5).
|
|
self._await_input(
|
|
cause=f"boot: restarted in {state.state}",
|
|
prompt=f"restarted mid-{state.state}; {_BOOT_CHOICES}",
|
|
pending={"kind": "boot", "interrupted": state.state},
|
|
)
|
|
|
|
def _handle(self, msg: InboundMessage) -> None:
|
|
"""Runs on the transport's thread, so it touches nothing durable and
|
|
hands everything to the worker."""
|
|
if msg.sender not in self._operators:
|
|
return
|
|
text = msg.text.strip()
|
|
if command_form(text) == "stop":
|
|
# Straight to the flag, not behind the work in the queue: the
|
|
# work is what it has to reach (L2).
|
|
if self._worker.busy:
|
|
self._cancel.set()
|
|
self._worker.submit(lambda: self._finish_stop(msg))
|
|
return
|
|
if self._working:
|
|
# Queued, it would run after the work, in whatever state the work
|
|
# left behind, which is not the state the operator was answering.
|
|
self._transport.send_message(
|
|
f"busy ({self._state_name}); say 'stop' to halt"
|
|
)
|
|
self._worker.submit(lambda: self._record(msg))
|
|
return
|
|
# Stamped here, on arrival: by the time the worker reaches it, the
|
|
# plan it was about may have been replaced.
|
|
about = self._shown_rev
|
|
self._worker.submit(
|
|
lambda: self._settled("your message", lambda: self._handle_now(msg, about))
|
|
)
|
|
|
|
def _settled(self, what: str, job: Callable[[], None]) -> None:
|
|
"""Run one job and make sure it ends in a state that can wait for the
|
|
operator, however it ended. An exception is reported rather than left
|
|
to the worker, which would only print it; a job left in a working
|
|
state is moved to AWAIT_INPUT with the choice a restart offers."""
|
|
error: Exception | None = None
|
|
try:
|
|
job()
|
|
except Exception as exc: # noqa: BLE001 -- reported below
|
|
error = exc
|
|
traceback.print_exception(exc, file=sys.stderr)
|
|
state = self._store.get_state()
|
|
current = state.state if state else IDLE
|
|
reason = f"{type(error).__name__}: {error}" if error else "no outcome"
|
|
try:
|
|
if current in _WORKING:
|
|
self._await_input(
|
|
cause=f"stranded in {current}: {reason}",
|
|
prompt=f"{what} ended mid-{current} ({reason}); {_BOOT_CHOICES}",
|
|
# The same choice as after a restart, answered the same way.
|
|
pending={"kind": "boot", "interrupted": current},
|
|
)
|
|
elif error is not None:
|
|
self._transport.send_message(
|
|
f"could not handle {what} ({reason}); still {current}"
|
|
)
|
|
except Exception as exc: # noqa: BLE001 -- nothing left to tell
|
|
traceback.print_exception(exc, file=sys.stderr)
|
|
|
|
def _record(self, msg: InboundMessage) -> None:
|
|
self._store.append_event(
|
|
UserMessage(
|
|
text=(msg.original or msg.text).strip(),
|
|
source=f"transport:{msg.sender}",
|
|
)
|
|
)
|
|
|
|
def _finish_stop(self, msg: InboundMessage) -> None:
|
|
"""The worker's half of `stop`. Whatever was running has returned by
|
|
now, and has already moved to AWAIT_INPUT if the stop reached it."""
|
|
self._record(msg)
|
|
halted = self._halted_by_stop
|
|
self._halted_by_stop = False
|
|
self._cancel.clear()
|
|
if not halted:
|
|
self._transport.send_message(f"nothing to stop (state={self._state_name})")
|
|
|
|
@contextmanager
|
|
def _long_work(self) -> Iterator[None]:
|
|
self._working = True
|
|
try:
|
|
yield
|
|
finally:
|
|
self._working = False
|
|
|
|
def _halt_for_stop(
|
|
self, *, what: str, note: str, next_step: str, pending: dict[str, Any]
|
|
) -> None:
|
|
"""L2: stopped work moves to AWAIT_INPUT, and what happens next is the
|
|
operator's call. The prompt asks why, because the model should hear
|
|
the reason before it tries again."""
|
|
self._halted_by_stop = True
|
|
self._await_input(
|
|
cause=f"operator: stop ({what})",
|
|
prompt=(
|
|
f"stopped {what} ({note}). Why did you stop it? Your reply is "
|
|
f"recorded for the model. {next_step}"
|
|
),
|
|
pending=pending,
|
|
)
|
|
|
|
def _handle_now(self, msg: InboundMessage, about: int | None = None) -> None:
|
|
# Every operator message enters the canonical transcript, so planning
|
|
# turns see feedback and answers as conversation rather than as
|
|
# out-of-band parameters (L7).
|
|
self._store.append_event(
|
|
UserMessage(
|
|
text=(msg.original or msg.text).strip(),
|
|
source=f"transport:{msg.sender}",
|
|
)
|
|
)
|
|
state = self._store.get_state()
|
|
current = state.state if state else IDLE
|
|
text = msg.text.strip()
|
|
command = command_form(text)
|
|
if command == "status":
|
|
self._send_status(current)
|
|
elif command == "abandon":
|
|
# Like status, a command in every waiting state: it takes
|
|
# precedence over reading the word as an answer or feedback.
|
|
if current == IDLE:
|
|
self._transport.send_message("no active task; nothing to abandon")
|
|
else:
|
|
self._abandon(msg.sender)
|
|
elif current == IDLE:
|
|
if command.startswith("use "):
|
|
self._begin_project(_project_name(text))
|
|
else:
|
|
self._transport.send_message("no active project; say 'use <project>'")
|
|
elif current in (AWAIT_ARCH_SIGNOFF, AWAIT_MILESTONE_SIGNOFF):
|
|
if not self._data.get("gate"):
|
|
# A sign-off state with no plan to sign off: replies could
|
|
# never be applied, so plan again from the operator's choice.
|
|
self._await_input(
|
|
cause=f"{current} without an open gate",
|
|
prompt=f"the plan awaiting sign-off is missing; {_BOOT_CHOICES}",
|
|
pending={
|
|
"kind": "boot",
|
|
"interrupted": (
|
|
PLANNING_ARCH
|
|
if current == AWAIT_ARCH_SIGNOFF
|
|
else MILESTONE_PLANNING
|
|
),
|
|
},
|
|
)
|
|
return
|
|
self._handle_gate_reply(msg, text, about)
|
|
elif current == PR_OPEN:
|
|
self._handle_pr_open(text, msg.sender)
|
|
elif current == AWAIT_INPUT:
|
|
self._handle_answer(text)
|
|
else:
|
|
self._transport.send_message(f"busy ({current}); 'status' for details")
|
|
|
|
def _send_status(self, current: str) -> None:
|
|
gate = self._data.get("gate")
|
|
gate_note = (
|
|
f", awaiting sign-off on {gate['name']} rev-{gate['rev']}" if gate else ""
|
|
)
|
|
self._transport.send_message(
|
|
f"state={current} project={self._project}{gate_note}"
|
|
)
|
|
|
|
def _begin_project(self, project: str) -> None:
|
|
task_id = f"{project}-{uuid.uuid4().hex[:8]}"
|
|
# Pin the role mapping now (L9): a mid-task config change must not
|
|
# split one task across two planners.
|
|
pinned = self._roles.pinned() if self._roles is not None else None
|
|
resolved = (
|
|
self._catalog.pinned(pinned)
|
|
if self._catalog is not None and pinned is not None
|
|
else None
|
|
)
|
|
self._data = {
|
|
**_fresh_data(),
|
|
"task_id": task_id,
|
|
"roles": pinned,
|
|
"models": resolved,
|
|
}
|
|
self._project = project
|
|
self._store.oplog.task_id = task_id
|
|
if self._board is not None:
|
|
try:
|
|
self._data["board"] = {
|
|
"issue": self._board.open_project_issue(project, task_id),
|
|
"last_comment": 0,
|
|
}
|
|
except GiteaError as exc:
|
|
# Gitea being down must not block planning; Matrix remains a
|
|
# complete approval channel on its own.
|
|
self._transport.send_message(
|
|
f"note: could not open the Gitea plan issue ({exc}); "
|
|
"approvals will be Matrix-only for this task"
|
|
)
|
|
self._transition(PLANNING_ARCH, cause=f"operator command: use {project}")
|
|
self._run_planning_turn("arch")
|
|
|
|
def _run_planning_turn(
|
|
self, gate_name: str, feedback: str | None = None, answer: str | None = None
|
|
) -> None:
|
|
assert self._project is not None
|
|
try:
|
|
with self._long_work():
|
|
action = self._agent.plan(
|
|
gate_name, self._project, feedback=feedback, answer=answer
|
|
)
|
|
except PlanningStopped as exc:
|
|
# Answering resumes planning with the answer, so the reason for
|
|
# the stop is the first thing the planner hears.
|
|
self._halt_for_stop(
|
|
what=f"planning {gate_name}",
|
|
note=str(exc),
|
|
next_step="Planning resumes with your reply.",
|
|
pending={"kind": "question", "gate": gate_name},
|
|
)
|
|
return
|
|
except PlanningError as exc:
|
|
self._await_input(
|
|
cause=f"planning failed: {exc}",
|
|
prompt=f"planning for {gate_name} failed ({exc}); how should I proceed?",
|
|
pending={"kind": "planning_failed", "gate": gate_name},
|
|
)
|
|
return
|
|
if isinstance(action, AskHuman):
|
|
self._await_input(
|
|
cause=f"agent question during {gate_name} planning",
|
|
prompt=action.question,
|
|
pending={"kind": "question", "gate": gate_name},
|
|
)
|
|
return
|
|
rev = int(self._data.get("last_rev") or 0) + 1
|
|
self._data["last_rev"] = rev
|
|
self._data["revs"][gate_name] = rev
|
|
self._data["gate"] = {
|
|
"name": gate_name,
|
|
"rev": rev,
|
|
"plan": action.content,
|
|
"approved": False,
|
|
}
|
|
await_state = (
|
|
AWAIT_ARCH_SIGNOFF if gate_name == "arch" else AWAIT_MILESTONE_SIGNOFF
|
|
)
|
|
roles = self._data.get("roles") or {}
|
|
self._store.append_event(
|
|
PlanSubmitted(plan=action.content, model=roles.get("planner", "stub"))
|
|
)
|
|
self._store.append_event(
|
|
GateOpened(gate=gate_name, reason=f"plan submitted (rev-{rev})")
|
|
)
|
|
self._transition(await_state, cause=f"plan submitted ({gate_name} rev-{rev})")
|
|
self._transport.send_approval_request(
|
|
f"{action.content}\n\nreply 'approve' to approve {gate_name} rev-{rev}; "
|
|
"any other reply is rejection feedback"
|
|
)
|
|
# Up only once sent: a reply that arrived before was about another.
|
|
self._data["shown_rev"] = rev
|
|
self._shown_rev = rev
|
|
self._post_plan_to_board(gate_name, rev, action.content)
|
|
self._store.oplog.emit(
|
|
"gate.open",
|
|
cause=f"plan submitted (rev-{rev})",
|
|
gate=gate_name,
|
|
rev=f"rev-{rev}",
|
|
channel="transport",
|
|
)
|
|
|
|
def _handle_gate_reply(
|
|
self, msg: InboundMessage, text: str, about: int | None
|
|
) -> None:
|
|
self._apply_gate_reply(
|
|
sender=msg.sender, text=text, channel="transport", about=about
|
|
)
|
|
|
|
def _apply_gate_reply(
|
|
self, *, sender: str, text: str, channel: str, about: int | None = None
|
|
) -> None:
|
|
"""Shared by every approval source (L3). The allowlist is per channel:
|
|
a Matrix MXID and a Gitea login are different names, and holding one
|
|
says nothing about holding the other."""
|
|
gate_data = self._data["gate"]
|
|
name, rev = gate_data["name"], gate_data["rev"]
|
|
milestone = self._data["milestone"]
|
|
if gate_data["approved"]:
|
|
if command_form(text) == f"start milestone {milestone}":
|
|
self._transition(
|
|
EXECUTING, cause=f"operator command: start milestone {milestone}"
|
|
)
|
|
self._execute_milestone(milestone)
|
|
else:
|
|
self._transport.send_message(
|
|
f"plan approved; say 'start milestone {milestone}' to begin"
|
|
)
|
|
return
|
|
revision = PlanRevision(gate=name, rev=rev, content=gate_data["plan"])
|
|
allowlist = self._operators if channel == "transport" else self._gitea_operators
|
|
result = Gate(revision, allowlist).evaluate(
|
|
sender, text, channel=channel, about=about
|
|
)
|
|
if isinstance(result, Ignored):
|
|
if result.notice:
|
|
self._transport.send_message(result.notice)
|
|
return
|
|
if isinstance(result, Rejected):
|
|
self._store.oplog.emit(
|
|
"gate.reject",
|
|
cause=f"rejected by {result.approver}",
|
|
gate=name,
|
|
rev=f"rev-{rev}",
|
|
channel=result.channel,
|
|
)
|
|
self._echo_outcome(
|
|
channel,
|
|
f"{name} rev-{rev} rejected by {result.approver}: {result.feedback}",
|
|
)
|
|
self._data["gate"] = None
|
|
back = PLANNING_ARCH if name == "arch" else MILESTONE_PLANNING
|
|
self._transition(back, cause=f"plan rejected ({name} rev-{rev})")
|
|
self._run_planning_turn(name, feedback=result.feedback)
|
|
return
|
|
assert isinstance(result, Approved)
|
|
self._echo_outcome(
|
|
channel, f"{name} rev-{rev} approved by {result.approver} via {channel}"
|
|
)
|
|
self._store.oplog.emit(
|
|
"gate.approve",
|
|
cause=f"approved by {result.approver}",
|
|
gate=name,
|
|
rev=f"rev-{rev}",
|
|
channel=result.channel,
|
|
)
|
|
if name == "arch":
|
|
self._data["gate"] = None
|
|
self._transition(
|
|
MILESTONE_PLANNING, cause=f"arch plan approved (rev-{rev})"
|
|
)
|
|
self._transport.send_message(
|
|
f"architecture approved; planning milestone {milestone}"
|
|
)
|
|
self._run_planning_turn(f"milestone-{milestone}")
|
|
else:
|
|
gate_data["approved"] = True
|
|
self._transition(
|
|
AWAIT_MILESTONE_SIGNOFF,
|
|
cause=f"milestone plan approved (rev-{rev})",
|
|
)
|
|
self._transport.send_message(
|
|
f"milestone plan approved; say 'start milestone {milestone}' to begin"
|
|
)
|
|
|
|
def _execute_milestone(self, milestone: int) -> None:
|
|
if self._executor is None:
|
|
# The stub completes at once, so it still leaves EXECUTING.
|
|
self._transport.send_message(f"[stub] executing milestone {milestone}")
|
|
self._publish_milestone(milestone)
|
|
return
|
|
self._transport.send_message(f"executing milestone {milestone}")
|
|
with self._long_work():
|
|
outcome = self._executor.run(milestone)
|
|
if outcome.status == "stopped":
|
|
self._halt_for_stop(
|
|
what=f"milestone {milestone}",
|
|
note=outcome.note,
|
|
next_step=(
|
|
f"The plan is still approved; say 'start milestone "
|
|
f"{milestone}' to run it again."
|
|
),
|
|
pending={"kind": "milestone_stopped", "milestone": milestone},
|
|
)
|
|
return
|
|
if outcome.status == "completed":
|
|
self._transport.send_message(f"milestone {milestone} done: {outcome.note}")
|
|
self._publish_milestone(milestone)
|
|
return
|
|
self._await_input(
|
|
cause=f"milestone {milestone} {outcome.status}",
|
|
prompt=f"milestone {milestone} stopped ({outcome.status}): {outcome.note}",
|
|
pending={"kind": "milestone_stopped", "milestone": milestone},
|
|
)
|
|
|
|
def _echo_outcome(self, deciding_channel: str, text: str) -> None:
|
|
"""A gate satisfied on one channel is echoed to the other, so neither
|
|
record silently disagrees about what was approved (L3)."""
|
|
board_state = self._data.get("board")
|
|
if deciding_channel != "gitea" and self._board is not None and board_state:
|
|
self._board.echo(board_state["issue"], text)
|
|
elif deciding_channel == "gitea":
|
|
self._transport.send_message(text)
|
|
|
|
def _post_plan_to_board(self, gate_name: str, rev: int, plan: str) -> None:
|
|
board_state = self._data.get("board")
|
|
if self._board is None or not board_state:
|
|
return
|
|
try:
|
|
post = self._board.post_plan(board_state["issue"], gate_name, rev, plan)
|
|
except GiteaError:
|
|
# Matrix already carries the plan; a Gitea outage degrades the
|
|
# durability of the record, not the ability to approve.
|
|
return
|
|
board_state["comment_id"] = post.comment_id
|
|
# Which comment put which revision up: a Gitea reply binds to the
|
|
# latest plan posted before it, since comment IDs only grow.
|
|
board_state.setdefault("posts", []).append([post.comment_id, rev])
|
|
# Approvals can arrive on the issue as well as over transport (L3),
|
|
# so watch it for as long as the gate stands. A new revision skips
|
|
# what came before it: an early "approve" must not approve it.
|
|
board_state["last_comment"] = self._watch(
|
|
"plan", board_state["issue"], self.handle_plan_comment
|
|
)
|
|
self._save(f"{gate_name} rev-{rev} posted to Gitea")
|
|
|
|
def handle_plan_comment(self, comment: Any) -> None:
|
|
"""A comment on the project issue may satisfy an open plan gate (L3).
|
|
|
|
Approvals bind to a revision here exactly as over transport, and the
|
|
first valid approval on either channel wins. Called from the poller
|
|
thread, which waits for the worker so its cursor never moves past a
|
|
comment that has not been handled.
|
|
"""
|
|
self._worker.call(
|
|
lambda: self._settled(
|
|
"a plan comment", lambda: self._handle_plan_comment_now(comment)
|
|
)
|
|
)
|
|
|
|
def _handle_plan_comment_now(self, comment: Any) -> None:
|
|
board_state = self._data.get("board")
|
|
if comment.author not in self._gitea_operators:
|
|
self._take_comment(board_state, comment) # never actionable
|
|
return
|
|
state = self._store.get_state()
|
|
if state is None or state.state not in (
|
|
AWAIT_ARCH_SIGNOFF,
|
|
AWAIT_MILESTONE_SIGNOFF,
|
|
):
|
|
return # not taken: a watch attached later still sees it
|
|
self._take_comment(board_state, comment)
|
|
self._store.append_event(
|
|
UserMessage(text=comment.body, source=f"gitea:comment:{comment.id}")
|
|
)
|
|
self._apply_gate_reply(
|
|
sender=comment.author,
|
|
text=comment.body.strip(),
|
|
channel="gitea",
|
|
about=_rev_before(board_state, comment.id),
|
|
)
|
|
|
|
def handle_review_comment(self, comment: Any) -> None:
|
|
"""React to a PR review comment (D5). Called from the poller thread.
|
|
|
|
Comment authors are checked against the Gitea operator allowlist:
|
|
a PR comment is an instruction channel into an agent that writes
|
|
code, so it is operator-scoped like any other (L11, and the same
|
|
reasoning O5 applies to tickets).
|
|
"""
|
|
self._worker.call(
|
|
lambda: self._settled(
|
|
"a review comment", lambda: self._handle_review_comment_now(comment)
|
|
)
|
|
)
|
|
|
|
def _handle_review_comment_now(self, comment: Any) -> None:
|
|
if comment.author not in self._gitea_operators:
|
|
self._take_comment(self._data.get("pr"), comment) # never actionable
|
|
self._store.oplog.emit(
|
|
"gitea.comment_ignored",
|
|
cause=f"author {comment.author!r} not an operator",
|
|
comment_id=comment.id,
|
|
)
|
|
return
|
|
state = self._store.get_state()
|
|
if state is None or state.state != PR_OPEN:
|
|
# Not taken: back in PR_OPEN, the watch resumes from the cursor
|
|
# and delivers it again.
|
|
self._store.oplog.emit(
|
|
"gitea.comment_ignored",
|
|
cause=f"not in PR_OPEN (state={state.state if state else None})",
|
|
comment_id=comment.id,
|
|
)
|
|
return
|
|
self._take_comment(self._data.get("pr"), comment)
|
|
if self._executor is None or self._publisher is None:
|
|
return
|
|
|
|
pr = self._data.get("pr") or {}
|
|
milestone = int(pr.get("milestone", self._data.get("milestone", 1)))
|
|
# Taken at most once, but answered at least once: until the executor
|
|
# returns, the comment is marked, and a restart delivers it again.
|
|
pr["reviewing"] = comment.id
|
|
self._save(f"answering review comment {comment.id}")
|
|
# The comment enters the transcript, so the executor sees review
|
|
# feedback as conversation rather than as an out-of-band instruction.
|
|
self._store.append_event(
|
|
UserMessage(text=comment.body, source=f"gitea:comment:{comment.id}")
|
|
)
|
|
try:
|
|
with self._long_work():
|
|
outcome = self._executor.run(milestone)
|
|
except Exception:
|
|
# Reported to the operator by _settled: answered, if not well.
|
|
self._done_reviewing(pr)
|
|
raise
|
|
self._done_reviewing(pr)
|
|
if outcome.status == "stopped":
|
|
self._halt_for_stop(
|
|
what=f"the response to review comment {comment.id}",
|
|
note=outcome.note,
|
|
next_step="Your reply returns to the open PR.",
|
|
pending={"kind": "review_stopped", "milestone": milestone},
|
|
)
|
|
return
|
|
if outcome.status != "completed":
|
|
self._await_input(
|
|
cause=f"review response {outcome.status}",
|
|
prompt=(
|
|
f"could not address the review comment ({outcome.status}): "
|
|
f"{outcome.note}. Reply to return to the open PR; comment "
|
|
"again there to retry."
|
|
),
|
|
pending={"kind": "review_stopped", "milestone": milestone},
|
|
)
|
|
return
|
|
try:
|
|
sha = self._publisher.push_followup(
|
|
task_id=str(self._data.get("task_id")),
|
|
milestone=milestone,
|
|
message=f"Address review comment {comment.id}",
|
|
)
|
|
except (GiteaError, GitError) as exc:
|
|
self._transport.send_message(f"could not push the fix: {exc}")
|
|
return
|
|
note = (
|
|
f"pushed {sha[:8]} addressing the review comment"
|
|
if sha
|
|
else "no code change was needed for that comment"
|
|
)
|
|
self._transport.send_message(note)
|
|
if pr.get("number"):
|
|
self._publisher.comment(int(pr["number"]), note)
|
|
|
|
def _done_reviewing(self, pr: dict[str, Any]) -> None:
|
|
"""The executor returned: the comment is being answered one way or
|
|
another. Deliberately not in a `finally`: if the process dies
|
|
mid-response, the mark must survive so the comment is redelivered."""
|
|
if pr.pop("reviewing", None) is not None:
|
|
self._save("review comment answered")
|
|
|
|
def _publish_milestone(self, milestone: int) -> None:
|
|
if self._publisher is None:
|
|
# No PR to wait on, but PR_OPEN's commands still lead on.
|
|
self._transition(PR_OPEN, cause=f"[stub] milestone {milestone} done")
|
|
self._transport.send_message(
|
|
f"[stub] milestone {milestone} done; no publisher, so no PR — "
|
|
"say 'next milestone', 'project complete' or 'abandon'"
|
|
)
|
|
return
|
|
gate = self._data.get("gate") or {}
|
|
try:
|
|
result = self._publisher.publish(
|
|
task_id=str(self._data.get("task_id")),
|
|
milestone=milestone,
|
|
project=str(self._project),
|
|
plan=str(gate.get("plan", "")),
|
|
)
|
|
except (GiteaError, GitError) as exc:
|
|
# The intent log already records what was in flight, so boot
|
|
# reconciliation can settle it against Gitea. Gate rather than
|
|
# retry blindly into a possible duplicate.
|
|
self._await_input(
|
|
cause=f"publishing milestone {milestone} failed: {exc}",
|
|
prompt=f"could not publish milestone {milestone}: {exc}",
|
|
pending={"kind": "milestone_stopped", "milestone": milestone},
|
|
)
|
|
return
|
|
if result.pull_request is None:
|
|
# Nothing left EXECUTING behind: the operator decides what next,
|
|
# and answering returns to the still-approved plan.
|
|
self._await_input(
|
|
cause=f"milestone {milestone}: {result.note}",
|
|
prompt=(
|
|
f"milestone {milestone}: {result.note}. Reply to return to "
|
|
"the approved plan."
|
|
),
|
|
pending={"kind": "milestone_stopped", "milestone": milestone},
|
|
)
|
|
return
|
|
self._unwatch("plan") # the gate is behind us; the PR is the thread now
|
|
self._data["pr"] = {
|
|
"number": result.pull_request.number,
|
|
"url": result.pull_request.url,
|
|
"milestone": milestone,
|
|
"last_comment": self._watch(
|
|
"review", result.pull_request.number, self.handle_review_comment
|
|
),
|
|
}
|
|
self._transition(PR_OPEN, cause=f"milestone {milestone} published")
|
|
self._transport.send_message(
|
|
f"{result.note} — review it, then say 'next milestone' or "
|
|
"'project complete'"
|
|
)
|
|
|
|
def _abandon(self, sender: str) -> None:
|
|
"""End the task on the operator's word (L2: continue, re-plan, or
|
|
abandon), closing its Gitea records. Only reached from a waiting
|
|
state: while work runs the reply is "busy; say 'stop'" instead.
|
|
|
|
The move to IDLE is made durable first, so a failure while tidying
|
|
Gitea can never leave a live task behind a closed plan issue."""
|
|
note = f"abandoned by {sender}"
|
|
board_state = self._data.get("board") or {}
|
|
pr = self._data.get("pr") or {}
|
|
project = self._project
|
|
self.stop_watching()
|
|
self._data = {**_fresh_data()}
|
|
self._project = None
|
|
self._store.oplog.task_id = None
|
|
self._transition(IDLE, cause=f"operator: abandon ({sender})")
|
|
|
|
# Dropped on purpose: boot reconciliation must not gate on it.
|
|
for intent in self._store.pending_intents():
|
|
self._store.abort_intent(intent.id, cause=note)
|
|
problems: list[str] = []
|
|
if problem := self._close_plan_issue(board_state, note):
|
|
problems.append(problem)
|
|
if self._publisher is not None and pr.get("number"):
|
|
try:
|
|
self._publisher.close_pull_request(int(pr["number"]), note)
|
|
except GiteaError as exc:
|
|
problems.append(f"PR #{pr['number']}: {exc}")
|
|
closed = "; could not close " + ", ".join(problems) if problems else ""
|
|
self._transport.send_message(
|
|
f"task on {project} abandoned{closed}. Say 'use <project>' to start another"
|
|
)
|
|
|
|
def _published_pr(self) -> Any:
|
|
"""The open PR for the current milestone, if Gitea shows one; None
|
|
when there is none or Gitea cannot say (then the restart question
|
|
is asked as before)."""
|
|
task_id = self._data.get("task_id")
|
|
if self._publisher is None or not task_id:
|
|
return None
|
|
try:
|
|
return self._publisher.published_pull_request(
|
|
str(task_id), int(self._data.get("milestone", 1))
|
|
)
|
|
except GiteaError:
|
|
return None
|
|
|
|
def _close_plan_issue(self, board_state: dict[str, Any], note: str) -> str | None:
|
|
"""Close a finished task's plan issue with a note; what went wrong,
|
|
if anything, for the operator's message."""
|
|
if self._board is None or not board_state.get("issue"):
|
|
return None
|
|
try:
|
|
self._board.close(int(board_state["issue"]), note)
|
|
except GiteaError as exc:
|
|
return f"plan issue #{board_state['issue']}: {exc}"
|
|
return None
|
|
|
|
def _return_to_pr_open(self, cause: str) -> None:
|
|
self._transition(PR_OPEN, cause=cause)
|
|
pr = self._data.get("pr") or {}
|
|
self._watch_pr(pr)
|
|
self._transport.send_message(
|
|
f"PR #{pr.get('number')} is open and watched for review comments; "
|
|
"say 'next milestone', 'project complete' or 'abandon'"
|
|
)
|
|
|
|
def _watch_pr(self, pr: dict[str, Any]) -> None:
|
|
"""Watch the open PR from its recorded cursor, catching up on what
|
|
arrived while it was not watched."""
|
|
if pr.get("number"):
|
|
after = pr.get("last_comment")
|
|
reviewing = pr.get("reviewing")
|
|
if reviewing:
|
|
# Its answer never finished: resume just before it.
|
|
after = min(
|
|
int(after if after is not None else reviewing), reviewing - 1
|
|
)
|
|
self._watch(
|
|
"review", int(pr["number"]), self.handle_review_comment, after=after
|
|
)
|
|
|
|
def _handle_pr_open(self, text: str, sender: str = "the operator") -> None:
|
|
command = command_form(text)
|
|
if command == "next milestone":
|
|
self._unwatch("review")
|
|
self._data["milestone"] = int(self._data.get("milestone", 1)) + 1
|
|
self._data["gate"] = None
|
|
self._data["pr"] = None
|
|
self._transition(MILESTONE_PLANNING, cause="operator: next milestone")
|
|
self._run_planning_turn(f"milestone-{self._data['milestone']}")
|
|
elif command == "project complete":
|
|
board_state = self._data.get("board") or {}
|
|
self.stop_watching()
|
|
self._data = {**_fresh_data()}
|
|
self._project = None
|
|
self._store.oplog.task_id = None
|
|
self._transition(IDLE, cause="operator: project complete")
|
|
# IDLE first, as for abandon; the PR is the operator's to merge.
|
|
problem = self._close_plan_issue(board_state, f"completed by {sender}")
|
|
self._transport.send_message(
|
|
"project closed"
|
|
+ (f"; could not close {problem}" if problem else "")
|
|
+ ". Say 'use <project>' to start another"
|
|
)
|
|
else:
|
|
pr = self._data.get("pr") or {}
|
|
self._transport.send_message(
|
|
f"PR #{pr.get('number')} is open; say 'next milestone', "
|
|
"'project complete' or 'abandon'"
|
|
)
|
|
|
|
def _watch(
|
|
self, kind: str, index: int, handler: Any, *, after: int | None = None
|
|
) -> int:
|
|
"""Start watching one Gitea thread, replacing any previous watch of
|
|
the same kind, and return the comment cursor to record.
|
|
|
|
A fresh watch (after=None) skips everything already there, so it
|
|
does not replay comments already addressed. Watching again from a
|
|
recorded cursor delivers what came in while nothing was watching."""
|
|
if self._poller_factory is None:
|
|
return after or 0
|
|
self._unwatch(kind)
|
|
poller = self._poller_factory(handler)
|
|
if after is None:
|
|
cursor = poller.prime(index)
|
|
else:
|
|
poller.resume_after(after)
|
|
cursor = after
|
|
poller.start(index)
|
|
self._pollers[kind] = poller
|
|
return cursor
|
|
|
|
def _save(self, cause: str) -> None:
|
|
"""Make changes to the task's data durable without a state change."""
|
|
state = self._store.get_state()
|
|
self._transition(state.state if state else IDLE, cause=cause)
|
|
|
|
def _take_comment(self, record: dict[str, Any] | None, comment: Any) -> None:
|
|
"""Advance a thread's cursor past a comment being acted on, durably,
|
|
before acting: a comment is handled at most once, even across a
|
|
crash mid-way."""
|
|
if record is not None:
|
|
record["last_comment"] = max(
|
|
int(record.get("last_comment") or 0), comment.id
|
|
)
|
|
self._save(f"gitea comment {comment.id} taken")
|
|
|
|
def _unwatch(self, kind: str) -> None:
|
|
poller = self._pollers.pop(kind, None)
|
|
if poller is not None:
|
|
poller.stop()
|
|
|
|
def stop_watching(self) -> None:
|
|
for kind in list(self._pollers):
|
|
self._unwatch(kind)
|
|
|
|
def _await_input(self, *, cause: str, prompt: str, pending: dict[str, Any]) -> None:
|
|
# The prompt is kept with what it resumes, so a restart can ask the
|
|
# same question again.
|
|
self._data["pending"] = {**pending, "prompt": prompt}
|
|
self._transition(AWAIT_INPUT, cause=cause)
|
|
self._transport.ask_question(prompt)
|
|
|
|
def _handle_answer(self, text: str) -> None:
|
|
pending = self._data.get("pending")
|
|
if pending is None:
|
|
# Reached only if state was forced externally or a pending item
|
|
# was lost; starting over must still work from here.
|
|
if command_form(text).startswith("use "):
|
|
self.stop_watching()
|
|
self._begin_project(_project_name(text))
|
|
return
|
|
self._transport.send_message(
|
|
"waiting for direction, but I have no pending item to resume; "
|
|
"try 'status', or 'use <project>' to start over"
|
|
)
|
|
return
|
|
self._data["pending"] = None
|
|
match pending.get("kind"):
|
|
case "question" | "planning_failed":
|
|
gate_name = pending["gate"]
|
|
back = PLANNING_ARCH if gate_name == "arch" else MILESTONE_PLANNING
|
|
self._transition(back, cause="operator answered; resuming planning")
|
|
self._run_planning_turn(gate_name, answer=text)
|
|
case "review_stopped":
|
|
self._return_to_pr_open("operator answered; back to the open PR")
|
|
case "boot":
|
|
self._resume_after_restart(pending, text)
|
|
case "milestone_stopped":
|
|
milestone = pending["milestone"]
|
|
self._transition(
|
|
AWAIT_MILESTONE_SIGNOFF,
|
|
cause="operator answered; plan still approved",
|
|
)
|
|
self._transport.send_message(
|
|
f"noted. the milestone {milestone} plan is still approved — "
|
|
f"say 'start milestone {milestone}' to run it again"
|
|
)
|
|
case _:
|
|
self._transport.send_message(
|
|
"noted. say 'status' for where things stand"
|
|
)
|
|
|
|
def _resume_after_restart(self, pending: dict[str, Any], text: str) -> None:
|
|
"""Answer to the question a restart asks (L8 boot step 5). Never a blind
|
|
resume: 'continue' re-runs the interrupted planning turn, or returns
|
|
an interrupted milestone to its approved plan to be started again."""
|
|
choice = command_form(text)
|
|
if choice not in ("continue", "re-plan", "replan"):
|
|
self._data["pending"] = pending
|
|
self._transport.ask_question(_BOOT_CHOICES)
|
|
return
|
|
milestone = int(self._data.get("milestone", 1))
|
|
gate = self._data.get("gate") or {}
|
|
interrupted = pending.get("interrupted")
|
|
if interrupted == PR_OPEN:
|
|
# Recorded before a restart in PR_OPEN resumed quietly. Its PR is
|
|
# still open, so either reply goes back to it; re-planning the
|
|
# milestone would open a second PR.
|
|
self._return_to_pr_open(f"operator: {choice} after restart")
|
|
return
|
|
if choice == "continue" and interrupted == EXECUTING and gate.get("approved"):
|
|
self._transition(
|
|
AWAIT_MILESTONE_SIGNOFF, cause="operator: continue after restart"
|
|
)
|
|
self._transport.send_message(
|
|
f"the milestone {milestone} plan is still approved; say "
|
|
f"'start milestone {milestone}' to run it"
|
|
)
|
|
return
|
|
if interrupted == PLANNING_ARCH:
|
|
gate_name = "arch"
|
|
elif interrupted in (MILESTONE_PLANNING, EXECUTING):
|
|
gate_name = f"milestone-{milestone}"
|
|
else:
|
|
# Not recorded (a question asked before the interrupted state
|
|
# was): judge by how far planning got.
|
|
planned = self._data.get("revs", {})
|
|
gate_name = gate.get("name") or (
|
|
"arch" if "arch" not in planned else f"milestone-{milestone}"
|
|
)
|
|
self._data["gate"] = None
|
|
back = PLANNING_ARCH if gate_name == "arch" else MILESTONE_PLANNING
|
|
self._transition(back, cause=f"operator: {choice} after restart")
|
|
self._run_planning_turn(gate_name)
|
|
|
|
def _transition(self, new_state: str, *, cause: str) -> None:
|
|
state = self._store.get_state()
|
|
current = state.state if state else None
|
|
allowed = (
|
|
current is None
|
|
or current == new_state
|
|
or new_state == AWAIT_INPUT
|
|
or current == AWAIT_INPUT
|
|
or (current, new_state) in TRANSITIONS
|
|
)
|
|
if not allowed:
|
|
raise TransitionError(f"illegal transition {current} -> {new_state}")
|
|
self._store.transition(
|
|
new_state, cause=cause, project=self._project, data=self._data
|
|
)
|
|
self._state_name = new_state
|
|
self._shown_rev = int(self._data.get("shown_rev") or 0)
|