Publish finished milestones as PRs (B2/A3) — step 6a, part 2
Completes the B1 loop past EXECUTING, and answers the question raised reviewing #6: PR acceptance is a milestone's final gate, so there is no separate FINISHED state — completion returns to milestone planning or to IDLE. - publish.py: MilestonePublisher commits the work on a task-namespaced branch, pushes, and opens a PR carrying the approved plan. Intent is recorded before the first externally visible act, so a crash anywhere in the sequence leaves a durable record and boot reconciliation asks Gitea whether the PR exists rather than opening a second one - Nothing to commit is reported, not raised: the executor may legitimately conclude there was nothing to change - control.py: PR_OPEN state with EXECUTING -> PR_OPEN and PR_OPEN -> {MILESTONE_PLANNING, IDLE}; operator commands 'next milestone' and 'project complete' - A configured publisher always contributes its own reconciler at boot, so an interrupted publish is settled against external truth without the caller having to remember to register it - A publish failure gates to the operator rather than retrying into a possible duplicate Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4025ac7875
commit
3117565928
4 files changed
+546
-3
No files matched your search
@@ -15,6 +15,8 @@ from typing import Any, Protocol
|
||||
|
||||
from nisse_dev.canonical import GateOpened, PlanSubmitted, UserMessage
|
||||
from nisse_dev.gates import Approved, Gate, Ignored, PlanRevision, Rejected
|
||||
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
|
||||
@@ -26,6 +28,7 @@ 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 = {
|
||||
@@ -36,6 +39,12 @@ TRANSITIONS = {
|
||||
(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 (B1): 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),
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +76,17 @@ class MilestoneExecutor(Protocol):
|
||||
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: ...
|
||||
|
||||
|
||||
class PlanningAgent(Protocol):
|
||||
def plan(
|
||||
self,
|
||||
@@ -105,6 +125,7 @@ def _fresh_data() -> dict[str, Any]:
|
||||
"milestone": 1,
|
||||
"task_id": None,
|
||||
"roles": None, # A11 mapping, pinned at task start
|
||||
"pr": None, # the open PR for the current milestone, once published
|
||||
}
|
||||
|
||||
|
||||
@@ -117,19 +138,26 @@ class Orchestrator:
|
||||
operators: Iterable[str],
|
||||
roles: RoleMap | None = None,
|
||||
executor: "MilestoneExecutor | None" = None,
|
||||
publisher: "MilestonePublisher | None" = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._transport = transport
|
||||
self._agent = agent
|
||||
self._roles = roles
|
||||
self._executor = executor
|
||||
self._publisher = publisher
|
||||
self._operators = set(operators)
|
||||
self._data = _fresh_data()
|
||||
self._project: str | None = None
|
||||
transport.on_message(self._handle)
|
||||
|
||||
def start(self, reconcilers: Mapping[str, Reconciler] | None = None) -> None:
|
||||
report = reconcile_on_boot(self._store, reconcilers or {})
|
||||
# The publisher owns the external-truth query for its own intents, so
|
||||
# a configured publisher always contributes its reconciler (A3).
|
||||
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 None:
|
||||
self._transition(IDLE, cause="boot: fresh state")
|
||||
@@ -189,6 +217,8 @@ class Orchestrator:
|
||||
self._transport.send_message("no active project; say 'use <project>'")
|
||||
elif current in (AWAIT_ARCH_SIGNOFF, AWAIT_MILESTONE_SIGNOFF):
|
||||
self._handle_gate_reply(msg, text)
|
||||
elif current == PR_OPEN:
|
||||
self._handle_pr_open(text)
|
||||
elif current == AWAIT_INPUT:
|
||||
self._handle_answer(text)
|
||||
else:
|
||||
@@ -337,9 +367,8 @@ class Orchestrator:
|
||||
self._transport.send_message(f"executing milestone {milestone}")
|
||||
outcome = self._executor.run(milestone)
|
||||
if outcome.status == "completed":
|
||||
# PR_OPEN and the review loop arrive with the Gitea layer (B2);
|
||||
# until then a finished milestone simply reports and waits.
|
||||
self._transport.send_message(f"milestone {milestone} done: {outcome.note}")
|
||||
self._publish_milestone(milestone)
|
||||
return
|
||||
self._await_input(
|
||||
cause=f"milestone {milestone} {outcome.status}",
|
||||
@@ -347,6 +376,63 @@ class Orchestrator:
|
||||
pending={"kind": "milestone_stopped", "milestone": milestone},
|
||||
)
|
||||
|
||||
def _publish_milestone(self, milestone: int) -> None:
|
||||
if self._publisher is None:
|
||||
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:
|
||||
self._transport.send_message(result.note)
|
||||
return
|
||||
self._data["pr"] = {
|
||||
"number": result.pull_request.number,
|
||||
"url": result.pull_request.url,
|
||||
"milestone": milestone,
|
||||
}
|
||||
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 _handle_pr_open(self, text: str) -> None:
|
||||
if text == "next milestone":
|
||||
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 text == "project complete":
|
||||
self._data = {**_fresh_data()}
|
||||
self._project = None
|
||||
self._store.oplog.task_id = None
|
||||
self._transition(IDLE, cause="operator: project complete")
|
||||
self._transport.send_message(
|
||||
"project closed; 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' or "
|
||||
"'project complete'"
|
||||
)
|
||||
|
||||
def _await_input(self, *, cause: str, prompt: str, pending: dict[str, Any]) -> None:
|
||||
self._data["pending"] = pending
|
||||
self._transition(AWAIT_INPUT, cause=cause)
|
||||
|
||||
Reference in new issue
Block a user