From ecacf81cf4c2a0c4aec3392f878b94eead4d72b2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 10:49:48 -0400 Subject: [PATCH 01/15] P433: derive commander order refusals from standing rules --- src/agent_comms/commander/order.py | 14 +++++--------- src/agent_comms/commander/standing.py | 14 ++++++++++---- src/agent_comms/standing_rules.py | 17 ++++++++++++++++- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index ea099ca454..50238ff361 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -56,7 +56,7 @@ from agent_comms.progress_claims import MergedClaim, WorkingClaim from agent_comms.recorded_time import RecordedEventTime from agent_comms.repo import Repo -from agent_comms.standing_rules import HuntDue +from agent_comms.standing_rules import HuntDue, OrderRefusingStandingRule from agent_comms.storage_rules import UnitStorage from agent_comms.thread_identity import ThreadIncarnation from agent_comms.todos import ( @@ -820,14 +820,10 @@ def answer_references(self, ctx: Commander) -> tuple[MessageReference, ...]: def gate(self, ctx: Commander) -> None: answers = self.answer_references(ctx) thread = ctx.comms.registry.require(self.who(ctx)) - overdue = tuple(directive.original(ctx.comms.goals) - for goal in thread.goal.active() - for directive in goal.order.overdue_directives( - goal, ctx.comms.goals, thread.incarnation, time.time())) - if overdue and not answers: - lines = '\n'.join(f'{message.message_id}: {message.body[:80]}' for message in overdue) - sys.exit('REFUSED, nothing recorded: overdue owner directives:\n' + lines - + '\nReply to the original message or use --answers MESSAGE_ID.') + refusals = tuple(line for rule in OrderRefusingStandingRule.broken(ctx.comms, thread.name) + for line in rule.refusal(ctx.comms, answers)) + if refusals: + sys.exit('REFUSED, nothing recorded:\n' + '\n'.join(refusals)) super().gate(ctx) diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index bd8e827a01..bd248f82ff 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -23,7 +23,9 @@ from agent_comms.bus_page_index import BusPageIndex from agent_comms.wire_log import WireSnapshot from agent_comms.message_bus import WatchPass -from agent_comms.standing_rules import ReasonedStandingRule, StandingRule, UndatedStandingRule +from agent_comms.standing_rules import ( + OrderRefusingStandingRule, ReasonedStandingRule, StandingRule, UndatedStandingRule, +) from agent_comms.storage_rules import OrderCheckoutWork from agent_comms.thread_identity import CoreWatcherIncarnation, ThreadIncarnation from agent_comms.threads import Thread @@ -367,9 +369,13 @@ def heartbeat_line(self, current: Sequence[WaitingUnownedWheelSpin]) -> str: @dataclass(frozen=True) -class Unjudged(ReasonedStandingRule, UndatedStandingRule): - limit: ClassVar[int] = 30 - text = "UNJUDGED {why} edits -> judge or fork a judge slice" +class Unjudged(OrderRefusingStandingRule, ReasonedStandingRule, UndatedStandingRule): + limit: ClassVar[int] = 50 + text = "UNJUDGED {why} edits -> judge the edits before sending more orders" + + def refusal(self, comms: Comms, answers: Sequence[MessageReference]) -> tuple[str, ...]: + return (f"{self.why} unjudged edits exceed the {self.limit} edit ceiling; " + "judge is the way out: judge the edits before sending more orders.",) @classmethod def reason(cls, comms: Comms, commander: str) -> str | None: diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index 3cc6261062..d5fe2a52e4 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -24,6 +24,7 @@ if TYPE_CHECKING: from agent_comms.comms import Comms from agent_comms.goals import RequestDirective + from agent_comms.message_reference import MessageReference from agent_comms.storage_rules import MemoryUse, StorageRule from agent_comms.todos import Todo @@ -107,6 +108,13 @@ def broken(cls, comms: Comms, commander: str, after: float = 0.0) -> tuple[Stand return tuple(rule for rule in found if isinstance(rule, cls) and rule.broke_after(after)) +class OrderRefusingStandingRule(StandingRule, declared_name=None): + """A broken rule owns the reason an unrelated commander order cannot proceed.""" + + @abstractmethod + def refusal(self, comms: Comms, answers: Sequence[MessageReference]) -> tuple[str, ...]: ... + + class UndatedStandingRule(StandingRule, declared_name=None): """A rule that cannot say when its condition began: it is broken on every read.""" @@ -290,7 +298,7 @@ def line(self) -> str: @dataclass(frozen=True) -class DirectiveUnacted(DatedStandingRule): +class DirectiveUnacted(OrderRefusingStandingRule, DatedStandingRule): """A claude-ai directive recorded on an open request, past its deadline with no order since.""" request: str @@ -316,6 +324,13 @@ def line(self) -> str: f" due {self.directive.due_time} -> order its next step or mark the request done" ) + def refusal(self, comms: Comms, answers: Sequence[MessageReference]) -> tuple[str, ...]: + message = self.directive.original(comms.goals) + if message.reference in answers: + return () + return (f"{message.message_id}: overdue owner directive on request {self.request}; " + "reply to the original message or use --answers MESSAGE_ID.",) + def heartbeat_group(self) -> object: return type(self), self.request From ce22c97462d864bca99e98502a43d5482b304bbd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 10:59:36 -0400 Subject: [PATCH 02/15] P433: centralize unit state derivation and review-head ceiling --- src/agent_comms/commander/judge.py | 4 +- src/agent_comms/commander/order.py | 17 +- src/agent_comms/commander/standing.py | 204 +--------- src/agent_comms/messages.py | 10 +- .../skills/comms-commander/SKILL.md | 10 +- src/agent_comms/standing_rules.py | 46 --- src/agent_comms/todos.py | 23 ++ src/agent_comms/ui_model/unit_state.py | 378 ++++++++++++++++++ 8 files changed, 434 insertions(+), 258 deletions(-) create mode 100644 src/agent_comms/ui_model/unit_state.py diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index 8f9490dcbe..7e59feb182 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -97,7 +97,9 @@ def show(ctx, pending: list[Edit]) -> None: by_thread.setdefault(edit.thread, []).append(edit) for name, edits in by_thread.items(): orders = ctx.held_by(name) - intent = '\n'.join(todo.order.instructions(todo) for todo in orders) or 'NO OPEN ORDER TODO' + intent = '\n'.join('\n'.join((todo.order.instructions(todo), + *(f'Sent: {message.message_id}' for message in todo.messages[-1:]))) + for todo in orders) or 'NO OPEN ORDER TODO' print(f'===== {name}: {len(edits)} unjudged\nCURRENT ORDERS:\n{intent}\n') for edit in edits: print(edit.rendered()) diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index 50238ff361..13d132a963 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -101,7 +101,8 @@ TodoList, ) from agent_comms.commander.config import CommanderConfig, CommanderRole -from agent_comms.commander.standing import UnitAskUnanswered, UnownedWheelSpin +from agent_comms.commander.standing import UnownedWheelSpin +from agent_comms.ui_model.unit_state import UnitState OUTGOING = DirectiveText.OUTGOING # every commander text passes each one @@ -794,8 +795,8 @@ def parse(cls, argv: list[str], description: str) -> CommanderVerb: @dataclass(frozen=True, kw_only=True) -class DirectiveVerb(CommanderVerb): - """An action may answer named directives; unrelated work cannot clear them.""" +class AnsweringVerb(CommanderVerb): + """An action answers only original directive or unit message references.""" answers: Annotated[ tuple[str, ...], @@ -810,13 +811,17 @@ def answer_references(self, ctx: Commander) -> tuple[MessageReference, ...]: thread = ctx.comms.registry.require(self.who(ctx)) answerable = (*(message for goal in thread.goal.active() for message in goal.order.directive_messages(ctx.comms.goals)), - *UnitAskUnanswered.asks(ctx.comms, thread.name, time.time())) + *UnitState.asks(ctx.comms, thread.name, time.time())) originals = tuple(message for message in answerable if message.message_id in self.answers) if set(self.answers) != {message.message_id for message in originals}: sys.exit('REFUSED: --answers must name original directives on this request ' 'or unanswered unit messages to this commander.') return tuple(message.reference for message in originals) + +class DirectiveVerb(AnsweringVerb): + """New commander work cannot grow past a broken order-refusing rule.""" + def gate(self, ctx: Commander) -> None: answers = self.answer_references(ctx) thread = ctx.comms.registry.require(self.who(ctx)) @@ -875,7 +880,7 @@ def run(self, ctx: Commander) -> None: @dataclass(frozen=True, kw_only=True) -class StrikeVerb(OrderVerb, DirectiveVerb): +class StrikeVerb(OrderVerb, AnsweringVerb): help = "a send-back or a false claim" unit: Annotated[str, Argument(flags=("unit",))] pid: Annotated[str, Argument(flags=("pid",), options={"help": "the struck problem"})] @@ -1205,7 +1210,7 @@ def run(self, ctx: Commander) -> None: @dataclass(frozen=True, kw_only=True) -class HuntVerb(OrderVerb): +class HuntVerb(OrderVerb, DirectiveVerb): help = "a hot-path read: clean unless --sent names the order it became" where: Annotated[ str, Argument(flags=("where",), options={"metavar": "FILE:LINE"}) diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index bd248f82ff..77dcd16188 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -4,24 +4,13 @@ import time from collections.abc import Iterable, Sequence -from abc import abstractmethod -from contextlib import closing -from dataclasses import dataclass, field, replace -from pathlib import Path +from dataclasses import dataclass from typing import ClassVar, Self from agent_comms.comms import Comms -from agent_comms.goal_actions import GoalPrecondition, ReissuedOrderGoalAction +from agent_comms.goal_actions import ReissuedOrderGoalAction from agent_comms.goals import Goal -from agent_comms.goal_states import BlockedState -from agent_comms.goal_history import GoalHistoryStore -from agent_comms.goal_generation import GenerationState, RetryableGeneration -from agent_comms.message_page import MessageReadRequest from agent_comms.message_reference import MessageReference -from agent_comms.messages import Message -from agent_comms.read_basis import RepliesDisplayScope -from agent_comms.bus_page_index import BusPageIndex -from agent_comms.wire_log import WireSnapshot from agent_comms.message_bus import WatchPass from agent_comms.standing_rules import ( OrderRefusingStandingRule, ReasonedStandingRule, StandingRule, UndatedStandingRule, @@ -30,129 +19,10 @@ from agent_comms.thread_identity import CoreWatcherIncarnation, ThreadIncarnation from agent_comms.threads import Thread from agent_comms.todos import Todo -from agent_comms.turn_digests import OrderEditCountRead, TurnDigestRead, TurnsSince +from agent_comms.turn_digests import TurnDigestRead, TurnsSince from agent_comms.work_dispatch import ReusedWorkDispatch, WorkDispatch from agent_comms.commander.config import CommanderConfig - - -@dataclass(frozen=True) -class HolderCannotRun(UndatedStandingRule, declared_name=None): - unit: str - orders: tuple[str, ...] - - @staticmethod - def holders(comms: Comms, commander: str) -> dict[ThreadIncarnation, list[Todo]]: - held: dict[ThreadIncarnation, list[Todo]] = {} - for todo in StandingRule.pending_todos(comms, commander): - held.setdefault(todo.held().owner_identity, []).append(todo) - return held - - @classmethod - def scan_owner(cls) -> type[StandingRule]: - return HolderCannotRun - - @staticmethod - @abstractmethod - def stalled(comms: Comms, thread: Thread) -> bool: ... - - @classmethod - @abstractmethod - def found(cls, comms: Comms, thread: Thread, orders: tuple[str, ...]) -> Self: ... - - @classmethod - def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: - registry = comms.registry.snapshot() - found: list[Self] = [] - members = cls.members_with(cls) - for owner, todos in cls.holders(comms, commander).items(): - thread = registry.require(owner.name) - if thread.incarnation != owner: - raise ValueError("Held order owner incarnation changed") - orders = tuple(todo.id for todo in todos) - found.extend(member.found(comms, thread, orders) - for member in members if member.stalled(comms, thread)) - return tuple(found) - - -@dataclass(frozen=True) -class Detached(HolderCannotRun): - unread: int = field(compare=False) - - @staticmethod - def stalled(comms: Comms, thread: Thread) -> bool: - return not comms.registry.status(thread.name).receiving - - @classmethod - def found(cls, comms: Comms, thread: Thread, orders: tuple[str, ...]) -> Self: - return cls(thread.name, orders, comms.bus.pending_count(thread.name)) - - def line(self) -> str: - return (f"DETACHED {self.unit}: {self.unread} unread, orders {', '.join(self.orders)}" - " -> wake it or reassign") - - -@dataclass(frozen=True) -class BlockedHolder(HolderCannotRun, declared_name=None): - """An idle holder classified by its goal and original attempt ledger.""" - - block_record: Path - goal: Goal - - @staticmethod - @abstractmethod - def accepts(goal: Goal, generation: GenerationState) -> bool: ... - - @classmethod - def stalled(cls, comms: Comms, thread: Thread) -> bool: - store = comms.goals.open_goal_store() - return not thread.executing and any( - cls.accepts(goal, store.require_generation(goal.id).lifecycle) - for goal in thread.goal) - - @classmethod - def found(cls, comms: Comms, thread: Thread, orders: tuple[str, ...]) -> Self: - return cls( - thread.name, orders, GoalHistoryStore.path_in(comms.root), thread.goal.require() - ) - - -@dataclass(frozen=True) -class UnresolvedBlock(BlockedHolder): - """An attempt with an unknown outcome: Core never retries it, so it is never replayed.""" - - @staticmethod - def accepts(goal: Goal, generation: GenerationState) -> bool: - return not isinstance(generation, RetryableGeneration) - - def line(self) -> str: - return (f"UNRESOLVED BLOCK {self.unit}: orders {', '.join(self.orders)}; " - f"goal {self.goal.id}@{self.goal.revision} has an attempt with an unknown result " - "-> inspect it, then fail or finish the attempt") - - -@dataclass(frozen=True) -class Blocked(BlockedHolder): - @staticmethod - def accepts(goal: Goal, generation: GenerationState) -> bool: - return isinstance(goal.state, BlockedState) and isinstance(generation, RetryableGeneration) - - def enforce(self, comms: Comms, commander: str) -> None: - observed = self.goal - for problem in self.orders: - todo = comms.goals.todos.get(problem) - todo.require_assignee(comms.registry.require(self.unit).incarnation, observed.id) - action = UnownedWheelSpin.order_for(todo, commander, CoreWatcherIncarnation.watcher()) - observed = comms.goals.update_goal(self.unit, replace( - action, expect=GoalPrecondition( - goal_id=observed.id, expected_state=observed.state, - expected_checkpoint=observed.captured, - ), - )) - - def line(self) -> str: - return (f"BLOCKED {self.unit}: orders {', '.join(self.orders)}; " - f"block record {self.block_record}, goal {self.goal.id}@{self.goal.revision} " - "-> Core reissues the orders") +from agent_comms.ui_model.unit_state import UnitState @dataclass(frozen=True) @@ -172,61 +42,6 @@ def line(self) -> str: " -> order its next step or mark it done") -@dataclass(frozen=True, slots=True) -class UnansweredUnitAsks(MessageReadRequest[tuple[Message, ...]]): - scope: RepliesDisplayScope - since: float - before: float - empty: ClassVar[tuple[Message, ...]] = () - - def indexed(self, index: BusPageIndex, source: WireSnapshot) -> tuple[Message, ...]: - answered: set[MessageReference] = set() - found: list[Message] = [] - with closing(index.recent_offsets( - self.since, predicate=self.scope.index_predicate, before_offset=source.boundary, - )) as offsets: - for row in self.indexed_records(source, offsets): - message = row.message - if message.sender in self.scope.first_names: - answered.update(message.answers) - elif message.timestamp <= self.before and message.reference not in answered: - found.append(message) - return tuple(found) - - -@dataclass(frozen=True) -class UnitAskUnanswered(UndatedStandingRule): - unit: str - reference: MessageReference - sent_at: float - seconds: ClassVar[int] = 15 * 60 - - @staticmethod - def asks(comms: Comms, commander: str, before: float) -> tuple[Message, ...]: - """Holders' messages to the commander, sent by ``before``, that no commander reply answers.""" - held = HolderCannotRun.holders(comms, commander) - if not held: - return () - snapshot = comms.registry.snapshot() - peers = frozenset(name for owner in held for name in snapshot.names_for(owner.name)) - since = min(todo.order.checked().at.recorded_at("A held order") - for todos in held.values() for todo in todos) - return comms.bus.log.read_page(UnansweredUnitAsks( - RepliesDisplayScope(snapshot.names_for(commander), peers), since, before, - )) - - @classmethod - def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: - snapshot = comms.registry.snapshot() - return tuple(cls(snapshot.canonical_name(message.sender), message.reference, message.timestamp) - for message in cls.asks(comms, commander, time.time() - cls.seconds)) - - def line(self) -> str: - age = max(0, int((time.time() - self.sent_at) // 60)) - return (f"UNANSWERED {self.unit}: message {self.reference.seq}:{self.reference.message_id}, " - f"{age} min old -> commander must answer") - - @dataclass(frozen=True) class UnownedWheelSpin(UndatedStandingRule): """A unit holds no next order while the board has unowned rows; Core hands the row to it @@ -239,7 +54,7 @@ class UnownedWheelSpin(UndatedStandingRule): @classmethod def idle(cls, comms: Comms, commander: str) -> list[Thread]: """Units with a recent turn in their own transcript and no order, parked threads aside.""" - held = {todo.holder for todo in cls.todos(comms, commander)} + held = {owner.name for owner in UnitState.holders(comms, commander)} excluded = held | CommanderConfig.read_root(comms.root).parked | {commander, ""} bound = TurnsSince(time.time() - cls.recent) return sorted((thread for thread in comms.registry.all_threads().values() @@ -371,13 +186,14 @@ def heartbeat_line(self, current: Sequence[WaitingUnownedWheelSpin]) -> str: @dataclass(frozen=True) class Unjudged(OrderRefusingStandingRule, ReasonedStandingRule, UndatedStandingRule): limit: ClassVar[int] = 50 - text = "UNJUDGED {why} edits -> judge the edits before sending more orders" + text = "UNJUDGED {why} pushed heads -> judge the commits before sending more orders" def refusal(self, comms: Comms, answers: Sequence[MessageReference]) -> tuple[str, ...]: - return (f"{self.why} unjudged edits exceed the {self.limit} edit ceiling; " - "judge is the way out: judge the edits before sending more orders.",) + return (f"{self.why} unreviewed pushed heads exceed the {self.limit} head ceiling; " + "judge is the way out: review the commits before sending more orders.",) @classmethod def reason(cls, comms: Comms, commander: str) -> str | None: - total = OrderEditCountRead.count(comms, commander) + total = len(comms.goals.todos.unreviewed_heads( + comms.registry.require(commander).incarnation, comms.registry)) return str(total) if total > cls.limit else None diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index c7bf2bb939..5354ace18d 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -108,7 +108,11 @@ def decode_message(cls, data: Mapping) -> Message: return cast(Message, compile_decoder(Message | ClaimMessage).decode(data)) -class QuestionKind(SendableMessageKind): +class NeedsAnswerKind(SendableMessageKind, declared_name=None): + """A question, decision alert or handoff asks its recipient to act.""" + + +class QuestionKind(NeedsAnswerKind): pass @@ -116,7 +120,7 @@ class AckKind(SendableMessageKind): pass -class HandoffKind(SendableMessageKind): +class HandoffKind(NeedsAnswerKind): """A handoff declares its evidence; completion claims carry references.""" CLAIMS = ("fixed", "single owner", "no other callers") @@ -137,7 +141,7 @@ def require_publishable(cls, message: Message, worktree: Path) -> None: ) -class AlertKind(SendableMessageKind): +class AlertKind(NeedsAnswerKind): pass diff --git a/src/agent_comms/skills/comms-commander/SKILL.md b/src/agent_comms/skills/comms-commander/SKILL.md index 93917d800f..cfef947d2d 100644 --- a/src/agent_comms/skills/comms-commander/SKILL.md +++ b/src/agent_comms/skills/comms-commander/SKILL.md @@ -14,7 +14,7 @@ All tools are `agent-comms-commander ...`, configured by `~/.config/agent - **The commander cannot trust itself to remember these rules.** Read in full, as files, before acting: `src/agent_comms/commander/indictment.md`, every file in `src/agent_comms/commander/rules/` (each fact has one owner there; this skill does not restate them), and the pinned refactor-audit catalog `src/agent_comms/skills/refactor-audit/references/patterns/` (`README.md` and every rung file; orders cite its IDs). The session hook rereads them at every start and compaction; `rules review` prints every rule with its file:line. A summary or memory note is a paraphrase that drifts and never counts. - **Memory files point at this skill; they never restate its rules.** -Arm two Monitors: `agent-comms-claude-bridge watch` (messages to the commander; the `comms` skill covers the first binding) and `agent-comms-commander tool-watch` (every unit tool call, turn digests, hard-rule violations). Core's connector closes merged orders and owns rotation: a live commander holding owner requests gets one inbox heartbeat when its standing-rule violations change, naming the next unit to review. A stopped commander gets none. +The reshaped Core heartbeat is the board the commander works from. It brings only new decisions, at most five lines, each naming the action that clears it. Read the named unit's code and act on that line; do not arm raw tool-call or message-stream Monitors as a second board. An alarm no action can clear is noise: Unjudged counts unreviewed pushed pull request heads and commits, and the judgment of that head clears it. Core closes merged orders; a stopped commander gets no heartbeat. ## Why a dictator @@ -65,13 +65,7 @@ Priority: the user's latest request; crashes, lost or replayed input, wrong mess ## One rotation tick -Pick the next unit. Read its actual state, not its message: worktree, `git log -1`, `git status`, the diff, its last turns in tool-watch. Decide: -- **Moving:** a fresh commit that deletes or fixes the right thing. Leave it. -- **Spinning:** repeated send-backs, no commit for 20 minutes on exact edits, tests rerun as investigation. Write the exact target shape into the order. -- **Stuck:** no tool call for 3 minutes while holding an order. Order it. -- **Stalled on nothing:** ended its turn holding an order with a question it could answer itself, a summary, a plan, "ready to proceed", or a wait on something already given. Send the next concrete step the same tick; strike the second time. -- **Idle:** give it the top unowned board row. -- **Adding slop:** send it back with the result it violates. +Work from Core's derived `UnitState` and the heartbeat's default action, not a second classification from prose or a raw event stream. Inspect the named worktree, diff and newest turns before judgment. If the code adds slop, send it back with the result it violates. Leave useful moving work alone; give an idle unit the next useful unowned row. One line per unit touched, then the next unit. diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index d5fe2a52e4..f9c96f08d5 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -193,52 +193,6 @@ def line(self) -> str: f"order {self.order} -> read its diff and judge it") -class FirstCallMissing(HeldOrderDue): - @classmethod - def conditions(cls, comms: Comms, commander: str) -> Iterable[tuple[Todo, EventTime]]: - now = time.time() - for todo in cls.pending_todos(comms, commander): - order = todo.order.checked() - if not order.first_call_deadline.before(now): - continue - thread = comms.registry.require(todo.held().owner) - last = thread.runtime.history_source(comms.root, thread.incarnation).last_tool_call() - if not last.since(order.at.recorded_at("An order")): - yield todo, order.first_call_deadline - - def line(self) -> str: - return (f"FIRST CALL MISSING {self.problem} {self.holder}, due {self.since.clock()}, " - f"order {self.order} -> push the work or reassign it") - - -@dataclass(frozen=True) -class OwnerIdle(DatedStandingRule): - seconds: ClassVar[int] = 1200 - holder: str - since: float - - @classmethod - def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: - now = time.time() - threads = (comms.registry.require(holder) - for holder in sorted({todo.held().owner - for todo in cls.pending_todos(comms, commander)})) - calls = ( - (thread.name, - thread.runtime.history_source(comms.root, thread.incarnation).last_tool_call()) - for thread in threads - ) - return tuple(cls(holder, last.recorded_at("A tool call") + cls.seconds) - for holder, last in calls if last.before(now - cls.seconds)) - - def began(self) -> float: - return self.since - - def line(self) -> str: - return (f"OWNER IDLE {self.holder}, since {RecordedEventTime(self.since).clock()}" - " -> push or reassign the work") - - @dataclass(frozen=True) class HuntDue(ReasonedStandingRule, DatedStandingRule): since: float diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 2d0ecc5867..0978e514a8 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -2517,6 +2517,10 @@ def story_text(self) -> str: def implied_strikes(self) -> tuple[StrikeTodo, ...]: return () + def reviewed_heads(self) -> tuple[str, ...]: + """Only a judgment of a commit clears review of that immutable head.""" + return () + @abstractmethod def judged(self, current: Todo) -> Todo: ... @@ -2585,6 +2589,9 @@ def __post_init__(self) -> None: super().__post_init__() GitCommitText.decode(self.head) + def reviewed_heads(self) -> tuple[str, ...]: + return (self.head,) + @property def line(self) -> str: return f"Reviewed head {self.head}" @@ -2947,6 +2954,14 @@ def recurrence( def held_order_ids(self) -> tuple[str, ...]: return tuple(order.id for order in self.order_history) + def unreviewed_heads(self, registry: Registration, pulls: PullRequests) -> frozenset[str]: + """Published code awaiting a commit judgment, not the edits that produced it.""" + source = ProgressSource.of(self, registry, pulls) + published = {request.head for request in source.requests(UnmergedListedPullRequest)} + published.update(point.sha for point in source.published()) + reviewed = {head for verdict in self.verdicts for head in verdict.reviewed_heads()} + return frozenset(published - reviewed) + @property def reference_pattern(self) -> str: return rf"(^|[^A-Za-z0-9_]){re.escape(self.id)}([^A-Za-z0-9_]|$)" @@ -3362,6 +3377,14 @@ def held_for_commander(self, commander: ThreadIncarnation) -> tuple[Todo, ...]: return self._open("creator=? AND creator_created=? AND assignment IS NOT NULL", (commander.name, commander.wire_birth)) + def unreviewed_heads(self, commander: ThreadIncarnation, + registry: Registration) -> frozenset[tuple[RepositoryName, str]]: + """One pushed head linked to several open problems is still one review.""" + held = tuple(todo for todo in self.held_for_commander(commander) if todo.pending) + pulls = PullRequests(held) + return frozenset((todo.repo, head) for todo in held + for head in todo.unreviewed_heads(registry, pulls)) + def held_by(self, commander: ThreadIncarnation, unit: str) -> tuple[Todo, ...]: return self._open( "creator=? AND creator_created=? AND json_extract(assignment,'$.owner')=?", diff --git a/src/agent_comms/ui_model/unit_state.py b/src/agent_comms/ui_model/unit_state.py new file mode 100644 index 0000000000..ab33a3c0d0 --- /dev/null +++ b/src/agent_comms/ui_model/unit_state.py @@ -0,0 +1,378 @@ +"""Unit cards and decisions, derived from the original Core records. + +This is a subfamily of StandingRule, not a second classification registry. The +inheritance chain declares urgency: a more urgent state is a subtype of every +less urgent state. Readers use that MRO, never a separately numbered priority. +""" +from __future__ import annotations + +import time +from abc import abstractmethod +from collections.abc import Iterator +from contextlib import closing +from dataclasses import dataclass, replace +from functools import cached_property +from typing import TYPE_CHECKING, ClassVar, Self + +from agent_comms.bus_page_index import BusPageIndex +from agent_comms.declared_family import DeclaredFamily +from agent_comms.goal_actions import GoalPrecondition +from agent_comms.goal_generation import GenerationState, RetryableGeneration +from agent_comms.goal_states import BlockedState +from agent_comms.message_page import MessageReadRequest +from agent_comms.messages import Message, NeedsAnswerKind +from agent_comms.read_basis import RepliesDisplayScope +from agent_comms.standing_rules import StandingRule, UndatedStandingRule +from agent_comms.thread_identity import CoreWatcherIncarnation, ThreadIncarnation +from agent_comms.todos import ( + OrderProgress, ProgressSource, PullRequests, SendBackVerdict, Todo, +) +from agent_comms.transcript_events import OrderCall, TurnDigest +from agent_comms.turn_digests import LastTurns, TurnDigestRead +from agent_comms.wire_log import WireSnapshot + +if TYPE_CHECKING: + from agent_comms.comms import Comms + from agent_comms.goals import Goal + from agent_comms.message_reference import MessageReference + from agent_comms.threads import Thread + + +@dataclass(frozen=True, slots=True) +class UnansweredUnitAsks(MessageReadRequest[tuple[Message, ...]]): + scope: RepliesDisplayScope + since: float + before: float + empty: ClassVar[tuple[Message, ...]] = () + + def indexed(self, index: BusPageIndex, source: WireSnapshot) -> tuple[Message, ...]: + answered: set[MessageReference] = set() + found: list[Message] = [] + with closing(index.recent_offsets( + self.since, predicate=self.scope.index_predicate, before_offset=source.boundary, + )) as offsets: + for row in self.indexed_records(source, offsets): + message = row.message + if message.sender in self.scope.first_names: + answered.update(message.answers) + elif (issubclass(message.type, NeedsAnswerKind) + and message.timestamp <= self.before and message.reference not in answered): + found.append(message) + return tuple(found) + + +@dataclass(frozen=True) +class UnitObservation: + """One bounded read shared by all candidate states; never retained across reads.""" + + comms: Comms + thread: Thread + todos: tuple[Todo, ...] + pulls: PullRequests + questions: tuple[Message, ...] + now: float + + @cached_property + def pending(self) -> tuple[Todo, ...]: + return tuple(todo for todo in self.todos if todo.pending) + + @cached_property + def turns(self) -> tuple[TurnDigest, ...]: + return TurnDigestRead(self.comms.root, self.thread, LastTurns(2)).read().turns + + @cached_property + def calls(self) -> tuple[OrderCall, ...]: + calls: dict[str, OrderCall] = {} + for turn in reversed(self.turns): + for event in turn.events: + event.record_call(calls, self.since) + return tuple(calls.values()) + + @cached_property + def since(self) -> float: + return min(todo.order.checked().at.recorded_at("A held order") for todo in self.pending) + + @cached_property + def sources(self) -> tuple[ProgressSource, ...]: + return tuple(ProgressSource.of(todo, self.comms.registry, self.pulls) for todo in self.todos) + + @cached_property + def progress(self) -> tuple[OrderProgress, ...]: + return tuple(OrderProgress.from_source(source) for source in self.sources) + + @property + def orders(self) -> tuple[str, ...]: + return tuple(todo.id for todo in self.todos) + + +@dataclass(frozen=True) +class UnitState(UndatedStandingRule, declared_name=None): + """One selected state per unit. Urgency is the states' declared MRO.""" + + unit: ThreadIncarnation + orders: tuple[str, ...] + action: ClassVar[str] + + @classmethod + def worst_first(cls) -> tuple[type[UnitState], ...]: + members = UnitState.members_with(UnitState) + most_urgent = max(members, key=lambda member: len(member.__mro__)) + ordered = tuple(base for base in most_urgent.__mro__ if base in members) + if len(ordered) != len(members): + raise TypeError("UnitState urgency must be declared by one complete MRO") + return ordered + + @classmethod + def scan_owner(cls) -> type[StandingRule]: + return UnitState + + @classmethod + def holders(cls, comms: Comms, commander: str) -> dict[ThreadIncarnation, list[Todo]]: + held: dict[ThreadIncarnation, list[Todo]] = {} + for todo in StandingRule.todos(comms, commander): + held.setdefault(todo.held().owner_identity, []).append(todo) + return held + + @classmethod + def asks(cls, comms: Comms, commander: str, before: float) -> tuple[Message, ...]: + held = cls.holders(comms, commander) + pending = tuple(todo for todos in held.values() for todo in todos if todo.pending) + if not pending: + return () + snapshot = comms.registry.snapshot() + peers = frozenset(name for owner in held for name in snapshot.names_for(owner.name)) + since = min(todo.order.checked().at.recorded_at("A held order") for todo in pending) + return comms.bus.log.read_page(UnansweredUnitAsks( + RepliesDisplayScope(snapshot.names_for(commander), peers), since, before, + )) + + @classmethod + def read(cls, observation: UnitObservation) -> UnitState: + return next(state for member in cls.worst_first() for state in member.derive(observation)) + + @classmethod + def observations(cls, comms: Comms, commander: str, + pulls: PullRequests | None = None) -> tuple[UnitObservation, ...]: + snapshot, now = comms.registry.snapshot(), time.time() + held = cls.holders(comms, commander) + for owner in held: + if snapshot.require(owner.name).incarnation != owner: + raise ValueError("Held order owner incarnation changed") + questions = cls.asks(comms, commander, now - AwaitingCommander.seconds) + requests = pulls if pulls is not None else PullRequests(tuple( + todo for todos in held.values() for todo in todos)) + found = [] + for thread in snapshot.threads.values(): + todos = tuple(held.get(thread.incarnation, ())) + if todos or thread.role.executor(thread) and thread.name != commander: + found.append(UnitObservation(comms, thread, todos, requests, + tuple(message for message in questions + if snapshot.canonical_name(message.sender) == thread.name), now)) + return tuple(found) + + @classmethod + def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: + return tuple(state for source in cls.observations(comms, commander) + for state in (UnitState.read(source),) + if (cls is UnitState or type(state) is cls) and state.attention()) + + @classmethod + @abstractmethod + def derive(cls, source: UnitObservation) -> Iterator[Self]: ... + + def attention(self) -> bool: + return False + + @property + def default_action(self) -> str: + return self.action + + def detail(self) -> str: + return f"orders {', '.join(self.orders)}" + + def line(self) -> str: + return (f"{self.declared_name.replace('_', ' ').upper()} {self.unit.name}: " + f"{self.detail()} -> {self.default_action}") + + def more_urgent_than(self, previous: type[UnitState]) -> bool: + return type(self) is not previous and issubclass(type(self), previous) + + +class Idle(UnitState): + action = "give it the next useful order" + + @classmethod + def derive(cls, source: UnitObservation) -> Iterator[Self]: + if not source.todos: + yield cls(source.thread.incarnation, ()) + + +class Moving(Idle): + action = "let it continue" + + @classmethod + def derive(cls, source: UnitObservation) -> Iterator[Self]: + if source.todos: + yield cls(source.thread.incarnation, source.orders) + + +class DeliveredUnclosed(Moving): + action = "install the merge or close with the working application evidence" + + @classmethod + def derive(cls, source: UnitObservation) -> Iterator[Self]: + if source.todos and (not source.pending or any( + progress.waiting_on_install(todo) for todo, progress in zip(source.todos, source.progress) + )): + yield cls(source.thread.incarnation, source.orders) + + def attention(self) -> bool: + return True + + +class StalledOnNothing(DeliveredUnclosed): + action = "resume its work or give it the missing next step" + + @classmethod + def derive(cls, source: UnitObservation) -> Iterator[Self]: + if (source.pending and source.turns and not source.thread.executing + and not source.comms.bus.pending_count(source.thread.name) + and not source.comms.goals.goal_execution(source.thread.name).standby()): + yield cls(source.thread.incarnation, source.orders) + + +class Spinning(StalledOnNothing): + action = "read the failed approach and change it before another rerun" + + @classmethod + def derive(cls, source: UnitObservation) -> Iterator[Self]: + if not source.pending: + return + sends = sorted(verdict.at.recorded_at("A send-back") + for todo in source.pending for verdict in todo.verdicts + if isinstance(verdict, SendBackVerdict)) + commit_at = max((commit.at for origin in source.sources for commit in origin.commits()), + default=source.since) + calls = source.calls + repeats = len(calls) >= 2 and calls[-1].digest == calls[-2].digest + repeated_after_commit = repeats and source.turns[-1].start.since(commit_at) + if (len(sends) >= 2 and sends[-2] > commit_at) or repeated_after_commit: + yield cls(source.thread.incarnation, source.orders) + + +class Stuck(Spinning): + seconds: ClassVar[int] = 20 * 60 + action = "inspect its last turn and restart only known-safe work" + + @classmethod + def derive(cls, source: UnitObservation) -> Iterator[Self]: + if not source.pending: + return + calls = source.calls + latest = max((event.timestamp for turn in source.turns for event in turn.events), + default=source.since) + if (not calls and any(todo.order.checked().first_call_deadline.before(source.now) + for todo in source.pending)) or latest < source.now - cls.seconds: + yield cls(source.thread.incarnation, source.orders) + + +@dataclass(frozen=True) +class AwaitingCommander(Stuck): + questions: tuple[Message, ...] = () + seconds: ClassVar[int] = 15 * 60 + action = "answer the original message" + + @classmethod + def derive(cls, source: UnitObservation) -> Iterator[Self]: + if source.pending and source.questions: + yield cls(source.thread.incarnation, source.orders, source.questions) + + def detail(self) -> str: + return ", ".join(f"message {message.reference.seq}:{message.message_id}" + for message in self.questions) + + +@dataclass(frozen=True) +class Detached(AwaitingCommander): + unread: int = 0 + action = "wake it or reassign the order" + + @classmethod + def derive(cls, source: UnitObservation) -> Iterator[Self]: + if source.pending and not source.comms.registry.status(source.thread.name).receiving: + yield cls(source.thread.incarnation, source.orders, + unread=source.comms.bus.pending_count(source.thread.name)) + + def detail(self) -> str: + return f"{self.unread} unread; {UnitState.detail(self)}" + + +@dataclass(frozen=True) +class UnitBlock(DeclaredFamily, affix="UnitBlock"): + goal: Goal + action: ClassVar[str] + + @staticmethod + @abstractmethod + def accepts(goal: Goal, generation: GenerationState) -> bool: ... + + def enforce(self, comms: Comms, commander: str, unit: str, orders: tuple[str, ...]) -> None: + """Unknown attempts never run again merely because the connector restarted.""" + + +class UnresolvedUnitBlock(UnitBlock): + action = "inspect the unknown attempt, then fail or finish it; never replay its input" + + @staticmethod + def accepts(goal: Goal, generation: GenerationState) -> bool: + return not isinstance(generation, RetryableGeneration) + + +class RetryableUnitBlock(UnitBlock): + action = "Core reissues the known-safe orders" + + @staticmethod + def accepts(goal: Goal, generation: GenerationState) -> bool: + return isinstance(goal.state, BlockedState) and isinstance(generation, RetryableGeneration) + + def enforce(self, comms: Comms, commander: str, unit: str, orders: tuple[str, ...]) -> None: + from agent_comms.commander.standing import UnownedWheelSpin + + observed = self.goal + for problem in orders: + todo = comms.goals.todos.get(problem) + todo.require_assignee(comms.registry.require(unit).incarnation, observed.id) + action = UnownedWheelSpin.order_for(todo, commander, CoreWatcherIncarnation.watcher()) + observed = comms.goals.update_goal(unit, replace(action, expect=GoalPrecondition( + goal_id=observed.id, expected_state=observed.state, + expected_checkpoint=observed.captured, + ))) + + +@dataclass(frozen=True) +class Blocked(Detached): + blocks: tuple[UnitBlock, ...] = () + + @classmethod + def derive(cls, source: UnitObservation) -> Iterator[Self]: + if not source.pending or source.thread.executing: + return + store = source.comms.goals.open_goal_store() + blocks = tuple(member(goal) for goal in source.thread.goal + for generation in (store.require_generation(goal.id).lifecycle,) + for member in UnitBlock.members_with(UnitBlock) + if member.accepts(goal, generation)) + if blocks: + yield cls(source.thread.incarnation, tuple(todo.id for todo in source.pending), blocks=blocks) + + @property + def default_action(self) -> str: + return "; ".join(block.action for block in self.blocks) + + def detail(self) -> str: + goals = ", ".join(f"goal {block.goal.id}@{block.goal.revision}" for block in self.blocks) + return f"{UnitState.detail(self)}; {goals}" + + def enforce(self, comms: Comms, commander: str) -> None: + for block in self.blocks: + block.enforce(comms, commander, self.unit.name, self.orders) From 8a43ca5174a2afc7971a71082e92cb6e8b0d0f10 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 11:22:15 -0400 Subject: [PATCH 03/15] P433: replace heartbeat walls with typed durable decision packets --- src/agent_comms/commander/standing.py | 6 - src/agent_comms/commander_heartbeat.py | 127 +++++------ src/agent_comms/commander_pings.py | 276 ++++++++++++++++++++++++ src/agent_comms/messages.py | 10 + src/agent_comms/standing_rules.py | 67 ++---- src/agent_comms/todos.py | 194 +++++++++++++++-- src/agent_comms/ui_model/unit_state.py | 12 +- tests/test_holder_standing_rules.py | 115 +++++----- tests/test_order_progress.py | 25 +-- tests/test_storage_violations.py | 2 +- tests/test_todos.py | 37 ++-- tests/test_unit_state.py | 81 +++++++ tests/test_unowned_checkout_work.py | 21 +- tools/cutover/carry_commander_cursor.py | 36 ++++ 14 files changed, 760 insertions(+), 249 deletions(-) create mode 100644 src/agent_comms/commander_pings.py create mode 100644 tests/test_unit_state.py create mode 100644 tools/cutover/carry_commander_cursor.py diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index 77dcd16188..44685988ed 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -175,12 +175,6 @@ def pass_body(self, comms: Comms, observed: tuple[UnownedObservation, ...]) -> N class WaitingUnownedWheelSpin(UnownedWheelSpin, declared_name=None): """A row needs a new unit; a proposed dispatch name is not an idle thread.""" - def heartbeat_group(self) -> object: - return type(self) - - def heartbeat_line(self, current: Sequence[WaitingUnownedWheelSpin]) -> str: - rows = ", ".join(sorted(rule.todo.id for rule in current)) - return f"{len(current)} unowned rows wait for a new unit: {rows}" @dataclass(frozen=True) diff --git a/src/agent_comms/commander_heartbeat.py b/src/agent_comms/commander_heartbeat.py index 9d659e4d52..102009406a 100644 --- a/src/agent_comms/commander_heartbeat.py +++ b/src/agent_comms/commander_heartbeat.py @@ -1,58 +1,25 @@ -"""Service-owned commander review: changed standing rules wake through the real inbox.""" - +"""Core's commander wake delivers only new, typed decisions, at most five lines.""" from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import TYPE_CHECKING +from agent_comms.commander_pings import ( + CommanderPingMessage, DeliveredPing, LatestCommanderPing, OrderRevision, + Ping, +) from agent_comms.message_bus import WatchPass +from agent_comms.message_reference import MessageReference from agent_comms.registration import Registration from agent_comms.service_sender import ServiceSender from agent_comms.standing_rules import StandingRule -from agent_comms.thread_identity import ThreadIncarnation from agent_comms.threads import Thread +from agent_comms.todos import CommanderCursor, ListedPullRequest, PullRequests, Todo +from agent_comms.ui_model.unit_state import Moving, UnitState if TYPE_CHECKING: from agent_comms.comms import Comms from agent_comms.goal_management import Goals - from agent_comms.todos import Todo - - -@dataclass -class CommanderReview: - """One commander's handled observations and its next review position.""" - - violations: frozenset[StandingRule] - turn: int = 0 - - def wake(self, comms: Comms, sender: str, commander: Thread, - rules: tuple[StandingRule, ...]) -> None: - found = frozenset(rules) - previous = self.violations - if found == previous: - return - # Mark the observation before any effect; an uncertain publication is not replayed. - self.violations = found - rejected = StandingRule.enforce_all( - comms, commander.name, (rule for rule in rules if rule not in previous) - ) - # A checked rejection has no effects and must not suppress a fresh observation. - self.violations = found - rejected - lines = ["COMMANDER HEARTBEAT: standing-rule violations changed."] - owed = comms.goals.todos.held_for_commander(commander.incarnation) - units = sorted({todo.held().owner for todo in owed}) - if units: - unit = units[self.turn % len(units)] - orders = ", ".join(todo.id for todo in owed if todo.held().owner == unit) - lines.append(f"Review next: @{unit} ({orders}); read its git state and newest turn.") - self.turn += 1 - lines.extend(StandingRule.heartbeat_changes( - previous, tuple(rule for rule in rules if rule not in rejected) - )) - lines.extend(rule.rejected_line() for rule in rules if rule in rejected) - if not found: - lines.append("Standing rules clear.") - comms.messaging.send(sender, commander.name, "\n".join(lines)) @dataclass(frozen=True) @@ -60,43 +27,67 @@ class CommanderObservation: thread: Thread merged: tuple[Todo, ...] rules: tuple[StandingRule, ...] + pings: tuple[Ping, ...] + known: tuple[DeliveredPing, ...] + cursor: MessageReference | None + + @classmethod + def read(cls, comms: Comms, sender: str, thread: Thread) -> CommanderObservation: + held = comms.goals.todos.held_for_commander(thread.incarnation) + pulls = PullRequests(held) + # The PR watch and merge reconciliation consume the same forge read. + merged = comms.goals.todos.merged_orders(thread.incarnation, comms.registry, pulls) + rules = StandingRule.broken(comms, thread.name) + states = {state.unit: state for rule in rules for state in rule.unit_states()} + todos = {todo.id: todo for todo in held} + previous = LatestCommanderPing.read(comms, sender, thread.incarnation) + known = previous.delivered(comms) if previous is not None else () + candidates = [ping for rule in rules for ping in rule.commander_pings(todos, states)] + for todo in held: + state = type(states[todo.held().owner_identity]) if todo.held().owner_identity in states else Moving + candidates.extend(ping for strike in todo.strikes for ping in strike.commander_pings(todo, state)) + candidates.extend(ping for request in pulls.listed(todo.repo, ListedPullRequest, todo) + for ping in request.commander_pings(todo, state)) + # Installation removes the open assignment. The last delivered order reference + # leads directly to that row, without scanning closed history or mirroring owners. + for item in known: + for order in item.ping.orders: + current = comms.goals.todos.get(order.problem) + if current.order.checked().id == order.order: + candidates.extend(current.order.installation_pings(item.ping, current)) + fresh = tuple(dict.fromkeys(ping for ping in candidates if ping.new_since(known))) + urgency = UnitState.worst_first() + selected = tuple(sorted(fresh, key=lambda ping: urgency.index(ping.state)))[:5] + active = {OrderRevision.of(todo).key for todo in held} + relevant = active | {order.key for item in known + if any(order.key in active for order in item.ping.orders) for order in item.ping.orders} + relevant.update(order.key for ping in fresh if ping not in selected for order in ping.orders) + kept = tuple(item for item in known + if any(order.key in relevant for order in item.ping.orders)) + return cls(thread, merged, rules, selected, kept, + previous.reference if previous is not None else None) -@dataclass class CommanderHeartbeat(ServiceSender, WatchPass[tuple[CommanderObservation, ...]]): sender = "commander-heartbeat" - reviews: dict[ThreadIncarnation, CommanderReview] = field(default_factory=dict) @classmethod def commanders(cls, registry: Registration, goals: Goals) -> tuple[Thread, ...]: - """The live request holders whose current incarnation owns commander work.""" - return tuple( - thread - for thread in registry.all_threads().values() - if thread.process_alive - and any( - goal.order.open_requests(goal, goals, thread.incarnation) for goal in thread.goal - ) - ) + return tuple(thread for thread in registry.all_threads().values() + if thread.process_alive and any( + goal.order.open_requests(goal, goals, thread.incarnation) for goal in thread.goal)) def observe(self, comms: Comms) -> tuple[CommanderObservation, ...]: - return tuple( - CommanderObservation( - thread, - comms.goals.todos.merged_orders(thread.incarnation, comms.registry), - StandingRule.broken(comms, thread.name), - ) - for thread in self.commanders(comms.registry, comms.goals) - ) + return tuple(CommanderObservation.read(comms, self.sender, thread) + for thread in self.commanders(comms.registry, comms.goals)) def pass_body(self, comms: Comms, observed: tuple[CommanderObservation, ...]) -> None: - """Inactive commanders retire their review after the pass enters the bus.""" self.register(comms.registry) - live = {item.thread.incarnation for item in observed} - self.reviews = { - owner: review for owner, review in self.reviews.items() if owner in live - } for item in observed: comms.goals.todos.record_merges(item.merged) - review = self.reviews.setdefault(item.thread.incarnation, CommanderReview(frozenset())) - review.wake(comms, self.sender, item.thread, item.rules) + StandingRule.enforce_all(comms, item.thread.name, item.rules) + if item.pings: + CommanderPingMessage.send(comms, self.sender, item.thread.incarnation, + item.pings, item.known) + elif item.cursor is not None: + CommanderCursor(item.thread.incarnation, item.cursor).advance(comms.goals.todos) diff --git a/src/agent_comms/commander_pings.py b/src/agent_comms/commander_pings.py new file mode 100644 index 0000000000..a342faae9b --- /dev/null +++ b/src/agent_comms/commander_pings.py @@ -0,0 +1,276 @@ +"""Typed commander decisions and references to their committed deliveries.""" +from __future__ import annotations + +from abc import abstractmethod +from collections.abc import Iterable +from contextlib import closing +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar, Self, cast + +from agent_comms.bus_publication import AgentOrigin +from agent_comms.declared_family import DeclaredFamily +from agent_comms.message_page import MessageReadRequest +from agent_comms.message_reference import MessageReference +from agent_comms.messages import CommanderPingKind, Message +from agent_comms.read_basis import DMDisplayScope +from agent_comms.recorded_time import RecordedEventTime +from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.todos import CommanderCursor, ListedPullRequest, PullRequestLink, Todo +from agent_comms.ui_model.unit_state import UnitState + +if TYPE_CHECKING: + from agent_comms.comms import Comms + + +@dataclass(frozen=True) +class OrderKey: + problem: str + order: str + + +@dataclass(frozen=True) +class OrderRevision(OrderKey): + revision: int + + @classmethod + def of(cls, todo: Todo) -> Self: + return cls(todo.id, todo.order.checked().id, todo.revision) + + @property + def key(self) -> OrderKey: + return OrderKey(self.problem, self.order) + + +@dataclass(frozen=True) +class PingTopic: + unit: ThreadIncarnation + kind: type[Ping] + orders: tuple[OrderKey, ...] + + +@dataclass(frozen=True) +class PullRequestTopic(PingTopic): + link: PullRequestLink + + +@dataclass(frozen=True) +class Ping(DeclaredFamily, affix="Ping"): + unit: ThreadIncarnation + state: type[UnitState] + orders: tuple[OrderRevision, ...] + + @property + def topic(self) -> PingTopic: + return PingTopic(self.unit, type(self), tuple(order.key for order in self.orders)) + + @property + @abstractmethod + def event(self) -> object: ... + + action: ClassVar[str] + + @property + def default_action(self) -> str: + return self.action + + @abstractmethod + def line(self) -> str: ... + + def previous(self, known: Iterable[DeliveredPing]) -> Self | None: + return next((cast(Self, item.ping) for item in known if item.ping.topic == self.topic), None) + + def new_since(self, known: Iterable[DeliveredPing]) -> bool: + previous = self.previous(known) + return previous is None or previous.event != self.event + + +@dataclass(frozen=True) +class UnitStatePing(Ping): + detail: str + action: str + + @property + def topic(self) -> PingTopic: + return PingTopic(self.unit, type(self), ()) + + @property + def event(self) -> object: + return self.state + + def new_since(self, known: Iterable[DeliveredPing]) -> bool: + previous = self.previous(known) + return (previous is None + or tuple(order.key for order in self.orders) != tuple(order.key for order in previous.orders) + or self.state is not previous.state and issubclass(self.state, previous.state)) + + def line(self) -> str: + return (f"{self.state.declared_name.replace('_', ' ').upper()} {self.unit.name}: " + f"{self.detail} -> {self.default_action}") + + +@dataclass(frozen=True) +class ReviewTimerPing(Ping): + action = "read its diff and judge the head" + due: float + + @property + def event(self) -> object: + return self.due + + def line(self) -> str: + return (f"TIMER UP {self.unit.name} {self.orders[0].problem} " + f"at {RecordedEventTime(self.due).clock()} -> {self.default_action}") + + +@dataclass(frozen=True) +class PullRequestChange: + head: str + status: type[ListedPullRequest] + + +@dataclass(frozen=True) +class PullRequestPing(Ping): + link: PullRequestLink + change: PullRequestChange + + @property + def topic(self) -> PingTopic: + return PullRequestTopic(self.unit, type(self), tuple(order.key for order in self.orders), self.link) + + @property + def event(self) -> object: + return self.change + + @property + def default_action(self) -> str: + return self.change.status.action + + def line(self) -> str: + return (f"PR #{self.link.number} {self.unit.name} {self.orders[0].problem}: " + f"{self.change.status.declared_name.replace('_', ' ')} -> {self.default_action}") + + +@dataclass(frozen=True) +class FailedCheckPing(Ping): + action = "read the failed delivery check and order its repair" + failed_at: float + + @property + def event(self) -> object: + return self.failed_at + + def line(self) -> str: + return f"CHECK FAILED {self.unit.name} {self.orders[0].problem} -> {self.default_action}" + + +@dataclass(frozen=True) +class MergePing(Ping): + action = "install the merge and run the affected application" + commit: str + + @property + def event(self) -> object: + return self.commit + + def line(self) -> str: + return (f"MERGED {self.unit.name} {self.orders[0].problem} " + f"{self.commit[:9]} -> {self.default_action}") + + +@dataclass(frozen=True) +class InstallPing(Ping): + action = "read the application evidence and close the work" + artifact: str + + @property + def event(self) -> object: + return self.artifact + + def line(self) -> str: + return f"INSTALLED {self.unit.name} {self.orders[0].problem} -> {self.default_action}" + + +@dataclass(frozen=True) +class PingReference: + message: MessageReference + index: int + + +@dataclass(frozen=True) +class DeliveredPing: + """A read of original delivery evidence, never a retained copy of PR state.""" + reference: PingReference + ping: Ping + + +@dataclass(frozen=True, slots=True, kw_only=True) +class CommanderPingMessage(Message): + commander: ThreadIncarnation + pings: tuple[Ping, ...] + prior: tuple[PingReference, ...] + + @classmethod + def send(cls, comms: Comms, sender: str, commander: ThreadIncarnation, + pings: tuple[Ping, ...], known: tuple[DeliveredPing, ...]) -> CommanderPingMessage: + replaced = {ping.topic for ping in pings} + message = cls(sender=sender, target=commander.name, type=CommanderPingKind, + body="\n".join(ping.line() for ping in pings), commander=commander, pings=pings, + prior=tuple(item.reference for item in known if item.ping.topic not in replaced)) + # Publisher owns recipient, claim and publication validation. No raw bus append. + committed = cast(Self, comms.bus.publisher.publish_ordinary(message, origin=AgentOrigin())) + CommanderCursor(commander, committed.reference).advance(comms.goals.todos) + return committed + + def delivered(self, comms: Comms) -> tuple[DeliveredPing, ...]: + known = [] + with comms.bus.log.checked_read() as source: + references = tuple(dict.fromkeys(item.message for item in self.prior)) + messages = {delivery.message.reference: cast(CommanderPingMessage, delivery.message) + for delivery in source.references(references)} + for reference in self.prior: + original = messages[reference.message] + if original.commander != self.commander: + raise ValueError("A ping reference changed commander incarnation") + known.append(DeliveredPing(reference, original.pings[reference.index])) + known.extend(DeliveredPing(PingReference(self.reference, index), ping) + for index, ping in enumerate(self.pings)) + return tuple(known) + + +@dataclass(frozen=True, slots=True) +class LatestCommanderPing(MessageReadRequest[CommanderPingMessage | None]): + commander: ThreadIncarnation + after: MessageReference | None = None + empty: ClassVar[None] = None + + def indexed(self, index, source) -> CommanderPingMessage | None: + with closing(index.recent_offsets( + self.commander.created_at, predicate=self.scope.index_predicate, + before_offset=source.boundary, + )) as offsets: + for row in self.indexed_records(source, offsets): + message = row.message + if self.after is not None and message.seq <= self.after.seq: + break + if message.type is CommanderPingKind: + ping = cast(CommanderPingMessage, message) + if ping.commander == self.commander: + return ping + return None + + @classmethod + def read(cls, comms: Comms, sender: str, + commander: ThreadIncarnation) -> CommanderPingMessage | None: + with comms.goals.todos.snapshot() as db: + position = next(CommanderCursor.select(db, commander=commander), None) + after = position.message if position is not None else None + latest = comms.bus.log.read_page(cls( + DMDisplayScope.capture(sender, commander.name, comms.registry.snapshot()), commander, after)) + if latest is not None or position is None: + return latest + with comms.bus.log.checked_read() as source: + original, = source.references((position.message,)) + message = cast(CommanderPingMessage, original.message) + if message.commander != commander: + raise ValueError("Commander cursor references another incarnation") + return message diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index 5354ace18d..ea5b1a7154 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -108,6 +108,16 @@ def decode_message(cls, data: Mapping) -> Message: return cast(Message, compile_decoder(Message | ClaimMessage).decode(data)) +class CommanderPingKind(MessageKind): + """Only Core's heartbeat publishes these typed decision packets.""" + + @classmethod + def decode_message(cls, data: Mapping) -> Message: + from .commander_pings import CommanderPingMessage + + return FieldCodec.decode(CommanderPingMessage, data) + + class NeedsAnswerKind(SendableMessageKind, declared_name=None): """A question, decision alert or handoff asks its recipient to act.""" diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index f9c96f08d5..9780c08620 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -9,8 +9,8 @@ import logging import time from abc import abstractmethod -from collections import Counter, defaultdict -from collections.abc import Iterable, Sequence +from collections import Counter +from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, ClassVar, Self @@ -25,6 +25,9 @@ from agent_comms.comms import Comms from agent_comms.goals import RequestDirective from agent_comms.message_reference import MessageReference + from agent_comms.commander_pings import Ping + from agent_comms.thread_identity import ThreadIncarnation + from agent_comms.ui_model.unit_state import UnitState from agent_comms.storage_rules import MemoryUse, StorageRule from agent_comms.todos import Todo @@ -40,6 +43,14 @@ def line(self) -> str: ... @abstractmethod def broke_after(self, after: float) -> bool: ... + def unit_states(self) -> tuple[UnitState, ...]: + return () + + def commander_pings(self, todos: Mapping[str, Todo], + states: Mapping[ThreadIncarnation, UnitState]) -> tuple[Ping, ...]: + """Only rules that declare a commander decision contribute a ping.""" + return () + def rejected_line(self) -> str: return f"Not applied (goal changed): {self.line()}" @@ -58,35 +69,6 @@ def enforce_all(comms: Comms, commander: str, rejected.add(rule) return frozenset(rejected) - def heartbeat_group(self) -> object: - """Ordinary violations each own one heartbeat line.""" - return self - - def heartbeat_line(self, current: Sequence[Self]) -> str: - return self.line() - - @staticmethod - def _heartbeat_groups(rules: Iterable[StandingRule]) -> dict[object, list[StandingRule]]: - groups: dict[object, list[StandingRule]] = defaultdict(list) - for rule in rules: - groups[rule.heartbeat_group()].append(rule) - return groups - - @classmethod - def heartbeat_changes(cls, previous: Iterable[StandingRule], - current: Iterable[StandingRule]) -> tuple[str, ...]: - """Render changed groups through their owning rule, without replaying unchanged ones.""" - before, after = cls._heartbeat_groups(previous), cls._heartbeat_groups(current) - lines = [] - for group in dict.fromkeys((*after, *before)): - old, new = before.get(group, []), after.get(group, []) - if frozenset(old) == frozenset(new): - continue - rule = (new or old)[0] - prefix = "RULE" if new else "NO LONGER CURRENT:" - lines.append(f"{prefix} {rule.heartbeat_line(new or old)}") - return tuple(lines) - @staticmethod def todos(comms: Comms, commander: str) -> tuple[Todo, ...]: return comms.goals.todos.held_for_commander(comms.registry.require(commander).incarnation) @@ -188,6 +170,16 @@ def conditions(cls, comms: Comms, commander: str) -> Iterable[tuple[Todo, EventT return comms.goals.todos.timers_up_for_commander( actor, comms.registry, time.time()) + def commander_pings(self, todos, states): + from .commander_pings import OrderRevision, ReviewTimerPing + from .ui_model.unit_state import Moving + + todo = todos[self.problem] + unit = todo.held().owner_identity + state = type(states[unit]) if unit in states else Moving + return (ReviewTimerPing(unit, state, (OrderRevision.of(todo),), + self.since.recorded_at("A review timer")),) + def line(self) -> str: return (f"TIMER UP: {self.holder} {self.problem} at {self.since.clock()}, " f"order {self.order} -> read its diff and judge it") @@ -285,19 +277,6 @@ def refusal(self, comms: Comms, answers: Sequence[MessageReference]) -> tuple[st return (f"{message.message_id}: overdue owner directive on request {self.request}; " "reply to the original message or use --answers MESSAGE_ID.",) - def heartbeat_group(self) -> object: - return type(self), self.request - - def heartbeat_line(self, current: Sequence[Self]) -> str: - oldest = min(current, key=lambda rule: rule.directive.seq).directive - newest = max(current, key=lambda rule: rule.directive.seq).directive - return ( - f"DIRECTIVE UNACTED: request {self.request}: {len(current)} claude-ai directives; " - f"oldest {oldest.seq} due {oldest.due_time}; " - f"newest {newest.seq} due {newest.due_time} " - "-> order the next step or mark the request done" - ) - @dataclass(frozen=True) class BridgeDown(ReasonedStandingRule, UndatedStandingRule): diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 4639fdc184..4c9843ead9 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -66,6 +66,7 @@ from agent_comms.locked_store import DurableFile, RegistryFile from agent_comms.maintenance_barrier import MaintenanceBarrier from agent_comms.messages import InfoKind, Message +from agent_comms.message_reference import MessageReference from agent_comms.order_story import ProblemStory, StoryEvent, StoryLine, StorySource from agent_comms.plan_phases import OrderPhase, UnplannedPhase from agent_comms.private_bus_checkpoint import PublicMessages @@ -455,6 +456,7 @@ class PullRequest(DeclaredFamily, affix="PullRequest"): orders: tuple[str, ...] = () base: str = field(default=WorkBranches.main, kw_only=True) problems: tuple[str, ...] = field(default=(), kw_only=True) + status: type[ListedPullRequest] = field(kw_only=True) def belongs_to(self, todo: Todo) -> bool: return (todo.id in self.problems @@ -463,6 +465,12 @@ def belongs_to(self, todo: Todo) -> bool: def merged_commits(self, since: float) -> Iterator[str]: return iter(()) + def commander_pings(self, todo: Todo, state): + from .commander_pings import OrderRevision, PullRequestChange, PullRequestPing + + return (PullRequestPing(todo.held().owner_identity, state, (OrderRevision.of(todo),), + PullRequestLink(todo.repo, self.number), PullRequestChange(self.head, self.status)),) + async def delivery_merges(self, delivery: CommitDelivery, check: OrderCheck) -> tuple[DeliveryMerge, ...]: return () @@ -481,6 +489,11 @@ def merged_commits(self, since: float) -> Iterator[str]: if self.merged_at >= since: yield self.merge + def commander_pings(self, todo: Todo, state): + from .commander_pings import MergePing, OrderRevision + + return (MergePing(todo.held().owner_identity, state, (OrderRevision.of(todo),), self.merge),) + async def delivery_merges(self, delivery: CommitDelivery, check: OrderCheck) -> tuple[DeliveryMerge, ...]: if not await delivery.contains(check, self.head): @@ -499,6 +512,23 @@ class PullRequestCommit: oid: str +@dataclass(frozen=True) +class GithubPullRequest: + """One external GraphQL row, decoded once at the forge boundary.""" + number: int + branch: str + head: str + updated: str + base: str + closed: bool + isDraft: bool + mergeStateStatus: str + checks: str | None + queued: bool + merge: PullRequestCommit | None + merged: str | None + + @dataclass(frozen=True) class ListedPullRequest(DeclaredFamily, affix="ListedPullRequest"): """The external query row; conversion lives here, never in its consumers.""" @@ -508,46 +538,130 @@ class ListedPullRequest(DeclaredFamily, affix="ListedPullRequest"): updated: str base: str + action: ClassVar[str] = "read the pull request" + + @classmethod + def scan_owner(cls) -> type[ListedPullRequest]: + return cls + + @classmethod + def from_github(cls, row: GithubPullRequest) -> Iterator[ListedPullRequest]: + return iter(()) + + @classmethod + def read_github(cls, row: GithubPullRequest) -> ListedPullRequest: + owners = dict.fromkeys(member.scan_owner() for member in cls.members_with(cls)) + found = tuple(request for owner in owners for request in owner.from_github(row)) + if len(found) != 1: + raise WorkBranchError(f"GitHub PR #{row.number} has no unique listed state") + return found[0] + def parsed(self, problems: tuple[str, ...], orders: tuple[str, ...]) -> PullRequest: return UnmergedPullRequest(self.number, self.branch, self.head, _github_time(self.updated), - orders, base=self.base, problems=problems) + orders, base=self.base, problems=problems, status=type(self)) class UnmergedListedPullRequest(ListedPullRequest): - """An open request, selected by its declared member rather than a state string.""" + """The open-state scan owns precedence through its most specific member's MRO.""" + action = "wait for the checks, then review the head" + + @classmethod + def scan_owner(cls) -> type[ListedPullRequest]: + return UnmergedListedPullRequest + + @classmethod + def accepts(cls, row: GithubPullRequest) -> bool: + return True + + @classmethod + def from_github(cls, row: GithubPullRequest) -> Iterator[ListedPullRequest]: + if row.closed or row.merged is not None: + return + members = cls.members_with(cls) + order = max(members, key=lambda member: len(member.__mro__)).__mro__ + member = next(member for member in order if member in members and member.accepts(row)) + yield member(row.number, row.branch, row.head, row.updated, row.base) + + +class CleanListedPullRequest(UnmergedListedPullRequest): + action = "review the head and send it to the merge queue" + + @classmethod + def accepts(cls, row: GithubPullRequest) -> bool: + return row.mergeStateStatus == "CLEAN" and row.checks in (None, "SUCCESS") + + +class QueuedListedPullRequest(CleanListedPullRequest): + action = "let the merge queue finish" + + @classmethod + def accepts(cls, row: GithubPullRequest) -> bool: + return row.queued + + +class FailingChecksListedPullRequest(QueuedListedPullRequest): + action = "read the failed check and order the repair" + + @classmethod + def accepts(cls, row: GithubPullRequest) -> bool: + return row.checks in ("ERROR", "FAILURE") + + +class DraftListedPullRequest(FailingChecksListedPullRequest): + action = "finish the working change and mark the pull request ready" + + @classmethod + def accepts(cls, row: GithubPullRequest) -> bool: + return row.isDraft class ClosedListedPullRequest(ListedPullRequest): """Closed without merging; never selected as an open request.""" + action = "restore any unfinished work instead of abandoning it" + + @classmethod + def from_github(cls, row: GithubPullRequest) -> Iterator[ListedPullRequest]: + if row.closed and row.merged is None: + yield cls(row.number, row.branch, row.head, row.updated, row.base) @dataclass(frozen=True, kw_only=True) class MergedListedPullRequest(ListedPullRequest): merge: PullRequestCommit merged: str + action = "install the merge and verify the affected application" + + @classmethod + def from_github(cls, row: GithubPullRequest) -> Iterator[ListedPullRequest]: + if row.merged is not None: + if row.merge is None: + raise WorkBranchError(f"GitHub merged PR #{row.number} without a merge commit") + yield cls(row.number, row.branch, row.head, row.updated, row.base, + merge=row.merge, merged=row.merged) def parsed(self, problems: tuple[str, ...], orders: tuple[str, ...]) -> MergedPullRequest: return MergedPullRequest(self.number, self.branch, self.head, _github_time(self.updated), orders, base=self.base, merge=self.merge.oid, merged_at=_github_time(self.merged), - problems=problems) + problems=problems, status=type(self)) class PullRequests: """GitHub facts for recorded links, cached within one board read.""" def __init__(self, todos: Sequence[Todo] = ()) -> None: - self._read: dict[tuple[RepositoryName, type[ListedPullRequest], str], tuple[PullRequest, ...]] = {} + self._read: dict[tuple[RepositoryName, str], tuple[PullRequest, ...]] = {} self._todos: dict[RepositoryName, dict[str, Todo]] = {} for todo in todos: self._todos.setdefault(todo.repo, {})[todo.id] = todo def listed(self, repo: RepositoryName, kind: type[ListedPullRequest], todo: Todo) -> tuple[PullRequest, ...]: - key = (repo, kind, todo.id) + key = (repo, todo.id) if key not in self._read: - self._read[key] = self.read(repo, kind, todo) - return tuple(request for request in self._read[key] if request.belongs_to(todo)) + self.read(repo, kind, todo) + return tuple(request for request in self._read[key] + if issubclass(request.status, kind) and request.belongs_to(todo)) def read(self, repo: RepositoryName, kind: type[ListedPullRequest], todo: Todo) -> tuple[PullRequest, ...]: @@ -560,37 +674,39 @@ def read(self, repo: RepositoryName, kind: type[ListedPullRequest], linked.setdefault(number, []).append(candidate) if not linked: for candidate in problems.values(): - self._read[repo, kind, candidate.id] = () + self._read[repo, candidate.id] = () return () selections = [ f'p{number}: pullRequest(number: {number}) {{ number branch: headRefName ' - 'head: headRefOid base: baseRefName closed merge: mergeCommit { oid } ' - 'merged: mergedAt updated: updatedAt }' for number in linked] + 'head: headRefOid base: baseRefName closed isDraft mergeStateStatus ' + 'queue: mergeQueueEntry { position } ' + 'checks: commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } ' + 'merge: mergeCommit { oid } merged: mergedAt updated: updatedAt }' for number in linked] query = (f'query {{ repository(owner: {json.dumps(repo.owner)}, ' f'name: {json.dumps(repo.name)}) {{ {" ".join(selections)} }} }}') listed = subprocess.run( ("gh", "api", "graphql", "-f", "query=" + query, - "--jq", '.data.repository | map_values((if .merged == null then ' - '(if .closed then ' - f'del(.merge,.merged) + {{kind: "{ClosedListedPullRequest.declared_name}"}} else ' - f'del(.merge,.merged) + {{kind: "{UnmergedListedPullRequest.declared_name}"}} end) else ' - f'. + {{kind: "{MergedListedPullRequest.declared_name}"}} end) | del(.closed))'), + "--jq", '.data.repository | map_values(. + ' + '{queued: (.queue != null), checks: .checks.nodes[0].commit.statusCheckRollup.state}' + ' | del(.queue))'), capture_output=True, text=True, timeout=60) if listed.returncode: raise WorkBranchError( listed.stderr.strip() or f"GitHub order lookup in {repo.to_wire()} failed.") - rows = FieldCodec.decode(dict[str, ListedPullRequest], FieldCodec.parse(listed.stdout)) + rows = tuple(ListedPullRequest.read_github(row) for row in FieldCodec.decode( + dict[str, GithubPullRequest], FieldCodec.parse(listed.stdout)).values()) requests = tuple(row.parsed( tuple(candidate.id for candidate in linked[row.number]), tuple(dict.fromkeys(order.id for candidate in linked[row.number] for order in candidate.order_history if PullRequestLink(repo, row.number) in order.pull_requests))) - for row in rows.values() if isinstance(row, kind)) + for row in rows) for candidate in problems.values(): - self._read[repo, kind, candidate.id] = tuple(sorted( + self._read[repo, candidate.id] = tuple(sorted( (request for request in requests if request.belongs_to(candidate)), key=lambda request: request.updated_at, reverse=True)) - return self._read[repo, kind, todo.id] + return tuple(request for request in self._read[repo, todo.id] + if issubclass(request.status, kind)) @dataclass(frozen=True) @@ -1507,6 +1623,9 @@ def require_transfer(self, todo: Todo) -> None: def require_close(self): raise ValueError("Closing requires recorded installed application evidence.") + def installation_pings(self, previous, todo: Todo): + return () + def require_installation(self) -> None: raise ValueError("Check the delivered order before recording installation.") @@ -1996,6 +2115,11 @@ def __post_init__(self) -> None: if not self.works.strip() or not self.artifact.strip(): raise ValueError("Describe what worked and name its artifact file.") + def installation_pings(self, previous, todo: Todo): + from .commander_pings import InstallPing, OrderRevision + + return (InstallPing(previous.unit, previous.state, (OrderRevision.of(todo),), self.artifact),) + def installed(self, works: str, artifact: str) -> Self: if (works, artifact) != (self.works, self.artifact): raise ValueError("The order already records different installed evidence.") @@ -2109,6 +2233,23 @@ class HuntRecord(TypedTable): hunt: Hunt +@dataclass(frozen=True) +class CommanderCursor(TypedTable): + """The committed ping message on the bus is the only record of delivery, and + this cursor is a reader position rebuilt from that message after a crash, + never a second sent or not-sent store. Two databases cannot share one atomic + transaction, so pretending they do would be the lie. + """ + commander: Annotated[ThreadIncarnation, Column(primary_key=True)] + message: MessageReference + + def advance(self, store: TodoStore) -> None: + with store.writing() as db: + previous = next(type(self).select(db, commander=self.commander), None) + if previous is None or previous.message.seq < self.message.seq: + self.upsert(db) + + class TodoError(ToolRefusedError): """A todo request is invalid or its persisted state cannot be trusted.""" @@ -2442,6 +2583,9 @@ def story_text(self) -> str: def prior_strikes(self, current: Todo) -> tuple[StrikeTodo, ...]: return current.live_strikes(self.previous.owner) + def commander_pings(self, todo: Todo, state): + return () + def matches_failed_check(self, strike: FailedCheckStrike) -> bool: return False @@ -2451,6 +2595,12 @@ def repeats(self, recorded: StrikeTodo) -> bool: @dataclass(frozen=True, kw_only=True) class FailedCheckStrike(StrikeTodo): + def commander_pings(self, todo: Todo, state): + from .commander_pings import FailedCheckPing, OrderRevision + + return (FailedCheckPing(todo.held().owner_identity, state, (OrderRevision.of(todo),), + self.at.recorded_at("A failed check")),) + """DeliverTodo records a failed order check at the check boundary, before rejecting done; once per order and holder, in the existing strike history.""" @@ -3166,7 +3316,7 @@ class TodoStore(DurableStore): root: Path filename: ClassVar[str] = "todos.sqlite3" - rows: ClassVar[tuple[type[TypedTable], ...]] = (Todo, HuntRecord) + rows: ClassVar[tuple[type[TypedTable], ...]] = (Todo, HuntRecord, CommanderCursor) @contextmanager def writing(self) -> Iterator[sqlite3.Connection]: @@ -3323,10 +3473,10 @@ def reassignable(self) -> tuple[Todo, ...]: if todo.pending and todo.handed_out_on_release) def merged_orders(self, commander: ThreadIncarnation, - registry: Registration) -> tuple[Todo, ...]: + registry: Registration, pulls: PullRequests | None = None) -> tuple[Todo, ...]: """Observe linked merges without holding a write lock.""" held = self.held_for_commander(commander) - pulls = PullRequests(held) + pulls = pulls if pulls is not None else PullRequests(held) return tuple(closed for todo in held for closed in todo.order.closed_by_merge(todo, registry, pulls)) diff --git a/src/agent_comms/ui_model/unit_state.py b/src/agent_comms/ui_model/unit_state.py index ab33a3c0d0..ad75de839e 100644 --- a/src/agent_comms/ui_model/unit_state.py +++ b/src/agent_comms/ui_model/unit_state.py @@ -180,6 +180,16 @@ def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: @abstractmethod def derive(cls, source: UnitObservation) -> Iterator[Self]: ... + def unit_states(self) -> tuple[UnitState, ...]: + return (self,) + + def commander_pings(self, todos, states): + from agent_comms.commander_pings import OrderRevision, UnitStatePing + + return (UnitStatePing(self.unit, type(self), tuple( + OrderRevision.of(todos[problem]) for problem in self.orders), + self.detail(), self.default_action),) + def attention(self) -> bool: return False @@ -329,7 +339,7 @@ def accepts(goal: Goal, generation: GenerationState) -> bool: class RetryableUnitBlock(UnitBlock): - action = "Core reissues the known-safe orders" + action = "reissue only the known-safe orders" @staticmethod def accepts(goal: Goal, generation: GenerationState) -> bool: diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index 42b55f2beb..41bffef3f3 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -9,7 +9,7 @@ import pytest -from order_scope_fixture import FIX_SCOPE +from source_revision_fixture import SourceRepository from agent_comms.bus_publication import AgentOrigin from agent_comms.change_report import FileLine @@ -20,8 +20,9 @@ from agent_comms.locked_store import SQLiteFile from agent_comms.commander.config import CommanderConfig from agent_comms.commander.order import Commander, SayVerb -from agent_comms.commander.standing import ( - Blocked, Detached, HolderCannotRun, UnitAskUnanswered, UnownedWheelSpin, UnresolvedBlock, +from agent_comms.commander.standing import UnownedWheelSpin +from agent_comms.ui_model.unit_state import ( + AwaitingCommander, Blocked, Detached, UnitState, RetryableUnitBlock, UnresolvedUnitBlock, ) from agent_comms.commander_heartbeat import CommanderHeartbeat from agent_comms.goal_actions import BlockedGoalAction, CreateProblemGoalAction, SetGoalAction @@ -31,7 +32,7 @@ from agent_comms.goal_states import ActiveGoal, BlockedGoal from agent_comms.goals import CountCheckResult, Goal, OrderCheck, RequestGoalOrder from agent_comms.input_disposition import InputDispositions -from agent_comms.messages import InfoKind, Message +from agent_comms.messages import Message, QuestionKind from agent_comms.message_page import MessagePageRequest from agent_comms.read_basis import DMDisplayScope from agent_comms.order_runs import MechanicalOrderRun @@ -50,12 +51,10 @@ @pytest.fixture def held_bus(comms, tmp_path): """One setup owner: real declarations, checked orders, UNKNOWN row, then a copy.""" - Repo.git(tmp_path, "init", "-q", "-b", "main") - Repo.git(tmp_path, "-c", "user.name=Held-order fixture", "-c", "user.email=held@example.invalid", - "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-qm", "Held-order source") - Repo.git(tmp_path, "update-ref", "refs/remotes/origin/main", "HEAD") + source = SourceRepository.create(tmp_path / "source") for name in ("lead", "worker", "outsider"): - comms.registry.declare(comms.registry.new_thread(name=name, tags=frozenset(), worktree=str(tmp_path))) + comms.registry.declare(comms.registry.new_thread(name=name, tags=frozenset(), worktree=str(source.checkout), + process_identity=ProcessIdentity.capture(os.getpid()))) comms.goals.update_goal("lead", SetGoalAction( text="Repair the held work", goal_id="request", order=RequestGoalOrder(), )) @@ -66,8 +65,8 @@ def held_bus(comms, tmp_path): repo=RepositoryName.from_wire("OpenHCSDev/agent-comms"), text=f"Repair {problem}", review_at=RecordedEventTime(now + 3600), order=PendingProblemOrder( - id=f"order-{problem}", owner=FileLine(__file__, 1), scope=FIX_SCOPE, run=MechanicalOrderRun(), - check=OrderCheck(str(tmp_path), "printf 1", CountCheckResult(1)), + id=f"order-{problem}", owner=FileLine("owner.py", 2), scope=source.scope(), run=MechanicalOrderRun(), + check=OrderCheck(str(source.checkout), "printf 1", CountCheckResult(1)), edits=("Repair the existing owner",), banned=("No second store",), report="Report the result", at=RecordedEventTime(now - 3600), first_call_deadline=RecordedEventTime(now + 3600), @@ -107,8 +106,8 @@ def test_an_active_goal_with_an_unresolved_attempt_is_reported_never_retried(hel store.recover_unreserved_ready(goal.id, generation.number) store.reserve(goal.id, generation.number, ready_grant=store.ready_grant(goal.id, generation.number)) reserved = store.require_generation(goal.id) - assert Blocked.violations(held_bus, "lead") == () - violation, = UnresolvedBlock.violations(held_bus, "lead") + violation, = Blocked.violations(held_bus, "lead") + assert isinstance(violation.blocks[0], UnresolvedUnitBlock) assert violation.orders == ("P1", "P2") violation.enforce(held_bus, "lead") assert held_bus.registry.require("worker").goal.require() == goal @@ -125,22 +124,22 @@ def test_unresolved_block_accepts_ledger_state_independently_of_goal_state(holde generation = holder_generation goal = Goal("Held work", "goal", published_seq=0) expected = not isinstance(generation, RetryableGeneration) - assert UnresolvedBlock.accepts(goal, generation) == expected + assert UnresolvedUnitBlock.accepts(goal, generation) == expected blocked = replace(goal, state=BlockedGoal("Failed turn")) - assert UnresolvedBlock.accepts(blocked, generation) == expected + assert UnresolvedUnitBlock.accepts(blocked, generation) == expected def test_blocked_accepts_only_real_goal_blocks_with_retryable_generations(holder_generation): generation = holder_generation goal = Goal("Held work", "goal", published_seq=0) - assert not Blocked.accepts(goal, generation) + assert not RetryableUnitBlock.accepts(goal, generation) blocked = replace(goal, state=BlockedGoal("Failed turn")) - assert Blocked.accepts(blocked, generation) == isinstance(generation, RetryableGeneration) + assert RetryableUnitBlock.accepts(blocked, generation) == isinstance(generation, RetryableGeneration) def publish(comms, sender, target, timestamp, *, answers=()): return comms.bus.publisher.publish_ordinary( - Message(sender=sender, target=target, type=InfoKind, body="Not a question", timestamp=timestamp, + Message(sender=sender, target=target, type=QuestionKind, body="A decision is needed", timestamp=timestamp, answers=answers), origin=AgentOrigin(), ) @@ -151,7 +150,7 @@ def test_blocked_holder_republishes_every_order_without_replaying_unknown(held_b inputs = InputDispositions(held_bus.root) unresolved = inputs.lookup("unresolved-input") violation, = Blocked.violations(held_bus, "lead") - assert violation.block_record.is_file() + assert violation.blocks[0].goal.id == original.id print(violation.line()) previous_orders = {todo.id: todo.order.checked().id for todo in held_bus.goals.todos.held_orders()} @@ -183,17 +182,17 @@ def test_blocked_holder_reissues_only_undelivered_orders(held_bus): def test_blocked_holder_is_reissued_its_accepted_order_after_editing_its_scope(held_bus, tmp_path): - owner = tmp_path / "owner.py" - owner.write_text("class Owner:\n pass\n") + directory = Path(held_bus.goals.todos.get("P1").order.checked().check.directory) + owner = directory / "owner.py" + owner.write_text(SourceRepository.main_source) now = time.time() held_bus.goals.update_goal("worker", CreateProblemGoalAction( problem="P3", commander="lead", request="request", repo=RepositoryName.from_wire("OpenHCSDev/agent-comms"), text="Repair P3", review_at=RecordedEventTime(now + 3600), order=PendingProblemOrder( - id="order-P3", owner=FileLine(str(owner), 1), - scope=FixScope(FixShape(parents=(FileLine(str(owner), 1),), members=())), - run=MechanicalOrderRun(), check=OrderCheck(str(tmp_path), "printf 1", CountCheckResult(1)), + id="order-P3", owner=FileLine("owner.py", 2), scope=SourceRepository.scope(), + run=MechanicalOrderRun(), check=OrderCheck(str(directory), "printf 1", CountCheckResult(1)), edits=("Repair the existing owner",), banned=("No second store",), report="Report the result", at=RecordedEventTime(now - 3600), first_call_deadline=RecordedEventTime(now + 3600), @@ -256,15 +255,15 @@ def test_unanswered_uses_age_direct_target_and_later_answer_references(held_bus) question = publish(held_bus, "worker", "lead", now - 901) # An unrelated later answer and message text do not answer this reference. publish(held_bus, "lead", "worker", now - 800) - violation, = UnitAskUnanswered.violations(held_bus, "lead") - assert violation.reference == question.reference + violation, = AwaitingCommander.violations(held_bus, "lead") + assert violation.questions[0].reference == question.reference print(violation.line()) held_bus.messaging.send("lead", "worker", "Responding", answers=(question.reference,)) - assert UnitAskUnanswered.violations(held_bus, "lead") == () + assert AwaitingCommander.violations(held_bus, "lead") == () another = publish(held_bus, "worker", "lead", now - 950) held_bus.messaging.send("lead", "outsider", "Answer in another conversation", answers=(another.reference,)) - assert UnitAskUnanswered.violations(held_bus, "lead") == () + assert AwaitingCommander.violations(held_bus, "lead") == () def test_commander_say_answers_an_unanswered_unit_message(held_bus): @@ -272,22 +271,20 @@ def test_commander_say_answers_an_unanswered_unit_message(held_bus): ctx = Commander(**CommanderConfig.read_root(held_bus.root).model_dump()) say = SayVerb(units="worker", body_file="unused", answers=(question.message_id,)) held_bus.messaging.send("lead", "worker", "Responding", answers=say.answer_references(ctx)) - assert UnitAskUnanswered.violations(held_bus, "lead") == () + assert AwaitingCommander.violations(held_bus, "lead") == () with pytest.raises(SystemExit, match="unanswered unit messages"): SayVerb(units="worker", body_file="unused", answers=("not-a-message",)).answer_references(ctx) -def test_detached_and_whole_family_scan_still_find_nonreceiving_holders(held_bus): - blocked_holder(held_bus) +def test_one_mro_state_owns_simultaneous_conditions(held_bus): held_bus.registry.register(held_bus.registry.require("worker"), StoppedThreadStatus()) detached, = Detached.violations(held_bus, "lead") - assert detached.unit == "worker" and set(detached.orders) == {"P1", "P2"} - family = HolderCannotRun.violations(held_bus, "lead") - assert detached in family - blocked, = Blocked.violations(held_bus, "lead") - assert blocked in family - broken = StandingRule.broken(held_bus, "lead") - assert all(rule in broken for rule in family) + assert detached.unit.name == "worker" and set(detached.orders) == {"P1", "P2"} + blocked_holder(held_bus) + family = UnitState.violations(held_bus, "lead") + assert len(family) == 1 and type(family[0]) is Blocked + assert Detached.violations(held_bus, "lead") == () + assert family[0] in StandingRule.broken(held_bus, "lead") def legacy_decision_table(comms): @@ -349,22 +346,28 @@ def test_install_discovers_and_records_decision_cutover_once(held_bus): assert GoalWorkDecisions not in chain.pending(ledger.applied()) -def test_startup_heartbeat_enforces_and_publishes_both_new_rule_lines(held_bus): +def test_two_restarted_heartbeats_never_repeat_a_committed_ping(held_bus): + from agent_comms.commander_pings import LatestCommanderPing + from agent_comms.launch_runtime import LaunchRoot + from agent_comms.todos import CommanderCursor + blocked_holder(held_bus) - question = publish(held_bus, "worker", "lead", time.time() - 901) - expected = tuple(rule.line() for rule in ( - *Blocked.violations(held_bus, "lead"), *UnitAskUnanswered.violations(held_bus, "lead"), - )) - heartbeat = CommanderHeartbeat() - commander = held_bus.registry.require("lead") - held_bus.registry.register(replace(commander, process_identity=ProcessIdentity.capture(os.getpid()))) - heartbeat.pass_body(held_bus, heartbeat.observe(held_bus)) - assert commander.incarnation in heartbeat.reviews - assert isinstance(held_bus.registry.require("worker").goal.require().state, ActiveGoal) - page = held_bus.bus.log.read_page(MessagePageRequest(DMDisplayScope.capture( - heartbeat.sender, "lead", held_bus.registry.snapshot(), - ))) - message = next(message for message in page.messages if message.sender == heartbeat.sender) - assert all(line in message.body for line in expected) - assert question.reference.message_id in message.body - print(message.body) + with LaunchRoot.from_path(held_bus.root).selected(): + first = CommanderHeartbeat() + first.pass_body(held_bus, first.observe(held_bus)) + commander = held_bus.registry.require("lead").incarnation + original = LatestCommanderPing.read(held_bus, first.sender, commander) + assert original is not None + assert len(original.pings) == 1 and original.body.startswith("BLOCKED worker:") + assert isinstance(held_bus.registry.require("worker").goal.require().state, ActiveGoal) + # A missing cursor simulates death after the committed send, before cursor advance. + with held_bus.goals.todos.writing() as db: + CommanderCursor.delete(db, commander=commander) + for _ in range(2): + restarted = CommanderHeartbeat() + restarted.pass_body(held_bus, restarted.observe(held_bus)) + assert LatestCommanderPing.read(held_bus, restarted.sender, commander) == original + with held_bus.goals.todos.snapshot() as db: + cursor, = CommanderCursor.select(db, commander=commander) + assert cursor.message == original.reference + print(original.body, "RESTART 1: 0 repeated lines; RESTART 2: 0 repeated lines", sep="\n") diff --git a/tests/test_order_progress.py b/tests/test_order_progress.py index b0c3c9c5a5..17a9b181ed 100644 --- a/tests/test_order_progress.py +++ b/tests/test_order_progress.py @@ -20,23 +20,11 @@ from agent_comms.tools import CommsTodoCreateTool from agent_comms.todos import (CommittedProgress, DistanceReadings, EditingProgress, MergedProgress, NoCommitProgress, OpenPrProgress, OrderProgress, MergedPullRequest, PresentCheckout, ProgressPoint, - PullRequest, PullRequests, PushedProgress, UnmergedPullRequest, UnreadProgress) + PullRequest, PullRequests, PushedProgress, UnmergedPullRequest, UnreadProgress, + UnmergedListedPullRequest, MergedListedPullRequest) -class Listed(PullRequests): - """GitHub as a test states it: the pull requests of each state.""" - - def __init__(self, **listed: tuple[PullRequest, ...]) -> None: - super().__init__() - self.states = listed - - def read(self, repo, state, order): - if isinstance(self.states, Exception): - raise self.states - return self.states.get(state, ()) - - def git(checkout, *args): return subprocess.run(['git', '-C', str(checkout), *args], check=True, capture_output=True, text=True).stdout.strip() @@ -63,8 +51,13 @@ def readings(self): return DistanceReadings() def requests(self, state): - return {'open': [UnmergedPullRequest(7, 'fix/p1', 'b' * 40, updated_at=1000.0, orders=('order-P1',))], - 'merged': [MergedPullRequest(8, 'fix/p1', 'f' * 40, merge='c' * 40, merged_at=200.0, orders=('order-P1',))]}[state] + requests = ( + UnmergedPullRequest(7, 'fix/p1', 'b' * 40, updated_at=1000.0, + orders=('order-P1',), status=UnmergedListedPullRequest), + MergedPullRequest(8, 'fix/p1', 'f' * 40, merge='c' * 40, + merged_at=200.0, orders=('order-P1',), status=MergedListedPullRequest), + ) + return [request for request in requests if issubclass(request.status, state)] class EmptySource: diff --git a/tests/test_storage_violations.py b/tests/test_storage_violations.py index 1a74dfdbae..5f019ee50e 100644 --- a/tests/test_storage_violations.py +++ b/tests/test_storage_violations.py @@ -150,4 +150,4 @@ def observed_read(_code: CodeType, _offset: int, value: UnitStorage) -> None: assert str(home) in text assert str(comms.registry.storage_rule.order_checkout(worker.name, "P")) in text assert str(worker.storage_roots.scratch) in text - print("\n".join(StandingRule.heartbeat_changes((), found))) + print("\n".join(rule.line() for rule in found)) diff --git a/tests/test_todos.py b/tests/test_todos.py index f146d0f1ec..e003dc44f9 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -245,14 +245,17 @@ def pr_commander(tmp_path, monkeypatch): source.git(checkout, "remote", "add", "origin", f"https://github.com/{source.github.to_wire()}.git") merged = source.git(checkout, "rev-parse", "HEAD").strip() source.git(checkout, "update-ref", f"refs/remotes/origin/{WorkBranches.main}", merged) + from agent_comms.todos import GithubPullRequest + common = dict(branch="feature/orders", head=merged, updated="2026-10-20T12:00:00Z", - base=WorkBranches.main) + base=WorkBranches.main, closed=True, isDraft=False, + mergeStateStatus="CLEAN", checks="SUCCESS", queued=False) requests = ( - (MergedListedPullRequest(number=1, merge=PullRequestCommit(merged), - merged="2026-10-20T12:00:00Z", **common), "No order metadata."), - (MergedListedPullRequest(number=2, merge=PullRequestCommit(merged), - merged="2026-10-20T12:00:00Z", **common), "## P343\n\n## P344"), - (ClosedListedPullRequest(number=3, **common), "Closed without merging."), + (GithubPullRequest(number=1, merge=PullRequestCommit(merged), + merged="2026-10-20T12:00:00Z", **common), "No order metadata."), + (GithubPullRequest(number=2, merge=PullRequestCommit(merged), + merged="2026-10-20T12:00:00Z", **common), "## P343\n\n## P344"), + (GithubPullRequest(number=3, merge=None, merged=None, **common), "Closed without merging."), ) binaries = tmp_path / "bin" binaries.mkdir() @@ -425,11 +428,11 @@ def test_watcher_git_and_github_reads_leave_wire_free(pr_commander, monkeypatch) def test_blocked_enforcement_rejects_a_recovered_goal(held_bus, caplog): from test_holder_standing_rules import blocked_holder - from agent_comms.commander.standing import Blocked + from agent_comms.ui_model.unit_state import Blocked from agent_comms.goal_actions import RetryGoalAction from agent_comms.goals import GoalChangedError from agent_comms.input_disposition import InputDispositions - from agent_comms.commander_heartbeat import CommanderReview + from agent_comms.standing_rules import StandingRule caplog.set_level(logging.INFO) with LaunchRoot.from_path(held_bus.root).selected(): @@ -440,12 +443,9 @@ def test_blocked_enforcement_rejects_a_recovered_goal(held_bus, caplog): generation = held_bus.goals.open_goal_store().require_generation(recovered.id) inputs = InputDispositions(held_bus.root) unresolved = inputs.lookup("unresolved-input") - review = CommanderReview(frozenset()) with held_bus.bus.locked(): - CommanderHeartbeat.register(held_bus.registry) - review.wake(held_bus, CommanderHeartbeat.sender, - held_bus.registry.require("lead"), (observed,)) - assert observed not in review.violations + rejected_rules = StandingRule.enforce_all(held_bus, "lead", (observed,)) + assert observed in rejected_rules assert held_bus.registry.require("worker").goal == recovered assert tuple(held_bus.goals.todos.get(row.id) for row in rows) == rows assert held_bus.goals.open_goal_store().require_generation(recovered.id) == generation @@ -453,12 +453,13 @@ def test_blocked_enforcement_rejects_a_recovered_goal(held_bus, caplog): rejected = caplog.records[-1].args[0] assert isinstance(rejected, GoalChangedError) assert rejected.thread.name == "worker" - assert rejected.expected == observed.goal.captured + assert rejected.expected == observed.blocks[0].goal.captured def test_blocked_enforcement_preserves_reassigned_orders(held_bus): from test_holder_standing_rules import blocked_holder - from agent_comms.commander.standing import Blocked, UnownedWheelSpin + from agent_comms.commander.standing import UnownedWheelSpin + from agent_comms.ui_model.unit_state import Blocked from agent_comms.standing_rules import StandingRule with LaunchRoot.from_path(held_bus.root).selected(): @@ -468,13 +469,13 @@ def test_blocked_enforcement_preserves_reassigned_orders(held_bus): reassigned = held_bus.goals.update_goal("outsider", UnownedWheelSpin.order_for( row, "lead", held_bus.registry.require("lead").incarnation)) assigned = held_bus.goals.todos.get(row.id) - generation = held_bus.goals.open_goal_store().require_generation(observed.goal.id) + generation = held_bus.goals.open_goal_store().require_generation(observed.blocks[0].goal.id) with held_bus.bus.locked(), pytest.raises(TodoError): StandingRule.enforce_all(held_bus, "lead", (observed,)) - assert held_bus.registry.require("worker").goal == observed.goal + assert held_bus.registry.require("worker").goal == observed.blocks[0].goal assert held_bus.registry.require("outsider").goal == reassigned assert held_bus.goals.todos.get(row.id) == assigned - assert held_bus.goals.open_goal_store().require_generation(observed.goal.id) == generation + assert held_bus.goals.open_goal_store().require_generation(observed.blocks[0].goal.id) == generation def test_reused_enforcement_rejects_a_new_idle_goal(checkout_bus, caplog): diff --git a/tests/test_unit_state.py b/tests/test_unit_state.py new file mode 100644 index 0000000000..244cd32eed --- /dev/null +++ b/tests/test_unit_state.py @@ -0,0 +1,81 @@ +"""One family journey: each state is derived from actual Core records, not patched methods.""" +from dataclasses import replace +from datetime import UTC, datetime +import json +import time + +import pytest + +from test_holder_standing_rules import held_bus, blocked_holder, publish +from agent_comms.launch_runtime import LaunchRoot +from agent_comms.recorded_time import RecordedEventTime +from agent_comms.thread_status import StoppedThreadStatus +from agent_comms.thread_text import SavedSessionFile +from agent_comms.todos import DeliverTodo, OperationDelivery +from agent_comms.ui_model.unit_state import ( + AwaitingCommander, Blocked, DeliveredUnclosed, Detached, Idle, Moving, + Spinning, StalledOnNothing, Stuck, UnitState, +) + + +def recorded_turn(comms, calls): + now = time.time() + stamp = datetime.fromtimestamp(now, UTC).isoformat() + rows = [dict(type="session", id="state-source", version=3), + dict(type="message", id="input", timestamp=stamp, + message=dict(role="user", content="Repair the existing owner"))] + for index in range(calls): + rows.append(dict(type="message", id=f"call-{index}", timestamp=stamp, + message=dict(role="assistant", content=[dict(type="toolCall", id=f"read-{index}", + name="read", arguments=dict(path="owner.py"))], stopReason="toolUse"))) + rows.append(dict(type="message", id=f"result-{index}", timestamp=stamp, + message=dict(role="toolResult", toolCallId=f"read-{index}", toolName="read", + isError=False, content=[dict(type="text", text="contents")])) ) + rows.append(dict(type="message", id="answer", timestamp=stamp, + message=dict(role="assistant", content=[dict(type="text", text="Continue the repair")], + stopReason="stop"))) + path = comms.root / "state-source.jsonl" + path.write_text("".join(json.dumps(row) + "\n" for row in rows)) + path.chmod(0o600) + thread = comms.registry.require("worker") + comms.registry.register(replace(thread, session_file=SavedSessionFile(str(path)))) + + +@pytest.mark.parametrize("member", UnitState.members_with(UnitState), ids=lambda member: member.declared_name) +def test_unit_state_family_uses_real_records(held_bus, member): + comms = held_bus + unit = "worker" + with LaunchRoot.from_path(comms.root).selected(): + if member is Idle: + unit = "outsider" + elif member is Blocked: + blocked_holder(comms) + elif member is Detached: + comms.registry.register(comms.registry.require(unit), StoppedThreadStatus()) + elif member is AwaitingCommander: + publish(comms, unit, "lead", time.time() - AwaitingCommander.seconds - 1) + elif member is Stuck: + with comms.goals.todos.writing() as db: + for todo in comms.goals.todos.held_for_commander(comms.registry.require("lead").incarnation): + replace(todo, order=replace(todo.order.checked(), + first_call_deadline=RecordedEventTime(time.time() - 1))).upsert(db) + elif member is Spinning: + recorded_turn(comms, 2) + elif member is StalledOnNothing: + recorded_turn(comms, 1) + elif member is DeliveredUnclosed: + for todo in comms.goals.todos.held_for_commander(comms.registry.require("lead").incarnation): + comms.goals.todos.apply(todo.id, DeliverTodo(todo.revision, + comms.registry.require("lead").incarnation, + OperationDelivery(at=RecordedEventTime(time.time())), todo.order.id)) + elif member is Moving: + pass # Freshly assigned work before its first call deadline. + else: + pytest.fail(f"New UnitState needs its real-record scenario: {member}") + source = next(source for source in UnitState.observations(comms, "lead") + if source.thread.name == unit) + state = UnitState.read(source) + assert type(state) is member + assert state.unit == comms.registry.require(unit).incarnation + assert state.default_action and state.unit.name in state.line() + print(state.line()) diff --git a/tests/test_unowned_checkout_work.py b/tests/test_unowned_checkout_work.py index 2fd93eaa1a..097bc1d787 100644 --- a/tests/test_unowned_checkout_work.py +++ b/tests/test_unowned_checkout_work.py @@ -22,7 +22,7 @@ UnownedWheelSpin, WaitingUnownedWheelSpin, ) -from agent_comms.commander_heartbeat import CommanderHeartbeat, CommanderReview +from agent_comms.commander_heartbeat import CommanderHeartbeat from agent_comms.comms import Comms from agent_comms.field_codec import FieldCodec from agent_comms.goal_actions import SetGoalAction @@ -166,12 +166,10 @@ def test_dirty_then_unpushed_then_pushed_linked_checkout(checkout_bus: Comms, tm checkout = original.registry.storage_rule.order_checkout('worker', 'P1') with practice_copy(original.root, tmp_path / 'practice') as (root, _measurement): c = Comms(root) - review = CommanderReview(frozenset()) with LaunchRoot.from_path(root).selected(): (checkout / 'work.py').write_text('# not yet published\n') assert_retained(c) - review.wake(c, CommanderHeartbeat.sender, c.registry.require('lead'), - StandingRule.broken(c, 'lead')) + StandingRule.enforce_all(c, 'lead', StandingRule.broken(c, 'lead')) git(checkout, 'add', 'work.py') git(checkout, 'commit', '-q', '-m', 'Unpushed work') assert git(checkout, 'status', '--porcelain') == '' @@ -184,28 +182,17 @@ def test_dirty_then_unpushed_then_pushed_linked_checkout(checkout_bus: Comms, tm assert isinstance(waiting[0], WaitingUnownedWheelSpin) UnownedOrderWatch().watch_pass(lambda: nullcontext(c)) assert c.goals.todos.get('P2').holder is None - review.wake(c, CommanderHeartbeat.sender, c.registry.require('lead'), - StandingRule.broken(c, 'lead')) + StandingRule.enforce_all(c, 'lead', StandingRule.broken(c, 'lead')) config.write(tmp_path / 'copy-home') available = UnownedWheelSpin.violations(c, 'lead') assert len(available) == 1 assert isinstance(available[0], ReassignedWheelSpin) assert available[0].unit == 'idle' - assert set(available).isdisjoint(review.violations) UnownedOrderWatch().watch_pass(lambda: nullcontext(c)) assigned = c.goals.todos.get('P2') assert assigned.holder == 'idle' assert c.registry.require('idle').goal.require().text - review.wake(c, CommanderHeartbeat.sender, c.registry.require('lead'), - StandingRule.broken(c, 'lead')) - drain_candidate_catchup(root) - history = subprocess.run( - (sys.executable, '-m', 'agent_comms.cli', '--root', str(root), 'history', - '--target', '@commander-heartbeat', '--as', 'lead', '--limit', '10'), - env=os.environ.copy(), capture_output=True, text=True, timeout=30, check=True) - assert f'WORK HELD: P2 worker retains work in {checkout}' in history.stdout - assert f'NO LONGER CURRENT: WORK HELD: P2 worker retains work in {checkout}' in history.stdout - print('PRIVATE HEARTBEAT', history.stdout) + StandingRule.enforce_all(c, 'lead', StandingRule.broken(c, 'lead')) print('PRIVATE WATCHER: dirty and unpushed retained; clean pushed assigned to', assigned.holder) assert original.goals.todos.get('P2') == untouched assert original.registry.require('idle').goal == idle_goal diff --git a/tools/cutover/carry_commander_cursor.py b/tools/cutover/carry_commander_cursor.py new file mode 100644 index 0000000000..64d70f6cfe --- /dev/null +++ b/tools/cutover/carry_commander_cursor.py @@ -0,0 +1,36 @@ +"""Add the rebuildable commander reader position without changing any order row. + +Delete after installation has carried the existing buses. +""" +from contextlib import closing +from pathlib import Path + +from agent_comms.coordination_database import CoordinationStore +from agent_comms.installer.carry import Carried, Carry +from agent_comms.locked_store import SQLiteFile +from agent_comms.todos import CommanderCursor, TodoStore + + +class CommanderReaderPosition(Carry): + @classmethod + def backup_files(cls, root: Path) -> tuple[Path, ...]: + return tuple(path for path in SQLiteFile.files(TodoStore.path_in(root)) if path.exists()) + + @classmethod + def carry(cls, root: Path, *, apply: bool) -> Carried: + path = TodoStore.path_in(root) + if not path.exists(): + return Carried(0) + cls.require_confined(root, path) + with closing(CoordinationStore.read_snapshot(path)) as db: + present = db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (CommanderCursor.declared_name,)).fetchone() + if present: + return Carried(0) + if not apply: + return Carried(1) + with closing(CoordinationStore.writer(path, isolation_level=None)) as db: + with SQLiteFile.write_transaction(db): + CommanderCursor.create(db) + TodoStore.require_schema(db) + return Carried(1, rewritten=(str(path.relative_to(root)),)) From ea987af73d04731a085992f07fd49c0cbcd37fc3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 11:25:52 -0400 Subject: [PATCH 04/15] P433: own accepted and rejected head review on one verdict family --- src/agent_comms/connector.py | 4 +-- src/agent_comms/goal_actions.py | 4 +-- src/agent_comms/goal_management.py | 4 +-- src/agent_comms/todos.py | 55 ++++++++++++++++++++++-------- 4 files changed, 46 insertions(+), 21 deletions(-) diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index a45e9c4683..170bd29fe0 100644 --- a/src/agent_comms/connector.py +++ b/src/agent_comms/connector.py @@ -61,7 +61,7 @@ from .commander.standing import UnownedOrderWatch from .commander_heartbeat import CommanderHeartbeat from .coordinator import Coordination -from .todos import DirectiveText, ReviewedHeadVerdict +from .todos import AcceptedHeadVerdict, DirectiveText from .tool_caller import ThreadCaller from .tools import ( CommsVerdictTool, ReadTool, TodoChanged, ToolRequest, tool_field, @@ -98,7 +98,7 @@ class ConnectorVerdictTool(ConnectorTool, declared_name=CommsVerdictTool.declare label = "Review PR head" description = "Record the connector reviewer's acceptance of the exact PR commit it reviewed." id: Annotated[str, *tool_field("Problem ID")] - operation: Annotated[ReviewedHeadVerdict, *tool_field("Accepted PR-head verdict")] + operation: Annotated[AcceptedHeadVerdict, *tool_field("Accepted PR-head verdict")] def apply(self, comms: Comms, caller: DirectorCaller) -> TodoChanged: todo = self.operation.reviewed( diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index 0df7116425..56200121a5 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -47,7 +47,7 @@ from .repository_name import RepositoryName from .thread_identity import ThreadIncarnation from .threads import Thread -from .todos import GoalRef, PendingProblemOrder, SendBackVerdict, Todo, TodoOperation +from .todos import GoalRef, PendingProblemOrder, RejectingVerdict, Todo, TodoOperation if TYPE_CHECKING: from .goal_management import Goals @@ -392,7 +392,7 @@ def change(self, goal: Goal, ctx: GoalActionContext) -> Goal: @dataclass(frozen=True, kw_only=True) class SentBackOrderGoalAction(HeldOrderGoalAction): - operation: SendBackVerdict + operation: RejectingVerdict def revised(self, goal: Goal, ctx: GoalActionContext) -> Goal: return goal diff --git a/src/agent_comms/goal_management.py b/src/agent_comms/goal_management.py index b4ee37d15b..14388f638f 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -57,7 +57,7 @@ from .message_bus import MessageBus from .store_files import SharedLock, _store_lock from .threads import Thread -from .todos import SendBackVerdict, StrikeReason, Todo, TodoCommand, TodoStore +from .todos import RejectingVerdict, StrikeReason, Todo, TodoCommand, TodoStore from .turn_lease import TurnCompletion from .wire_log import ConversationSourceWindowRequest @@ -89,7 +89,7 @@ def apply_problem(self, problem: str, operation: TodoCommand) -> Todo: applied: Todo = self.todos.apply(problem, operation) return applied - def send_back(self, problem: str, verdict: SendBackVerdict) -> Todo: + def send_back(self, problem: str, verdict: RejectingVerdict) -> Todo: """The verdict and strike land on the Todo in the write that revises the holder's Goal.""" holder = verdict.previous self.update_goal(holder.owner, SentBackOrderGoalAction( diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 4c9843ead9..3d91640f66 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -2674,8 +2674,8 @@ def reviewed_heads(self) -> tuple[str, ...]: """Only a judgment of a commit clears review of that immutable head.""" return () - @abstractmethod - def judged(self, current: Todo) -> Todo: ... + def judged(self, current: Todo) -> Todo: + return current @property @abstractmethod @@ -2715,7 +2715,7 @@ def require_quote(self, text: str) -> None: def judged(self, current: Todo) -> Todo: self.require_quote(current.order.instructions(current)) - return current + return super().judged(current) @dataclass(frozen=True) @@ -2733,9 +2733,10 @@ def require_branches(cls, added: int, no_owner: str | None) -> None: @dataclass(frozen=True) -class ReviewedHeadVerdict(QuotedVerdict): - """The connector reviewer accepts this immutable commit, never an inferred checkout head.""" +class HeadVerdict(QuotedVerdict, TodoOperation, declared_name=None): + """One judgment of immutable code; its members own only their GitHub result.""" context: ClassVar[str] = "agent-comms/review" + state: ClassVar[str] head: str def __post_init__(self) -> None: @@ -2747,22 +2748,34 @@ def reviewed_heads(self) -> tuple[str, ...]: @property def line(self) -> str: - return f"Reviewed head {self.head}" + return f"{self.state} head {self.head}" + + def prepare(self, goals: Goals, current: Todo) -> Self: + requests = PullRequests((current,)).listed(current.repo, ListedPullRequest, current) + if not any(request.head == self.head for request in requests): + raise TodoError("The verdict must name a linked pull request's current head; read it again.") + return super().prepare(goals, current) def reviewed(self, goals: Goals, problem: str, reviewer: ThreadIncarnation) -> Todo: - if self.actor != reviewer: - raise TodoError("The verdict actor must be the connector reviewer recording it.") current = goals.todos.get(problem) - recorded = self.prepare(goals, current).record(goals, problem) + if self.actor != reviewer: + current.require_commander(self.actor) + return self.prepare(goals, current).record(goals, problem) + + def after_record(self, goals: Goals, recorded: Todo) -> None: posted = subprocess.run( ("gh", "api", "--method", "POST", f"repos/{recorded.repo.to_wire()}/statuses/{self.head}", - "-f", "state=success", "-f", f"context={self.context}", - "-f", f"description=Accepted by {reviewer.name} on {problem}"), + "-f", f"state={self.state}", "-f", f"context={self.context}", + "-f", f"description=Reviewed by {self.actor.name} on {recorded.id}"), capture_output=True, text=True, timeout=60, ) if posted.returncode: raise TodoError(posted.stderr.strip() or f"Review status on {self.head} failed.") - return recorded + + +@dataclass(frozen=True) +class AcceptedHeadVerdict(HeadVerdict): + state = "success" @dataclass(frozen=True) @@ -2811,8 +2824,8 @@ def judged(self, current: Todo) -> Todo: @dataclass(frozen=True) -class SendBackVerdict(ToolCallVerdict, CitedStrike): - """The tool call goes back; the same write strikes the holder and revises the order Goal.""" +class RejectingVerdict(OrderVerdict, CitedStrike, declared_name=None): + """Shared strike and goal revision, for current heads and stored tool-call history.""" reason: StrikeReason @property @@ -2831,7 +2844,19 @@ def judged(self, current: Todo) -> Todo: return strike.apply(current) def record(self, goals: Goals, problem: str) -> Todo: - return goals.send_back(problem, self) + changed = goals.send_back(problem, self) + self.after_record(goals, changed) + return changed + + +@dataclass(frozen=True) +class RejectedHeadVerdict(HeadVerdict, RejectingVerdict): + state = "failure" + + +@dataclass(frozen=True) +class SendBackVerdict(ToolCallVerdict, RejectingVerdict): + """Stored tool-call verdicts still decode; judge no longer writes this form.""" @dataclass(frozen=True) From 9e46ab7b8013283a2b2cfdc0448056ff7c9ff8e3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 11:59:36 -0400 Subject: [PATCH 05/15] P433: judge linked PR heads and refuse overdue unreviewed work --- src/agent_comms/commander/capacity.py | 17 +- src/agent_comms/commander/judge.py | 326 +++++------------- src/agent_comms/commander/standing.py | 39 ++- src/agent_comms/commander_pings.py | 2 +- src/agent_comms/external_transcript.py | 10 +- src/agent_comms/goal_actions.py | 4 +- .../skills/comms-commander/SKILL.md | 1 + src/agent_comms/todos.py | 120 ++++--- src/agent_comms/tools.py | 28 +- src/agent_comms/turn_digests.py | 22 +- src/agent_comms/ui_model/unit_state.py | 7 +- tests/github_cli_stub.py | 11 + tests/test_commander_capacity.py | 14 +- tests/test_commander_parity_load.py | 2 +- tests/test_delivery_merge.py | 4 +- tests/test_head_review.py | 138 ++++++++ tests/test_todos.py | 4 +- tools/cutover/carry_commander_cursor.py | 31 +- 18 files changed, 416 insertions(+), 364 deletions(-) create mode 100644 tests/test_head_review.py diff --git a/src/agent_comms/commander/capacity.py b/src/agent_comms/commander/capacity.py index 3445d6f3c6..b8101c7eb4 100644 --- a/src/agent_comms/commander/capacity.py +++ b/src/agent_comms/commander/capacity.py @@ -7,15 +7,14 @@ from ..comms import Comms from ..field_codec import projected from ..tool_result import ToolResult -from ..turn_digests import OrderEditCountRead @dataclass(frozen=True) class Load(ToolResult): - """Order and edit debt, with receiving children that themselves command orders.""" + """Held work and ready heads, with receiving children that command orders.""" owed: int timers_up: int - unjudged: int + heads_waiting: int forks: tuple[str, ...] @classmethod @@ -29,18 +28,18 @@ def of(cls, comms: Comms, commander: str) -> Load: if thread.parent == owner.name and snapshot.status(thread.name).receiving and todos.held_for_commander(thread.incarnation) )) - unjudged = sum( - read.read() for read in OrderEditCountRead.for_orders(comms.root, snapshot, held) - ) - return cls(len(owed), sum(bool(t.timer_up(now)) for t in owed), unjudged, forks) + from .standing import Unjudged + + return cls(len(owed), sum(bool(t.timer_up(now)) for t in owed), + len(Unjudged.violations(comms, commander)), forks) @projected(view="wire") def total(self) -> float: - return self.owed + 2 * self.timers_up + self.unjudged / 10 + return self.owed + 2 * self.timers_up + self.heads_waiting def __str__(self) -> str: return (f'load {self.total:.0f} ({self.owed} open orders, {self.timers_up} review timers up, ' - f'{self.unjudged} unjudged edits), {len(self.forks)} live forks {list(self.forks)}') + f'{self.heads_waiting} unreviewed pull requests), {len(self.forks)} live forks {list(self.forks)}') def main(a, ctx): diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index 7e59feb182..5982af2980 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -1,46 +1,35 @@ -"""The commander's verdict on every unit edit, judged against the order that unit is carrying out. +"""Review the current commit of a pull request linked to a held problem. -A send-back's --roast is posted to its verdict's audience with the quoted slop line, so the -unit learns what was stupid and nobody repeats it; a send-back without a roast is refused. ---serves quotes the order Todo, which also owns every verdict. - -A verdict is a set of semantic claims the tool can falsify: --serves must be words from -the unit's current order (which clause this edit carries out), and every edit must contain -one of the --line quotes (the deleted authority or added owner I judged it by). Without -reading each edit and deciding what it does, the claims cannot be written. - -Each edit's CODE is printed only after its last hunk line, so a verdict needs the -whole edit on screen: a show piped into head or /dev/null yields no codes. - -A verdict without a reason is refused; a send-back must cite a real message from -the commander to that unit. Nothing here judges code; it only refuses to let an edit pass unread. +The Todo owns the verdict; HeadVerdict posts its GitHub status on that exact commit. +A new push has no verdict. Edit IDs and read codes are evidence, not review identities. +Every --line quote is checked against the linked request's diff at the selected head. """ import hashlib import json -import os import re import sys import time -from typing import Annotated from abc import abstractmethod -from dataclasses import asdict, dataclass, field - +from dataclasses import asdict, dataclass +from typing import Annotated from agent_comms.comms import Comms from agent_comms.change_report import FileLine from agent_comms.field_codec import Argument, FieldCodec from agent_comms.todos import ( - AcceptedVerdict, + AcceptedHeadVerdict, BluntText, Derivation, - PeerAcceptedVerdict, - SendBackVerdict, + HeadVerdict, + ListedPullRequest, + PullRequest, + PullRequests, + RejectedHeadVerdict, Slop, StrikeReason, - SupersededVerdict, Todo, - ToolCallVerdict, + UnmergedListedPullRequest, ) from agent_comms.tools import CommsUnjudgedTool, CommsVerdictTool from agent_comms.tool_caller import ThreadCaller @@ -48,23 +37,15 @@ from agent_comms.commander import order from agent_comms.commander.order import CommanderVerb -BRANCH = re.compile(r"\bisinstance\(|\bhasattr\(|^\s*(if|elif|match|case)\b|\bif\b.*\belse\b") - class Hunk(ReplacementEdit): - - def branches_added(self) -> int: - """Branches the new text has beyond the old text (branch-vs-polymorphism: presumed slop).""" - return sum(1 for line in self.new_text.splitlines() if BRANCH.search(line)) - sum( - 1 for line in self.old_text.splitlines() if BRANCH.search(line)) - def rendered(self) -> str: return (''.join(f'- {line}\n' for line in self.old_text.splitlines()) + ''.join(f'+ {line}\n' for line in self.new_text.splitlines())) class Edit(EditCall): - """The Core result row, decoded at the commander's tool boundary.""" + """Original edit evidence, decoded at the commander's tool boundary.""" hunks: tuple[Hunk, ...] @classmethod @@ -73,259 +54,129 @@ def unjudged(cls, comms: Comms, commander: str) -> list[Edit]: return list(FieldCodec.decode(tuple[cls, ...], reply['edits'])) def code(self) -> str: - """Four hex digits derived from the edit's own text; shown only after its full hunks.""" return hashlib.sha256((self.id + json.dumps([asdict(h) for h in self.hunks])).encode()).hexdigest()[:4] - def branches_added(self) -> int: - return sum(hunk.branches_added() for hunk in self.hunks) - - def text(self) -> str: - return ''.join(hunk.old_text + hunk.new_text for hunk in self.hunks) - - def added(self) -> str: - return ''.join('+' + line + '\n' for hunk in self.hunks for line in hunk.new_text.splitlines()) - def rendered(self) -> str: return (f'--- {self.id} {self.at.clock()} {self.path}\n' + '\n'.join(hunk.rendered() for hunk in self.hunks) + f'\n=== end {self.id} code {self.code()}') -def show(ctx, pending: list[Edit]) -> None: - by_thread: dict[str, list[Edit]] = {} - for edit in pending: - by_thread.setdefault(edit.thread, []).append(edit) - for name, edits in by_thread.items(): - orders = ctx.held_by(name) - intent = '\n'.join('\n'.join((todo.order.instructions(todo), - *(f'Sent: {message.message_id}' for message in todo.messages[-1:]))) - for todo in orders) or 'NO OPEN ORDER TODO' - print(f'===== {name}: {len(edits)} unjudged\nCURRENT ORDERS:\n{intent}\n') - for edit in edits: +def show(ctx: order.Commander, pending: list[Edit], held: tuple[Todo, ...]) -> None: + pulls = PullRequests(held) + for todo in held: + print(f'===== {todo.id} {todo.holder}\n{todo.order.instructions(todo)}') + for message in todo.messages[-1:]: + print(f'Sent: {message.message_id}') + requests = pulls.listed(todo.repo, ListedPullRequest, todo) + if not requests: + print('No linked pull request; publish the working branch first.') + for request in requests: + verdict = todo.head_verdict(request.head) + print(f'PR #{request.number} head {request.head}: ' + + (verdict.line if verdict is not None else 'unreviewed')) + evidence = [edit for edit in pending if edit.problem == todo.id] + print(f'{len(evidence)} edits since the last head verdict:') + for edit in evidence: print(edit.rendered()) - print(f'{len(pending)} unjudged') def roast(ctx, audience, thread, lines, text, fix, slop): - """Every send-back is a lesson, sent to its verdict's audience: the quoted slop, - the required fact it restates and the declaration that derives it (paper 1's definition), why that is - stupid, and what replaces it.""" + """Keep the rejected code, duplicated fact and replacement visible to its audience.""" body = (f'{thread}, this is stupid slop and it is going back:\n' - + '\n'.join(f' {l.strip()}' for l in lines) + + '\n'.join(f' {line.strip()}' for line in lines) + f'\nThe slop: {slop.render()}' + f'\nWhy it is stupid: {text}\nWhat replaces it: {fix}\nNobody writes this again.') print(f'roast sent to {", ".join(audience.threads)}: ' + ctx.post(audience, body)) -def alarm(ctx): - while True: - late = [edit for edit in Edit.unjudged(ctx.comms, ctx.commander) - if edit.at.before(time.time() - 300)] - if late: - counts: dict[str, int] = {} - for edit in late: - counts[edit.thread] = counts.get(edit.thread, 0) + 1 - oldest = min(edit.at.recorded_at("An overdue edit") for edit in late) - print(f'UNJUDGED {len(late)} edits over 5 min (oldest {int((time.time() - oldest) / 60)} min): ' - + ', '.join(f'{t} {n}' for t, n in counts.items()) + ' -> judge show', flush=True) - time.sleep(60) - - class JudgeVerb(CommanderVerb): - """A verb of `judge`.""" + """A verb of `judge`; judgment is never gated by unrelated order refusals.""" @dataclass(frozen=True, kw_only=True) class ShowVerb(JudgeVerb): - help = "every unit's unjudged edits in full, beside its current orders" - thread: Annotated[ - str, Argument(flags=("thread",), options={"nargs": "?"}) - ] = "" - - def selects(self, edit: Edit) -> bool: - return not self.thread or edit.thread == self.thread - - def run(self, ctx: order.Commander) -> None: - show(ctx, [edit for edit in Edit.unjudged(ctx.comms, ctx.commander) if self.selects(edit)]) - - -class AlarmVerb(JudgeVerb): - help = "loop: every minute, name edits unjudged for 5 min" + help = "linked pull request heads, their verdicts, and original edit evidence" + thread: Annotated[str, Argument(flags=("thread",), options={"nargs": "?"})] = "" def run(self, ctx: order.Commander) -> None: - alarm(ctx) + held = tuple(todo for todo in ctx.held_orders() + if not self.thread or todo.holder == self.thread) + show(ctx, Edit.unjudged(ctx.comms, ctx.commander), held) @dataclass(frozen=True, kw_only=True) class VerdictVerb(JudgeVerb): - """Every verdict, mine or a fork's, is made after reviewing paper 1's - correct-maintenance results. Each member's required options are the facts its verdict carries.""" - - ids: Annotated[ - tuple[str, ...], - Argument(flags=("ids",), options={"nargs": "+", "metavar": "ID:CODE"}), - ] + problem: Annotated[str, Argument(flags=("problem",), options={"metavar": "PROBLEM"})] why: str - problem: Annotated[ - str, - Argument(options={"metavar": "PROBLEM", "help": "the held problem id from order board"}), - ] = "" + serves: Annotated[str, Argument(options={"help": "exact words of the order"})] lines: Annotated[ tuple[str, ...], - Argument( - flags=("--line",), - options={ - "action": "append", - "metavar": "LINE", - "help": "an exact diff line; repeat for each", - }, - ), + Argument(flags=("--line",), options={"action": "append", "metavar": "LINE", + "help": "an exact line of the PR diff; repeat"}), ] @abstractmethod - def operation(self, ctx: order.Commander, todo: Todo, edit_id: str) -> ToolCallVerdict: ... + def operation(self, ctx: order.Commander, todo: Todo, request: PullRequest) -> HeadVerdict: ... - @abstractmethod - def check_edits(self, edits: list[Edit]) -> None: ... + def check_diff(self, diff: str) -> None: + if len(self.why.split()) < 6: + sys.exit('Refused: say which intent the head serves or violates and why (6+ words).') + if not self.lines or any(line not in diff for line in self.lines): + sys.exit('Refused: every --line must quote the linked PR diff at its current head.') - @abstractmethod - def report(self, ctx: order.Commander, todos: tuple[Todo, ...]) -> None: ... + def report(self, ctx: order.Commander, todo: Todo) -> None: + """Acceptance has no additional public effect beyond the recorded review status.""" def run(self, ctx: order.Commander) -> None: - if len(self.why.split()) < 6: - sys.exit('Refused: state which intent the edit serves or violates, and why (6+ words).') - orders = {todo.id: todo for todo in ctx.held_orders() - if not self.problem or todo.id == self.problem} - edits = {edit.id: edit for edit in Edit.unjudged(ctx.comms, ctx.commander) - if edit.problem in orders} - ids = [token.split(':')[0] for token in self.ids] - if missing := [i for i in ids if i not in edits]: - sys.exit(f'Refused: unknown edit ids {missing}') - if unread := [token for token in self.ids - if token != f'{token.split(":")[0]}:{edits[token.split(":")[0]].code()}']: - sys.exit(f'Refused: wrong or missing read codes {unread}; read each edit to its "=== end" line') - for i in ids: - if not any(line in edits[i].text() for line in self.lines): - sys.exit(f'Refused: none of your --line quotes is in edit {i}; judge it by its own text') - selected = {i: orders[edits[i].problem] for i in ids} - self.check_edits([edits[i] for i in ids]) - # Validate the whole batch through the operation owner before its first write. - for i, todo in selected.items(): - self.operation(ctx, todo, i).prepare(ctx.comms.goals, todo).apply(todo) - current = {todo.id: todo for todo in selected.values()} - for i, todo in selected.items(): - result = ctx.call(CommsVerdictTool(problem=todo.id, - operation=self.operation(ctx, current[todo.id], i))) - current[todo.id] = result.todo - left = len(Edit.unjudged(ctx.comms, ctx.commander)) - print(f'{self.declared_name}: {len(ids)} edits; {left} still unjudged') - self.report(ctx, tuple(current.values())) + todo = next((todo for todo in ctx.held_orders() if todo.id == self.problem), None) + if todo is None: + sys.exit(f'Refused: {self.problem} is not a held order of this commander.') + requests = PullRequests((todo,)).listed(todo.repo, UnmergedListedPullRequest, todo) + if len(requests) != 1: + sys.exit(f'Refused: {todo.id} must link exactly one open pull request; found {len(requests)}.') + request, = requests + self.check_diff(request.diff(todo.repo)) + result = ctx.call(CommsVerdictTool(problem=todo.id, + operation=self.operation(ctx, todo, request))) + print(f'{self.declared_name}: {todo.id} PR #{request.number} head {request.head}') + self.report(ctx, result.todo) @dataclass(frozen=True, kw_only=True) class OkVerb(VerdictVerb): - help = "the edit serves the order" - serves: Annotated[ - str, Argument(options={"help": "exact words of the order"}) - ] - no_owner: Annotated[ - str, - Argument( - flags=("--no-owner",), options={"help": "12+ words with because and absolute file:line"} - ), - ] = "" - peer: Annotated[ - str, - Argument( - flags=("--peer",), - options={"help": "another sender's retained order message to the unit"}, - ), - ] = "" - - def operation(self, ctx: order.Commander, todo: Todo, edit_id: str) -> AcceptedVerdict: - actor = ctx.incarnation - if self.peer: - return PeerAcceptedVerdict(todo.revision, actor, todo.held(), edit_id, - quote=self.serves, peer=self.peer) - return AcceptedVerdict(todo.revision, actor, todo.held(), edit_id, quote=self.serves) - - def check_edits(self, edits: list[Edit]) -> None: - for edit in edits: - path = edit.path - after = [open(path).read()] if path.endswith('.py') and os.path.exists(path) else [] - if added := order.forwarders_added(edit.added(), after): - sys.exit(f'Refused: edit {edit.id} adds forwarders {added[:5]}; it goes back with judge back, never ok') - AcceptedVerdict.require_branches(edit.branches_added(), self.no_owner) - - def report(self, ctx: order.Commander, todos: tuple[Todo, ...]) -> None: - pass + help = "accept the linked pull request's current head" - -@dataclass(frozen=True, kw_only=True) -class SupersededVerb(VerdictVerb): - help = "the task moved to another unit; these edits are neither ok nor sent back" - by: Annotated[ - str, - Argument( - flags=("--by",), - options={"metavar": "PROBLEM", "help": "the replacing problem id from order board"}, - ), - ] - - def operation(self, ctx: order.Commander, todo: Todo, edit_id: str) -> SupersededVerdict: - return SupersededVerdict(todo.revision, - ctx.incarnation, - todo.held(), edit_id, replacement=self.by, reason=self.why) - - def check_edits(self, edits: list[Edit]) -> None: - pass - - def report(self, ctx: order.Commander, todos: tuple[Todo, ...]) -> None: - pass + def operation(self, ctx: order.Commander, todo: Todo, request: PullRequest) -> HeadVerdict: + return AcceptedHeadVerdict(todo.revision, ctx.incarnation, todo.held(), + quote=self.serves, head=request.head) @dataclass(frozen=True, kw_only=True) class BackVerb(VerdictVerb): - help = "send the edit back; the roast is posted to the verdict's audience" - # A bare annotation would inherit VerdictVerb's default; field() keeps it required. - problem: Annotated[ - str, - Argument(options={"metavar": "PROBLEM", "help": "required problem id from order board"}), - ] = field() - roast: Annotated[ - str, Argument(options={"help": "blunt, why it is stupid, with because"}) - ] + help = "reject the linked current head, record its strike and post the roast" + roast: Annotated[str, Argument(options={"help": "blunt, why it is stupid, with because"})] fix: Annotated[str, Argument(options={"help": "owner and replacing shape"})] pattern: Annotated[ - str, - Argument( - flags=("--slop",), options={"help": "pattern ID from the pinned refactor-audit catalog"} - ), - ] - fact: Annotated[ - str, Argument(options={"help": "the required fact, 4+ words"}) - ] - at: Annotated[ - str, Argument(options={"help": "PATH:LINE where it is restated"}) - ] - derive: Annotated[ - str, Argument(options={"help": "|".join(Derivation.names())}) - ] - by: Annotated[ - str, Argument(options={"help": "PATH:LINE of the declaration that owns it"}) + str, Argument(flags=("--slop",), options={"help": "refactor-audit pattern ID"}), ] + fact: Annotated[str, Argument(options={"help": "the required fact, 4+ words"})] + at: Annotated[str, Argument(options={"help": "PATH:LINE where it is restated"})] + derive: Annotated[str, Argument(options={"help": "|".join(Derivation.names())})] + by: Annotated[str, Argument(options={"help": "PATH:LINE of its owning declaration"})] def slop(self) -> Slop: return FieldCodec.decode(Slop, dict(pattern=self.pattern, fact=self.fact, at=self.at, derive=self.derive, by=self.by)) - def operation(self, ctx: order.Commander, todo: Todo, edit_id: str) -> SendBackVerdict: - return SendBackVerdict(todo.revision, ctx.incarnation, - todo.held(), edit_id, - reason=StrikeReason(f'{todo.id} edit sent back: {self.why}'), - evidence=(FileLine.from_wire(self.at),)) + def operation(self, ctx: order.Commander, todo: Todo, request: PullRequest) -> HeadVerdict: + return RejectedHeadVerdict(todo.revision, ctx.incarnation, todo.held(), + quote=self.serves, head=request.head, + reason=StrikeReason(f'{todo.id} head sent back: {self.why}'), + evidence=(FileLine.from_wire(self.at),)) - def check_edits(self, edits: list[Edit]) -> None: + def check_diff(self, diff: str) -> None: + super().check_diff(diff) if len(self.roast.split()) < 12 or not re.search(r'\bbecause\b', self.roast): sys.exit('Refused: --roast needs 12+ words with because, naming the duplicated fact and its owner') if len(self.fix.split()) < 8: @@ -334,14 +185,15 @@ def check_edits(self, edits: list[Edit]) -> None: order.refuse(self.roast, BluntText) order.refuse(' '.join((self.why, self.roast, self.fix)), *order.OUTGOING) - def report(self, ctx: order.Commander, todos: tuple[Todo, ...]) -> None: - for todo in todos: - audience = todo.verdicts[-1].audience.of(todo.held().owner, todo.goal) - roast(ctx, audience, todo.holder, self.lines, self.roast, self.fix, self.slop()) - line = (f'{time.strftime("%Y-%m-%d %H:%M")} {todo.holder} ' - f'(strike {todo.strike_count} on {todo.id}; caught by {ctx.commander} at review): ' - f'{todo.strikes[-1].reason}') - ctx.on_record(line, 'Send-back', audience) + def report(self, ctx: order.Commander, todo: Todo) -> None: + verdict = todo.verdicts[-1] + holder = verdict.previous.owner + audience = verdict.audience.of(holder, todo.goal) + roast(ctx, audience, holder, self.lines, self.roast, self.fix, self.slop()) + line = (f'{time.strftime("%Y-%m-%d %H:%M")} {holder} ' + f'(strike {todo.strike_count} on {todo.id}; caught by {ctx.commander} at review): ' + f'{todo.strikes[-1].reason}') + ctx.on_record(line, 'Send-back', audience) def main(args, ctx): diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index 44685988ed..8da02eb431 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -13,12 +13,12 @@ from agent_comms.message_reference import MessageReference from agent_comms.message_bus import WatchPass from agent_comms.standing_rules import ( - OrderRefusingStandingRule, ReasonedStandingRule, StandingRule, UndatedStandingRule, + DatedStandingRule, OrderRefusingStandingRule, StandingRule, UndatedStandingRule, ) from agent_comms.storage_rules import OrderCheckoutWork from agent_comms.thread_identity import CoreWatcherIncarnation, ThreadIncarnation from agent_comms.threads import Thread -from agent_comms.todos import Todo +from agent_comms.todos import PullRequest, PullRequests, Todo, UnmergedListedPullRequest from agent_comms.turn_digests import TurnDigestRead, TurnsSince from agent_comms.work_dispatch import ReusedWorkDispatch, WorkDispatch from agent_comms.commander.config import CommanderConfig @@ -178,16 +178,29 @@ class WaitingUnownedWheelSpin(UnownedWheelSpin, declared_name=None): @dataclass(frozen=True) -class Unjudged(OrderRefusingStandingRule, ReasonedStandingRule, UndatedStandingRule): - limit: ClassVar[int] = 50 - text = "UNJUDGED {why} pushed heads -> judge the commits before sending more orders" - - def refusal(self, comms: Comms, answers: Sequence[MessageReference]) -> tuple[str, ...]: - return (f"{self.why} unreviewed pushed heads exceed the {self.limit} head ceiling; " - "judge is the way out: review the commits before sending more orders.",) +class Unjudged(OrderRefusingStandingRule, DatedStandingRule): + """One ready current head without a verdict on its held problem.""" + problem: str + holder: str + request: PullRequest + seconds: ClassVar[int] = 10 * 60 @classmethod - def reason(cls, comms: Comms, commander: str) -> str | None: - total = len(comms.goals.todos.unreviewed_heads( - comms.registry.require(commander).incarnation, comms.registry)) - return str(total) if total > cls.limit else None + def violations(cls, comms: Comms, commander: str) -> tuple[Unjudged, ...]: + held = cls.todos(comms, commander) + pulls = PullRequests(held) + return tuple(cls(todo.id, todo.holder, request) for todo in held + for request in pulls.listed(todo.repo, UnmergedListedPullRequest, todo) + if request.status.reviewable and todo.head_verdict(request.head) is None) + + def began(self) -> float: + return self.request.reviewable_at + + def line(self) -> str: + age = max(0, int((time.time() - self.began()) / 60)) + return (f"UNJUDGED {self.problem} {self.holder} PR #{self.request.number} " + f"head {self.request.head} ({age} min) " + f"-> judge ok {self.problem} or judge back {self.problem}") + + def refusal(self, comms: Comms, answers: Sequence[MessageReference]) -> tuple[str, ...]: + return (self.line(),) if time.time() - self.began() >= self.seconds else () diff --git a/src/agent_comms/commander_pings.py b/src/agent_comms/commander_pings.py index a342faae9b..ed8b1fc2dd 100644 --- a/src/agent_comms/commander_pings.py +++ b/src/agent_comms/commander_pings.py @@ -262,7 +262,7 @@ def indexed(self, index, source) -> CommanderPingMessage | None: def read(cls, comms: Comms, sender: str, commander: ThreadIncarnation) -> CommanderPingMessage | None: with comms.goals.todos.snapshot() as db: - position = next(CommanderCursor.select(db, commander=commander), None) + position = CommanderCursor.one(db, commander=commander) after = position.message if position is not None else None latest = comms.bus.log.read_page(cls( DMDisplayScope.capture(sender, commander.name, comms.registry.snapshot()), commander, after)) diff --git a/src/agent_comms/external_transcript.py b/src/agent_comms/external_transcript.py index 0a27e4cd36..8db91d55b6 100644 --- a/src/agent_comms/external_transcript.py +++ b/src/agent_comms/external_transcript.py @@ -243,9 +243,9 @@ def unjudged_query(cls, thread, todo): from .transcript_events import ToolStartTranscript tools = tuple(tool.declared_name for tool in ToolDeclaration.members_with(MutatingPathTool)) - judged = tuple(verdict.tool_call_id for verdict in todo.verdicts) + judged = todo.judged_call_ids placeholders = lambda values: ",".join("?" for _ in values) - since = todo.order.at.recorded_at("An order") + since = todo.evidence_since where = (f'e.thread=? AND e.kind=? AND e.tool IN ({placeholders(tools)}) ' 'AND (e.timestamp>=? OR e.timestamp IS NULL)') parameters = (thread, ToolStartTranscript.declared_name, *tools, since) @@ -408,12 +408,6 @@ def unjudged(self, todo): yield from tool.edit_calls(identity, GeneratedTimestamp.decode(timestamp), todo.held().owner, todo.id) - def unjudged_count(self, todo) -> int: - where, parameters = ExternalEventRow.unjudged_query(self.transcript.lookup, todo) - return self.connection.execute( - f'SELECT count(DISTINCT e.call_id) FROM "{ExternalEventRow.declared_name}" e WHERE {where}', - parameters).fetchone()[0] - @dataclass(frozen=True, slots=True) class ExternalTranscript(TranscriptSource, DurableStore): diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index 56200121a5..d97e8fc846 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -47,7 +47,7 @@ from .repository_name import RepositoryName from .thread_identity import ThreadIncarnation from .threads import Thread -from .todos import GoalRef, PendingProblemOrder, RejectingVerdict, Todo, TodoOperation +from .todos import GoalRef, PendingProblemOrder, RejectingVerdict, Todo, TodoCommand if TYPE_CHECKING: from .goal_management import Goals @@ -371,7 +371,7 @@ def require_claims(cls, claims: tuple[ProgressClaim, ...]) -> None: class HeldOrderGoalAction(GoalAction[Goal], RuntimeInvocable): """A Todo operation on a held order and the revision of the holder's Goal, in one write.""" problem: str - operation: TodoOperation + operation: TodoCommand @abstractmethod def revised(self, goal: Goal, ctx: GoalActionContext) -> Goal: ... diff --git a/src/agent_comms/skills/comms-commander/SKILL.md b/src/agent_comms/skills/comms-commander/SKILL.md index cfef947d2d..7b682b17b2 100644 --- a/src/agent_comms/skills/comms-commander/SKILL.md +++ b/src/agent_comms/skills/comms-commander/SKILL.md @@ -54,6 +54,7 @@ Each order has: the defect in one plain sentence; the outcome the owner will see The declaration supplies the ID and first-call times when omitted. A mechanical order declares `"run": {"kind": "mechanical"}`. `serves`, `finding` and `deletes` use their declared Core family shapes; rendered instructions are never input. - **A wrong check is corrected, not worked around.** `order check PID --check "repo :: cmd -> N" --why "..."` replaces a check that cannot pass on correct work; the new one must already pass on main. The order keeps its id, timer, holder and strikes, and no strike is recorded. Never record an op for a commit or close without the check. +- **Judge the linked pull request head, never edit IDs.** `judge show` prints each held problem's current PR head and verdict, latest send, and edit evidence since its last head verdict. `judge ok PROBLEM --serves WORDS --why REASON --line DIFF-LINE` accepts that head; `judge back PROBLEM` takes the same evidence plus its roast. A new push needs a new verdict. Ready heads waiting ten minutes refuse new orders, not judgment. Core posts the verdict's review status; after installation the merge queue requires it. - **A roast says why the slop is stupid, to the unit that wrote it.** `judge back` needs `--roast` (12+ words with "because": the fact it restates and what already answers it) and `--fix` (owner and replacing shape); it goes to the unit and #comms. - **Hate jargon.** One list (`PlainText` in Core's todos.py) refuses commander messages, strikes and roasts; Core answers any commanded unit message using it with a correction on its open orders. diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 3d91640f66..0de0a2c013 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -27,6 +27,7 @@ from types import get_original_bases from typing import TYPE_CHECKING, Annotated, ClassVar, Self, get_args, get_origin from uuid import uuid4 +from urllib.parse import quote from agent_comms.order_runs import OrderRun @@ -457,6 +458,17 @@ class PullRequest(DeclaredFamily, affix="PullRequest"): base: str = field(default=WorkBranches.main, kw_only=True) problems: tuple[str, ...] = field(default=(), kw_only=True) status: type[ListedPullRequest] = field(kw_only=True) + reviewable_at: float = field(kw_only=True) + + def diff(self, repo: RepositoryName) -> str: + """The linked request's base versus this immutable head, not a local checkout.""" + result = subprocess.run( + ("gh", "api", f"repos/{repo.to_wire()}/compare/{quote(self.base, safe='')}...{self.head}", + "-H", "Accept: application/vnd.github.diff"), + capture_output=True, text=True, timeout=60) + if result.returncode: + raise TodoError(result.stderr.strip() or f"Cannot read PR #{self.number} at {self.head}.") + return result.stdout def belongs_to(self, todo: Todo) -> bool: return (todo.id in self.problems @@ -520,6 +532,9 @@ class GithubPullRequest: head: str updated: str base: str + created: str + head_time: str + ready_events: tuple[str, ...] closed: bool isDraft: bool mergeStateStatus: str @@ -528,6 +543,10 @@ class GithubPullRequest: merge: PullRequestCommit | None merged: str | None + @property + def reviewable_at(self) -> float: + return max(_github_time(at) for at in (self.created, self.head_time, *self.ready_events)) + @dataclass(frozen=True) class ListedPullRequest(DeclaredFamily, affix="ListedPullRequest"): @@ -538,6 +557,8 @@ class ListedPullRequest(DeclaredFamily, affix="ListedPullRequest"): updated: str base: str + reviewable_at: float = field(kw_only=True) + reviewable: ClassVar[bool] = False action: ClassVar[str] = "read the pull request" @classmethod @@ -558,11 +579,13 @@ def read_github(cls, row: GithubPullRequest) -> ListedPullRequest: def parsed(self, problems: tuple[str, ...], orders: tuple[str, ...]) -> PullRequest: return UnmergedPullRequest(self.number, self.branch, self.head, _github_time(self.updated), - orders, base=self.base, problems=problems, status=type(self)) + orders, base=self.base, problems=problems, status=type(self), + reviewable_at=self.reviewable_at) class UnmergedListedPullRequest(ListedPullRequest): """The open-state scan owns precedence through its most specific member's MRO.""" + reviewable = True action = "wait for the checks, then review the head" @classmethod @@ -580,7 +603,8 @@ def from_github(cls, row: GithubPullRequest) -> Iterator[ListedPullRequest]: members = cls.members_with(cls) order = max(members, key=lambda member: len(member.__mro__)).__mro__ member = next(member for member in order if member in members and member.accepts(row)) - yield member(row.number, row.branch, row.head, row.updated, row.base) + yield member(row.number, row.branch, row.head, row.updated, row.base, + reviewable_at=row.reviewable_at) class CleanListedPullRequest(UnmergedListedPullRequest): @@ -608,6 +632,7 @@ def accepts(cls, row: GithubPullRequest) -> bool: class DraftListedPullRequest(FailingChecksListedPullRequest): + reviewable = False action = "finish the working change and mark the pull request ready" @classmethod @@ -622,7 +647,8 @@ class ClosedListedPullRequest(ListedPullRequest): @classmethod def from_github(cls, row: GithubPullRequest) -> Iterator[ListedPullRequest]: if row.closed and row.merged is None: - yield cls(row.number, row.branch, row.head, row.updated, row.base) + yield cls(row.number, row.branch, row.head, row.updated, row.base, + reviewable_at=row.reviewable_at) @dataclass(frozen=True, kw_only=True) @@ -637,13 +663,13 @@ def from_github(cls, row: GithubPullRequest) -> Iterator[ListedPullRequest]: if row.merge is None: raise WorkBranchError(f"GitHub merged PR #{row.number} without a merge commit") yield cls(row.number, row.branch, row.head, row.updated, row.base, - merge=row.merge, merged=row.merged) + merge=row.merge, merged=row.merged, reviewable_at=row.reviewable_at) def parsed(self, problems: tuple[str, ...], orders: tuple[str, ...]) -> MergedPullRequest: return MergedPullRequest(self.number, self.branch, self.head, _github_time(self.updated), orders, base=self.base, merge=self.merge.oid, merged_at=_github_time(self.merged), - problems=problems, status=type(self)) + problems=problems, status=type(self), reviewable_at=self.reviewable_at) class PullRequests: @@ -678,17 +704,21 @@ def read(self, repo: RepositoryName, kind: type[ListedPullRequest], return () selections = [ f'p{number}: pullRequest(number: {number}) {{ number branch: headRefName ' - 'head: headRefOid base: baseRefName closed isDraft mergeStateStatus ' + 'head: headRefOid base: baseRefName closed isDraft mergeStateStatus created: createdAt ' + 'ready: timelineItems(last: 1, itemTypes: [READY_FOR_REVIEW_EVENT]) ' + '{ nodes { ... on ReadyForReviewEvent { createdAt } } } ' 'queue: mergeQueueEntry { position } ' - 'checks: commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } ' + 'checks: commits(last: 1) { nodes { commit { committedDate statusCheckRollup { state } } } } ' 'merge: mergeCommit { oid } merged: mergedAt updated: updatedAt }' for number in linked] query = (f'query {{ repository(owner: {json.dumps(repo.owner)}, ' f'name: {json.dumps(repo.name)}) {{ {" ".join(selections)} }} }}') listed = subprocess.run( ("gh", "api", "graphql", "-f", "query=" + query, "--jq", '.data.repository | map_values(. + ' - '{queued: (.queue != null), checks: .checks.nodes[0].commit.statusCheckRollup.state}' - ' | del(.queue))'), + '{queued: (.queue != null), head_time: .checks.nodes[0].commit.committedDate, ' + 'ready_events: [.ready.nodes[].createdAt], ' + 'checks: .checks.nodes[0].commit.statusCheckRollup.state}' + ' | del(.queue, .ready))'), capture_output=True, text=True, timeout=60) if listed.returncode: raise WorkBranchError( @@ -2245,7 +2275,7 @@ class CommanderCursor(TypedTable): def advance(self, store: TodoStore) -> None: with store.writing() as db: - previous = next(type(self).select(db, commander=self.commander), None) + previous = type(self).one(db, commander=self.commander) if previous is None or previous.message.seq < self.message.seq: self.upsert(db) @@ -2546,7 +2576,7 @@ def actor(self) -> ThreadIncarnation: @dataclass(frozen=True) -class CitedStrike(TodoOperation, declared_name=None): +class CitedStrike(TodoCommand, declared_name=None): evidence: Annotated[tuple[FileLine, ...], WireOmitDefault()] = field( default=(), kw_only=True) @@ -2561,7 +2591,7 @@ def prepare(self, goals: Goals, current: Todo) -> Self: @dataclass(frozen=True) -class StrikeTodo(CitedStrike, StoryEvent): +class StrikeTodo(CitedStrike, TodoOperation, StoryEvent): actor: ThreadIncarnation previous: Assignment reason: str @@ -2658,7 +2688,7 @@ def addressed(self, target: str, body: str) -> str: @dataclass(frozen=True) class OrderVerdict(TodoCommand, StoryEvent, declared_name=None): - """The commander's verdict on one tool call the holder made for its order, kept on the order.""" + """A judgment kept on the Todo that owns the reviewed work and its history.""" audience: ClassVar[type[VerdictAudience]] = VerdictAudience actor: ThreadIncarnation previous: Assignment @@ -2674,6 +2704,15 @@ def reviewed_heads(self) -> tuple[str, ...]: """Only a judgment of a commit clears review of that immutable head.""" return () + def judged_calls(self) -> tuple[str, ...]: + return () + + def head_review_times(self) -> tuple[float, ...]: + return () + + def send_back_times(self) -> tuple[float, ...]: + return () + def judged(self, current: Todo) -> Todo: return current @@ -2689,9 +2728,12 @@ def apply(self, current: Todo) -> Todo: @dataclass(frozen=True) -class ToolCallVerdict(OrderVerdict, TodoOperation, declared_name=None): +class ToolCallVerdict(OrderVerdict, declared_name=None): tool_call_id: str + def judged_calls(self) -> tuple[str, ...]: + return (self.tool_call_id,) + def story_text(self) -> str: return f"{super().story_text()}; tool call {self.tool_call_id}" @@ -2720,20 +2762,12 @@ def judged(self, current: Todo) -> Todo: @dataclass(frozen=True) class AcceptedVerdict(QuotedVerdict, ToolCallVerdict): - """The tool call carries out the order clause it quotes.""" + """A stored edit acceptance; new review commands write immutable head verdicts.""" line = "Accepted" - @classmethod - def require_branches(cls, added: int, no_owner: str | None) -> None: - if added <= 0: - return - if not (no_owner and len(no_owner.split()) >= 12 and re.search(r'\bbecause\b', no_owner) - and re.search(r'/\S+:\d+', no_owner)): - raise ValueError('Added branches need --no-owner: 12+ words with because and an absolute file:line.') - @dataclass(frozen=True) -class HeadVerdict(QuotedVerdict, TodoOperation, declared_name=None): +class HeadVerdict(QuotedVerdict, declared_name=None): """One judgment of immutable code; its members own only their GitHub result.""" context: ClassVar[str] = "agent-comms/review" state: ClassVar[str] @@ -2746,6 +2780,9 @@ def __post_init__(self) -> None: def reviewed_heads(self) -> tuple[str, ...]: return (self.head,) + def head_review_times(self) -> tuple[float, ...]: + return (self.at.recorded_at("A head review"),) + @property def line(self) -> str: return f"{self.state} head {self.head}" @@ -2775,7 +2812,7 @@ def after_record(self, goals: Goals, recorded: Todo) -> None: @dataclass(frozen=True) class AcceptedHeadVerdict(HeadVerdict): - state = "success" + state: ClassVar[str] = "success" @dataclass(frozen=True) @@ -2828,6 +2865,9 @@ class RejectingVerdict(OrderVerdict, CitedStrike, declared_name=None): """Shared strike and goal revision, for current heads and stored tool-call history.""" reason: StrikeReason + def send_back_times(self) -> tuple[float, ...]: + return (self.at.recorded_at("A send-back"),) + @property def line(self) -> str: return f"Sent back: {self.reason.text}" @@ -2851,7 +2891,7 @@ def record(self, goals: Goals, problem: str) -> Todo: @dataclass(frozen=True) class RejectedHeadVerdict(HeadVerdict, RejectingVerdict): - state = "failure" + state: ClassVar[str] = "failure" @dataclass(frozen=True) @@ -3132,13 +3172,19 @@ def recurrence( def held_order_ids(self) -> tuple[str, ...]: return tuple(order.id for order in self.order_history) - def unreviewed_heads(self, registry: Registration, pulls: PullRequests) -> frozenset[str]: - """Published code awaiting a commit judgment, not the edits that produced it.""" - source = ProgressSource.of(self, registry, pulls) - published = {request.head for request in source.requests(UnmergedListedPullRequest)} - published.update(point.sha for point in source.published()) - reviewed = {head for verdict in self.verdicts for head in verdict.reviewed_heads()} - return frozenset(published - reviewed) + def head_verdict(self, head: str) -> OrderVerdict | None: + """The last verdict on this problem and immutable head; a new push has none.""" + return next((verdict for verdict in reversed(self.verdicts) + if head in verdict.reviewed_heads()), None) + + @property + def evidence_since(self) -> float: + return max((self.order.checked().at.recorded_at("An order"), + *(at for verdict in self.verdicts for at in verdict.head_review_times()))) + + @property + def judged_call_ids(self) -> tuple[str, ...]: + return tuple(call for verdict in self.verdicts for call in verdict.judged_calls()) @property def reference_pattern(self) -> str: @@ -3555,14 +3601,6 @@ def held_for_commander(self, commander: ThreadIncarnation) -> tuple[Todo, ...]: return self._open("creator=? AND creator_created=? AND assignment IS NOT NULL", (commander.name, commander.wire_birth)) - def unreviewed_heads(self, commander: ThreadIncarnation, - registry: Registration) -> frozenset[tuple[RepositoryName, str]]: - """One pushed head linked to several open problems is still one review.""" - held = tuple(todo for todo in self.held_for_commander(commander) if todo.pending) - pulls = PullRequests(held) - return frozenset((todo.repo, head) for todo in held - for head in todo.unreviewed_heads(registry, pulls)) - def held_by(self, commander: ThreadIncarnation, unit: str) -> tuple[Todo, ...]: return self._open( "creator=? AND creator_created=? AND json_extract(assignment,'$.owner')=?", diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index e3229c4f4a..d2982dd5fa 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -106,11 +106,12 @@ InstallTodo, OrderDelivery, OrderProgress, - ToolCallVerdict, + HeadVerdict, PlainText, PullRequests, StrikeReason, Todo, + TodoCommand, TodoOperation, VerdictAudience, ) @@ -884,7 +885,7 @@ def apply(self, comms: Comms, caller: ThreadCaller) -> TodoChanged | ProblemClos self.problem, self.operation_for(todo, actor, comms), caller.name)) @abstractmethod - def operation_for(self, todo: Todo, actor: ThreadIncarnation, comms: Comms) -> TodoOperation: + def operation_for(self, todo: Todo, actor: ThreadIncarnation, comms: Comms) -> TodoCommand: """Build the change, verifying any attached application claims before writing.""" def result(self, changed: Todo) -> TodoChanged | ProblemClosed: @@ -902,22 +903,27 @@ def operation_for(self, todo: Todo, actor: ThreadIncarnation, comms: Comms) -> T @dataclass(frozen=True, kw_only=True) -class CommsVerdictTool(CommsChangeProblemTool): - label = "Judge order tool call" +class CommsVerdictTool(CommsTodoTool): + label = "Review pull request head" description = ( - "Record the commander's verdict on one tool call the holder made for its " - "order: an accepted verdict quotes 4+ words of the order; a send-back strikes " - "the holder and revises its Goal in the same write." + "Record an accepted or rejected verdict on a linked pull request's current head " + "and post its review status on that commit. A rejection strikes the holder and " + "revises its Goal in the same write." ) operation: Annotated[ - ToolCallVerdict, *tool_field("Accepted or send-back verdict on the judged tool call ID") + HeadVerdict, *tool_field("Accepted or rejected verdict on the linked current head") ] + def operation_for(self, todo: Todo, actor: ThreadIncarnation, comms: Comms) -> HeadVerdict: + if self.operation.actor != actor: + raise ToolRefusedError("The verdict actor must be the commander recording it.") + return self.operation + @dataclass(frozen=True, kw_only=True) class CommsUnjudgedTool(ThreadReadTool): - label = "Unjudged edits" - description = "Read original edit calls since each open order, excluding that order's recorded verdicts." + label = "Review evidence" + description = "Read original edit calls since each open order's last head verdict." def apply(self, comms: Comms, caller: ToolCaller) -> UnjudgedEdits: return UnjudgedEdits(tuple(edit for read in OrderEditsRead.for_commander( @@ -927,7 +933,7 @@ def apply(self, comms: Comms, caller: ToolCaller) -> UnjudgedEdits: @dataclass(frozen=True, kw_only=True) class CommsCommanderLoadTool(ThreadReadTool): label = "Measured commander load" - description = "Read canonical order/edit debt and receiving child commanders; never self-reported." + description = "Read held work, ready heads awaiting review, timers and receiving child commanders." def apply(self, comms: Comms, caller: ToolCaller) -> Load: return Load.of(comms, caller.subject(self.thread)) diff --git a/src/agent_comms/turn_digests.py b/src/agent_comms/turn_digests.py index c897f18d69..7b3bb35081 100644 --- a/src/agent_comms/turn_digests.py +++ b/src/agent_comms/turn_digests.py @@ -181,12 +181,12 @@ def for_orders( if holder.incarnation != assignment.owner_identity: raise ToolRefusedError(f"Order {todo.id} holder incarnation no longer exists") yield cls( - root, holder, TurnsSince(todo.order.checked().at.recorded_at("An order")), todo + root, holder, TurnsSince(todo.evidence_since), todo ) def edits_from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall, ...]: - judged = {verdict.tool_call_id for verdict in self.todo.verdicts} - since = self.todo.order.checked().at.recorded_at("An order") + judged = set(self.todo.judged_call_ids) + since = self.todo.evidence_since edits = {edit.id: edit for turn in reversed(turns) for event in turn.events for edit in event.edit_calls(self.thread.name, self.todo.id, since) @@ -195,24 +195,10 @@ def edits_from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall, ... class OrderEditsRead(OrderRead[tuple[EditCall, ...]]): - """Original unjudged calls; external events are selected before decoding.""" + """Original edits since the head verdict; external events are selected before decoding.""" def from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall, ...]: return self.edits_from_turns(turns) def from_external(self, source) -> tuple[EditCall, ...]: return tuple(source.unjudged(self.todo)) - - -class OrderEditCountRead(OrderRead[int]): - """Load needs a count, never external event bodies or edit hunks.""" - - @classmethod - def count(cls, comms: Comms, commander: str) -> int: - return sum(read.read() for read in cls.for_commander(comms, commander)) - - def from_turns(self, turns: tuple[TurnDigest, ...]) -> int: - return len(self.edits_from_turns(turns)) - - def from_external(self, source) -> int: - return source.unjudged_count(self.todo) diff --git a/src/agent_comms/ui_model/unit_state.py b/src/agent_comms/ui_model/unit_state.py index ad75de839e..a547ea61b0 100644 --- a/src/agent_comms/ui_model/unit_state.py +++ b/src/agent_comms/ui_model/unit_state.py @@ -25,7 +25,7 @@ from agent_comms.standing_rules import StandingRule, UndatedStandingRule from agent_comms.thread_identity import CoreWatcherIncarnation, ThreadIncarnation from agent_comms.todos import ( - OrderProgress, ProgressSource, PullRequests, SendBackVerdict, Todo, + OrderProgress, ProgressSource, PullRequests, Todo, ) from agent_comms.transcript_events import OrderCall, TurnDigest from agent_comms.turn_digests import LastTurns, TurnDigestRead @@ -258,9 +258,8 @@ class Spinning(StalledOnNothing): def derive(cls, source: UnitObservation) -> Iterator[Self]: if not source.pending: return - sends = sorted(verdict.at.recorded_at("A send-back") - for todo in source.pending for verdict in todo.verdicts - if isinstance(verdict, SendBackVerdict)) + sends = sorted(at for todo in source.pending for verdict in todo.verdicts + for at in verdict.send_back_times()) commit_at = max((commit.at for origin in source.sources for commit in origin.commits()), default=source.since) calls = source.calls diff --git a/tests/github_cli_stub.py b/tests/github_cli_stub.py index 8dde8e38f0..7e3ed2f473 100755 --- a/tests/github_cli_stub.py +++ b/tests/github_cli_stub.py @@ -30,6 +30,17 @@ def main(): # gh --jq returns the typed projection, not the raw GraphQL envelope. print(json.dumps({f"p{number}": data["requests"][number]["listed"] for _, number in selections})) + elif arguments[:3] == ["api", "--method", "POST"]: + assert arguments[3].startswith(f'repos/{data["repo"]}/statuses/') + fields = dict(arguments[index + 1].split("=", 1) + for index, argument in enumerate(arguments) if argument == "-f") + assert fields["state"] in ("success", "failure") + print(json.dumps(fields)) + elif arguments[:1] == ["api"] and arguments[1].startswith(f'repos/{data["repo"]}/compare/'): + assert arguments[arguments.index("-H") + 1] == "Accept: application/vnd.github.diff" + assert any(arguments[1].endswith("..." + request["listed"]["head"]) + for request in data["requests"].values()) + print(data["diff"]) else: raise AssertionError(f"Unexpected gh invocation: {arguments!r}") diff --git a/tests/test_commander_capacity.py b/tests/test_commander_capacity.py index 0dfaa7235d..2faf55d826 100644 --- a/tests/test_commander_capacity.py +++ b/tests/test_commander_capacity.py @@ -6,14 +6,12 @@ from agent_comms.field_codec import FieldCodec -@pytest.mark.parametrize("unjudged", [0, 1, 395, 1_000_000]) -def test_capacity_reports_load_and_forks(unjudged): - load = Load(24, 14, unjudged, ("judge",)) +@pytest.mark.parametrize("heads", [0, 1, 395, 1_000_000]) +def test_capacity_reports_head_work_and_forks(heads): + load = Load(24, 14, heads, ("judge",)) assert FieldCodec.encode(load)["total"] == load.total - assert load.total == 24 + 2 * 14 + unjudged / 10 - assert str(load) == ( - f"load {load.total:.0f} (24 open orders, 14 review timers up, {unjudged} unjudged edits), " - "1 live forks ['judge']" - ) + assert load.heads_waiting == heads + assert load.total == 24 + 2 * 14 + heads + assert load.forks == ("judge",) diff --git a/tests/test_commander_parity_load.py b/tests/test_commander_parity_load.py index 1a5cfe9250..c7dfef6b70 100644 --- a/tests/test_commander_parity_load.py +++ b/tests/test_commander_parity_load.py @@ -16,7 +16,7 @@ def test_load_comparison_requires_every_reported_field_and_no_private_write(tmp_ for different in ( replace(load, owed=38), replace(load, timers_up=9), - replace(load, unjudged=1693), + replace(load, heads_waiting=1693), replace(load, forks=()), ): assert not replace(comparison, observation=replace(observed, native=str(different))).passed diff --git a/tests/test_delivery_merge.py b/tests/test_delivery_merge.py index 21617545d8..580b059c8d 100644 --- a/tests/test_delivery_merge.py +++ b/tests/test_delivery_merge.py @@ -20,7 +20,7 @@ def test_delivery_integration_members_keep_their_data_through_the_only_codec(): def test_named_forge_row_preserves_the_actual_nonmain_merge_target(): original = MergedListedPullRequest(42, 'fix/order', 'a' * 40, - updated='2026-10-19T12:00:01Z', base='release', + updated='2026-10-19T12:00:01Z', base='release', reviewable_at=0.0, merge=PullRequestCommit('b' * 40), merged='2026-10-19T12:00:00Z') row = FieldCodec.decode(ListedPullRequest, FieldCodec.encode(original)) request = row.parsed((), ('proof-order',)) @@ -34,7 +34,7 @@ def test_named_forge_row_preserves_the_actual_nonmain_merge_target(): def test_unmerged_query_row_has_no_merge_fields(): original = UnmergedListedPullRequest(43, 'fix/order', 'a' * 40, - updated='2026-10-19T12:00:01Z', base='main') + updated='2026-10-19T12:00:01Z', base='main', reviewable_at=0.0) row = FieldCodec.decode(ListedPullRequest, FieldCodec.encode(original)) request = row.parsed((), ('proof-order',)) assert isinstance(request, UnmergedPullRequest) diff --git a/tests/test_head_review.py b/tests/test_head_review.py new file mode 100644 index 0000000000..68399be7c4 --- /dev/null +++ b/tests/test_head_review.py @@ -0,0 +1,138 @@ +"""One verdict family, a real Todo and the external gh executable boundary.""" +import json +import os +import time +from dataclasses import dataclass, fields, replace +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from test_holder_standing_rules import held_bus +from source_revision_fixture import SourceRepository +from agent_comms.commander.judge import OkVerb, ShowVerb +from agent_comms.commander.order import Commander, SayVerb +from agent_comms.commander.standing import Unjudged +from agent_comms.comms import Comms +from agent_comms.field_codec import FieldCodec, MalformedRecord +from agent_comms.repo import Repo +from agent_comms.todos import ( + AcceptedVerdict, GithubPullRequest, HeadVerdict, OrderVerdict, PullRequestLink, + RecordPullRequest, StrikeReason, TodoOperation, +) +from agent_comms.change_report import FileLine +from agent_comms.tools import CommsVerdictTool +from agent_comms.turn_digests import OrderEditsRead + + +def stamp(at: float) -> str: + return datetime.fromtimestamp(at, UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + +@dataclass +class ReviewCase: + comms: Comms + ctx: Commander + binaries: Path + row: GithubPullRequest + diff: str + + def write(self, row: GithubPullRequest) -> None: + self.row = row + self.binaries.joinpath("pull_requests.json").write_text(json.dumps({ + "repo": self.comms.goals.todos.get("P1").repo.to_wire(), + "requests": {str(row.number): {"listed": FieldCodec.encode(row), "body": ""}}, + "diff": self.diff, + })) + + def posts(self) -> list[list[str]]: + return [call for line in self.binaries.joinpath("calls.jsonl").read_text().splitlines() + if (call := json.loads(line))[:3] == ["api", "--method", "POST"]] + + +@pytest.fixture +def review_case(held_bus, tmp_path, monkeypatch): + binaries = tmp_path / "bin" + binaries.mkdir() + binaries.joinpath("gh").symlink_to(Path(__file__).with_name("github_cli_stub.py")) + monkeypatch.setenv("PATH", os.pathsep.join((str(binaries), os.environ["PATH"]))) + ctx = Commander(commander="lead", root=held_bus.root, state=tmp_path / "state", repos=(), + worktrees=tmp_path, scratch=tmp_path, disk=tmp_path) + todo = held_bus.goals.todos.get("P1") + held_bus.goals.todos.apply(todo.id, RecordPullRequest(todo.revision, todo.creator_identity, + PullRequestLink(todo.repo, SourceRepository.number), todo.held().generation)) + checkout = Path(todo.order.checked().check.directory) + head = Repo.git(checkout, "rev-parse", "feature").strip() + diff = Repo.git(checkout, "diff", f"main...{head}") + now = time.time() + row = GithubPullRequest(number=SourceRepository.number, branch="feature", head=head, + updated=stamp(now), base="main", created=stamp(now - 3600), head_time=stamp(now - 1200), + ready_events=(stamp(now - 700),), closed=False, isDraft=False, mergeStateStatus="CLEAN", + checks="SUCCESS", queued=False, merge=None, merged=None) + case = ReviewCase(held_bus, ctx, binaries, row, diff) + case.write(row) + return case + + +def test_head_verdict_family_records_status_and_new_push_needs_new_verdict(review_case): + case = review_case + violation, = Unjudged.violations(case.comms, "lead") + assert violation.began() == datetime.strptime(case.row.ready_events[-1], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC).timestamp() + assert violation.refusal(case.comms, ()) + # The same Todo and immutable head are judged through both declared members. + for index, verdict_type in enumerate(HeadVerdict.members_with(HeadVerdict), 1): + todo = case.comms.goals.todos.get("P1") + parameters = dict(expected_revision=todo.revision, actor=todo.creator_identity, + previous=todo.held(), quote="Repair the existing owner", head=case.row.head, + reason=StrikeReason("The required fact is duplicated in this code instead of derived from its declaration"), + evidence=(FileLine("owner.py", 2),)) + operation = verdict_type(**{field.name: parameters[field.name] + for field in fields(verdict_type) if field.name in parameters}) + result = case.ctx.call(CommsVerdictTool(problem="P1", operation=operation)).todo + assert result.head_verdict(case.row.head) == operation + assert Unjudged.violations(case.comms, "lead") == () + posts = case.posts() + assert len(posts) == index + assert posts[-1][3].endswith("/statuses/" + case.row.head) + assert "state=" + verdict_type.state in posts[-1] + assert "context=" + HeadVerdict.context in posts[-1] + assert result.strike_count == todo.strike_count + len(operation.implied_strikes()) + assert all(read.read() == () for read in OrderEditsRead.for_commander(case.comms, "lead")) + checkout = Path(result.order.checked().check.directory) + tree = Repo.git(checkout, "rev-parse", f"{case.row.head}^{{tree}}").strip() + next_head = Repo.git(checkout, "commit-tree", tree, "-p", case.row.head, "-m", "New review head").strip() + Repo.git(checkout, "push", "origin", f"{next_head}:refs/pull/{case.row.number}/head") + case.write(replace(case.row, head=next_head, head_time=stamp(time.time()))) + new, = Unjudged.violations(case.comms, "lead") + assert new.request.head == next_head + assert new.refusal(case.comms, ()) == () + assert result.head_verdict(operation.head) == operation + + +def test_overdue_head_refuses_say_but_not_judgment(review_case): + case = review_case + with pytest.raises(SystemExit, match=f"PR #{case.row.number}.*head {case.row.head}"): + SayVerb(units="worker", body_file="unused").apply(case.ctx) + verb = OkVerb(problem="P1", why="The declared parent now owns the shared behavior", + serves="Repair the existing owner", lines=("+class Member(Parent):",)) + verb.apply(case.ctx) + assert Unjudged.violations(case.comms, "lead") == () + assert "state=success" in case.posts()[-1] + + +def test_show_names_linked_head_and_diff_quotes_cannot_judge_other_code(review_case, capsys): + case = review_case + ShowVerb().run(case.ctx) + assert f"PR #{case.row.number} head {case.row.head}: unreviewed" in capsys.readouterr().out + with pytest.raises(SystemExit, match="every --line"): + OkVerb(problem="P1", why="This claim quotes code outside the linked request", + serves="Repair the existing owner", lines=("not in this diff",)).run(case.ctx) + assert case.comms.goals.todos.get("P1").verdicts == () + assert case.posts() == [] + todo = case.comms.goals.todos.get("P1") + historical = AcceptedVerdict(todo.revision, todo.creator_identity, todo.held(), + "old-edit", quote="Repair the existing owner") + encoded = FieldCodec.encode(historical) + assert FieldCodec.decode(OrderVerdict, encoded) == historical + with pytest.raises(MalformedRecord): + FieldCodec.decode(TodoOperation, encoded) diff --git a/tests/test_todos.py b/tests/test_todos.py index e003dc44f9..bfac6482ec 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -247,7 +247,9 @@ def pr_commander(tmp_path, monkeypatch): source.git(checkout, "update-ref", f"refs/remotes/origin/{WorkBranches.main}", merged) from agent_comms.todos import GithubPullRequest - common = dict(branch="feature/orders", head=merged, updated="2026-10-20T12:00:00Z", + clock = "2026-10-20T12:00:00Z" + common = dict(branch="feature/orders", head=merged, updated=clock, + created=clock, head_time=clock, ready_events=(), base=WorkBranches.main, closed=True, isDraft=False, mergeStateStatus="CLEAN", checks="SUCCESS", queued=False) requests = ( diff --git a/tools/cutover/carry_commander_cursor.py b/tools/cutover/carry_commander_cursor.py index 64d70f6cfe..8700080746 100644 --- a/tools/cutover/carry_commander_cursor.py +++ b/tools/cutover/carry_commander_cursor.py @@ -1,4 +1,4 @@ -"""Add the rebuildable commander reader position without changing any order row. +"""Add the rebuildable commander reader and rename retained accepted head verdicts. Delete after installation has carried the existing buses. """ @@ -8,7 +8,7 @@ from agent_comms.coordination_database import CoordinationStore from agent_comms.installer.carry import Carried, Carry from agent_comms.locked_store import SQLiteFile -from agent_comms.todos import CommanderCursor, TodoStore +from agent_comms.todos import AcceptedHeadVerdict, CommanderCursor, Todo, TodoStore class CommanderReaderPosition(Carry): @@ -22,15 +22,30 @@ def carry(cls, root: Path, *, apply: bool) -> Carried: if not path.exists(): return Carried(0) cls.require_confined(root, path) + old_kind = "reviewed_head_verdict" with closing(CoordinationStore.read_snapshot(path)) as db: present = db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (CommanderCursor.declared_name,)).fetchone() - if present: - return Carried(0) - if not apply: - return Carried(1) + verdicts = db.execute( + f'SELECT count(*) FROM "{Todo.declared_name}", json_each(verdicts) ' + "WHERE json_extract(value, '$.kind')=?", (old_kind,)).fetchone()[0] + changed = int(not present) + verdicts + if not changed or not apply: + return Carried(changed) with closing(CoordinationStore.writer(path, isolation_level=None)) as db: with SQLiteFile.write_transaction(db): - CommanderCursor.create(db) + if not present: + CommanderCursor.create(db) + db.execute( + f'UPDATE "{Todo.declared_name}" SET verdicts=(' + "SELECT json_group_array(json(CASE WHEN json_extract(value, '$.kind')=? " + "THEN json_set(value, '$.kind', ?) ELSE value END)) FROM json_each(verdicts)) " + "WHERE EXISTS (SELECT 1 FROM json_each(verdicts) " + "WHERE json_extract(value, '$.kind')=?)", + (old_kind, AcceptedHeadVerdict.declared_name, old_kind)) TodoStore.require_schema(db) - return Carried(1, rewritten=(str(path.relative_to(root)),)) + return Carried(changed, rewritten=(str(path.relative_to(root)),)) + + @classmethod + def before_stop(cls, root: Path, *, apply: bool) -> Carried: + return cls.carry(root, apply=apply) From cf7999f63f0bd1bf9514010378103ba849dbc290 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 12:41:40 -0400 Subject: [PATCH 06/15] P433: migrate parity producers to linked head verdicts --- src/agent_comms/commander/judge.py | 6 +- .../commander_parity/github_fixture.py | 109 ++++++++++++++++++ src/agent_comms/commander_parity/lifecycle.py | 36 ++++++ src/agent_comms/commander_parity/rows.py | 4 +- src/agent_comms/commander_parity/story.py | 80 ++++++------- src/agent_comms/commander_parity/verdicts.py | 41 +++---- src/agent_comms/todos.py | 8 ++ tests/github_cli_stub.py | 49 -------- tests/test_commander_parity_verdicts.py | 6 +- tests/test_head_review.py | 54 ++++++--- tests/test_holder_standing_rules.py | 9 +- tests/test_todos.py | 12 +- tests/test_unit_state.py | 2 +- 13 files changed, 269 insertions(+), 147 deletions(-) create mode 100755 src/agent_comms/commander_parity/github_fixture.py delete mode 100755 tests/github_cli_stub.py diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index a9417dfa12..2a3d55e407 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -29,7 +29,6 @@ Slop, StrikeReason, Todo, - UnmergedListedPullRequest, ) from agent_comms.tools import CommsUnjudgedTool, CommsVerdictTool from agent_comms.tool_caller import ThreadCaller @@ -132,10 +131,7 @@ def run(self, ctx: order.Commander) -> None: todo = next((todo for todo in ctx.held_orders() if todo.id == self.problem), None) if todo is None: sys.exit(f'Refused: {self.problem} is not a held order of this commander.') - requests = PullRequests((todo,)).listed(todo.repo, UnmergedListedPullRequest, todo) - if len(requests) != 1: - sys.exit(f'Refused: {todo.id} must link exactly one open pull request; found {len(requests)}.') - request, = requests + request = PullRequests((todo,)).current(todo) self.check_diff(request.diff(todo.repo)) result = ctx.call(CommsVerdictTool(problem=todo.id, operation=self.operation(ctx, todo, request))) diff --git a/src/agent_comms/commander_parity/github_fixture.py b/src/agent_comms/commander_parity/github_fixture.py new file mode 100755 index 0000000000..62c31207cf --- /dev/null +++ b/src/agent_comms/commander_parity/github_fixture.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""One controlled gh boundary for copied-bus checks; never contacts GitHub. + +The parent writes canonical GitHub response bodies at their API addresses. The +child serves those external responses using only the standard library: there is +no private fixture schema or second product decoder to load in every gh child. +""" +from __future__ import annotations + +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar + +if TYPE_CHECKING: + from agent_comms.repository_name import RepositoryName + + +@dataclass(frozen=True) +class GithubFixtureRequest: + listed: dict[str, object] + body: str + + +@dataclass(frozen=True) +class GithubFixture: + repo: RepositoryName + requests: dict[str, GithubFixtureRequest] + diff: str = "" + call_file: ClassVar[str] = "calls.jsonl" + + def write(self, directory: Path) -> None: + directory.mkdir(parents=True, exist_ok=True) + program = directory / "gh" + source = f"#!{sys.executable}\n" + Path(__file__).read_text().partition("\n")[2] + if program.exists(): + if program.read_text() != source: + raise ValueError(f"The fixture refuses another executable at {program}") + else: + program.write_text(source) + program.chmod(0o755) + for number, request in self.requests.items(): + response = self.address(directory, f"repos/{self.repo.to_wire()}/pulls/{number}") + response.parent.mkdir(parents=True, exist_ok=True) + response.write_text(json.dumps(dict(request.listed, body=request.body))) + comparison = self.address(directory, f"repos/{self.repo.to_wire()}/compare/" + f"{request.listed['base']}...{request.listed['head']}") + comparison.parent.mkdir(parents=True, exist_ok=True) + comparison.write_text(self.diff) + + @staticmethod + def address(directory: Path, endpoint: str) -> Path: + path = directory.joinpath(endpoint) + if not path.resolve().is_relative_to(directory.resolve()): + raise ValueError("A fixture response cannot leave its directory") + return path + + @classmethod + def read(cls, directory: Path, repo: str, number: str) -> dict[str, object]: + return json.loads(cls.address(directory, f"repos/{repo}/pulls/{number}").read_text()) + + @classmethod + def calls(cls, directory: Path) -> tuple[list[str], ...]: + return tuple(json.loads(line) for line in directory.joinpath(cls.call_file).read_text().splitlines()) + + +def main(): + directory = Path(sys.argv[0]).parent + arguments = sys.argv[1:] + with directory.joinpath(GithubFixture.call_file).open("a") as calls: + calls.write(json.dumps(arguments) + "\n") + if arguments[:2] == ["pr", "view"]: + repo = arguments[arguments.index("-R") + 1] + request = GithubFixture.read(directory, repo, arguments[2]) + field = arguments[arguments.index("--json") + 1] + assert arguments[arguments.index("--jq") + 1] == "." + field + print(request[field]) + elif arguments[:2] == ["api", "graphql"]: + query = arguments[arguments.index("-f") + 1] + assert query.startswith("query=") + repository = re.search(r'repository\(owner: "([^"]+)", name: "([^"]+)"\)', query) + assert repository is not None, "The query must select one repository" + repo = "/".join(repository.groups()) + selections = re.findall(r"p(\d+): pullRequest\(number: (\d+)\)", query) + assert selections, "Only explicit recorded pull-request selections are supported" + assert all(alias == number for alias, number in selections) + responses = {f"p{number}": GithubFixture.read(directory, repo, number) + for _, number in selections} + for response in responses.values(): + del response["body"] + print(json.dumps(responses)) + elif arguments[:3] == ["api", "--method", "POST"]: + repo, separator, head = arguments[3].partition("/statuses/") + assert separator and head and GithubFixture.address(directory, repo).is_dir() + fields = dict(arguments[index + 1].split("=", 1) + for index, argument in enumerate(arguments) if argument == "-f") + assert fields["state"] in ("success", "failure") + print(json.dumps(fields)) + elif arguments[:1] == ["api"]: + assert arguments[arguments.index("-H") + 1] == "Accept: application/vnd.github.diff" + print(GithubFixture.address(directory, arguments[1]).read_text()) + else: + raise AssertionError(f"Unexpected gh invocation: {arguments!r}") + + +if __name__ == "__main__": + main() diff --git a/src/agent_comms/commander_parity/lifecycle.py b/src/agent_comms/commander_parity/lifecycle.py index f773cc5428..c3ddd38b0a 100644 --- a/src/agent_comms/commander_parity/lifecycle.py +++ b/src/agent_comms/commander_parity/lifecycle.py @@ -10,6 +10,7 @@ import shlex import subprocess import sys +import time from abc import ABC, abstractmethod from collections.abc import Iterator from dataclasses import dataclass, replace @@ -27,6 +28,7 @@ from agent_comms.commander.order import Commander, OrderFile from agent_comms.commander.request import RequestAddVerb from agent_comms.commander_parity import ParityComparison, UnverifiedParity +from agent_comms.commander_parity.github_fixture import GithubFixture, GithubFixtureRequest from agent_comms.field_codec import FieldCodec from agent_comms.goals import CountCheckResult, OrderCheck, ProblemGoalOrder from agent_comms.order_runs import ApplicationOrderRun @@ -37,9 +39,14 @@ FileDeliveryEvidence, FixScope, FixShape, + GithubPullRequest, OperationDelivery, PendingProblemOrder, + PullRequestLink, + PullRequests, + RecordPullRequest, Todo, + UnmergedListedPullRequest, ) from agent_comms.tool_caller import ThreadCaller, ToolCaller from agent_comms.tool_result import ToolResult @@ -214,6 +221,35 @@ def create(self) -> Todo: raise ValueError(f"A fresh copied workflow must create one problem, got {len(rows)}") return rows[0] + @contextlib.contextmanager + def forge(self, todo: Todo, diff: str) -> Iterator[None]: + """Real linked code/source with controlled gh responses, never real status writes.""" + original = MeasuredCommander.read(CommanderConfig.path()) + held = original.comms.goals.todos.held_by(original.incarnation, self.worker) + pulls = PullRequests(held) + requests = tuple(dict.fromkeys(request for row in held if row.repo == todo.repo + for request in pulls.listed(row.repo, UnmergedListedPullRequest, row))) + if len(requests) != 1: + raise ValueError("Head-review comparison requires one open PR linked to the copied worker.") + request, = requests + link = PullRequestLink(todo.repo, request.number) + self.commander.comms.goals.todos.apply(todo.id, RecordPullRequest( + todo.revision, self.commander.incarnation, link, todo.held().generation)) + clock = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + row = GithubPullRequest(number=request.number, branch=request.branch, head=request.head, + updated=clock, base=request.base, created=clock, head_time=clock, ready_events=(), + closed=False, isDraft=False, mergeStateStatus="CLEAN", checks="SUCCESS", queued=False, + merge=None, merged=None) + directory = self.commander.root / "forge" + GithubFixture(todo.repo, { + str(row.number): GithubFixtureRequest(FieldCodec.encode(row), "")}, diff).write(directory) + previous = os.environ["PATH"] + os.environ["PATH"] = os.pathsep.join((str(directory), previous)) + try: + yield + finally: + os.environ["PATH"] = previous + def complete(self, todo: Todo) -> Todo: goal = self.commander.comms.registry.require(self.worker).goal self.artifact.write_text(json.dumps(FieldCodec.encode(goal))) diff --git a/src/agent_comms/commander_parity/rows.py b/src/agent_comms/commander_parity/rows.py index 3d751ddbbc..c9dea6b5b5 100644 --- a/src/agent_comms/commander_parity/rows.py +++ b/src/agent_comms/commander_parity/rows.py @@ -99,9 +99,9 @@ class StrikeLimitParityRow(ParityRow): class VerdictParityRow(ParityRow): number = 8 - capability = "Verdicts on a unit's tool calls" + capability = "Verdicts on linked pull request heads" current = "`judge.py ok`, `back`; both invoke `CommsVerdictTool`" - native = "`CommsVerdictTool`, `SendBackVerdict`: one retained event and Goal revision" + native = "`HeadVerdict`: one review status, verdict and rejection Goal revision" phases = () comparison = VerdictComparison.read diff --git a/src/agent_comms/commander_parity/story.py b/src/agent_comms/commander_parity/story.py index f629eaa8e0..2dcc033733 100644 --- a/src/agent_comms/commander_parity/story.py +++ b/src/agent_comms/commander_parity/story.py @@ -6,7 +6,7 @@ from agent_comms.commander_parity.lifecycle import LifecycleCase, LifecycleRun from agent_comms.goal_actions import CreateProblemGoalAction from agent_comms.order_story import FleetStory, OrderStory -from agent_comms.todos import SendBackVerdict, StrikeReason, Todo +from agent_comms.todos import PullRequests, RejectedHeadVerdict, StrikeReason, Todo from agent_comms.tool_result import ToolResult from agent_comms.tools import ( CommsFleetStoryTool, @@ -25,48 +25,44 @@ class StoryCase(LifecycleCase): @classmethod def exercise(cls, run: LifecycleRun) -> tuple[Todo, ...]: created = run.create() - verdict = SendBackVerdict( - created.revision, - run.commander.incarnation, - created.held(), - "story-review", - StrikeReason(cls.back_reason), - evidence=(created.order.checked().owner,), - ) - run.commander.call(CommsVerdictTool(problem=created.id, operation=verdict)) - document = replace(OrderFile.read(run.document.read_text()), problems=(created.id,)) - run.commander.send( - run.worker, - document, - clean=True, - ) - run.commander.strike(run.worker, created.id, cls.strike_reason, - evidence=(created.order.checked().owner,)) - saved = run.commander.comms.goals.todos.get(created.id) - run.commander.call( - CommsTodoCreateTool( - owner=run.worker, - order=CreateProblemGoalAction( - problem="P2", - order=replace(document.order, at=saved.order.checked().at), - request=run.commander.comms.registry.require(run.commander.commander) - .goal.require() - .id, - review_at=saved.review_at, - commander=run.commander.commander, - repo=saved.repo, - text="The second problem shares the same real sent order.", - ), + with run.forge(created, "+ copied decision\n"): + created = run.commander.comms.goals.todos.get(created.id) + request = PullRequests((created,)).current(created) + verdict = RejectedHeadVerdict( + created.revision, run.commander.incarnation, created.held(), + reason=StrikeReason(cls.back_reason), head=request.head, + quote="Remove the copied workflow decision", + evidence=(created.order.checked().owner,), ) - ) - for _ in range(2): - story = run.commander.call(CommsOrderStoryTool(order_id=saved.order.checked().id)) - assert isinstance(story, OrderStory) - print(story.line) - fleet = run.commander.call(CommsFleetStoryTool()) - assert isinstance(fleet, FleetStory) - print("\n".join(fleet.lines)) - return created, saved, run.commander.comms.goals.todos.get(created.id) + run.commander.call(CommsVerdictTool(problem=created.id, operation=verdict)) + document = replace(OrderFile.read(run.document.read_text()), problems=(created.id,)) + run.commander.send(run.worker, document, clean=True) + run.commander.strike(run.worker, created.id, cls.strike_reason, + evidence=(created.order.checked().owner,)) + saved = run.commander.comms.goals.todos.get(created.id) + run.commander.call( + CommsTodoCreateTool( + owner=run.worker, + order=CreateProblemGoalAction( + problem="P2", + order=replace(document.order, at=saved.order.checked().at), + request=run.commander.comms.registry.require(run.commander.commander) + .goal.require().id, + review_at=saved.review_at, + commander=run.commander.commander, + repo=saved.repo, + text="The second problem shares the same real sent order.", + ), + ) + ) + for _ in range(2): + story = run.commander.call(CommsOrderStoryTool(order_id=saved.order.checked().id)) + assert isinstance(story, OrderStory) + print(story.line) + fleet = run.commander.call(CommsFleetStoryTool()) + assert isinstance(fleet, FleetStory) + print("\n".join(fleet.lines)) + return created, saved, run.commander.comms.goals.todos.get(created.id) @classmethod def checks( diff --git a/src/agent_comms/commander_parity/verdicts.py b/src/agent_comms/commander_parity/verdicts.py index 47c90a8fb0..43710b3df2 100644 --- a/src/agent_comms/commander_parity/verdicts.py +++ b/src/agent_comms/commander_parity/verdicts.py @@ -1,4 +1,4 @@ -"""Judge/native verdicts on actual native edits in LifecycleRun's disposable bus.""" +"""Judge/native head verdicts with copied native edits and controlled gh responses.""" from __future__ import annotations @@ -15,7 +15,7 @@ from agent_comms.active_route import resolve_comms_route from agent_comms.commander.judge import BackVerb, Edit, OkVerb, VerdictVerb -from agent_comms.todos import AcceptedVerdict, SendBackVerdict, Todo, ToolCallVerdict +from agent_comms.todos import AcceptedHeadVerdict, HeadVerdict, PullRequests, RejectedHeadVerdict, Todo from agent_comms.tools import CommsVerdictTool, TodoChanged from agent_comms.turn_context import GoalSegment from agent_comms.commander_parity import ParityComparison @@ -26,7 +26,7 @@ @dataclass(frozen=True) class VerdictMutation: - call_id: str + head: str verdicts: int todo_revisions: int strikes: int @@ -51,7 +51,7 @@ class VerdictObservation(LifecycleObservation): class VerdictCase(ABC): changes: ClassVar[int] - native_owner: ClassVar[type[ToolCallVerdict]] + native_owner: ClassVar[type[HeadVerdict]] @classmethod @abstractmethod @@ -67,11 +67,10 @@ def judge(run: LifecycleRun, command: VerdictVerb, todo: Todo, edit: Edit) -> No @staticmethod def native(run: LifecycleRun, command: VerdictVerb, todo: Todo, edit: Edit) -> object: - return run.commander.call( - CommsVerdictTool( - problem=todo.id, operation=command.operation(run.commander, todo, edit.id) - ) - ) + request = PullRequests((todo,)).current(todo) + command.check_diff(request.diff(todo.repo)) + return run.commander.call(CommsVerdictTool( + problem=todo.id, operation=command.operation(run.commander, todo, request))) @classmethod def capture( @@ -110,7 +109,9 @@ def capture( if len(edits) != 1 or edits[0].id != call_id: raise ValueError("The copied unit must expose exactly its one real native edit") edit = edits[0] - invoke(run, cls.command(run, before, edit), before, edit) + with run.forge(before, '\n'.join(hunk.rendered() for hunk in edit.hunks)): + before = run.commander.comms.goals.todos.get(before.id) + invoke(run, cls.command(run, before, edit), before, edit) saved = run.commander.comms.goals.todos.get(before.id) worker = run.commander.comms.registry.require(run.worker) after = worker.goal.require() @@ -119,8 +120,8 @@ def capture( for item in run.commander.invocations if isinstance(item.request, CommsVerdictTool) ) - if len(native) != 1 or native[0][0].operation.tool_call_id != call_id: - raise ValueError("The native verdict must judge this one original copied tool call") + if len(native) != 1: + raise ValueError("The native verdict must judge exactly one linked current PR head") request, result = native[0] # goal_history returns committed revisions; its internal tags stay there. history_entries = run.commander.comms.goals.goal_history(run.worker, goal_id=goal.id)[ @@ -128,7 +129,7 @@ def capture( ] progress = GoalSegment.capture(worker, after, run.commander.comms.goals).progress mutation = VerdictMutation( - request.operation.tool_call_id, + request.operation.head, len(saved.verdicts) - len(before.verdicts), saved.revision - before.revision, saved.strike_count - before.strike_count, @@ -161,14 +162,14 @@ def capture( f"{cls.native_owner.__module__}.{cls.native_owner.__qualname__}", len(native), checks, - "", + "Copied native history and Todo; controlled gh responses, no live review status changed.", mutation, ) class AcceptedCase(VerdictCase): changes = 0 - native_owner = AcceptedVerdict + native_owner = AcceptedHeadVerdict @classmethod def replacement(cls, run: LifecycleRun) -> str: @@ -177,7 +178,7 @@ def replacement(cls, run: LifecycleRun) -> str: @classmethod def command(cls, run: LifecycleRun, todo: Todo, edit: Edit) -> OkVerb: return OkVerb( - ids=(f"{edit.id}:{edit.code()}",), + problem=todo.id, why="The native edit removes the copied workflow decision.", serves="Remove the copied workflow decision", lines=(edit.hunks[0].new_text,), @@ -186,7 +187,7 @@ def command(cls, run: LifecycleRun, todo: Todo, edit: Edit) -> OkVerb: class SendBackCase(VerdictCase): changes = 1 - native_owner = SendBackVerdict + native_owner = RejectedHeadVerdict why: ClassVar[str] = "The edit duplicates the copied workflow decision instead of removing it." @classmethod @@ -196,8 +197,8 @@ def replacement(cls, run: LifecycleRun) -> str: @classmethod def command(cls, run: LifecycleRun, todo: Todo, edit: Edit) -> BackVerb: return BackVerb( - ids=(f"{edit.id}:{edit.code()}",), - msg=todo.messages[-1].message_id, + problem=todo.id, + serves="Remove the copied workflow decision", lines=(edit.hunks[0].new_text,), why=cls.why, roast=( @@ -207,7 +208,7 @@ def command(cls, run: LifecycleRun, todo: Todo, edit: Edit) -> BackVerb: fix="Remove the copied decision and let the existing check measure its absence.", pattern="AGENT-2", fact="the copied workflow decision collection", - at=f"{run.decisions}:1", + at=todo.order.checked().owner.to_wire(), derive="delete-copy", by=f"{Path(__file__).resolve()}:1", ) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 805f45e1e5..38cbad1104 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -679,6 +679,14 @@ def __init__(self, todos: Sequence[Todo] = ()) -> None: for todo in todos: self._todos.setdefault(todo.repo, {})[todo.id] = todo + def current(self, todo: Todo) -> PullRequest: + """The one open linked request a head judgment can select without guessing.""" + requests = self.listed(todo.repo, UnmergedListedPullRequest, todo) + if len(requests) != 1: + raise TodoError(f"{todo.id} must link exactly one open pull request; found {len(requests)}.") + request, = requests + return request + def listed(self, repo: RepositoryName, kind: type[ListedPullRequest], todo: Todo) -> tuple[PullRequest, ...]: key = (repo, todo.id) diff --git a/tests/github_cli_stub.py b/tests/github_cli_stub.py deleted file mode 100755 index 7e3ed2f473..0000000000 --- a/tests/github_cli_stub.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 -"""The external gh boundary for local pull-request fixtures; never contacts GitHub.""" - -import json -import re -import sys -from pathlib import Path - - -def main(): - directory = Path(sys.argv[0]).parent - data = json.loads((directory / "pull_requests.json").read_text()) - arguments = sys.argv[1:] - with (directory / "calls.jsonl").open("a") as calls: - calls.write(json.dumps(arguments) + "\n") - if arguments[:2] == ["pr", "view"]: - assert arguments[arguments.index("-R") + 1] == data["repo"] - request = data["requests"][arguments[2]] - field = arguments[arguments.index("--json") + 1] - assert arguments[arguments.index("--jq") + 1] == "." + field - print({"number": request["listed"]["number"], "body": request["body"]}[field]) - elif arguments[:2] == ["api", "graphql"]: - query = arguments[arguments.index("-f") + 1] - assert query.startswith("query=") - owner, name = data["repo"].split("/") - assert f'repository(owner: "{owner}", name: "{name}")' in query - selections = re.findall(r"p(\d+): pullRequest\(number: (\d+)\)", query) - assert selections, "Only explicit recorded pull-request selections are supported" - assert all(alias == number for alias, number in selections) - # gh --jq returns the typed projection, not the raw GraphQL envelope. - print(json.dumps({f"p{number}": data["requests"][number]["listed"] - for _, number in selections})) - elif arguments[:3] == ["api", "--method", "POST"]: - assert arguments[3].startswith(f'repos/{data["repo"]}/statuses/') - fields = dict(arguments[index + 1].split("=", 1) - for index, argument in enumerate(arguments) if argument == "-f") - assert fields["state"] in ("success", "failure") - print(json.dumps(fields)) - elif arguments[:1] == ["api"] and arguments[1].startswith(f'repos/{data["repo"]}/compare/'): - assert arguments[arguments.index("-H") + 1] == "Accept: application/vnd.github.diff" - assert any(arguments[1].endswith("..." + request["listed"]["head"]) - for request in data["requests"].values()) - print(data["diff"]) - else: - raise AssertionError(f"Unexpected gh invocation: {arguments!r}") - - -if __name__ == "__main__": - main() diff --git a/tests/test_commander_parity_verdicts.py b/tests/test_commander_parity_verdicts.py index ffa1f9db45..8f2d59ca97 100644 --- a/tests/test_commander_parity_verdicts.py +++ b/tests/test_commander_parity_verdicts.py @@ -13,8 +13,8 @@ def observation(mutation: VerdictMutation, changes: int) -> VerdictObservation: return VerdictObservation("native verdict owner", 1, mutation.checks(changes), "", mutation) -def test_verdict_requires_the_actual_call_event_goal_and_strike_together(): - accepted = VerdictMutation("one-call", 1, 1, 0, 0, 0, "P1: Open · Accepted") +def test_verdict_requires_the_actual_head_event_goal_and_strike_together(): + accepted = VerdictMutation("a" * 40, 1, 1, 0, 0, 0, "P1: Open · Accepted") back = replace( accepted, strikes=1, @@ -40,5 +40,5 @@ def test_verdict_requires_the_actual_call_event_goal_and_strike_together(): replace(sent_back, checks=(*sent_back.checks, ("retained native identity", False))), ): assert not replace(compared, back_current=changed, back_native=changed).passed - for changed in (replace(back, call_id="other-call"), replace(back, progress="")): + for changed in (replace(back, head="b" * 40), replace(back, progress="")): assert not replace(compared, back_native=observation(changed, 1)).passed diff --git a/tests/test_head_review.py b/tests/test_head_review.py index 68399be7c4..e13a28190b 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -1,5 +1,4 @@ """One verdict family, a real Todo and the external gh executable boundary.""" -import json import os import time from dataclasses import dataclass, fields, replace @@ -8,14 +7,15 @@ import pytest -from test_holder_standing_rules import held_bus -from source_revision_fixture import SourceRepository -from agent_comms.commander.judge import OkVerb, ShowVerb +from test_holder_standing_rules import held_bus, held_source +from agent_comms.commander.judge import BackVerb, OkVerb, ShowVerb +from agent_comms.commander_parity.github_fixture import GithubFixture, GithubFixtureRequest from agent_comms.commander.order import Commander, SayVerb from agent_comms.commander.standing import Unjudged from agent_comms.comms import Comms from agent_comms.field_codec import FieldCodec, MalformedRecord from agent_comms.repo import Repo +from agent_comms.input_disposition import InputDispositions from agent_comms.todos import ( AcceptedVerdict, GithubPullRequest, HeadVerdict, OrderVerdict, PullRequestLink, RecordPullRequest, StrikeReason, TodoOperation, @@ -39,33 +39,31 @@ class ReviewCase: def write(self, row: GithubPullRequest) -> None: self.row = row - self.binaries.joinpath("pull_requests.json").write_text(json.dumps({ - "repo": self.comms.goals.todos.get("P1").repo.to_wire(), - "requests": {str(row.number): {"listed": FieldCodec.encode(row), "body": ""}}, - "diff": self.diff, - })) + GithubFixture(self.comms.goals.todos.get("P1").repo, + {str(row.number): GithubFixtureRequest(FieldCodec.encode(row), "")}, self.diff).write(self.binaries) def posts(self) -> list[list[str]]: - return [call for line in self.binaries.joinpath("calls.jsonl").read_text().splitlines() - if (call := json.loads(line))[:3] == ["api", "--method", "POST"]] + return [call for call in GithubFixture.calls(self.binaries) + if call[:3] == ["api", "--method", "POST"]] @pytest.fixture -def review_case(held_bus, tmp_path, monkeypatch): +def review_case(held_bus, held_source, tmp_path, monkeypatch): binaries = tmp_path / "bin" - binaries.mkdir() - binaries.joinpath("gh").symlink_to(Path(__file__).with_name("github_cli_stub.py")) monkeypatch.setenv("PATH", os.pathsep.join((str(binaries), os.environ["PATH"]))) + held_bus.channels.create_tag("comms") + for name in ("lead", "worker"): + held_bus.channels.update_tags(name, add=frozenset({"comms"})) ctx = Commander(commander="lead", root=held_bus.root, state=tmp_path / "state", repos=(), worktrees=tmp_path, scratch=tmp_path, disk=tmp_path) todo = held_bus.goals.todos.get("P1") held_bus.goals.todos.apply(todo.id, RecordPullRequest(todo.revision, todo.creator_identity, - PullRequestLink(todo.repo, SourceRepository.number), todo.held().generation)) + PullRequestLink(todo.repo, held_source.number), todo.held().generation)) checkout = Path(todo.order.checked().check.directory) head = Repo.git(checkout, "rev-parse", "feature").strip() diff = Repo.git(checkout, "diff", f"main...{head}") now = time.time() - row = GithubPullRequest(number=SourceRepository.number, branch="feature", head=head, + row = GithubPullRequest(number=held_source.number, branch="feature", head=head, updated=stamp(now), base="main", created=stamp(now - 3600), head_time=stamp(now - 1200), ready_events=(stamp(now - 700),), closed=False, isDraft=False, mergeStateStatus="CLEAN", checks="SUCCESS", queued=False, merge=None, merged=None) @@ -120,6 +118,30 @@ def test_overdue_head_refuses_say_but_not_judgment(review_case): assert "state=success" in case.posts()[-1] +def test_back_posts_failure_roast_and_strike_without_replaying_unknown(review_case, capsys): + case = review_case + before = case.comms.goals.todos.get("P1") + inputs = InputDispositions(case.comms.root) + unresolved = inputs.lookup("unresolved-input") + BackVerb(problem="P1", why="The member copies behavior instead of deriving it from its parent", + serves="Repair the existing owner", lines=("+class Member(Parent):",), + roast="This is stupid because Member copies the shared behavior that Parent already owns and should supply.", + fix="Delete the member copy and inherit shared behavior from Parent.", + pattern="IMPL-12", fact="the owner of the shared behavior", at="owner.py:4", + derive="delete-copy", by="owner.py:2").apply(case.ctx) + saved = case.comms.goals.todos.get("P1") + assert "state=failure" in case.posts()[-1] + assert saved.head_verdict(case.row.head).head == case.row.head + assert saved.strike_count == before.strike_count + 1 + assert inputs.lookup("unresolved-input") == unresolved + assert Unjudged.violations(case.comms, "lead") == () + assert "roast sent to" in capsys.readouterr().out + with case.comms.bus.log.opened_wire_snapshot() as source: + bodies = tuple(message.body for message in source.newest_messages(1024 * 1024)) + assert any("The slop:" in body and "What replaces it:" in body for body in bodies) + assert any("On the record:" in body and "strike 1 on P1" in body for body in bodies) + + def test_show_names_linked_head_and_diff_quotes_cannot_judge_other_code(review_case, capsys): case = review_case ShowVerb().run(case.ctx) diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index 41bffef3f3..ed57fa4290 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -49,9 +49,14 @@ @pytest.fixture -def held_bus(comms, tmp_path): +def held_source(tmp_path): + return SourceRepository.create(tmp_path / "source") + + +@pytest.fixture +def held_bus(comms, tmp_path, held_source): """One setup owner: real declarations, checked orders, UNKNOWN row, then a copy.""" - source = SourceRepository.create(tmp_path / "source") + source = held_source for name in ("lead", "worker", "outsider"): comms.registry.declare(comms.registry.new_thread(name=name, tags=frozenset(), worktree=str(source.checkout), process_identity=ProcessIdentity.capture(os.getpid()))) diff --git a/tests/test_todos.py b/tests/test_todos.py index bfac6482ec..6bb5ec6837 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -260,13 +260,11 @@ def pr_commander(tmp_path, monkeypatch): (GithubPullRequest(number=3, merge=None, merged=None, **common), "Closed without merging."), ) binaries = tmp_path / "bin" - binaries.mkdir() - (binaries / "gh").symlink_to(Path(__file__).with_name("github_cli_stub.py")) - (binaries / "pull_requests.json").write_text(json.dumps({ - "repo": source.github.to_wire(), - "requests": {str(row.number): {"body": body, "listed": FieldCodec.encode(row)} - for row, body in requests}, - })) + from agent_comms.commander_parity.github_fixture import GithubFixture, GithubFixtureRequest + + GithubFixture(source.github, { + str(row.number): GithubFixtureRequest(FieldCodec.encode(row), body) + for row, body in requests}).write(binaries) monkeypatch.setenv("PATH", os.pathsep.join((str(binaries), os.environ["PATH"]))) lead = Registration(tmp_path / "registry.json").new_thread( source.checkout.name, frozenset(), str(source.checkout), created_at=1.0) diff --git a/tests/test_unit_state.py b/tests/test_unit_state.py index 244cd32eed..2bf85a3e3e 100644 --- a/tests/test_unit_state.py +++ b/tests/test_unit_state.py @@ -6,7 +6,7 @@ import pytest -from test_holder_standing_rules import held_bus, blocked_holder, publish +from test_holder_standing_rules import held_bus, held_source, blocked_holder, publish from agent_comms.launch_runtime import LaunchRoot from agent_comms.recorded_time import RecordedEventTime from agent_comms.thread_status import StoppedThreadStatus From ea07419211b392874c388367a598637d2fd04011 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 13:54:43 -0400 Subject: [PATCH 07/15] Preserve plan discovery and typed copied head-review fixtures --- src/agent_comms/commander/standing.py | 31 +++++++++++++++++++ .../commander_parity/github_fixture.py | 16 ++++++---- src/agent_comms/commander_parity/lifecycle.py | 25 +++++++-------- src/agent_comms/commander_parity/verdicts.py | 10 +++--- tests/test_head_review.py | 10 +++--- tests/test_holder_standing_rules.py | 11 ++++--- tests/test_todos.py | 5 ++- tests/test_unit_state.py | 2 +- 8 files changed, 72 insertions(+), 38 deletions(-) diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index 4743b3b022..960c59ec99 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -30,6 +30,8 @@ from agent_comms.recorded_time import RecordedEventTime from agent_comms.work_branches import WorkBranches from agent_comms.ui_model.unit_state import UnitState +from agent_comms.phase_evidence import PlanReading +from agent_comms.plan_phases import PlanPhase @dataclass(frozen=True) @@ -75,6 +77,35 @@ def line(self) -> str: " -> order its next step or mark it done") +@dataclass(frozen=True) +class PlanIdle(UndatedStandingRule): + """A phase whose dependencies are done stalls unnoticed unless some order serves it.""" + + phase: type[PlanPhase] + reading: PlanReading = field(compare=False) + + @classmethod + def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: + reading = CommanderConfig.read_root(comms.root).plan_reading( + comms.goals.todos.history_for_commander(comms.registry.require(commander).incarnation) + ) + return tuple( + cls(phase, reading) + for phase in PlanPhase.members_with(PlanPhase) + if phase.ready(reading) and not phase.ordered(reading) + ) + + def enforce(self, comms: Comms, commander: str) -> None: + self.phase.task.start(comms, comms.registry.require(commander), self.phase, self.reading) + + def line(self) -> str: + return ( + f"PLAN IDLE: {self.phase.plan.declared_name} phase {self.phase.declared_name} ready" + f" ({self.phase.plan.document}), no open order; done when" + f" {self.phase.evidence.describe()} -> {self.phase.task.instruction}" + ) + + @dataclass(frozen=True) class UnownedWheelSpin(UndatedStandingRule): """A unit holds no next order while the board has unowned rows; Core hands the row to it diff --git a/src/agent_comms/commander_parity/github_fixture.py b/src/agent_comms/commander_parity/github_fixture.py index 62c31207cf..03bb75c003 100755 --- a/src/agent_comms/commander_parity/github_fixture.py +++ b/src/agent_comms/commander_parity/github_fixture.py @@ -16,22 +16,25 @@ if TYPE_CHECKING: from agent_comms.repository_name import RepositoryName + from agent_comms.todos import GithubPullRequest @dataclass(frozen=True) class GithubFixtureRequest: - listed: dict[str, object] + listed: GithubPullRequest body: str @dataclass(frozen=True) class GithubFixture: repo: RepositoryName - requests: dict[str, GithubFixtureRequest] + requests: tuple[GithubFixtureRequest, ...] diff: str = "" call_file: ClassVar[str] = "calls.jsonl" def write(self, directory: Path) -> None: + from agent_comms.field_codec import FieldCodec + directory.mkdir(parents=True, exist_ok=True) program = directory / "gh" source = f"#!{sys.executable}\n" + Path(__file__).read_text().partition("\n")[2] @@ -41,12 +44,13 @@ def write(self, directory: Path) -> None: else: program.write_text(source) program.chmod(0o755) - for number, request in self.requests.items(): - response = self.address(directory, f"repos/{self.repo.to_wire()}/pulls/{number}") + for request in self.requests: + response = self.address(directory, + f"repos/{self.repo.to_wire()}/pulls/{request.listed.number}") response.parent.mkdir(parents=True, exist_ok=True) - response.write_text(json.dumps(dict(request.listed, body=request.body))) + response.write_text(json.dumps(dict(FieldCodec.encode(request.listed), body=request.body))) comparison = self.address(directory, f"repos/{self.repo.to_wire()}/compare/" - f"{request.listed['base']}...{request.listed['head']}") + f"{request.listed.base}...{request.listed.head}") comparison.parent.mkdir(parents=True, exist_ok=True) comparison.write_text(self.diff) diff --git a/src/agent_comms/commander_parity/lifecycle.py b/src/agent_comms/commander_parity/lifecycle.py index c3ddd38b0a..943fd69307 100644 --- a/src/agent_comms/commander_parity/lifecycle.py +++ b/src/agent_comms/commander_parity/lifecycle.py @@ -46,7 +46,6 @@ PullRequests, RecordPullRequest, Todo, - UnmergedListedPullRequest, ) from agent_comms.tool_caller import ThreadCaller, ToolCaller from agent_comms.tool_result import ToolResult @@ -113,6 +112,7 @@ class LifecycleRun: decisions: Path document: Path artifact: Path + source_repository: Repo @classmethod @contextlib.contextmanager @@ -195,7 +195,8 @@ def open(cls, *, extra_threads: tuple[str, ...] = ()) -> Iterator[LifecycleRun]: ) ) ) - yield cls(commander, worker.name, decisions, document, root / "application.json") + yield cls(commander, worker.name, decisions, document, root / "application.json", + Repo.of(Path(worker.worktree))) @staticmethod def python_environment() -> dict[str, str]: @@ -224,25 +225,21 @@ def create(self) -> Todo: @contextlib.contextmanager def forge(self, todo: Todo, diff: str) -> Iterator[None]: """Real linked code/source with controlled gh responses, never real status writes.""" - original = MeasuredCommander.read(CommanderConfig.path()) - held = original.comms.goals.todos.held_by(original.incarnation, self.worker) - pulls = PullRequests(held) - requests = tuple(dict.fromkeys(request for row in held if row.repo == todo.repo - for request in pulls.listed(row.repo, UnmergedListedPullRequest, row))) - if len(requests) != 1: - raise ValueError("Head-review comparison requires one open PR linked to the copied worker.") - request, = requests - link = PullRequestLink(todo.repo, request.number) - self.commander.comms.goals.todos.apply(todo.id, RecordPullRequest( + repository = self.source_repository + if repository.github != todo.repo: + raise ValueError("The copied order must cite the code repository being compared.") + branch = Repo.git(repository.checkout, "branch", "--show-current").strip() + link = PullRequestLink.created(todo.repo, branch) + saved = self.commander.comms.goals.todos.apply(todo.id, RecordPullRequest( todo.revision, self.commander.incarnation, link, todo.held().generation)) + request = PullRequests((saved,)).current(saved) clock = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) row = GithubPullRequest(number=request.number, branch=request.branch, head=request.head, updated=clock, base=request.base, created=clock, head_time=clock, ready_events=(), closed=False, isDraft=False, mergeStateStatus="CLEAN", checks="SUCCESS", queued=False, merge=None, merged=None) directory = self.commander.root / "forge" - GithubFixture(todo.repo, { - str(row.number): GithubFixtureRequest(FieldCodec.encode(row), "")}, diff).write(directory) + GithubFixture(todo.repo, (GithubFixtureRequest(row, ""),), diff).write(directory) previous = os.environ["PATH"] os.environ["PATH"] = os.pathsep.join((str(directory), previous)) try: diff --git a/src/agent_comms/commander_parity/verdicts.py b/src/agent_comms/commander_parity/verdicts.py index 43710b3df2..b810de83a5 100644 --- a/src/agent_comms/commander_parity/verdicts.py +++ b/src/agent_comms/commander_parity/verdicts.py @@ -14,6 +14,8 @@ from uuid import uuid4 from agent_comms.active_route import resolve_comms_route +from agent_comms.launch_root import LaunchRoot +from agent_comms.launch_runtime import ThreadLaunch from agent_comms.commander.judge import BackVerb, Edit, OkVerb, VerdictVerb from agent_comms.todos import AcceptedHeadVerdict, HeadVerdict, PullRequests, RejectedHeadVerdict, Todo from agent_comms.tools import CommsVerdictTool, TodoChanged @@ -81,9 +83,8 @@ def capture( worker = run.commander.comms.registry.require(run.worker) goal = worker.goal.require() history = len(run.commander.comms.goals.goal_history(run.worker, goal_id=goal.id)) - session_file = worker.session_file - if not session_file: - raise ValueError("The copied unit must have its own recorded native session") + session_file = worker.session_file.require( + "The copied unit must have its own recorded native session") for path in (Path(session_file), run.decisions, Path(worker.worktree)): path.resolve().relative_to(run.commander.root.resolve()) package = resolve_comms_route().launch().require_private().validate() @@ -99,7 +100,8 @@ def capture( cls.replacement(run), ), cwd=worker.worktree, - env=package.node_environment(dict(os.environ)), + env=package.node_environment(LaunchRoot.environment( + ThreadLaunch.strip(dict(os.environ)))), ) if not child.outcome.successful: raise RuntimeError( diff --git a/tests/test_head_review.py b/tests/test_head_review.py index e13a28190b..487d82258a 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -7,7 +7,7 @@ import pytest -from test_holder_standing_rules import held_bus, held_source +from test_holder_standing_rules import held_bus, plan_repository from agent_comms.commander.judge import BackVerb, OkVerb, ShowVerb from agent_comms.commander_parity.github_fixture import GithubFixture, GithubFixtureRequest from agent_comms.commander.order import Commander, SayVerb @@ -40,7 +40,7 @@ class ReviewCase: def write(self, row: GithubPullRequest) -> None: self.row = row GithubFixture(self.comms.goals.todos.get("P1").repo, - {str(row.number): GithubFixtureRequest(FieldCodec.encode(row), "")}, self.diff).write(self.binaries) + (GithubFixtureRequest(row, ""),), self.diff).write(self.binaries) def posts(self) -> list[list[str]]: return [call for call in GithubFixture.calls(self.binaries) @@ -48,7 +48,7 @@ def posts(self) -> list[list[str]]: @pytest.fixture -def review_case(held_bus, held_source, tmp_path, monkeypatch): +def review_case(held_bus, source_repository, tmp_path, monkeypatch): binaries = tmp_path / "bin" monkeypatch.setenv("PATH", os.pathsep.join((str(binaries), os.environ["PATH"]))) held_bus.channels.create_tag("comms") @@ -58,12 +58,12 @@ def review_case(held_bus, held_source, tmp_path, monkeypatch): worktrees=tmp_path, scratch=tmp_path, disk=tmp_path) todo = held_bus.goals.todos.get("P1") held_bus.goals.todos.apply(todo.id, RecordPullRequest(todo.revision, todo.creator_identity, - PullRequestLink(todo.repo, held_source.number), todo.held().generation)) + PullRequestLink(todo.repo, source_repository.number), todo.held().generation)) checkout = Path(todo.order.checked().check.directory) head = Repo.git(checkout, "rev-parse", "feature").strip() diff = Repo.git(checkout, "diff", f"main...{head}") now = time.time() - row = GithubPullRequest(number=held_source.number, branch="feature", head=head, + row = GithubPullRequest(number=source_repository.number, branch="feature", head=head, updated=stamp(now), base="main", created=stamp(now - 3600), head_time=stamp(now - 1200), ready_events=(stamp(now - 700),), closed=False, isDraft=False, mergeStateStatus="CLEAN", checks="SUCCESS", queued=False, merge=None, merged=None) diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index ed57fa4290..1295fd4e6e 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -49,14 +49,15 @@ @pytest.fixture -def held_source(tmp_path): - return SourceRepository.create(tmp_path / "source") +def plan_repository(): + """Read the actual plan repository and installed commits; never invent a Core history.""" + return Repo.of(Path(__file__).resolve().parents[1]) @pytest.fixture -def held_bus(comms, tmp_path, held_source): +def held_bus(comms, tmp_path, source_repository, plan_repository): """One setup owner: real declarations, checked orders, UNKNOWN row, then a copy.""" - source = held_source + source = source_repository for name in ("lead", "worker", "outsider"): comms.registry.declare(comms.registry.new_thread(name=name, tags=frozenset(), worktree=str(source.checkout), process_identity=ProcessIdentity.capture(os.getpid()))) @@ -83,7 +84,7 @@ def held_bus(comms, tmp_path, held_source): assert inputs.bind("unresolved-input", claim_scope=1, turn_id="old-turn", native_id=uuid4().hex, text="Do not replay this input") config = CommanderConfig( - commander="lead", root=comms.root, state=tmp_path, repos=(), + commander="lead", root=comms.root, state=tmp_path, repos=(plan_repository,), worktrees=tmp_path, scratch=tmp_path, disk=tmp_path, ) config.root_path(comms.root).write_text(config.model_dump_json()) diff --git a/tests/test_todos.py b/tests/test_todos.py index 834dca9492..3fcf10f018 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -260,9 +260,8 @@ def pr_commander(tmp_path, monkeypatch, proof_build_checkouts): binaries = tmp_path / "bin" from agent_comms.commander_parity.github_fixture import GithubFixture, GithubFixtureRequest - GithubFixture(source.github, { - str(row.number): GithubFixtureRequest(FieldCodec.encode(row), body) - for row, body in requests}).write(binaries) + GithubFixture(source.github, tuple( + GithubFixtureRequest(row, body) for row, body in requests)).write(binaries) monkeypatch.setenv("PATH", os.pathsep.join((str(binaries), os.environ["PATH"]))) lead = Registration(tmp_path / "registry.json").new_thread( 'lead', frozenset(), str(source.checkout), created_at=1.0) diff --git a/tests/test_unit_state.py b/tests/test_unit_state.py index 2bf85a3e3e..f630a27ea5 100644 --- a/tests/test_unit_state.py +++ b/tests/test_unit_state.py @@ -6,7 +6,7 @@ import pytest -from test_holder_standing_rules import held_bus, held_source, blocked_holder, publish +from test_holder_standing_rules import held_bus, plan_repository, blocked_holder, publish from agent_comms.launch_runtime import LaunchRoot from agent_comms.recorded_time import RecordedEventTime from agent_comms.thread_status import StoppedThreadStatus From 2691dd22a7baf75ab2aa60de01aef3d43c5a537c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 14:11:15 -0400 Subject: [PATCH 08/15] Read heartbeat from its owner and cover reviewed native edit evidence --- src/agent_comms/commander_parity/verdicts.py | 2 ++ tests/test_holder_standing_rules.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/commander_parity/verdicts.py b/src/agent_comms/commander_parity/verdicts.py index b810de83a5..7ad497da5b 100644 --- a/src/agent_comms/commander_parity/verdicts.py +++ b/src/agent_comms/commander_parity/verdicts.py @@ -145,6 +145,8 @@ def capture( ("same Goal state", after.state == goal.state), ("native result retained", result == TodoChanged(saved, saved.strike_count)), ("native verdict retained", request.operation in saved.verdicts), + ("reviewed native edit no longer unjudged", + not Edit.unjudged(run.commander.comms, run.commander.commander)), ( "strike is the verdict event", all( diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index 0a5a9e134f..50c21cdfdb 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -24,7 +24,7 @@ from agent_comms.locked_store import SQLiteFile from agent_comms.commander.config import CommanderConfig from agent_comms.commander.order import Commander, OrderFile, SayVerb -from agent_comms.commander.standing import UnownedWheelSpin +from agent_comms.commander.standing import HeartbeatSilent, UnownedWheelSpin from agent_comms.ui_model.unit_state import ( AwaitingCommander, Blocked, Detached, UnitState, RetryableUnitBlock, UnresolvedUnitBlock, ) @@ -44,7 +44,7 @@ from agent_comms.recorded_time import RecordedEventTime from agent_comms.repository_name import RepositoryName from agent_comms.repo import Repo -from agent_comms.standing_rules import HeartbeatSilent, StandingRule +from agent_comms.standing_rules import StandingRule from agent_comms.thread_status import StoppedThreadStatus from agent_comms.installer.backup import StoreBackup from agent_comms.child_process import ProcessIdentity From d40e1ac14bd43aaf7b051e2dd8297ca293fe8444 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 16:11:36 -0400 Subject: [PATCH 09/15] Checkpoint durable unit notifications on current state owners --- src/agent_comms/commander_heartbeat.py | 65 +++--------- src/agent_comms/commander_pings.py | 133 +++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 51 deletions(-) create mode 100644 src/agent_comms/commander_pings.py diff --git a/src/agent_comms/commander_heartbeat.py b/src/agent_comms/commander_heartbeat.py index 67702632d9..e31faabe1b 100644 --- a/src/agent_comms/commander_heartbeat.py +++ b/src/agent_comms/commander_heartbeat.py @@ -2,15 +2,15 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import TYPE_CHECKING +from agent_comms.commander_pings import CommanderUpdate from agent_comms.heartbeat_frame import HeartbeatFrame from agent_comms.message_bus import WatchPass from agent_comms.registration import Registration from agent_comms.service_sender import ServiceSender from agent_comms.standing_rules import StandingRule -from agent_comms.thread_identity import ThreadIncarnation from agent_comms.threads import Thread from agent_comms.todos import ProgressRead from agent_comms.ui_model.unit_state import UnitState @@ -21,46 +21,6 @@ from agent_comms.todos import OwnerRequest, Todo -@dataclass -class CommanderReview: - """One commander's handled observations and its last changed beat. - - Seq 0 precedes every published message: no changed beat has been sent yet. - """ - - rules: frozenset[StandingRule] - states: frozenset[UnitState] = frozenset() - changed: int = 0 - - def wake(self, comms: Comms, sender: str, commander: Thread, - rules: tuple[StandingRule, ...], states: tuple[UnitState, ...]) -> None: - """Every pass beats, so silence is a failure; an unchanged beat is one short line.""" - found_rules, found_states = frozenset(rules), frozenset(states) - actionable = (*rules, *(state for state in states if state.attention())) - old_rules = self.rules - previous = self.rules | self.states - if found_rules == self.rules and found_states == self.states and self.changed: - comms.messaging.send(sender, commander.name, - f"COMMANDER HEARTBEAT: unchanged since seq {self.changed}," - f" {len(actionable)} violations.") - return - # Mark the observation before any effect; an uncertain publication is not replayed. - self.rules, self.states = found_rules, found_states - rejected = StandingRule.enforce_all( - comms, commander.name, (rule for rule in actionable if rule not in previous) - ) - # A checked rejection has no effects and must not suppress a fresh observation. - self.rules -= rejected - self.states -= rejected - lines = ["COMMANDER HEARTBEAT: unit states and standing rules changed."] - lines.extend(state.line() for state in states) - lines.extend(StandingRule.heartbeat_changes(old_rules, self.rules)) - lines.extend(rule.rejected_line() for rule in actionable if rule in rejected) - if not actionable: - lines.append("Standing rules clear.") - self.changed = comms.messaging.send(sender, commander.name, "\n".join(lines)).message.seq - - @dataclass(frozen=True) class CommanderObservation: thread: Thread @@ -80,10 +40,8 @@ def read(cls, comms: Comms, thread: Thread, return cls(thread, merged, rules, states) -@dataclass class CommanderHeartbeat(ServiceSender, WatchPass[tuple[CommanderObservation, ...]]): sender = "commander-heartbeat" - reviews: dict[ThreadIncarnation, CommanderReview] = field(default_factory=dict) @classmethod def requested_by(cls, thread: Thread, goals: Goals) -> tuple[OwnerRequest, ...]: @@ -107,13 +65,18 @@ def observe(self, comms: Comms) -> tuple[CommanderObservation, ...]: ) def pass_body(self, comms: Comms, observed: tuple[CommanderObservation, ...]) -> None: - """Inactive commanders retire their review after the pass enters the bus.""" + """The committed packet survives connector replacement; local memory owns nothing.""" self.register(comms.registry) - live = {item.thread.incarnation for item in observed} - self.reviews = { - owner: review for owner, review in self.reviews.items() if owner in live - } for item in observed: comms.goals.todos.record_merges(item.merged) - review = self.reviews.setdefault(item.thread.incarnation, CommanderReview(frozenset())) - review.wake(comms, self.sender, item.thread, item.rules, item.states) + rejected = StandingRule.enforce_all( + comms, item.thread.name, + (*item.rules, *(state for state in item.states if state.attention())), + ) + previous = CommanderUpdate.latest(comms, item.thread.incarnation) + update = CommanderUpdate.derive( + item.thread.incarnation, + tuple(state for state in item.states if state not in rejected), previous, + ) + if update is not None: + update.send(comms) diff --git a/src/agent_comms/commander_pings.py b/src/agent_comms/commander_pings.py new file mode 100644 index 0000000000..b60743d7d4 --- /dev/null +++ b/src/agent_comms/commander_pings.py @@ -0,0 +1,133 @@ +"""Committed commander notifications own what the heartbeat has already handled. + +Quiet state changes are passive notices. Unhandled urgent states remain eligible +for the next five-decision packet; no process-local cache or pending queue owns them. +""" +from __future__ import annotations + +from abc import abstractmethod +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, ClassVar, Self + +from agent_comms.errors import RelationViolationError +from agent_comms.message_reference import MessageReference +from agent_comms.messages import ( + ConversationRole, InfoKind, Message, MessageRole, NoticeRole, +) +from agent_comms.task_sources import TaskAttachment +from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.ui_model.unit_state import UnitState + +if TYPE_CHECKING: + from agent_comms.comms import Comms + from agent_comms.registry_document import RegistrySnapshot + + +@dataclass(frozen=True) +class OrderKey: + problem: str + order: str + + +@dataclass(frozen=True) +class UnitPing: + """One handled observation, not a separately writable current unit state.""" + unit: ThreadIncarnation + state: type[UnitState] + orders: tuple[OrderKey, ...] + questions: tuple[MessageReference, ...] + text: str = field(compare=False) + + @classmethod + def capture(cls, state: UnitState) -> Self: + return cls( + state.unit, type(state), + tuple(OrderKey(todo.id, todo.order.checked().id) + for todo in state.source.todos if todo.id in state.orders), + tuple(message.reference for message in state.owed), + state.line(), + ) + + +@dataclass(frozen=True) +class CommanderUpdate(TaskAttachment, declared_name=None): + commander: ThreadIncarnation + handled: tuple[UnitPing, ...] + role: ClassVar[MessageRole] + + @abstractmethod + def body(self) -> str: ... + + def require_sender(self, sender: str) -> None: + from agent_comms.commander_heartbeat import CommanderHeartbeat + + if sender != CommanderHeartbeat.sender: + raise RelationViolationError("Only the commander heartbeat publishes its observations") + + def require_target(self, target: str, registry: RegistrySnapshot) -> None: + if registry.require(target).incarnation != self.commander: + raise RelationViolationError("Commander observations changed recipient incarnation") + + @classmethod + def latest(cls, comms: Comms, commander: ThreadIncarnation) -> CommanderUpdate | None: + from agent_comms.commander_heartbeat import CommanderHeartbeat + + messages = comms.bus.bus.latest_addressed( + frozenset((CommanderHeartbeat.sender,)), frozenset((commander.name,)), + ) + if not messages: + return None + message, = messages + # Ordinary messages carry no commander-observation evidence. + update = message.task + if not isinstance(update, CommanderUpdate): + return None + return update if update.commander == commander else None + + @classmethod + def derive(cls, commander: ThreadIncarnation, states: tuple[UnitState, ...], + previous: CommanderUpdate | None) -> CommanderUpdate | None: + known = {ping.unit: ping for ping in previous.handled} if previous else {} + current = tuple(UnitPing.capture(state) for state in states) + decisions = tuple(ping for state, ping in zip(states, current, strict=True) + if state.attention() and known.get(ping.unit) != ping)[:5] + handled = tuple( + ping if not state.attention() or ping in decisions else known[ping.unit] + for state, ping in zip(states, current, strict=True) + if not state.attention() or ping in decisions or ping.unit in known + ) + if decisions: + return CommanderDecisions(commander, handled, decisions) + if previous is None or handled != previous.handled: + return CommanderNotice(commander, handled) + return None + + def send(self, comms: Comms) -> Message: + from agent_comms.bus_publication import AgentOrigin + from agent_comms.channel_targets import ThreadTarget + from agent_comms.commander_heartbeat import CommanderHeartbeat + + message = Message( + sender=CommanderHeartbeat.sender, target=self.commander.name, + body=self.body(), type=InfoKind, role=self.role, task=self, + ) + return comms.bus.publisher.publish_ordinary( + message, origin=AgentOrigin(), destination=ThreadTarget(self.commander.name), + ) + + +@dataclass(frozen=True) +class CommanderNotice(CommanderUpdate): + role = NoticeRole() + + def body(self) -> str: + return "Commander observations updated; no new decision." + + +@dataclass(frozen=True) +class CommanderDecisions(CommanderUpdate): + decisions: tuple[UnitPing, ...] + role = ConversationRole() + + def body(self) -> str: + return "\n".join(ping.text for ping in self.decisions) From 53dd242e35f8519b50c5774ea4f2022be240fcf8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 16:58:27 -0400 Subject: [PATCH 10/15] Persist commander rule decisions and recipient pass completion --- src/agent_comms/commander/standing.py | 100 ++++++++------------ src/agent_comms/commander/vibe_check.py | 10 +- src/agent_comms/commander_heartbeat.py | 39 +++++--- src/agent_comms/commander_pings.py | 93 +++++++++++++----- src/agent_comms/field_codec.py | 75 ++++++++++++++- src/agent_comms/service_passes.py | 30 ++++-- src/agent_comms/standing_rules.py | 61 +++--------- tests/test_commander_notifications.py | 120 ++++++++++++++++++++++++ tests/test_field_codec_comparison.py | 49 ++++++++++ tests/test_fleet_replay.py | 4 +- tests/test_holder_standing_rules.py | 87 +++++++---------- tests/test_proof_build.py | 14 +-- tests/test_storage_violations.py | 2 +- tests/test_unowned_checkout_work.py | 12 +-- 14 files changed, 465 insertions(+), 231 deletions(-) create mode 100644 tests/test_commander_notifications.py create mode 100644 tests/test_field_codec_comparison.py diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index bfd9cba299..bcd0944e95 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -24,6 +24,8 @@ from agent_comms.read_basis import RepliesDisplayScope from agent_comms.recorded_time import RecordedEventTime from agent_comms.registry_document import RegistrySnapshot +from agent_comms.repository_name import RepositoryName +from agent_comms.service_passes import ServicePassStore from agent_comms.standing_rules import ( DatedStandingRule, IndependentStandingRule, @@ -50,7 +52,11 @@ class InstallDue(IndependentStandingRule, UndatedStandingRule): one install each. The window is read from the merges' own git times, not a schedule. """ - why: str + repo: RepositoryName + main: str + installed: str + commits: int = field(compare=False) + oldest_minutes: int = field(compare=False) settle: ClassVar[float] = 30 * 60 @classmethod @@ -69,14 +75,15 @@ def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: f"{recorded.commit}..{main}").split() waited = now - min(map(int, merged), default=now - cls.settle) if waited >= cls.settle: - due.append(cls( - f"{repo.github.to_wire()}: main {main}, installed {recorded.commit}, " - f"{len(merged)} commits, oldest {int(waited // 60)} min")) + due.append(cls(repo.github, main, recorded.commit, + len(merged), int(waited // 60))) return tuple(due) def line(self) -> str: - return (f"INSTALL DUE ({self.why}) -> the installer rehearses and installs main with " - "agent-comms install and posts the result to the commander") + return (f"INSTALL DUE ({self.repo.to_wire()}: main {self.main}, installed " + f"{self.installed}, {self.commits} commits, oldest {self.oldest_minutes} min) " + "-> the installer rehearses and installs main with agent-comms install " + "and posts the result to the commander") @dataclass(frozen=True) @@ -241,17 +248,17 @@ def idle(cls, comms: Comms, commander: str) -> list[Thread]: if TurnDigestRead(comms.root, candidate, bound).read().turns), key=lambda thread: thread.name) - def _identity(self) -> tuple[object, ...]: + def comparison_values(self) -> tuple[object, ...]: # Observation times and proposed idle units change; the row and its disposition do not. - return type(self), self.todo.id + return (self.todo.id,) def __eq__(self, other: object) -> bool: if not isinstance(other, UnownedWheelSpin): return NotImplemented - return self._identity() == other._identity() + return type(self) is type(other) and self.comparison_values() == other.comparison_values() def __hash__(self) -> int: - return hash(self._identity()) + return hash((type(self), self.comparison_values())) @classmethod def violations(cls, comms: Comms, commander: str) -> tuple[UnownedWheelSpin, ...]: @@ -308,8 +315,8 @@ def line(self) -> str: class CheckoutHeldWheelSpin(UnownedWheelSpin, declared_name=None): work: OrderCheckoutWork - def _identity(self) -> tuple[object, ...]: - return (*super()._identity(), self.work.owner.incarnation, self.work.checkout) + def comparison_values(self) -> tuple[object, ...]: + return (*super().comparison_values(), self.work.owner.incarnation, self.work.checkout) def line(self) -> str: return (f"WORK HELD: {self.todo.id} {self.work.owner.name} retains work in " @@ -345,38 +352,32 @@ def pass_body(self, comms: Comms, observed: tuple[UnownedObservation, ...]) -> N class WaitingUnownedWheelSpin(UnownedWheelSpin, declared_name=None): """A row needs a new unit; a proposed dispatch name is not an idle thread.""" - def heartbeat_group(self) -> object: - return type(self) - - def heartbeat_line(self, current: Sequence[WaitingUnownedWheelSpin]) -> str: - rows = ", ".join(sorted(rule.todo.id for rule in current)) - return f"{len(current)} unowned rows wait for a new unit: {rows}" - @dataclass(frozen=True) class HeartbeatSilent(IndependentStandingRule, DatedStandingRule, declared_name=None): - """Missing or stale heartbeat service for the commander's current open requests. - - The heartbeat beats on every pass, so its silence is the failure; this rule reads the - bus in the commander's own tool process, where a dead heartbeat cannot hide it. - """ + """A missing or stale successful pass, independent of whether a DM needed sending.""" seconds: ClassVar[int] = 180 since: float @classmethod - def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: - return tuple(rule for member in cls.members_with(cls) - for rule in member.violations(comms, commander)) - - @staticmethod - def beats(comms: Comms, commander: str) -> tuple[Message, ...]: - snapshot = comms.registry.snapshot() - if snapshot.canonical_name(CommanderHeartbeat.sender) not in snapshot.threads: - return () - return comms.bus.bus.latest_addressed( - snapshot.names_for(CommanderHeartbeat.sender), snapshot.names_for(commander), - ) + def scan_owner(cls) -> type[StandingRule]: + return HeartbeatSilent + + @classmethod + def violations(cls, comms: Comms, commander: str) -> tuple[HeartbeatSilent, ...]: + owner = comms.registry.require(commander) + completed = ServicePassStore.at(comms.root).read().commander_completed.get(owner.incarnation) + now = time.time() + found = [] + for request in CommanderHeartbeat.requested_by(owner, comms.goals): + if completed is None or completed < request.created_at: + rule = HeartbeatNotStarted(request.created_at + cls.seconds, request.id) + else: + rule = MissedHeartbeat(completed + cls.seconds) + if rule.since < now: + found.append(rule) + return tuple(found) def began(self) -> float: return self.since @@ -384,22 +385,9 @@ def began(self) -> float: @dataclass(frozen=True) class MissedHeartbeat(HeartbeatSilent): - beat: int - - @classmethod - def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: - requests = CommanderHeartbeat.requested_by(comms.registry.require(commander), comms.goals) - if not requests: - return () - now = time.time() - found = tuple( - cls(since=max(request.created_at, beat.timestamp) + cls.seconds, beat=beat.seq) - for request in requests for beat in cls.beats(comms, commander) - ) - return tuple(rule for rule in found if rule.since < now) - def line(self) -> str: - return (f"HEARTBEAT SILENT: no beat since seq {self.beat}, silent past " + return (f"HEARTBEAT SILENT: no completed pass since " + f"{RecordedEventTime(self.since - self.seconds).clock()}, silent past " f"{RecordedEventTime(self.since).clock()}" " -> read the connector journal for the failing pass and fix its owner") @@ -408,16 +396,6 @@ def line(self) -> str: class HeartbeatNotStarted(HeartbeatSilent): request: OwnerRequestId - @classmethod - def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: - requests = CommanderHeartbeat.requested_by(comms.registry.require(commander), comms.goals) - if not requests or cls.beats(comms, commander): - return () - now = time.time() - found = tuple(cls(since=request.created_at + cls.seconds, request=request.id) - for request in requests) - return tuple(rule for rule in found if rule.since < now) - def line(self) -> str: return (f"HEARTBEAT NOT STARTED: no beat for request {self.request.value}, overdue since " f"{RecordedEventTime(self.since).clock()}" diff --git a/src/agent_comms/commander/vibe_check.py b/src/agent_comms/commander/vibe_check.py index 5641bab12d..87190c5723 100644 --- a/src/agent_comms/commander/vibe_check.py +++ b/src/agent_comms/commander/vibe_check.py @@ -201,13 +201,6 @@ def scan_observed(cls, comms: Comms, commander: str, for sign in member.found(comms, commander, frame) ) - def heartbeat_group(self) -> object: - return VibeCheck - - def heartbeat_line(self, current: Sequence[Self]) -> str: - names = ", ".join(sorted({check.sign.signal.declared_name for check in current})) - return f"VIBE CHECK ({names}) -> answer the {VibeCheckSender.sender} messages now" - def enforce(self, comms: Comms, commander: str) -> None: indictment = self.indictment() VibeCheckSender.burst_once(comms, commander, indictment, indictment[0]) @@ -225,7 +218,8 @@ def indictment(self) -> tuple[str, ...]: ) def line(self) -> str: - return self.heartbeat_line((self,)) + return (f"VIBE CHECK ({self.sign.signal.declared_name}) " + f"-> answer the {VibeCheckSender.sender} messages now") @dataclass(frozen=True) diff --git a/src/agent_comms/commander_heartbeat.py b/src/agent_comms/commander_heartbeat.py index e31faabe1b..13f58769ee 100644 --- a/src/agent_comms/commander_heartbeat.py +++ b/src/agent_comms/commander_heartbeat.py @@ -5,11 +5,12 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from agent_comms.commander_pings import CommanderUpdate +from agent_comms.commander_pings import CommanderUpdate, RulePing, UnitPing from agent_comms.heartbeat_frame import HeartbeatFrame from agent_comms.message_bus import WatchPass from agent_comms.registration import Registration from agent_comms.service_sender import ServiceSender +from agent_comms.service_passes import ServicePassStore from agent_comms.standing_rules import StandingRule from agent_comms.threads import Thread from agent_comms.todos import ProgressRead @@ -67,16 +68,28 @@ def observe(self, comms: Comms) -> tuple[CommanderObservation, ...]: def pass_body(self, comms: Comms, observed: tuple[CommanderObservation, ...]) -> None: """The committed packet survives connector replacement; local memory owns nothing.""" self.register(comms.registry) - for item in observed: - comms.goals.todos.record_merges(item.merged) - rejected = StandingRule.enforce_all( - comms, item.thread.name, - (*item.rules, *(state for state in item.states if state.attention())), + completed = [] + try: + for item in observed: + comms.goals.todos.record_merges(item.merged) + previous = CommanderUpdate.latest(comms, item.thread.incarnation) + known = frozenset(previous.handled) if previous else frozenset() + rejected = StandingRule.enforce_all( + comms, item.thread.name, + (*(rule for rule in item.rules if RulePing.capture(rule) not in known), + *(state for state in item.states + if state.attention() and UnitPing.capture(state) not in known)), + ) + update = CommanderUpdate.derive( + item.thread.incarnation, + tuple(state for state in item.states if state not in rejected), + tuple(rule for rule in item.rules if rule not in rejected), previous, + ) + if update is not None: + update.send(comms, previous) + completed.append(item.thread.incarnation) + finally: + # A later commander's failure does not erase passes already completed. + ServicePassStore.at(comms.root).heartbeat_passed( + completed, (item.thread.incarnation for item in observed), ) - previous = CommanderUpdate.latest(comms, item.thread.incarnation) - update = CommanderUpdate.derive( - item.thread.incarnation, - tuple(state for state in item.states if state not in rejected), previous, - ) - if update is not None: - update.send(comms) diff --git a/src/agent_comms/commander_pings.py b/src/agent_comms/commander_pings.py index b60743d7d4..838c767a92 100644 --- a/src/agent_comms/commander_pings.py +++ b/src/agent_comms/commander_pings.py @@ -9,16 +9,20 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, ClassVar, Self +from agent_comms.declared_family import DeclaredFamily from agent_comms.errors import RelationViolationError from agent_comms.message_reference import MessageReference from agent_comms.messages import ( ConversationRole, InfoKind, Message, MessageRole, NoticeRole, ) +from agent_comms.standing_rules import StandingRule from agent_comms.task_sources import TaskAttachment +from agent_comms.text_digest import TextDigest from agent_comms.thread_identity import ThreadIncarnation from agent_comms.ui_model.unit_state import UnitState if TYPE_CHECKING: + from agent_comms.bus import BusReader from agent_comms.comms import Comms from agent_comms.registry_document import RegistrySnapshot @@ -30,13 +34,39 @@ class OrderKey: @dataclass(frozen=True) -class UnitPing: +class Ping(DeclaredFamily, affix="Ping"): + text: str = field(compare=False, kw_only=True) + + @property + @abstractmethod + def key(self) -> object: ... + + +@dataclass(frozen=True) +class RulePing(Ping): + observation: TextDigest + + @property + def key(self) -> TextDigest: + return self.observation + + @classmethod + def capture(cls, rule: StandingRule) -> Self: + return cls(rule.observation_key(), text=rule.line()) + + +@dataclass(frozen=True) +class UnitPing(Ping): """One handled observation, not a separately writable current unit state.""" unit: ThreadIncarnation state: type[UnitState] orders: tuple[OrderKey, ...] questions: tuple[MessageReference, ...] - text: str = field(compare=False) + observation: TextDigest + + @property + def key(self) -> ThreadIncarnation: + return self.unit @classmethod def capture(cls, state: UnitState) -> Self: @@ -45,23 +75,34 @@ def capture(cls, state: UnitState) -> Self: tuple(OrderKey(todo.id, todo.order.checked().id) for todo in state.source.todos if todo.id in state.orders), tuple(message.reference for message in state.owed), - state.line(), + state.observation_key(), text=state.line(), ) @dataclass(frozen=True) class CommanderUpdate(TaskAttachment, declared_name=None): commander: ThreadIncarnation - handled: tuple[UnitPing, ...] + handled: tuple[Ping, ...] role: ClassVar[MessageRole] @abstractmethod - def body(self) -> str: ... + def body(self, previous: CommanderUpdate | None) -> str: ... + + def changed_lines(self, previous: CommanderUpdate | None, + presented: tuple[Ping, ...] = ()) -> tuple[str, ...]: + prior = previous.handled if previous else () + known = frozenset(prior) + current = frozenset(ping.key for ping in self.handled) + return ( + *(ping.text for ping in self.handled if ping not in known and ping not in presented), + *(f"NO LONGER CURRENT: {ping.text}" for ping in prior if ping.key not in current), + ) - def require_sender(self, sender: str) -> None: + def require_publication(self, sender: str, registry: RegistrySnapshot, + original_source: BusReader) -> None: from agent_comms.commander_heartbeat import CommanderHeartbeat - if sender != CommanderHeartbeat.sender: + if registry.require(sender).incarnation != registry.require(CommanderHeartbeat.sender).incarnation: raise RelationViolationError("Only the commander heartbeat publishes its observations") def require_target(self, target: str, registry: RegistrySnapshot) -> None: @@ -72,8 +113,9 @@ def require_target(self, target: str, registry: RegistrySnapshot) -> None: def latest(cls, comms: Comms, commander: ThreadIncarnation) -> CommanderUpdate | None: from agent_comms.commander_heartbeat import CommanderHeartbeat + snapshot = comms.registry.snapshot() messages = comms.bus.bus.latest_addressed( - frozenset((CommanderHeartbeat.sender,)), frozenset((commander.name,)), + snapshot.names_for(CommanderHeartbeat.sender), snapshot.names_for(commander.name), ) if not messages: return None @@ -86,30 +128,31 @@ def latest(cls, comms: Comms, commander: ThreadIncarnation) -> CommanderUpdate | @classmethod def derive(cls, commander: ThreadIncarnation, states: tuple[UnitState, ...], + rules: tuple[StandingRule, ...], previous: CommanderUpdate | None) -> CommanderUpdate | None: - known = {ping.unit: ping for ping in previous.handled} if previous else {} - current = tuple(UnitPing.capture(state) for state in states) - decisions = tuple(ping for state, ping in zip(states, current, strict=True) - if state.attention() and known.get(ping.unit) != ping)[:5] - handled = tuple( - ping if not state.attention() or ping in decisions else known[ping.unit] - for state, ping in zip(states, current, strict=True) - if not state.attention() or ping in decisions or ping.unit in known - ) + known = {ping.key: ping for ping in previous.handled} if previous else {} + quiet = tuple(UnitPing.capture(state) for state in states if not state.attention()) + urgent = (*(UnitPing.capture(state) for state in states if state.attention()), + *(RulePing.capture(rule) for rule in rules)) + decisions = tuple(ping for ping in urgent if known.get(ping.key) != ping)[:5] + handled = (*quiet, *( + ping if ping in decisions else known[ping.key] + for ping in urgent if ping in decisions or ping.key in known + )) if decisions: return CommanderDecisions(commander, handled, decisions) if previous is None or handled != previous.handled: return CommanderNotice(commander, handled) return None - def send(self, comms: Comms) -> Message: + def send(self, comms: Comms, previous: CommanderUpdate | None) -> Message: from agent_comms.bus_publication import AgentOrigin from agent_comms.channel_targets import ThreadTarget from agent_comms.commander_heartbeat import CommanderHeartbeat message = Message( sender=CommanderHeartbeat.sender, target=self.commander.name, - body=self.body(), type=InfoKind, role=self.role, task=self, + body=self.body(previous), type=InfoKind, role=self.role, task=self, ) return comms.bus.publisher.publish_ordinary( message, origin=AgentOrigin(), destination=ThreadTarget(self.commander.name), @@ -120,14 +163,16 @@ def send(self, comms: Comms) -> Message: class CommanderNotice(CommanderUpdate): role = NoticeRole() - def body(self) -> str: - return "Commander observations updated; no new decision." + def body(self, previous: CommanderUpdate | None) -> str: + return "\n".join(("Commander observations updated; no new decision.", + *self.changed_lines(previous))) @dataclass(frozen=True) class CommanderDecisions(CommanderUpdate): - decisions: tuple[UnitPing, ...] + decisions: tuple[Ping, ...] role = ConversationRole() - def body(self) -> str: - return "\n".join(ping.text for ping in self.decisions) + def body(self, previous: CommanderUpdate | None) -> str: + return "\n".join((*(ping.text for ping in self.decisions), + *self.changed_lines(previous, self.decisions))) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index a57a5008ec..52b67cf3ee 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -1593,6 +1593,16 @@ def digest(cls, value: object) -> str: """SHA-256 of a declared value's sorted compact encoding.""" return hashlib.sha256(cls.compact(cls.encode(value), sort_keys=True).encode()).hexdigest() + @classmethod + def comparison_digest(cls, value: object) -> str: + """Stable identity of declared comparison fields, including nested records. + + This is an observation key, not another wire format or load path. Fields + excluded from dataclass comparison never become notification identity. + """ + compared = compile_decoder(None).comparison(value) + return hashlib.sha256(cls.compact(compared, sort_keys=True).encode()).hexdigest() + @classmethod def encode_document(cls, value: object, declared: object) -> object: """A document whose declaration names its leaf member stores that member untagged.""" @@ -2011,7 +2021,14 @@ def annotation_class(cls, annotation: object) -> type[ValueDecoder[object]]: try: return cls._by_annotation[annotation] except KeyError: - return cls._by_origin[get_origin(annotation) or annotation] + pass + declarations = annotation.__mro__ if isinstance(annotation, type) else (annotation,) + for declaration in declarations: + try: + return cls._by_origin[get_origin(declaration) or declaration] + except KeyError: + continue + raise KeyError(annotation) @classmethod def with_representation( @@ -2045,6 +2062,14 @@ def decode(self, data: object) -> T: @abstractmethod def encode(self, value: T) -> object: ... + def comparison(self, value: T) -> object: + """The nominal value and its declaration-owned comparison content.""" + declaration = type(value) + return (declaration.__module__, declaration.__qualname__, self.compared(value)) + + def compared(self, value: T) -> object: + return self.encode(value) + def decode_external(self, data: object, record: Mapping[str, object]) -> T: for decode in self.external_decoders(data): return self.decode_resolved(cast(T, decode(data, record))) @@ -2171,6 +2196,12 @@ def encode(self, value: T) -> T: return value +class _PathDecoder(PathText, ValueDecoder[Path]): + """The path representation also owns direct and concrete-subclass values.""" + + origins = (Path,) + + class _NullDecoder(_ExactScalarDecoder[None]): __slots__ = () annotations = (type(None),) @@ -2266,6 +2297,10 @@ def argument_options(self) -> dict[str, object]: def encode(self, value: T) -> object: return None if value is None else self.representation.encode(value) + def compared(self, value: T) -> object: + # Wire spellings (including rename labels) do not own value comparison. + return _structural_decoder(type(value)).compared(value) + def accepts_value(self, value: object) -> bool: return self.structural.accepts_value(value) @@ -2321,6 +2356,13 @@ def encode(cls, value: object) -> object: return [[cls.encode(key), cls.encode(item)] for key, item in value.items()] return compile_decoder(type(value)).encode(value) + def compared(self, value: object) -> object: + if isinstance(value, type): + return value.__module__, value.__qualname__ + if type(value) is dict: + return _PairKeyDictDecoder(dict, self, self).compared(value) + return compile_decoder(type(value)).compared(value) + def _declared_member[Family: DeclaredFamily]( family: type[Family], name: object, @@ -2462,12 +2504,18 @@ def argument_options(self) -> dict[str, object]: def accepts_value(self, value: object) -> bool: return any(alternative.accepts_value(value) for alternative in self.alternatives) - def encode(self, value: T) -> object: + def selected(self, value: T) -> ValueDecoder[T]: for alternative in self.alternatives: if alternative.accepts_value(value): - return alternative.encode(value) + return alternative raise TypeError(f"Value does not match {self.target}") + def encode(self, value: T) -> object: + return self.selected(value).encode(value) + + def compared(self, value: T) -> object: + return self.selected(value).compared(value) + def decode_bound(self, data: object, context: Mapping[str, object]) -> T: return self.decode_candidates( data, lambda alternative: alternative.decode_bound(data, context), @@ -2552,6 +2600,9 @@ def accepts_value(self, value: object) -> bool: def encode(self, value: T) -> list[object]: return self.encode_items(value) + def compared(self, value: T) -> list[object]: + return [plan.comparison(item) for plan, item in self.plan_items(list(value))] + def decode(self, data: object) -> T: return self.decode_items(data, lambda plan, value: plan.decode_bound(value, {})) @@ -2577,6 +2628,9 @@ class _FrozenSetDecoder[E](_ArrayDecoder[frozenset[E]]): def encode(self, value: frozenset[E]) -> list[object]: return self.encode_items(sorted(value)) + def compared(self, value: frozenset[E]) -> list[object]: + return sorted(super().compared(value), key=lambda item: FieldCodec.compact(item, sort_keys=True)) + class _ListDecoder[E](_ArrayDecoder[list[E]]): origins = (list,) @@ -2681,6 +2735,11 @@ def representation_plan( def accepts_value(self, value: object) -> bool: return isinstance(value, dict) + def compared(self, value: dict[K, V]) -> list[object]: + entries = [(self.key.comparison(key), self.value.comparison(item)) + for key, item in value.items()] + return sorted(entries, key=lambda item: FieldCodec.compact(item[0], sort_keys=True)) + def decode(self, data: object) -> dict[K, V]: return self.decode_entries( data, lambda key: self.key.decode_bound(key, {}), @@ -2772,6 +2831,9 @@ def _rewrite_records( def encode(self, value: Member) -> dict[str, object]: return {**value.record_tag(), **_member_decoder(type(value)).encode(value)} + def compared(self, value: Member) -> object: + return _member_decoder(type(value)).compared(value) + def editor_choices(self): return tuple((member.label, {self.target.family_discriminator: member.declared_name}) for member in self.target.members_with(self.target)) @@ -2876,6 +2938,13 @@ def encode(self, value: object) -> dict[str, object]: ) return result + def compared(self, value: T) -> object: + # Do not compile a full wire plan: excluded fields can be live readers + # that intentionally have no wire representation. + dynamic = compile_decoder(None) + return tuple((item.name, dynamic.comparison(getattr(value, item.name))) + for item in fields(value) if item.compare) + def problems(self, data: dict) -> dict[str, str]: plan = self.plan missing = plan.required_wire_keys.difference(data) diff --git a/src/agent_comms/service_passes.py b/src/agent_comms/service_passes.py index 34aa7b0e26..8e10062e98 100644 --- a/src/agent_comms/service_passes.py +++ b/src/agent_comms/service_passes.py @@ -1,13 +1,15 @@ -"""Each Core service watch whose newest pass failed, readable from every process on the bus.""" +"""Original completion times and current failures of Core's service watches.""" from __future__ import annotations import time +from collections.abc import Iterable from dataclasses import dataclass, field, replace from typing import ClassVar -from .locked_store import DocumentChange, LockedStore, RuntimeFile +from .locked_store import DocumentChange, DurableFile, LockedStore from .store_files import DependentStateTier +from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation @dataclass(frozen=True) @@ -21,10 +23,12 @@ class FailingPass: @dataclass(frozen=True) class ServicePasses: failing: dict[str, FailingPass] = field(default_factory=dict) + commander_completed: dict[RecordedThreadIncarnation, float] = field(default_factory=dict) + """Last observation completed for each commander, including quiet observations.""" @dataclass(frozen=True, slots=True) -class ServicePassStore(RuntimeFile, LockedStore[ServicePasses], DependentStateTier): +class ServicePassStore(DurableFile, LockedStore[ServicePasses], DependentStateTier): """The connector's log reaches nobody; the commander's standing rules read this file.""" filename: ClassVar[str] = "service_passes.json" @@ -47,8 +51,22 @@ def passed(self, watch: str) -> None: self.update( lambda passes: DocumentChange.to(replace( passes, - failing={ - name: failing for name, failing in passes.failing.items() if name != watch - }, + failing={name: failure for name, failure in passes.failing.items() if name != watch}, )) ) + + def heartbeat_passed(self, commanders: Iterable[ThreadIncarnation], + observed: Iterable[ThreadIncarnation]) -> None: + current = frozenset(observed) + completed = {owner.require_recorded(): time.time() for owner in commanders} + + def change(passes: ServicePasses) -> DocumentChange[ServicePasses, ServicePasses]: + retained = {owner: at for owner, at in passes.commander_completed.items() + if owner in current} + updated = retained | completed + return DocumentChange.to( + passes if updated == passes.commander_completed + else replace(passes, commander_completed=updated) + ) + + self.update(change) diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index 36d5cb37d0..b3a74953fd 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -9,15 +9,17 @@ import logging import time from abc import abstractmethod -from collections import Counter, defaultdict +from collections import Counter from collections.abc import Iterable, Mapping, Sequence -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields from pathlib import Path from itertools import combinations from typing import TYPE_CHECKING, ClassVar, Self from agent_comms.declared_family import DeclaredFamily +from agent_comms.field_codec import FieldCodec from agent_comms.goals import GoalChangedError +from agent_comms.text_digest import TextDigest from agent_comms.recorded_time import EventTime, RecordedEventTime from agent_comms.repo import Repo from agent_comms.repository_name import RepositoryName @@ -104,37 +106,18 @@ def enforce_all(comms: Comms, commander: str, rejected.add(rule) return frozenset(rejected) - def heartbeat_group(self) -> object: - """Ordinary violations each own one heartbeat line.""" - return self + def comparison_values(self) -> tuple[object, ...]: + """The declaration's equality fields, excluding presentation and live readers.""" + return tuple(getattr(self, item.name) for item in fields(self) if item.compare) - def heartbeat_line(self, current: Sequence[Self]) -> str: - return self.line() + def observation_key(self) -> TextDigest: + """Stable content identity of this occurrence, never a digest of age or wording. - def heartbeat_change(self, previous: Sequence[Self], current: Sequence[Self]) -> tuple[str, ...]: - prefix = "RULE" if current else "NO LONGER CURRENT:" - return (f"{prefix} {self.heartbeat_line(current or previous)}",) - - @staticmethod - def _heartbeat_groups(rules: Iterable[StandingRule]) -> dict[object, list[StandingRule]]: - groups: dict[object, list[StandingRule]] = defaultdict(list) - for rule in rules: - groups[rule.heartbeat_group()].append(rule) - return groups - - @classmethod - def heartbeat_changes(cls, previous: Iterable[StandingRule], - current: Iterable[StandingRule]) -> tuple[str, ...]: - """Render changed groups through their owning rule, without replaying unchanged ones.""" - before, after = cls._heartbeat_groups(previous), cls._heartbeat_groups(current) - lines = [] - for group in dict.fromkeys((*after, *before)): - old, new = before.get(group, []), after.get(group, []) - if frozenset(old) == frozenset(new): - continue - rule = (new or old)[0] - lines.extend(rule.heartbeat_change(old, new)) - return tuple(lines) + Python's hash changes across processes. The codec gives the comparison + values their declared encoding; the class's own name distinguishes siblings + even when they deliberately have no public StandingRule registration. + """ + return TextDigest(FieldCodec.comparison_digest((type(self), self.comparison_values()))) @staticmethod def todos(comms: Comms, commander: str) -> tuple[Todo, ...]: @@ -207,9 +190,6 @@ def scan_observed(cls, comms: Comms, commander: str, def line(self) -> str: return f"{self.thread.name}: {'; '.join(self.changes)}" - def heartbeat_change(self, previous: Sequence[Self], current: Sequence[Self]) -> tuple[str, ...]: - return super().heartbeat_change(previous, current) if current else () - @dataclass(frozen=True) class HeldBranch: @@ -586,19 +566,6 @@ def line(self) -> str: f" due {self.directive.due_time} -> order its next step or mark the request done" ) - def heartbeat_group(self) -> object: - return type(self), self.request - - def heartbeat_line(self, current: Sequence[Self]) -> str: - oldest = min(current, key=lambda rule: rule.directive.seq).directive - newest = max(current, key=lambda rule: rule.directive.seq).directive - return ( - f"DIRECTIVE UNACTED: request {self.request.value}: {len(current)} owner directives; " - f"oldest {oldest.seq} due {oldest.due_time}; " - f"newest {newest.seq} due {newest.due_time} " - "-> order the next step or mark the request done" - ) - @dataclass(frozen=True) class BridgeDown(ReasonedStandingRule, UndatedStandingRule): diff --git a/tests/test_commander_notifications.py b/tests/test_commander_notifications.py new file mode 100644 index 0000000000..29670105f6 --- /dev/null +++ b/tests/test_commander_notifications.py @@ -0,0 +1,120 @@ +"""Committed notifications survive a new watcher and use the real publisher/readers.""" + +import os +from dataclasses import replace + +import pytest + +from agent_comms.child_process import ProcessIdentity +from agent_comms.commander_heartbeat import CommanderHeartbeat, CommanderObservation +from agent_comms.commander_pings import CommanderDecisions, CommanderNotice, CommanderUpdate, RulePing +from agent_comms.errors import UnregisteredThreadError +from agent_comms.launch_root import LaunchRoot +from agent_comms.message_page import MessagePageRequest +from agent_comms.read_basis import DMDisplayScope +from agent_comms.service_passes import ServicePassStore +from agent_comms.standing_rules import ServicePassFailing +from agent_comms.todos import ProgressRead +from agent_comms.ui_model.unit_state import UnitState +from agent_comms.wake_policy import PassiveWake + +pytest_plugins = ("test_holder_standing_rules",) + + +@pytest.fixture +def notification_bus(held_bus, installed_runtime): + lead = held_bus.registry.require("lead") + held_bus.registry.register(replace(lead, process_identity=ProcessIdentity.capture(os.getpid()))) + CommanderHeartbeat.register(held_bus.registry) + with LaunchRoot.from_path(held_bus.root).selected(): + yield held_bus + + +def notifications(comms): + page = MessagePageRequest(DMDisplayScope.capture( + CommanderHeartbeat.sender, "lead", comms.registry.snapshot(), + )).read_opened(comms.bus.bus) + return tuple(message for message in page.messages if isinstance(message.task, CommanderUpdate)) + + +def beat(comms, rules=(), *, states=()): + frame = CommanderObservation(comms.registry.require("lead"), (), rules, states) + # Deliberately construct a fresh watcher on every pass, never reuse a cache. + CommanderHeartbeat().pass_body(comms, (frame,)) + + +def test_quiet_completion_is_per_commander_even_when_a_later_recipient_fails(notification_bus): + comms = notification_bus + lead = comms.registry.require("lead") + missing = replace(comms.registry.require("worker"), name="unregistered-recipient") + frames = tuple(CommanderObservation(thread, (), (), ()) for thread in (lead, missing)) + with pytest.raises(UnregisteredThreadError): + CommanderHeartbeat().pass_body(comms, frames) + completed = ServicePassStore.at(comms.root).read().commander_completed + assert set(completed) == {lead.incarnation} + assert missing.incarnation not in completed + # A quiet pass still completes after its original notice was committed. + beat(comms) + assert len(notifications(comms)) == 1 + CommanderHeartbeat().pass_body(comms, ()) + assert not ServicePassStore.at(comms.root).read().commander_completed + + +def test_recreated_watcher_reads_original_committed_unit_observation(notification_bus): + comms = notification_bus + states = UnitState.current(comms, "lead", ProgressRead()) + beat(comms, states=states) + first, = notifications(comms) + assert all(state.line() in first.body for state in states) + assert CommanderUpdate.latest(comms, comms.registry.require("lead").incarnation) == first.task + beat(comms, states=UnitState.current(comms, "lead", ProgressRead())) + unchanged, = notifications(comms) + assert unchanged.reference == first.reference + + +def test_rule_packets_admit_five_and_leave_remaining_occurrences_unhandled(notification_bus): + comms = notification_bus + rules = tuple(ServicePassFailing(f"watch-{index}", 1.0, "failed source read") + for index in range(7)) + beat(comms, rules) + first, = notifications(comms) + assert isinstance(first.task, CommanderDecisions) + assert len(first.task.decisions) == 5 + beat(comms, rules) + first, second = notifications(comms) + assert isinstance(second.task, CommanderDecisions) + assert len(second.task.decisions) == 2 + assert {RulePing.capture(rule) for rule in rules} == set(second.task.handled) + beat(comms, rules) + assert len(notifications(comms)) == 2 + # Changed diagnostic wording is not another failed occurrence. + beat(comms, tuple(replace(rule, error="new diagnostic detail") for rule in rules)) + assert len(notifications(comms)) == 2 + + +def test_resolution_is_passive_and_same_rule_can_recur(notification_bus): + comms = notification_bus + passes = ServicePassStore.at(comms.root) + passes.failed("dependent-watch", RuntimeError("unreadable source")) + rules = ServicePassFailing.violations(comms, "lead") + beat(comms, rules) + first, = notifications(comms) + passes.passed("dependent-watch") + beat(comms, ServicePassFailing.violations(comms, "lead")) + _, quiet = notifications(comms) + assert isinstance(quiet.task, CommanderNotice) + assert all(isinstance(decision.wake_mode, PassiveWake) for decision in quiet.decisions) + # Use the same source occurrence to prove it was retired, not merely hidden. + beat(comms, rules) + _, _, repeated = notifications(comms) + assert repeated.task.decisions == first.task.decisions + + +def test_latest_observation_follows_commander_and_service_rename_aliases(notification_bus): + comms = notification_bus + beat(comms) + first, = notifications(comms) + comms.threads._rename_thread("lead", "renamed-lead") + comms.threads._rename_thread(CommanderHeartbeat.sender, "renamed-heartbeat") + previous = CommanderUpdate.latest(comms, comms.registry.require("renamed-lead").incarnation) + assert previous == first.task diff --git a/tests/test_field_codec_comparison.py b/tests/test_field_codec_comparison.py new file mode 100644 index 0000000000..a1fcc07595 --- /dev/null +++ b/tests/test_field_codec_comparison.py @@ -0,0 +1,49 @@ +"""Observation identity follows comparison declarations, not presentation fields.""" + +from dataclasses import dataclass, field +from enum import IntEnum +from pathlib import Path + +import pytest + +from agent_comms.field_codec import FieldCodec +from agent_comms.thread_identity import RecordedThreadIncarnation + + +@dataclass(frozen=True) +class Observation: + owner: RecordedThreadIncarnation + revision: int + reader: object = field(compare=False, repr=False) + + +@pytest.mark.parametrize("wrap", ( + lambda value: (value,), + lambda value: [value], + lambda value: frozenset((value,)), + lambda value: {"observation": value}, +)) +def test_comparison_recurses_through_container_and_record_declarations(wrap): + original = Observation(RecordedThreadIncarnation("before", 123.0), 1, object()) + renamed = Observation(RecordedThreadIncarnation("after", 123.0), 1, object()) + changed = Observation(RecordedThreadIncarnation("after", 123.0), 2, object()) + assert original == renamed and original != changed + before, after, revised = (FieldCodec.comparison_digest(wrap(value)) + for value in (original, renamed, changed)) + assert before == after and before != revised + + +class Position(IntEnum): + FIRST = 1 + + +@pytest.mark.parametrize("value", (Position.FIRST, 1, True, "value", Path("nested/path"))) +def test_inherited_origin_does_not_replace_exact_scalar_or_enum_declarations(value): + encoded = FieldCodec.encode(value, type(value)) + assert FieldCodec.decode(type(value), encoded) == value + + +def test_unordered_comparison_is_independent_of_insertion_order(): + left = {"first": frozenset(("b", "a")), "second": 2} + right = {"second": 2, "first": frozenset(("a", "b"))} + assert FieldCodec.comparison_digest(left) == FieldCodec.comparison_digest(right) diff --git a/tests/test_fleet_replay.py b/tests/test_fleet_replay.py index e8c1d8fe36..8ec7d7920f 100644 --- a/tests/test_fleet_replay.py +++ b/tests/test_fleet_replay.py @@ -437,4 +437,6 @@ def test_replay_raises_each_incident_once(fleet): assert refused.value.holding_holder == behind.incarnation second = built.observe() - assert StandingRule.heartbeat_changes(first.rules, second.rules) == () + assert {rule.observation_key() for rule in first.rules} == { + rule.observation_key() for rule in second.rules + } diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index daf5791e60..14bac33392 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -29,6 +29,8 @@ ) from agent_comms.comms import wire from agent_comms.commander_heartbeat import CommanderHeartbeat +from agent_comms.commander_pings import CommanderUpdate +from agent_comms.service_passes import ServicePassStore from agent_comms.coordinator import Coordination from agent_comms.errors import CommittedMessageError, HumanSendRefusedError, RelationViolationError from agent_comms.field_codec import FieldCodec @@ -647,7 +649,8 @@ def test_startup_heartbeat_enforces_and_publishes_both_new_rule_lines(held_bus, commander = held_bus.registry.require("lead") held_bus.registry.register(replace(commander, process_identity=ProcessIdentity.capture(os.getpid()))) heartbeat.pass_body(held_bus, heartbeat.observe(held_bus)) - assert commander.incarnation in heartbeat.reviews + saved = CommanderUpdate.latest(held_bus, commander.incarnation) + assert saved is not None and saved.handled assert isinstance(held_bus.registry.require("worker").goal.require().state, ActiveGoal) page = MessagePageRequest(DMDisplayScope.capture( heartbeat.sender, "lead", held_bus.registry.snapshot(), @@ -658,80 +661,60 @@ def test_startup_heartbeat_enforces_and_publishes_both_new_rule_lines(held_bus, print(message.body) -def test_every_pass_beats_and_a_stale_beat_is_heartbeat_silent(held_bus, monkeypatch): - """10:08 on 2026-10-21: the heartbeat sent only on change, so 31 failed passes read as calm.""" - from agent_comms.commander_heartbeat import CommanderReview - +def test_successful_pass_health_does_not_depend_on_messages(held_bus, monkeypatch): commander = held_bus.registry.require("lead") held_bus.registry.register(replace(commander, process_identity=ProcessIdentity.capture(os.getpid()))) - commander = held_bus.registry.require("lead") - review = CommanderReview(frozenset()) - CommanderHeartbeat.register(held_bus.registry) - assert HeartbeatSilent.violations(held_bus, "lead") == () - states = UnitState.current(held_bus, "lead", ProgressRead()) - review.wake(held_bus, CommanderHeartbeat.sender, commander, (), states) - review.wake(held_bus, CommanderHeartbeat.sender, commander, (), states) - page = MessagePageRequest(DMDisplayScope.capture( - CommanderHeartbeat.sender, "lead", held_bus.registry.snapshot(), - )).read_opened(held_bus.bus.bus) - changed, unchanged = (message for message in page.messages - if message.sender == CommanderHeartbeat.sender) - assert "Standing rules clear." in changed.body - assert unchanged.body == f"COMMANDER HEARTBEAT: unchanged since seq {changed.seq}, 0 violations." + passes = ServicePassStore.at(held_bus.root) + passes.passed(CommanderHeartbeat.__name__) + assert not passes.read().commander_completed + worker = held_bus.registry.require("worker").incarnation + observed = (commander.incarnation, worker) + passes.heartbeat_passed((worker,), observed) + passes.heartbeat_passed((commander.incarnation,), observed) + completed = passes.read().commander_completed[commander.incarnation] assert HeartbeatSilent.violations(held_bus, "lead") == () - later = unchanged.timestamp + HeartbeatSilent.seconds + 1 + before = held_bus.bus.bus.revision() + later = completed + HeartbeatSilent.seconds + 1 monkeypatch.setattr(time, "time", lambda: later) - # Neither a commander reply nor a fresh beat sent to another commander proves - # this commander's heartbeat is alive. - held_bus.messaging.send("lead", CommanderHeartbeat.sender, "I read the last beat") - held_bus.messaging.send(CommanderHeartbeat.sender, "outsider", "Another commander is healthy") silent, = HeartbeatSilent.violations(held_bus, "lead") - assert silent.beat == unchanged.seq - assert silent.line().startswith(f"HEARTBEAT SILENT: no beat since seq {unchanged.seq}") + assert isinstance(silent, MissedHeartbeat) + assert silent.since == completed + HeartbeatSilent.seconds + # Another commander's pass cannot clear this alarm. + passes.heartbeat_passed((worker,), observed) + assert HeartbeatSilent.violations(held_bus, "lead") == (silent,) + # A successful quiet pass clears the alarm without inventing a conversation. + passes.heartbeat_passed((commander.incarnation,), observed) + assert HeartbeatSilent.violations(held_bus, "lead") == () + assert held_bus.bus.bus.revision() == before -def test_first_heartbeat_is_owed_from_the_oldest_open_request_then_survives_renames( - held_bus, monkeypatch): +def test_first_pass_is_owed_from_oldest_request_and_survives_renames(held_bus, monkeypatch): commander = held_bus.registry.require("lead") held_bus.registry.register(replace(commander, process_identity=ProcessIdentity.capture(os.getpid()))) request, = CommanderHeartbeat.requested_by(held_bus.registry.require("lead"), held_bus.goals) - assert HeartbeatSilent.violations(held_bus, "lead") == () due = request.created_at + HeartbeatSilent.seconds monkeypatch.setattr(time, "time", lambda: due + 1) never, = HeartbeatSilent.violations(held_bus, "lead") assert isinstance(never, HeartbeatNotStarted) assert never.request == request.id and never.since == due - assert "HEARTBEAT NOT STARTED" in never.line() - assert HeartbeatSilent.broken( - held_bus, "lead", observed=(), frame=HeartbeatFrame.read(held_bus, "lead", ProgressRead()), - ) == (never,) - - # A newer request and mere service registration cannot reset older unpaid service. held_bus.goals.todos.add_request(commander.incarnation, "A newer independent request") CommanderHeartbeat.register(held_bus.registry) + held_bus.messaging.send(CommanderHeartbeat.sender, "lead", "A DM is not a completed pass") assert HeartbeatSilent.violations(held_bus, "lead") == (never,) - fresh = held_bus.bus.publisher.publish_ordinary(Message( - sender=CommanderHeartbeat.sender, target="lead", body="The heartbeat is running now", - type=InfoKind, timestamp=due + 1, - ), destination=CommandTarget.named("lead"), origin=AgentOrigin()) + passes = ServicePassStore.at(held_bus.root) + observed = (commander.incarnation,) + passes.heartbeat_passed(observed, observed) + completed = passes.read().commander_completed[commander.incarnation] assert HeartbeatSilent.violations(held_bus, "lead") == () - held_bus.threads._rename_thread("lead", "renamed-lead") held_bus.threads._rename_thread(CommanderHeartbeat.sender, "renamed-heartbeat") - monkeypatch.setattr(time, "time", lambda: fresh.timestamp + HeartbeatSilent.seconds + 1) - held_bus.messaging.send("renamed-lead", "renamed-heartbeat", "Read the earlier beat") + monkeypatch.setattr(time, "time", lambda: completed + HeartbeatSilent.seconds + 1) silent, = HeartbeatSilent.violations(held_bus, "renamed-lead") - assert isinstance(silent, MissedHeartbeat) and silent.beat == fresh.seq - assert silent.since == fresh.timestamp + HeartbeatSilent.seconds - newer = held_bus.bus.publisher.publish_ordinary(Message( - sender="renamed-heartbeat", target="renamed-lead", body="Heartbeat after both renames", - type=InfoKind, timestamp=time.time(), - ), destination=CommandTarget.named("renamed-lead"), origin=AgentOrigin()) + assert isinstance(silent, MissedHeartbeat) + assert silent.since == completed + HeartbeatSilent.seconds + renamed = (held_bus.registry.require("renamed-lead").incarnation,) + passes.heartbeat_passed(renamed, renamed) assert HeartbeatSilent.violations(held_bus, "renamed-lead") == () - monkeypatch.setattr(time, "time", lambda: newer.timestamp + HeartbeatSilent.seconds + 1) - held_bus.messaging.send("renamed-lead", "renamed-heartbeat", "Read the new beat too") - latest, = HeartbeatSilent.violations(held_bus, "renamed-lead") - assert isinstance(latest, MissedHeartbeat) and latest.beat == newer.seq def test_one_answer_covers_an_escalation_burst_sent_before_it(held_bus): diff --git a/tests/test_proof_build.py b/tests/test_proof_build.py index a24d0b1b87..89852bd1c9 100644 --- a/tests/test_proof_build.py +++ b/tests/test_proof_build.py @@ -324,8 +324,6 @@ def test_build_containment_does_not_turn_git_failure_into_absence_or_success(pro def test_install_due_compares_fetched_main_to_installed_build( tmp_path, proof_build_checkouts, advance_main, monkeypatch, ): - # The settle window is its own test; here every uninstalled merge has settled. - monkeypatch.setattr(InstallDue, "settle", 0) graph = next(graph for graph in proof_build_checkouts if graph.repository.github == CorePackage.repository) checkout = graph.repository.checkout @@ -344,19 +342,18 @@ def test_install_due_compares_fetched_main_to_installed_build( bus.registry.declare(Thread("lead", frozenset(), str(checkout))) Commander.on_root("lead", (graph.repository,), bus.root) + after_settle = time.time() + InstallDue.settle + 1 + monkeypatch.setattr(time, "time", lambda: after_settle) due = InstallDue.violations(bus, "lead") assert Repo.git(checkout, "rev-parse", "HEAD") == before assert Repo.git(checkout, "rev-parse", "origin/main").strip() == ( graph.newer if advance_main else graph.installed) assert len(due) == int(advance_main) - changes = StandingRule.heartbeat_changes((), due) - assert len(changes) == int(advance_main) for alarm in due: - assert graph.repository.github.to_wire() in alarm.why - assert graph.newer in alarm.why - assert graph.installed in alarm.why - assert alarm.line() in changes[0] + assert alarm.repo == graph.repository.github + assert alarm.main == graph.newer + assert alarm.installed == graph.installed # An install advances the canonical manifest, not a clock or bus schedule. runtime = LaunchRuntime.installed().prefix @@ -364,7 +361,6 @@ def test_install_due_compares_fetched_main_to_installed_build( RuntimeResolution(core_commit=graph.newer, packages=resolution.packages).save(runtime) after_install = InstallDue.violations(bus, "lead") assert after_install == () - assert len(StandingRule.heartbeat_changes(due, after_install)) == int(advance_main) assert Repo.git(checkout, "rev-parse", "HEAD") == before diff --git a/tests/test_storage_violations.py b/tests/test_storage_violations.py index e5161c5309..67760ab38b 100644 --- a/tests/test_storage_violations.py +++ b/tests/test_storage_violations.py @@ -159,4 +159,4 @@ def observed_read(_code: CodeType, _offset: int, value: UnitStorage) -> None: assert str(home) in text assert str(comms.registry.storage_rule.order_checkout(worker.name, "P")) in text assert str(worker.storage_roots.scratch) in text - print("\n".join(StandingRule.heartbeat_changes((), found))) + print("\n".join(rule.line() for rule in found)) diff --git a/tests/test_unowned_checkout_work.py b/tests/test_unowned_checkout_work.py index 2ff66d9757..a6394e5145 100644 --- a/tests/test_unowned_checkout_work.py +++ b/tests/test_unowned_checkout_work.py @@ -25,8 +25,8 @@ from agent_comms.commander_heartbeat import ( CommanderHeartbeat, CommanderObservation, - CommanderReview, ) +from agent_comms.commander_pings import CommanderUpdate, RulePing from agent_comms.comms import Comms, wire from agent_comms.field_codec import FieldCodec from agent_comms.goals import OrderCheck @@ -201,11 +201,9 @@ def test_dirty_then_unpushed_then_pushed_linked_checkout(checkout_bus: Comms, tm practice.include(tuple(RootFile.existing_in(practice.root))) (root, _measurement) = practice.path, practice.measurement c = wire(root) - review = CommanderReview(frozenset()) - def beat(): frame = CommanderObservation.read(c, c.registry.require('lead')) - review.wake(c, CommanderHeartbeat.sender, frame.thread, frame.rules, frame.states) + CommanderHeartbeat().pass_body(c, (frame,)) with LaunchRoot.from_path(root).selected(): (checkout / 'work.py').write_text('# not yet published\n') @@ -228,14 +226,16 @@ def beat(): assert len(available) == 1 assert isinstance(available[0], ReassignedWheelSpin) assert available[0].unit == 'idle' - assert set(available).isdisjoint(review.rules) + previous = CommanderUpdate.latest(c, c.registry.require('lead').incarnation) + assert previous is not None + assert set(map(RulePing.capture, available)).isdisjoint(previous.handled) UnownedOrderWatch().watch_pass(lambda: nullcontext(c)) assigned = c.goals.todos.get('P2') assert assigned.holder == 'idle' assert c.registry.require('idle').goal.require().text beat() history = core('--root', str(root), 'history', - '--target', '@commander-heartbeat', '--as', 'lead', '--limit', '10') + '@commander-heartbeat', '--as', 'lead', '--limit', '10') assert history.returncode == 0, history.stderr assert f'WORK HELD: P2 worker retains work in {checkout}' in history.stdout assert f'NO LONGER CURRENT: WORK HELD: P2 worker retains work in {checkout}' in history.stdout From f12d495e5089e567fe9a120c339870d170a9a195 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 17:57:23 -0400 Subject: [PATCH 11/15] Persist optional-order head verdicts and refuse overdue reviews --- src/agent_comms/commander/judge.py | 211 +++++++++--- src/agent_comms/commander_heartbeat.py | 2 + src/agent_comms/connector.py | 56 +--- src/agent_comms/external_transcript.py | 11 +- src/agent_comms/goal_actions.py | 29 +- src/agent_comms/head_review.py | 35 +- src/agent_comms/registry_document.py | 10 + src/agent_comms/repo.py | 5 + src/agent_comms/standing_rules.py | 52 +++ src/agent_comms/todos.py | 432 +++++++++++++++++++------ src/agent_comms/tool_caller.py | 8 + src/agent_comms/tools.py | 43 ++- src/agent_comms/turn_digests.py | 16 +- src/agent_comms/work_dispatch.py | 1 + tests/conftest.py | 5 +- tests/github_cli_stub.py | 49 ++- tests/github_fixture.py | 48 +++ tests/test_delivery_merge.py | 4 +- tests/test_fleet_replay.py | 38 +-- tests/test_head_review.py | 128 ++++++-- tests/test_held_problem_orders.py | 11 +- tests/test_todos.py | 18 +- 22 files changed, 879 insertions(+), 333 deletions(-) create mode 100644 tests/github_fixture.py diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index d97aeee799..4f3423f16b 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -16,6 +16,8 @@ the commander to that unit. Nothing here judges code; it only refuses to let an edit pass unread. """ +from __future__ import annotations + import hashlib import json import os @@ -24,12 +26,16 @@ import time from typing import Annotated from abc import abstractmethod -from dataclasses import asdict, dataclass, field +from dataclasses import asdict, dataclass +from functools import cached_property from agent_comms.comms import Comms from agent_comms.change_report import FileLine from agent_comms.field_codec import Argument, FieldCodec +from agent_comms.declared_family import DeclaredFamily +from agent_comms.head_review import PullRequestHead +from agent_comms.repo import Repo from agent_comms.todos import ( AcceptedVerdict, BluntText, @@ -37,6 +43,12 @@ DroppedVerdict, PeerAcceptedVerdict, ReviewedHeadVerdict, + RejectedHeadVerdict, + HeadVerdict, + HeadReviewOrder, + LinkedHeadReview, + NoHeadOrder, + OrderHeadReview, SendBackVerdict, StrikeReason, SupersededVerdict, @@ -154,19 +166,115 @@ class HeadVerb(JudgeVerb): serves: Annotated[ str, Argument(options={"help": "exact words of the order the head carries out"}) ] + pr: int = 0 def run(self, ctx: order.Commander) -> None: held = [todo for todo in ctx.held_orders() if todo.id == self.problem] if not held: sys.exit(f'Refused: {self.problem} is not an order this commander holds') todo, = held - verdict = ReviewedHeadVerdict(todo.revision, ctx.actor_identity, todo.held(), - quote=self.serves, head=self.head) - ctx.call(CommsHeadVerdictTool(problem=todo.id, operation=verdict)) + links = tuple(link for link in todo.order.checked().pull_requests + if not self.pr or link.number == self.pr) + if len(links) != 1: + sys.exit('Refused: select --pr from this order\'s linked pull requests') + link, = links + ctx.call(CommsHeadVerdictTool( + request=PullRequestHead(link.repo, link.number, self.head), + operation=ReviewedHeadVerdict(), + order=OrderHeadReview(todo.revision, LinkedHeadReview.of(todo, self.serves)), + )) left = len(Edit.unjudged(ctx.comms, ctx.commander)) print(f'head {self.head} accepted on {todo.id}; {left} still unjudged') +class ReviewSubject(DeclaredFamily): + """Decode the CLI's mutually exclusive selectors once, then keep the selected subject.""" + + @classmethod + def from_options(cls, options: HeadVerdictVerb, ctx: order.Commander) -> ReviewSubject: + matches = tuple(member for member in cls.members_with(cls) if member.selected(options)) + if len(matches) != 1: + sys.exit('Refused: select exactly one of --ids or --pr') + member, = matches + return member.decode_options(options, ctx) + + @classmethod + @abstractmethod + def selected(cls, options: HeadVerdictVerb) -> bool: ... + + @classmethod + @abstractmethod + def decode_options(cls, options: HeadVerdictVerb, ctx: order.Commander) -> ReviewSubject: ... + + @abstractmethod + def run(self, options: VerdictVerb, ctx: order.Commander) -> None: ... + + +@dataclass(frozen=True) +class EditReview(ReviewSubject): + ids: tuple[str, ...] + + @classmethod + def selected(cls, options: HeadVerdictVerb) -> bool: + return bool(options.ids) + + @classmethod + def decode_options(cls, options: HeadVerdictVerb, ctx: order.Commander) -> EditReview: + if options.head or options.repo: + sys.exit('Refused: --head and --repo belong to --pr, not --ids') + return cls(options.ids) + + def run(self, options: VerdictVerb, ctx: order.Commander) -> None: + if not self.ids: + sys.exit('Refused: select at least one --ids ID:CODE') + orders = {todo.id: todo for todo in ctx.held_orders() + if not options.problem or todo.id == options.problem} + edits = {edit.id: edit for edit in Edit.unjudged(ctx.comms, ctx.commander) + if edit.problem in orders} + ids = [token.split(':')[0] for token in self.ids] + if missing := [i for i in ids if i not in edits]: + sys.exit(f'Refused: unknown edit ids {missing}') + if unread := [token for token in self.ids + if token != f'{token.split(":")[0]}:{edits[token.split(":")[0]].code()}']: + sys.exit(f'Refused: wrong or missing read codes {unread}; read each edit to its "=== end" line') + for i in ids: + if not any(line in edits[i].text() for line in options.lines): + sys.exit(f'Refused: none of your --line quotes is in edit {i}; judge it by its own text') + selected = {i: orders[edits[i].problem] for i in ids} + options.check_edits([edits[i] for i in ids]) + for i, todo in selected.items(): + options.operation(ctx, todo, edits[i]).prepare(ctx.comms.goals, todo).apply(todo) + current = {todo.id: todo for todo in selected.values()} + for i, todo in selected.items(): + result = ctx.call(CommsVerdictTool(problem=todo.id, + operation=options.operation(ctx, current[todo.id], edits[i]))) + current[todo.id] = result.todo + left = len(Edit.unjudged(ctx.comms, ctx.commander)) + print(f'{options.declared_name}: {len(ids)} edits; {left} still unjudged') + + +@dataclass(frozen=True) +class PullRequestReview(ReviewSubject): + request: PullRequestHead + + @classmethod + def selected(cls, options: HeadVerdictVerb) -> bool: + return bool(options.pr) + + @classmethod + def decode_options(cls, options: HeadVerdictVerb, ctx: order.Commander) -> PullRequestReview: + return cls(PullRequestHead(Repo.selected(ctx.repos, options.repo).github, + options.pr, options.head)) + + def run(self, options: HeadVerdictVerb, ctx: order.Commander) -> None: + options.check_edits([]) + linked = HeadReviewOrder.for_request(ctx.held_orders(), self.request, options.serves, + options.head_evidence(), options.problem) + ctx.call(CommsHeadVerdictTool(request=self.request, + operation=options.head_operation(), order=linked)) + print(f'PR #{self.request.pr} head {self.request.head}: {options.declared_name}') + + @dataclass(frozen=True, kw_only=True) class VerdictVerb(JudgeVerb): """Every verdict, mine or a fork's, is made after reviewing the OOPSLA paper's @@ -175,7 +283,7 @@ class VerdictVerb(JudgeVerb): ids: Annotated[ tuple[str, ...], Argument(flags=("ids",), options={"nargs": "+", "metavar": "ID:CODE"}), - ] + ] = () why: str problem: Annotated[ str, @@ -191,7 +299,7 @@ class VerdictVerb(JudgeVerb): "help": "an exact diff line; repeat for each", }, ), - ] + ] = () @abstractmethod def operation(self, ctx: order.Commander, todo: Todo, edit: Edit) -> ToolCallVerdict: ... @@ -199,42 +307,38 @@ def operation(self, ctx: order.Commander, todo: Todo, edit: Edit) -> ToolCallVer @abstractmethod def check_edits(self, edits: list[Edit]) -> None: ... + def subject(self, ctx: order.Commander) -> ReviewSubject: + return EditReview(self.ids) + def run(self, ctx: order.Commander) -> None: if len(self.why.split()) < 6: sys.exit('Refused: state which intent the edit serves or violates, and why (6+ words).') - orders = {todo.id: todo for todo in ctx.held_orders() - if not self.problem or todo.id == self.problem} - edits = {edit.id: edit for edit in Edit.unjudged(ctx.comms, ctx.commander) - if edit.problem in orders} - ids = [token.split(':')[0] for token in self.ids] - if missing := [i for i in ids if i not in edits]: - sys.exit(f'Refused: unknown edit ids {missing}') - if unread := [token for token in self.ids - if token != f'{token.split(":")[0]}:{edits[token.split(":")[0]].code()}']: - sys.exit(f'Refused: wrong or missing read codes {unread}; read each edit to its "=== end" line') - for i in ids: - if not any(line in edits[i].text() for line in self.lines): - sys.exit(f'Refused: none of your --line quotes is in edit {i}; judge it by its own text') - selected = {i: orders[edits[i].problem] for i in ids} - self.check_edits([edits[i] for i in ids]) - # Validate the whole batch through the operation owner before its first write. - for i, todo in selected.items(): - self.operation(ctx, todo, edits[i]).prepare(ctx.comms.goals, todo).apply(todo) - current = {todo.id: todo for todo in selected.values()} - for i, todo in selected.items(): - result = ctx.call(CommsVerdictTool(problem=todo.id, - operation=self.operation(ctx, current[todo.id], edits[i]))) - current[todo.id] = result.todo - left = len(Edit.unjudged(ctx.comms, ctx.commander)) - print(f'{self.declared_name}: {len(ids)} edits; {left} still unjudged') + self.subject(ctx).run(self, ctx) @dataclass(frozen=True, kw_only=True) -class OkVerb(VerdictVerb): - help = "the edit serves the order" - serves: Annotated[ - str, Argument(options={"help": "exact words of the order"}) - ] +class HeadVerdictVerb(VerdictVerb, declared_name=None): + pr: Annotated[int, Argument(options={"help": "review this PR instead of individual edits"})] = 0 + head: Annotated[str, Argument(options={"help": "the exact reviewed SHA; required with --pr"})] = "" + repo: Annotated[str, Argument(options={"help": "registered repository name; optional for one repo"})] = "" + serves: Annotated[str, Argument(options={"help": "exact words of the linked order"})] = "" + + def subject(self, ctx: order.Commander) -> ReviewSubject: + return ReviewSubject.from_options(self, ctx) + + def head_evidence(self) -> tuple[FileLine, ...]: + return () + + @abstractmethod + def head_operation(self) -> HeadVerdict: ... + + +@dataclass(frozen=True, kw_only=True) +class OkVerb(HeadVerdictVerb): + help = "the edit or exact PR head is accepted" + + def head_operation(self) -> HeadVerdict: + return ReviewedHeadVerdict() no_owner: Annotated[ str, Argument( @@ -296,13 +400,8 @@ def check_edits(self, edits: list[Edit]) -> None: pass @dataclass(frozen=True, kw_only=True) -class BackVerb(VerdictVerb): - help = "send the edit back; Core publishes the recorded reason once" - # A bare annotation would inherit VerdictVerb's default; field() keeps it required. - problem: Annotated[ - str, - Argument(options={"metavar": "PROBLEM", "help": "required problem id from order board"}), - ] = field() +class BackVerb(HeadVerdictVerb): + help = "reject the edit or exact PR head; Core records its reason once" roast: Annotated[ str, Argument(options={"help": "blunt, why it is stupid, with because"}) ] @@ -326,26 +425,36 @@ class BackVerb(VerdictVerb): str, Argument(options={"help": "PATH:LINE of the declaration that owns it"}) ] + @cached_property def slop(self) -> Slop: return FieldCodec.decode(Slop, dict(pattern=self.pattern, fact=self.fact, at=self.at, derive=self.derive, by=self.by)) + @cached_property + def rejection_reason(self) -> StrikeReason: + return StrikeReason("\n".join(( + self.why, self.roast, + *(f" {line.strip()}" for line in self.lines), + self.slop.render(), f"What replaces it: {self.fix}", + ))) + + def head_evidence(self) -> tuple[FileLine, ...]: + return (FileLine.from_wire(self.at),) + + def head_operation(self) -> HeadVerdict: + return RejectedHeadVerdict(self.rejection_reason) + def operation(self, ctx: order.Commander, todo: Todo, edit: Edit) -> SendBackVerdict: return SendBackVerdict(todo.revision, ctx.actor_identity, - todo.held(), edit.id, - reason=StrikeReason("\n".join(( - self.why, self.roast, - *(f" {line.strip()}" for line in self.lines), - self.slop().render(), f"What replaces it: {self.fix}", - ))), - evidence=(FileLine.from_wire(self.at),)) + todo.held(), edit.id, reason=self.rejection_reason, + evidence=self.head_evidence()) def check_edits(self, edits: list[Edit]) -> None: if len(self.roast.split()) < 12 or not re.search(r'\bbecause\b', self.roast): sys.exit('Refused: --roast needs 12+ words with because, naming the duplicated fact and its owner') if len(self.fix.split()) < 8: sys.exit('Refused: --fix needs 8+ words naming the owner and replacing shape') - self.slop() + _ = self.slop order.refuse(self.roast, BluntText) order.refuse(' '.join((self.why, self.roast, self.fix)), *DirectiveText.checks()) diff --git a/src/agent_comms/commander_heartbeat.py b/src/agent_comms/commander_heartbeat.py index 13f58769ee..bc42e76af0 100644 --- a/src/agent_comms/commander_heartbeat.py +++ b/src/agent_comms/commander_heartbeat.py @@ -34,6 +34,8 @@ def read(cls, comms: Comms, thread: Thread, reading: type[ProgressRead] = ProgressRead) -> CommanderObservation: """One frame's progress facts come from ``reading``; Core reads the live forge and Git.""" pulls = reading(StandingRule.todos(comms, thread.name)) + for repo in comms.registry.snapshot().commander_project.repository_names(): + pulls.open(repo) states = UnitState.current(comms, thread.name, pulls) frame = HeartbeatFrame.read(comms, thread.name, pulls) rules = StandingRule.broken(comms, thread.name, observed=states, frame=frame) diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index a6406b0ce3..809a01c33d 100644 --- a/src/agent_comms/connector.py +++ b/src/agent_comms/connector.py @@ -67,15 +67,14 @@ from .commander_heartbeat import CommanderHeartbeat from .coordinator import Coordination from .service_passes import ServicePassStore -from .head_review import HeadReview, PullRequestHead -from .repository_name import RepositoryName -from .todos import DirectiveText, ReviewedHeadVerdict +from .head_review import PullRequestHead +from .todos import DirectiveText, HeadReviewOrder from .tool_result import ToolResult from .task_sources import OwnerRequestAttachment from .tool_caller import ThreadCaller, ToolCaller from .tools import ( - CommsSendTool, CommsVerdictTool, MessagePublished, ReadTool, SendTool, - TodoChanged, ToolRequest, tool_field, + CommsHeadVerdictTool, CommsSendTool, HeadVerdictTool, MessagePublished, ReadTool, SendTool, + ToolRequest, tool_field, ) LOOPBACK = "127.0.0.1" @@ -95,6 +94,10 @@ def require_directive(self, comms: Comms, body: str) -> None: def origin(self) -> DirectorOrigin: return DirectorOrigin() + def head_review_actor(self, comms: Comms, request: PullRequestHead, + order: HeadReviewOrder): + return order.director_identity(comms.goals, self.identity(comms)) + class ConnectorTool(ToolRequest[DirectorCaller], affix="ConnectorTool"): """Connector-only declarations are absent from the native/CLI tool registry.""" @@ -113,46 +116,9 @@ def attachment(self, comms: Comms, target: NamedTarget) -> OwnerRequestAttachmen @dataclass(frozen=True, kw_only=True) -class ConnectorVerdictTool(ConnectorTool, declared_name=CommsVerdictTool.declared_name): - label = "Review PR head" - description = "Record the connector reviewer's acceptance of the exact PR commit it reviewed." - id: Annotated[str, *tool_field("Problem ID")] - operation: Annotated[ReviewedHeadVerdict, *tool_field("Accepted PR-head verdict")] - - def apply(self, comms: Comms, caller: DirectorCaller) -> TodoChanged: - todo = self.operation.reviewed( - comms.goals, self.id, comms.registry.require(caller.name).incarnation - ) - return TodoChanged(todo, todo.strike_count) - - -@dataclass(frozen=True) -class HeadReviewed(ToolResult): - repo: RepositoryName - pr: int - head: str - review: HeadReview - - -@dataclass(frozen=True, kw_only=True) -class ConnectorHeadReviewTool(ConnectorTool, declared_name="comms_review_head"): - """A pull request with no order still gets the connector reviewer's verdict on its head; - an order's pull request takes comms_verdict, which also records the verdict on the order.""" - - label = "Review PR head without an order" - description = ( - "Post the connector reviewer's verdict (accepted, or sent_back with its reason) as " - "agent-comms/review on the exact head commit of a pull request no order holds." - ) - repo: Annotated[RepositoryName, *tool_field("Repository, owner/name")] - pr: Annotated[int, *tool_field("Pull request number")] - head: Annotated[str, GitCommitText, *tool_field("The exact head commit reviewed")] - review: Annotated[HeadReview, *tool_field("accepted, or sent_back with the reason")] - - def apply(self, comms: Comms, caller: DirectorCaller) -> HeadReviewed: - head = PullRequestHead(self.repo, self.pr, self.head).current() - self.review.post(self.repo, head, caller.name, f"#{self.pr}") - return HeadReviewed(self.repo, self.pr, self.head, self.review) +class ConnectorHeadVerdictTool(HeadVerdictTool, ConnectorTool, + declared_name=CommsHeadVerdictTool.declared_name): + """Director and native callers share the same persisted verdict and publication path.""" CALLER = DirectorCaller("claude-ai") # Owner-typed calls act as this registered bus thread. diff --git a/src/agent_comms/external_transcript.py b/src/agent_comms/external_transcript.py index c104f2537a..d65324df5a 100644 --- a/src/agent_comms/external_transcript.py +++ b/src/agent_comms/external_transcript.py @@ -242,7 +242,7 @@ def append(cls, connection, thread, seq, events) -> None: cls(thread, seq, ordinal, event).insert(connection) @classmethod - def unjudged_query(cls, thread, todo): + def unjudged_query(cls, thread, todo, since): from .native_tools import MutatingPathTool from .tool_declaration import ToolDeclaration from .transcript_events import ToolStartTranscript @@ -250,7 +250,6 @@ def unjudged_query(cls, thread, todo): tools = tuple(tool.declared_name for tool in ToolDeclaration.members_with(MutatingPathTool)) judged = todo.judged_calls placeholders = lambda values: ",".join("?" for _ in values) - since = todo.unjudged_since where = (f'e.thread=? AND e.kind=? AND e.tool IN ({placeholders(tools)}) ' 'AND (e.timestamp>=? OR e.timestamp IS NULL)') parameters = (thread, ToolStartTranscript.declared_name, *tools, since) @@ -414,8 +413,8 @@ def tool_call_count(self, since: float) -> int: FieldCodec.encode(ToolStartTranscript), since), ).fetchone()[0] - def unjudged(self, todo): - where, parameters = ExternalEventRow.unjudged_query(self.transcript.lookup, todo) + def unjudged(self, todo, since): + where, parameters = ExternalEventRow.unjudged_query(self.transcript.lookup, todo, since) with closing(self.connection.execute( 'SELECT timestamp,call_id,json_extract(event,\'$.tool\') FROM ' '(SELECT e.event, e.timestamp, e.call_id, e.seq, e.ordinal, ' @@ -429,8 +428,8 @@ def unjudged(self, todo): yield from tool.edit_calls(identity, GeneratedTimestamp.decode(timestamp), todo.held().owner, todo.id) - def unjudged_count(self, todo) -> int: - where, parameters = ExternalEventRow.unjudged_query(self.transcript.lookup, todo) + def unjudged_count(self, todo, since) -> int: + where, parameters = ExternalEventRow.unjudged_query(self.transcript.lookup, todo, since) return self.connection.execute( f'SELECT count(DISTINCT e.call_id) FROM "{ExternalEventRow.declared_name}" e WHERE {where}', parameters).fetchone()[0] diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index 0eebe859cf..4a54c5966f 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -23,7 +23,9 @@ UnorderedGoalOrder, ) from agent_comms.input_disposition import InputDispositions -from agent_comms.todos import PendingProblemOrder, SendBackVerdict, Todo, TodoOperation +from agent_comms.todos import ( + PendingProblemOrder, RecordOrderHeadVerdict, SendBackVerdict, Todo, TodoCommand, TodoOperation, +) from .change_report import FileLine, NoSourceReference from .child_process import ProcessIdentity @@ -397,10 +399,10 @@ def require_claims(cls, claims: tuple[ProgressClaim, ...]) -> None: class HeldOrderGoalAction(GoalAction[Goal], RuntimeInvocable): """A Todo operation on a held order and the revision of the holder's Goal, in one write.""" problem: str - operation: TodoOperation + operation: TodoCommand - @abstractmethod - def revised(self, goal: Goal, ctx: GoalActionContext) -> Goal: ... + def revised(self, goal: Goal, ctx: GoalActionContext) -> Goal: + return goal def prepare(self, goals, name, actor): ctx = GoalActionContext(goals, goals.registry.require(name), actor) @@ -420,8 +422,10 @@ def change(self, goal: Goal, ctx: GoalActionContext) -> Goal: class SentBackOrderGoalAction(HeldOrderGoalAction): operation: SendBackVerdict - def revised(self, goal: Goal, ctx: GoalActionContext) -> Goal: - return goal + +@dataclass(frozen=True, kw_only=True) +class HeadReviewedOrderGoalAction(HeldOrderGoalAction): + operation: RecordOrderHeadVerdict @dataclass(frozen=True, kw_only=True) @@ -543,8 +547,19 @@ def continue_goal(self, goal: Goal, ctx: GoalActionContext) -> Goal: ProblemGoalOrder.text, goal.id, revision, ctx.thread, ctx.goals.registry.snapshot())) + def prepare(self, goals: Goals, name: str, actor: type) -> ProblemOrderGoalAction: + self.require_reviewed(goals) + return super().prepare(goals, name, actor) + + def require_reviewed(self, goals: Goals) -> None: + """Read remote review facts before the registry lock or child allocation.""" + from .standing_rules import OrderRefusingStandingRule + + if refused := OrderRefusingStandingRule.refusals(goals, self.commander): + raise RelationViolationError("Review overdue heads before sending new orders:\n" + "\n".join(refused)) + def require_ready(self, ctx: GoalActionContext) -> None: - """Refuse an invalid request before goal reservation or allocating a child.""" + """Recheck local request facts before goal reservation or allocating a child.""" self.require_sendable() commander = ctx.goals.registry.require(self.commander) ctx.goals.todos.owner_request(self.request).require_open(commander.incarnation) diff --git a/src/agent_comms/head_review.py b/src/agent_comms/head_review.py index 185b3eedda..7a3b316091 100644 --- a/src/agent_comms/head_review.py +++ b/src/agent_comms/head_review.py @@ -7,10 +7,11 @@ from abc import abstractmethod from dataclasses import dataclass -from typing import ClassVar +from typing import Annotated, ClassVar from .declared_family import DeclaredFamily from .errors import ToolRefusedError +from .field_codec import FieldCodec, GitCommitText from .repository_name import RepositoryName @@ -20,14 +21,19 @@ class PullRequestHead: repo: RepositoryName pr: int - head: str + head: Annotated[str, GitCommitText] + + def __post_init__(self) -> None: + if FieldCodec.decode(int, self.pr) <= 0: + raise ValueError("A pull request number must be positive.") + GitCommitText.decode(self.head) def current(self) -> str: """The PR's full head commit, refused unless the reviewed head is still it.""" read = self.repo.rest(f"pulls/{self.pr}", "--jq", ".head.sha") if read.returncode: raise ToolRefusedError(read.stderr.strip() or f"#{self.pr} could not be read.") - current = read.stdout.strip() + current = GitCommitText.decode(read.stdout.strip()) if not current.startswith(self.head): raise ToolRefusedError( f"#{self.pr} moved to {current[:9]} since {self.head} was reviewed; review again." @@ -59,26 +65,3 @@ def post(self, repo: RepositoryName, head: str, reviewer: str, subject: str) -> ) if posted.returncode: raise ToolRefusedError(posted.stderr.strip() or f"Review status on {head} failed.") - - -@dataclass(frozen=True) -class AcceptedHeadReview(HeadReview): - state = "success" - - def description(self, reviewer: str, subject: str) -> str: - return f"Accepted by {reviewer} on {subject}" - - -@dataclass(frozen=True) -class SentBackHeadReview(HeadReview): - """The head goes back; the reason names the defect, the PR's review comment the detail.""" - - reason: str - state = "failure" - - def __post_init__(self) -> None: - if len(self.reason.split()) < 4: - raise ValueError("Say what is wrong with the head in 4+ words.") - - def description(self, reviewer: str, subject: str) -> str: - return f"Sent back by {reviewer} on {subject}: {self.reason}" diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index f11390e0b7..295fdf5b34 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -27,6 +27,7 @@ UpdatedRegistration, ) from .repo import Repo, RepositorySelection +from .repository_name import RepositoryName from .registry_provenance import ( DeletedThread, RegisteredRecipient, @@ -348,6 +349,9 @@ class CommanderProject(DeclaredFamily, affix="CommanderProject"): @abstractmethod def commanders(self, snapshot: RegistrySnapshot) -> tuple[Thread, ...]: ... + @abstractmethod + def repository_names(self) -> tuple[RepositoryName, ...]: ... + @abstractmethod def require(self) -> RegisteredCommanderProject: ... @@ -363,6 +367,9 @@ class NoCommanderProject(CommanderProject): def commanders(self, snapshot: RegistrySnapshot) -> tuple[Thread, ...]: return () + def repository_names(self) -> tuple[RepositoryName, ...]: + return () + def require(self) -> RegisteredCommanderProject: raise RelationViolationError("This bus has no registered commander project.") @@ -386,6 +393,9 @@ def commanders(self, snapshot: RegistrySnapshot) -> tuple[Thread, ...]: raise RelationViolationError("The registered commander thread no longer exists.") return selected + def repository_names(self) -> tuple[RepositoryName, ...]: + return tuple(repo.github for repo in self.repos) + def require(self) -> RegisteredCommanderProject: return self diff --git a/src/agent_comms/repo.py b/src/agent_comms/repo.py index 2c5211c6c0..30a9d94aee 100644 --- a/src/agent_comms/repo.py +++ b/src/agent_comms/repo.py @@ -83,6 +83,11 @@ def from_wire(cls, data: object) -> Self: @classmethod def selected(cls, repositories: tuple[Repo, ...], name: str) -> Repo: + if not name: + if len(repositories) != 1: + raise ValueError("Select --repo when more than one repository is registered.") + repository, = repositories + return repository for repository in repositories: if repository.named(name): return repository diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index b3a74953fd..5c7d6b951e 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -23,6 +23,7 @@ from agent_comms.recorded_time import EventTime, RecordedEventTime from agent_comms.repo import Repo from agent_comms.repository_name import RepositoryName +from agent_comms.head_review import PullRequestHead from agent_comms.thread_identity import ThreadIncarnation from agent_comms.work_branches import BranchDifference from agent_comms.service_passes import ServicePassStore @@ -36,6 +37,7 @@ from agent_comms.registry_document import RegistrySnapshot from agent_comms.storage_rules import MemoryUse, StorageRule from agent_comms.todos import OrderClaim, OwnerRequest, ProgressRead, PullRequest, Todo + from agent_comms.goal_management import Goals from agent_comms.ui_model.unit_state import UnitObservation, UnitState @@ -151,6 +153,28 @@ def broken(cls, comms: Comms, commander: str, after: float = 0.0, *, if isinstance(rule, cls) and rule.attention() and rule.broke_after(after)) +class OrderRefusingStandingRule(StandingRule, declared_name=None): + """An overdue review prevents new orders, through the same backend admission path.""" + + @classmethod + @abstractmethod + def scan_reviews(cls, goals: Goals, commander: str, pulls: ProgressRead) -> tuple[Self, ...]: ... + + @classmethod + def scan_observed(cls, comms: Comms, commander: str, + observed: Sequence[UnitState], frame: HeartbeatFrame) -> tuple[Self, ...]: + return cls.scan_reviews(comms.goals, commander, frame.pulls) + + @classmethod + def refusals(cls, goals: Goals, commander: str) -> tuple[str, ...]: + from agent_comms.todos import ProgressRead + + pulls = ProgressRead(goals.todos.held_for_commander(goals.registry.require(commander).incarnation)) + owners = dict.fromkeys(member.scan_owner() for member in cls.members_with(cls)) + return tuple(rule.line() for owner in owners + for rule in owner.scan_reviews(goals, commander, pulls)) + + class IndependentStandingRule(StandingRule, declared_name=None): """A rule whose source read does not consume the unit observation frame.""" @@ -473,6 +497,34 @@ def line(self) -> str: "-> commander reviews this head") +@dataclass(frozen=True) +class UnreviewedHead(OrderRefusingStandingRule, DatedStandingRule): + """One ready head, including an orderless PR, waiting too long for a recorded verdict.""" + request: PullRequestHead + due_at: float + seconds: ClassVar[int] = 10 * 60 + + @classmethod + def scan_reviews(cls, goals: Goals, commander: str, pulls: ProgressRead) -> tuple[Self, ...]: + now = time.time() + return tuple( + cls(request, due) + for repo in goals.registry.snapshot().commander_project.repository_names() + for row in pulls.open(repo) + for due in row.review_due(now, cls.seconds) + for request in (PullRequestHead(repo, row.number, row.head),) + if not goals.todos.head_reviews(request) + ) + + def began(self) -> float: + return self.due_at + + def line(self) -> str: + return (f"UNREVIEWED HEAD: {self.request.repo.to_wire()}#{self.request.pr} " + f"head {self.request.head} -> judge ok/back --pr {self.request.pr} " + f"--head {self.request.head}") + + @dataclass(frozen=True) class HuntDue(ReasonedStandingRule, DatedStandingRule): since: float diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index d27b09bfc5..ab21bdc9d4 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -68,7 +68,7 @@ ) from agent_comms.goals import CheckedCommit, OrderCheck, ProblemGoalOrder from agent_comms.repo import GitCommandError -from agent_comms.head_review import AcceptedHeadReview +from agent_comms.head_review import PullRequestHead from agent_comms.lifecycle_capability import LifecycleCapability, Pending from agent_comms.message_reference import MessageReference from agent_comms.messages import InfoKind, Message @@ -592,11 +592,24 @@ class ListedPullRequest(DeclaredFamily, affix="ListedPullRequest"): head: str updated: str base: str + reviewable_times: tuple[str, ...] = field(kw_only=True) draft: bool = field(default=False, kw_only=True) status_contexts: tuple[str, ...] = field(default=(), kw_only=True) + @cached_property + def updated_at(self) -> float: + return _github_time(self.updated) + + @cached_property + def reviewable_at(self) -> float: + return max(map(_github_time, self.reviewable_times)) + + def review_due(self, now: float, seconds: float) -> tuple[float, ...]: + due = self.reviewable_at + seconds + return () if self.draft or now < due else (due,) + def parsed(self, problems: tuple[str, ...], orders: tuple[str, ...]) -> PullRequest: - return UnmergedPullRequest(self.number, self.branch, self.head, _github_time(self.updated), + return UnmergedPullRequest(self.number, self.branch, self.head, self.updated_at, orders, base=self.base, problems=problems, draft=self.draft, status_contexts=self.status_contexts) @@ -615,7 +628,7 @@ class MergedListedPullRequest(ListedPullRequest): merged: str def parsed(self, problems: tuple[str, ...], orders: tuple[str, ...]) -> MergedPullRequest: - return MergedPullRequest(self.number, self.branch, self.head, _github_time(self.updated), + return MergedPullRequest(self.number, self.branch, self.head, self.updated_at, orders, base=self.base, merge=self.merge.oid, merged_at=_github_time(self.merged), problems=problems, draft=self.draft, @@ -626,7 +639,8 @@ class PullRequests: """GitHub facts for recorded links, cached within one board read.""" def __init__(self, todos: Sequence[Todo] = ()) -> None: - self._read: dict[tuple[RepositoryName, type[ListedPullRequest], str], tuple[PullRequest, ...]] = {} + self._read: dict[tuple[RepositoryName, int], ListedPullRequest] = {} + self._open: dict[RepositoryName, tuple[int, ...]] = {} self._todos: dict[RepositoryName, dict[str, Todo]] = {} self.register(todos) @@ -650,41 +664,48 @@ def claimed_heads(self, todo: Todo) -> Iterator[tuple[OrderClaim, PullRequest]]: if request.number == claim.pull_request.number: yield claim, request - def listed(self, repo: RepositoryName, kind: type[ListedPullRequest], - todo: Todo) -> tuple[PullRequest, ...]: - key = (repo, kind, todo.id) - if key not in self._read: - self._read[key] = self.read(repo, kind, todo) - return tuple(request for request in self._read[key] if request.belongs_to(todo)) - - def read(self, repo: RepositoryName, kind: type[ListedPullRequest], - todo: Todo) -> tuple[PullRequest, ...]: - problems = self._todos.setdefault(repo, {}) - problems[todo.id] = todo + def linked(self, repo: RepositoryName) -> dict[int, list[Todo]]: linked: dict[int, list[Todo]] = {} - for candidate in problems.values(): - for number in {link.number for order in candidate.order_history + for candidate in self._todos.get(repo, {}).values(): + for number in {link.number for order in candidate.order.declarations for link in (*order.pull_requests, *(claim.pull_request for claim in order.commander_claims())) if link.repo == repo}: linked.setdefault(number, []).append(candidate) - if not linked: - for candidate in problems.values(): - self._read[repo, kind, candidate.id] = () - return () - requests = tuple((row, row.parsed( + return linked + + def rows(self, repo: RepositoryName, numbers: tuple[int, ...]) -> tuple[ListedPullRequest, ...]: + missing = tuple(number for number in numbers if (repo, number) not in self._read) + if missing: + self._read.update(((repo, row.number), row) for row in self.query(repo, missing)) + return tuple(self._read[repo, number] for number in numbers) + + def open(self, repo: RepositoryName) -> tuple[UnmergedListedPullRequest, ...]: + """Discover orderless open identities, then use the same metadata owner as linked PRs.""" + if repo not in self._open: + result = repo.rest("pulls", "--paginate", "--jq", ".[].number") + if result.returncode: + raise TodoError(result.stderr.strip() or "Open pull requests could not be read.") + self._open[repo] = tuple(FieldCodec.decode(int, FieldCodec.parse(line)) + for line in result.stdout.splitlines()) + numbers = tuple(dict.fromkeys((*self._open[repo], *self.linked(repo)))) + self.rows(repo, numbers) + return tuple(row for number in self._open[repo] + for row in (self._read[repo, number],) + if isinstance(row, UnmergedListedPullRequest)) + + def listed(self, repo: RepositoryName, kind: type[ListedPullRequest], + todo: Todo) -> tuple[PullRequest, ...]: + self.register((todo,)) + linked = self.linked(repo) + requests = tuple(row.parsed( tuple(candidate.id for candidate in linked[row.number]), tuple(dict.fromkeys(order.id for candidate in linked[row.number] - for order in candidate.order_history - if PullRequestLink(repo, row.number) in order.pull_requests)))) - for row in self.query(repo, tuple(linked))) - for selection in dict.fromkeys((kind, *ListedPullRequest.members_with(ListedPullRequest))): - for candidate in problems.values(): - self._read[repo, selection, candidate.id] = tuple(sorted( - (request for row, request in requests - if isinstance(row, selection) and request.belongs_to(candidate)), - key=lambda request: request.updated_at, reverse=True)) - return self._read[repo, kind, todo.id] + for order in candidate.order.declarations + if PullRequestLink(repo, row.number) in order.pull_requests))) + for row in self.rows(repo, tuple(linked)) if isinstance(row, kind)) + return tuple(sorted((request for request in requests if request.belongs_to(todo)), + key=lambda request: request.updated_at, reverse=True)) def query(self, repo: RepositoryName, numbers: tuple[int, ...]) -> tuple[ListedPullRequest, ...]: @@ -692,8 +713,10 @@ def query(self, repo: RepositoryName, selections = [ f'p{number}: pullRequest(number: {number}) {{ number branch: headRefName ' 'head: headRefOid base: baseRefName draft: isDraft closed merge: mergeCommit { oid } ' - 'merged: mergedAt updated: updatedAt ' - 'head_statuses: commits(last: 1) { nodes { commit { status { contexts { context } } } } } }' + 'merged: mergedAt updated: updatedAt created: createdAt ' + 'ready_events: timelineItems(itemTypes: [READY_FOR_REVIEW_EVENT], last: 1) ' + '{ nodes { ... on ReadyForReviewEvent { createdAt } } } ' + 'head_statuses: commits(last: 1) { nodes { commit { committedDate status { contexts { context } } } } } }' for number in numbers] query = (f'query {{ repository(owner: {json.dumps(repo.owner)}, ' f'name: {json.dumps(repo.name)}) {{ {" ".join(selections)} }} }}') @@ -704,8 +727,10 @@ def query(self, repo: RepositoryName, f'del(.merge,.merged) + {{kind: "{ClosedListedPullRequest.declared_name}"}} else ' f'del(.merge,.merged) + {{kind: "{UnmergedListedPullRequest.declared_name}"}} end) else ' f'. + {{kind: "{MergedListedPullRequest.declared_name}"}} end) ' - '| . + {status_contexts: [.head_statuses.nodes[].commit.status.contexts[]?.context]} ' - '| del(.closed,.head_statuses))') + '| . + {status_contexts: [.head_statuses.nodes[].commit.status.contexts[]?.context], ' + 'reviewable_times: [.created, .head_statuses.nodes[].commit.committedDate, ' + '.ready_events.nodes[].createdAt]} ' + '| del(.closed,.created,.head_statuses,.ready_events))') if listed.returncode: raise WorkBranchError( listed.stderr.strip() or f"GitHub order lookup in {repo.to_wire()} failed.") @@ -1894,6 +1919,9 @@ def declarations(self) -> tuple[CheckedProblemOrder, ...]: ) serves: Annotated[type[OrderPhase], WireOmitDefault()] = UnplannedPhase + def holds_head(self, request: PullRequestHead) -> bool: + return PullRequestLink(request.repo, request.pr) in self.pull_requests + @property def phase(self) -> type[OrderPhase]: return self.serves @@ -2734,6 +2762,14 @@ def after_record(self, goals: Goals, changed: Todo) -> None: def validate(self, db: sqlite3.Connection, current: Todo, store: TodoStore) -> None: """Validate related records inside the operation's write transaction.""" + def write(self, db: sqlite3.Connection, current: Todo, store: TodoStore) -> Todo: + """The operation owns its related writes in the Todo transaction.""" + result = self.apply(current) + if result != current: + self.validate(db, current, store) + result.upsert(db) + return result + def apply(self, current: Todo) -> Todo: self.require_current(current) return self.changed(current) @@ -2801,17 +2837,23 @@ def changed(self, current: Todo) -> Todo: @dataclass(frozen=True) -class CitedStrike(TodoOperation, declared_name=None): +class CitedEvidence: evidence: Annotated[tuple[FileLine, ...], WireOmitDefault()] = field( default=(), kw_only=True) - def prepare(self, goals: Goals, current: Todo) -> Self: + def require_evidence(self, current: Todo) -> None: if not self.evidence: raise ValueError("A strike needs a checkable file:line, not a board row or goal text.") order = current.order.checked() root, revision = Path(order.check.directory), order.cited_revision for cited in self.evidence: cited.require_exists(root, revision) + + +@dataclass(frozen=True) +class CitedStrike(CitedEvidence, TodoOperation, declared_name=None): + def prepare(self, goals: Goals, current: Todo) -> Self: + self.require_evidence(current) return self @@ -2831,6 +2873,14 @@ def changed(self, current: Todo) -> Todo: return current return replace(current, strikes=(*current.strikes, self), revision=current.revision + 1) + @classmethod + def send_back(cls, current: Todo, actor: ThreadIncarnation, previous: Assignment, + reason: StrikeReason, at: EventTime, evidence: tuple[FileLine, ...]) -> Todo: + struck = cls(current.revision, actor, previous, reason.text, at, + evidence=evidence).apply(current) + order = current.order.checked().sent_back(reason) + return replace(struck, order=order, prior_orders=current.orders_before(order)) + def story_text(self) -> str: return f"{self.actor.name} struck {self.previous.owner}: {self.reason}" @@ -2903,6 +2953,14 @@ def target(self) -> NamedTarget: name = self.threads[0] if len(self.threads) == 1 else CommsTag.channel() return CommandTarget.named(name) + def publish(self, goals: Goals, actor: ThreadIncarnation, text: str) -> None: + target = self.target + goals.bus.publisher.publish_ordinary( + Message(sender=actor.name, target=target.name, + body=target.addressed(self.threads, text), type=InfoKind), + destination=target, origin=AgentOrigin(), + ) + @dataclass(frozen=True) class OrderVerdict(TodoCommand, StoryEvent, declared_name=None): @@ -2926,10 +2984,6 @@ def judged_calls(self) -> tuple[str, ...]: """The holder's tool calls this verdict judges by identity.""" return () - def judged_through(self, since: float) -> float: - """The instant before which no edit is left unjudged once this verdict is recorded.""" - return since - @abstractmethod def judged(self, current: Todo) -> Todo: ... @@ -2965,17 +3019,20 @@ def require_current(self, current: Todo) -> None: @dataclass(frozen=True) -class QuotedVerdict(OrderVerdict, declared_name=None): +class OrderQuote: quote: str - def story_text(self) -> str: - return f'{super().story_text()}; quoted: "{self.quote}"' - def require_quote(self, text: str) -> None: quoted = " ".join(self.quote.split()) if len(quoted.split()) < 4 or quoted not in " ".join(text.split()): raise ValueError("Quote 4+ words of the order this verdict carries out.") + +@dataclass(frozen=True) +class QuotedVerdict(OrderQuote, OrderVerdict, declared_name=None): + def story_text(self) -> str: + return f'{super().story_text()}; quoted: "{self.quote}"' + def judged(self, current: Todo) -> Todo: self.require_quote(current.order.instructions(current)) return current @@ -2996,29 +3053,220 @@ def require_branches(cls, added: int, no_owner: str | None) -> None: @dataclass(frozen=True) -class ReviewedHeadVerdict(QuotedVerdict): - """The connector reviewer accepts this immutable commit, never an inferred checkout head.""" - head: str +class HeadVerdict(HeadReview, declared_name=None): + """One decision on a PR head, independent of whether an order holds it.""" + + def reviewed(self, goals: Goals, request: PullRequestHead, reviewer: ThreadIncarnation, + order: HeadReviewOrder) -> HeadReviewRecord: + request = replace(request, head=request.current()) + recorded = order.record_review(goals, request, self, reviewer) + self.after_record(goals, recorded) + self.post(request.repo, request.head, reviewer.name, f"#{request.pr}") + return recorded - def __post_init__(self) -> None: - super().__post_init__() - GitCommitText.decode(self.head) + def after_record(self, goals: Goals, record: HeadReviewRecord) -> None: + """A rejection wakes its linked holder after the write; acceptance needs no directive.""" - @property - def line(self) -> str: - return f"Reviewed head {self.head}" - - def judged_through(self, since: float) -> float: - """Accepting a head judges the whole diff, so every edit made before it is judged.""" - return self.at.latest_with(since) - - def reviewed(self, goals: Goals, problem: str, reviewer: ThreadIncarnation) -> Todo: - if self.actor != reviewer: - raise TodoError("The verdict actor must be the connector reviewer recording it.") - current = goals.todos.get(problem) - recorded = self.prepare(goals, current).record(goals, problem) - AcceptedHeadReview().post(recorded.repo, self.head, reviewer.name, problem) - return recorded + def prepare_order(self, current: Todo, link: LinkedHeadReview) -> None: + """Acceptance needs the quoted order; rejection also needs source evidence.""" + + def judge_order(self, current: Todo, record: OrderHeadReviewRecord) -> Todo: + return current + + +@dataclass(frozen=True) +class ReviewedHeadVerdict(HeadVerdict): + state = "success" + + def description(self, reviewer: str, subject: str) -> str: + return f"Accepted by {reviewer} on {subject}" + + +@dataclass(frozen=True) +class RejectedHeadVerdict(HeadVerdict): + reason: StrikeReason + state = "failure" + + def description(self, reviewer: str, subject: str) -> str: + return f"Sent back by {reviewer} on {subject}: {self.reason.text}" + + def after_record(self, goals: Goals, record: HeadReviewRecord) -> None: + record.order.rejected(goals, record) + + def prepare_order(self, current: Todo, link: LinkedHeadReview) -> None: + link.require_evidence(current) + + def judge_order(self, current: Todo, record: OrderHeadReviewRecord) -> Todo: + return StrikeTodo.send_back(current, record.actor, record.order.previous, + self.reason, record.at, record.order.evidence) + + +class HeadReviewRelation(DeclaredFamily): + """Order relationships share one declaration family and compose their capabilities.""" + + +class HeadOrderLink(HeadReviewRelation, declared_name=None): + """The historical order relationship of a recorded head decision.""" + + @abstractmethod + def rejected(self, goals: Goals, record: HeadReviewRecord) -> None: ... + + +class HeadReviewOrder(HeadReviewRelation, declared_name=None): + """The input that authorizes a head review's optional order change.""" + + @classmethod + def for_request(cls, todos: Sequence[Todo], request: PullRequestHead, quote: str, + evidence: tuple[FileLine, ...], problem: str = "") -> HeadReviewOrder: + linked = tuple(todo for todo in todos if todo.repo == request.repo + and (not problem or todo.id == problem) + and todo.order.checked().holds_head(request)) + if not linked: + if problem: + raise TodoError(f"{problem} does not hold this pull request.") + return NoHeadOrder() + if len(linked) != 1: + raise TodoError("More than one order holds this pull request; select --problem.") + todo, = linked + return OrderHeadReview(todo.revision, LinkedHeadReview.of(todo, quote, evidence)) + + @abstractmethod + def require_reviewer(self, goals: Goals, request: PullRequestHead, + actor: ThreadIncarnation) -> None: ... + + def director_identity(self, goals: Goals, actor: ThreadIncarnation) -> ThreadIncarnation: + return actor + + @abstractmethod + def record_review(self, goals: Goals, request: PullRequestHead, verdict: HeadVerdict, + actor: ThreadIncarnation) -> HeadReviewRecord: ... + + +@dataclass(frozen=True) +class NoHeadOrder(HeadReviewOrder, HeadOrderLink): + def rejected(self, goals: Goals, record: HeadReviewRecord) -> None: + """There is no holder to strike or wake.""" + + def require_reviewer(self, goals: Goals, request: PullRequestHead, + actor: ThreadIncarnation) -> None: + project = goals.registry.snapshot().commander_project.require() + if (actor.commanding_identity != project.commander or + request.repo not in {repo.github for repo in project.repos}): + raise TodoError("Only this repository's commander or the director may review its heads.") + + def record_review(self, goals: Goals, request: PullRequestHead, verdict: HeadVerdict, + actor: ThreadIncarnation) -> HeadReviewRecord: + record = HeadReviewRecord(request, actor, RecordedEventTime(time.time()), verdict, self) + goals.todos.record_head(record) + return record + + +@dataclass(frozen=True) +class ProblemHeadReview(OrderQuote, CitedEvidence, HeadOrderLink): + """A problem association without an observed order ID remains an honest partial fact.""" + problem: str + previous: Assignment + + def rejected(self, goals: Goals, record: HeadReviewRecord) -> None: + audience = VerdictAudience.of(self.previous.owner, goals.todos.get(self.problem)) + audience.publish(goals, record.actor, + f"{self.problem}: {record.verdict.description(record.actor.name, f'#{record.request.pr}')}") + + +@dataclass(frozen=True) +class LinkedHeadReview(ProblemHeadReview): + order_id: str + + @classmethod + def of(cls, todo: Todo, quote: str, evidence: tuple[FileLine, ...] = ()) -> Self: + return cls(quote=quote, problem=todo.id, order_id=todo.order.checked().id, + previous=todo.held(), evidence=evidence) + + def require_current(self, current: Todo, actor: ThreadIncarnation) -> None: + current.require_commander(actor) + current.require_assignment(self.previous) + if current.id != self.problem or current.order.checked().id != self.order_id: + raise TodoConflict(current) + self.require_quote(current.order.instructions(current)) + + +@dataclass(frozen=True) +class OrderHeadReview(HeadReviewOrder): + expected_revision: int + link: LinkedHeadReview + + def require_reviewer(self, goals: Goals, request: PullRequestHead, + actor: ThreadIncarnation) -> None: + self.link.require_current(goals.todos.get(self.link.problem), actor) + + def director_identity(self, goals: Goals, actor: ThreadIncarnation) -> ThreadIncarnation: + return actor.require_recorded().acting_for(goals.todos.get(self.link.problem).creator) + + def record_review(self, goals: Goals, request: PullRequestHead, verdict: HeadVerdict, + actor: ThreadIncarnation) -> OrderHeadReviewRecord: + from .goal_actions import GoalPrecondition, HeadReviewedOrderGoalAction + + record = OrderHeadReviewRecord(request, actor, RecordedEventTime(time.time()), verdict, self.link) + operation = RecordOrderHeadVerdict(self.expected_revision, record) + goals.update_goal(self.link.previous.owner, HeadReviewedOrderGoalAction( + expect=GoalPrecondition(goal_id=self.link.previous.generation), + problem=self.link.problem, operation=operation, + )) + return record + + +@dataclass(frozen=True) +class HeadReviewRecord: + request: PullRequestHead + actor: ThreadIncarnation + at: EventTime + verdict: HeadVerdict + order: HeadOrderLink + + +@dataclass(frozen=True) +class OrderHeadReviewRecord(HeadReviewRecord): + order: LinkedHeadReview + + +@dataclass(frozen=True) +class StoredHeadReview(TodoStoreRows, TypedTable): + seq: Annotated[int, Column(primary_key=True, auto_increment=True)] + record: HeadReviewRecord + + request: ClassVar[Generated] = Generated( + PullRequestHead, Column(generated=f"json_extract({{record}}, '$.{FieldCodec.record_key(HeadReviewRecord, 'request')}')"), + ) + problem: ClassVar[Generated] = Generated( + str, Column(generated=f"json_extract({{record}}, '$.{FieldCodec.record_key(HeadReviewRecord, 'order')}.{FieldCodec.record_key(LinkedHeadReview, 'problem')}')", nullable=True), + ) + order_id: ClassVar[Generated] = Generated( + str, Column(generated=f"json_extract({{record}}, '$.{FieldCodec.record_key(HeadReviewRecord, 'order')}.{FieldCodec.record_key(LinkedHeadReview, 'order_id')}')", nullable=True), + ) + indexes = (Index(("request", "seq")), Index(("problem", "order_id", "seq"))) + + +@dataclass(frozen=True) +class RecordOrderHeadVerdict(TodoCommand): + """The order change and its head record share the existing Todo write transaction.""" + review: OrderHeadReviewRecord + + def prepare(self, goals: Goals, current: Todo) -> Self: + self.review.order.require_current(current, self.review.actor) + if not current.order.checked().holds_head(self.review.request): + raise TodoError("The reviewed pull request is not linked to this order.") + self.review.verdict.prepare_order(current, self.review.order) + return self + + def changed(self, current: Todo) -> Todo: + self.review.order.require_current(current, self.review.actor) + judged = self.review.verdict.judge_order(current, self.review) + return replace(judged, revision=current.revision + 1) + + def write(self, db: sqlite3.Connection, current: Todo, store: TodoStore) -> Todo: + changed = super().write(db, current, store) + StoredHeadReview.insert_values(db, record=self.review) + return changed @dataclass(frozen=True) @@ -3125,20 +3373,13 @@ def story_text(self) -> str: return f"{super().story_text()} (strike recorded)" def judged(self, current: Todo) -> Todo: - (strike,) = self.implied_strikes() - struck = strike.apply(current) - order = current.order.checked().sent_back(self.reason) - return replace(struck, order=order, prior_orders=current.orders_before(order)) + return StrikeTodo.send_back(current, self.actor, self.previous, self.reason, + self.at, self.evidence) def record(self, goals: Goals, problem: str) -> Todo: changed = goals.send_back(problem, self) - audience = self.audience.of(self.previous.owner, changed) - target = audience.target - goals.bus.publisher.publish_ordinary( - Message(sender=self.actor.name, target=target.name, - body=target.addressed(audience.threads, f"{changed.id}: {self.story_text()}"), - type=InfoKind), - destination=target, origin=AgentOrigin(), + self.audience.of(self.previous.owner, changed).publish( + goals, self.actor, f"{changed.id}: {self.story_text()}", ) return changed @@ -3489,14 +3730,6 @@ def rows(cls, db: sqlite3.Connection, where: str, parameters: tuple) -> tuple[To def judged_calls(self) -> tuple[str, ...]: return tuple(call for verdict in self.verdicts for call in verdict.judged_calls()) - @property - def unjudged_since(self) -> float: - """Edits before the order, or before a later verdict judging a whole head, are judged.""" - since = self.order.checked().at.recorded_at("An order") - for verdict in self.verdicts: - since = verdict.judged_through(since) - return since - @property def order_history(self) -> tuple[CheckedProblemOrder, ...]: return (*self.prior_orders, *self.order.declarations) @@ -4138,14 +4371,31 @@ def create( ProblemAllocation.delete(db, number=number.number) return current + def record_head(self, record: HeadReviewRecord) -> None: + with self.writing() as db: + StoredHeadReview.insert_values(db, record=record) + + def head_reviews(self, request: PullRequestHead) -> tuple[HeadReviewRecord, ...]: + """An indexed exact-head lookup, newest committed verdict first.""" + with self.snapshot() as db: + return tuple(row.record for row in StoredHeadReview.select( + db, request=request, order_by=("seq",), descending=True, limit=1, + )) + + def unjudged_since(self, todo: Todo) -> float: + order = todo.order.checked() + with self.snapshot() as db: + reviews = StoredHeadReview.select( + db, problem=todo.id, order_id=order.id, + order_by=("seq",), descending=True, limit=1, + ) + began = order.at.recorded_at("An order") + return max((began, *(row.record.at.latest_with(began) for row in reviews))) + def apply(self, todo_id: str, command: TodoCommand) -> Todo: with self.writing() as db: current = self._current(db, todo_id) - result = command.apply(current) - if result == current: - return current - command.validate(db, current, self) - result.upsert(db) + command.write(db, current, self) return self._current(db, todo_id) def correct(self, unit: ThreadIncarnation, message_id: str, text: str) -> tuple[Todo, ...]: diff --git a/src/agent_comms/tool_caller.py b/src/agent_comms/tool_caller.py index 236e4bd12a..4e7b836a57 100644 --- a/src/agent_comms/tool_caller.py +++ b/src/agent_comms/tool_caller.py @@ -18,6 +18,8 @@ if TYPE_CHECKING: from .comms import Comms from .registration import Registration + from .head_review import PullRequestHead + from .todos import HeadReviewOrder from .threads import Thread from .tools import ToolRequest @@ -125,6 +127,12 @@ def commander(self, comms: Comms) -> Thread: def identity(self, comms: Comms) -> ThreadIncarnation: return comms.registry.require(self.name).incarnation + def head_review_actor(self, comms: Comms, request: PullRequestHead, + order: HeadReviewOrder) -> ThreadIncarnation: + actor = self.identity(comms) + order.require_reviewer(comms.goals, request, actor) + return actor + def invoke(self, request: ToolRequest[ThreadCaller], comms: Comms) -> ToolResult: return request.apply(comms, self) diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index 1dec51df11..75501ecc36 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -26,7 +26,10 @@ OrderProgress, PlainText, ProgressRead, - ReviewedHeadVerdict, + HeadReviewOrder, + HeadReviewRecord, + HeadVerdict, + NoHeadOrder, ServeTodo, StrikeReason, Todo, @@ -36,6 +39,7 @@ ) from . import cli as comms_cli +from .head_review import PullRequestHead from .change_report import NoChangeReport, ReportEvidence from .channel_management import ListTagEdit, TagEdit from agent_comms.channel_targets import ChannelTarget, CommandTarget, NamedTarget @@ -934,26 +938,31 @@ class CommsVerdictTool(CommsChangeProblemTool): ] -@dataclass(frozen=True, kw_only=True) -class CommsHeadVerdictTool(ThreadToolRequest): - """One reviewed commit judges the holder's whole diff: the commander, or any fork of it, - accepts an order's exact PR head, so every edit made before it is judged in one verdict and - the head carries agent-comms/review for the merge queue.""" +@dataclass(frozen=True) +class HeadReviewed(ToolResult): + record: HeadReviewRecord - label = "Accept an order's PR head" + +@dataclass(frozen=True, kw_only=True) +class HeadVerdictTool: + label = "Review a PR head" description = ( - "Accept the exact pull request head commit an order's holder delivered: one verdict " - "judges every edit made before it and posts agent-comms/review on that commit. Quote " - "4+ words of the order it carries out." + "Record acceptance or rejection of an exact repository/PR/head and post " + "agent-comms/review. An order link is optional; linked reviews quote 4+ " + "words of the order, and rejection strikes its holder in the same write." ) - problem: Annotated[str, *tool_field("Problem ID")] - operation: Annotated[ReviewedHeadVerdict, *tool_field("Accepted PR-head verdict")] + request: Annotated[PullRequestHead, *tool_field("Repository, PR number and reviewed head")] + operation: Annotated[HeadVerdict, *tool_field("Accepted or rejected head verdict")] + order: Annotated[HeadReviewOrder, *tool_field("Optional held-order link")] = NoHeadOrder() + + def apply(self, comms: Comms, caller: ThreadCaller) -> HeadReviewed: + actor = caller.head_review_actor(comms, self.request, self.order) + return HeadReviewed(self.operation.reviewed(comms.goals, self.request, actor, self.order)) - def apply(self, comms: Comms, caller: ThreadCaller) -> TodoChanged: - reviewer = caller.identity(comms) - comms.goals.todos.get(self.problem).require_commander(reviewer) - todo = self.operation.reviewed(comms.goals, self.problem, reviewer) - return TodoChanged(todo, todo.strike_count) + +@dataclass(frozen=True, kw_only=True) +class CommsHeadVerdictTool(HeadVerdictTool, ThreadToolRequest): + """The native/CLI member of the shared authenticated head-review operation.""" @dataclass(frozen=True, kw_only=True) diff --git a/src/agent_comms/turn_digests.py b/src/agent_comms/turn_digests.py index 2bbc49e2e9..13fbb9a738 100644 --- a/src/agent_comms/turn_digests.py +++ b/src/agent_comms/turn_digests.py @@ -184,6 +184,7 @@ def from_turns(self, turns: tuple[TurnDigest, ...]) -> ThreadTurns: @dataclass(frozen=True) class OrderRead[Result](TranscriptRead[Result]): + bound: TurnsSince todo: Todo @classmethod @@ -198,19 +199,22 @@ def for_commander(cls, comms: Comms, commander: str) -> Iterator[Self]: def for_orders( cls, root: Path, snapshot: RegistrySnapshot, todos: Iterable[Todo] ) -> Iterator[Self]: - """Read these orders against their captured roster, without reopening the registry.""" + """Read these orders against their captured roster and canonical review cut.""" + from .todos import TodoStore + + store = TodoStore(root) for todo in todos: assignment = todo.held() holder = snapshot.require(assignment.owner) if holder.incarnation != assignment.owner_identity: raise ToolRefusedError(f"Order {todo.id} holder incarnation no longer exists") yield cls( - root, holder, TurnsSince(todo.order.checked().at.recorded_at("An order")), todo + root, holder, TurnsSince(store.unjudged_since(todo)), todo ) def edits_from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall, ...]: judged = frozenset(self.todo.judged_calls) - since = self.todo.unjudged_since + since = self.bound.at edits = {edit.id: edit for turn in reversed(turns) for event in turn.events for edit in event.edit_calls(self.thread.name, self.todo.id, since) @@ -225,7 +229,7 @@ def from_turns(self, turns: tuple[TurnDigest, ...]) -> tuple[EditCall, ...]: return self.edits_from_turns(turns) def from_external(self, source: StoreRead) -> tuple[EditCall, ...]: - return tuple(source.unjudged(self.todo)) + return tuple(source.unjudged(self.todo, self.bound.at)) class OrderEditCountRead(OrderRead[int]): @@ -239,15 +243,13 @@ def from_turns(self, turns: tuple[TurnDigest, ...]) -> int: return len(self.edits_from_turns(turns)) def from_external(self, source: StoreRead) -> int: - return source.unjudged_count(self.todo) + return source.unjudged_count(self.todo, self.bound.at) @dataclass(frozen=True) class OrderToolCallCountRead(OrderRead[int]): """Original tool starts since a captured head/order cut, without loading external bodies.""" - bound: TurnsSince - def from_turns(self, turns: tuple[TurnDigest, ...]) -> int: return len(OrderWork.read( self.todo.id, self.thread.incarnation, self.bound.at, turns, diff --git a/src/agent_comms/work_dispatch.py b/src/agent_comms/work_dispatch.py index 6954ecac50..5426608eca 100644 --- a/src/agent_comms/work_dispatch.py +++ b/src/agent_comms/work_dispatch.py @@ -68,6 +68,7 @@ def start(self, comms: Comms) -> Thread: model=defaults.model, thinking_level=defaults.thinking_level, ) + self.action.require_reviewed(comms.goals) self.action.require_ready(GoalActionContext(comms.goals, candidate)) comms.registry.require_declaration_available(candidate, StoppedThreadStatus()) CheckoutStorageEffect(checkout, Path(self.action.order.check.directory)).check( diff --git a/tests/conftest.py b/tests/conftest.py index de4b3af453..52fab6f0ae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -49,7 +49,7 @@ from source_revision_fixture import SourceRepository from agent_comms.threads import Thread -pytest_plugins = (goal_owner_fixture.__name__,) +pytest_plugins = (goal_owner_fixture.__name__, "github_fixture") class TranscriptRecords: @@ -339,7 +339,7 @@ def root(tmp_path: Path) -> Path: @pytest.fixture -def configured_repository(durable_home: Path) -> Callable[[Comms, *tuple[Repo, ...]], Commander]: +def configured_repository(durable_home: Path, github_cli) -> Callable[[Comms, *tuple[Repo, ...]], Commander]: """Declare the temporary commander on its bus and use its real plan-bearing checkout.""" from agent_comms.commander.order import Commander from agent_comms.repo import Repo @@ -347,6 +347,7 @@ def configured_repository(durable_home: Path) -> Callable[[Comms, *tuple[Repo, . repository = Repo.of(Path(__file__).resolve().parents[1]) def configure(bus: Comms, *check_repositories: Repo) -> Commander: + github_cli.configure(*(repo.github for repo in (*check_repositories, repository))) return Commander.on_root( 'lead', (*check_repositories, repository), bus.root, ) diff --git a/tests/github_cli_stub.py b/tests/github_cli_stub.py index 8dde8e38f0..cd070725ee 100755 --- a/tests/github_cli_stub.py +++ b/tests/github_cli_stub.py @@ -9,27 +9,58 @@ def main(): directory = Path(sys.argv[0]).parent - data = json.loads((directory / "pull_requests.json").read_text()) + repositories = json.loads((directory / "pull_requests.json").read_text()) arguments = sys.argv[1:] with (directory / "calls.jsonl").open("a") as calls: calls.write(json.dumps(arguments) + "\n") if arguments[:2] == ["pr", "view"]: - assert arguments[arguments.index("-R") + 1] == data["repo"] + data = repositories[arguments[arguments.index("-R") + 1]] request = data["requests"][arguments[2]] field = arguments[arguments.index("--json") + 1] assert arguments[arguments.index("--jq") + 1] == "." + field print({"number": request["listed"]["number"], "body": request["body"]}[field]) + elif len(arguments) > 1 and arguments[0] == "api" and arguments[1].startswith("repos/"): + _, owner, name, *parts = arguments[1].split("/") + data = repositories[f"{owner}/{name}"] + if parts == ["pulls"]: + for entry in data["requests"].values(): + if entry["listed"]["kind"] == data["open_kind"]: + print(entry["listed"]["number"]) + elif parts[:1] == ["pulls"] and len(parts) == 2: + print(data["requests"][parts[1]]["listed"]["head"]) + elif parts[:1] == ["statuses"] and len(parts) == 2: + assert arguments[arguments.index("--method") + 1] == "POST" + print("{}") + else: + raise AssertionError(f"Unexpected repository endpoint: {parts}") elif arguments[:2] == ["api", "graphql"]: query = arguments[arguments.index("-f") + 1] assert query.startswith("query=") - owner, name = data["repo"].split("/") - assert f'repository(owner: "{owner}", name: "{name}")' in query + variables = dict(arguments[index + 1].split("=", 1) + for index, value in enumerate(arguments) if value == "-F") + inline = re.search(r'repository\(owner: "([^"]+)", name: "([^"]+)"\)', query) + repository = "/".join(inline.groups()) if inline else f"{variables['owner']}/{variables['name']}" + data = repositories[repository] selections = re.findall(r"p(\d+): pullRequest\(number: (\d+)\)", query) - assert selections, "Only explicit recorded pull-request selections are supported" - assert all(alias == number for alias, number in selections) - # gh --jq returns the typed projection, not the raw GraphQL envelope. - print(json.dumps({f"p{number}": data["requests"][number]["listed"] - for _, number in selections})) + if selections: + assert all(alias == number for alias, number in selections) + # gh --jq returns the typed projection, not the raw GraphQL envelope. + print(json.dumps({f"p{number}": data["requests"][number]["listed"] + for _, number in selections})) + elif "pullRequests(" in query: + print(json.dumps({"data": {"repository": {"pullRequests": { + "nodes": [{"number": entry["listed"]["number"], + "headRefName": entry["listed"]["branch"], "isCrossRepository": False} + for entry in data["requests"].values() + if entry["listed"]["kind"] == data["open_kind"]], + "pageInfo": {"hasNextPage": False, "endCursor": None}, + }}}})) + elif "issues(" in query: + print(json.dumps({"data": {"repository": {"issues": { + "nodes": [], "pageInfo": {"hasNextPage": False, "endCursor": None}, + }}}})) + else: + raise AssertionError(f"Unexpected GraphQL query: {query}") else: raise AssertionError(f"Unexpected gh invocation: {arguments!r}") diff --git a/tests/github_fixture.py b/tests/github_fixture.py new file mode 100644 index 0000000000..42d357befa --- /dev/null +++ b/tests/github_fixture.py @@ -0,0 +1,48 @@ +"""A real gh subprocess with controlled foreign responses and retained call records.""" + +import json +import os +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from agent_comms.field_codec import FieldCodec +from agent_comms.repository_name import RepositoryName +from agent_comms.todos import ListedPullRequest, UnmergedListedPullRequest + + +@dataclass(frozen=True) +class GitHubFixture: + directory: Path + + @property + def path(self) -> Path: + return self.directory / "pull_requests.json" + + def configure(self, *repositories: RepositoryName): + data = json.loads(self.path.read_text()) if self.path.exists() else {} + for repository in repositories: + data.setdefault(repository.to_wire(), { + "open_kind": UnmergedListedPullRequest.declared_name, "requests": {}, + }) + self.path.write_text(json.dumps(data)) + return self + + def put(self, repository: RepositoryName, row: ListedPullRequest, body: str = "") -> None: + data = json.loads(self.path.read_text()) + data[repository.to_wire()]["requests"][str(row.number)] = {"body": body, "listed": FieldCodec.encode(row)} + self.path.write_text(json.dumps(data)) + + def calls(self) -> tuple[tuple[str, ...], ...]: + path = self.directory / "calls.jsonl" + return tuple(tuple(json.loads(line)) for line in path.read_text().splitlines()) if path.exists() else () + + +@pytest.fixture +def github_cli(tmp_path, monkeypatch): + directory = tmp_path / "bin" + directory.mkdir() + (directory / "gh").symlink_to(Path(__file__).with_name("github_cli_stub.py")) + monkeypatch.setenv("PATH", os.pathsep.join((str(directory), os.environ["PATH"]))) + return GitHubFixture(directory) diff --git a/tests/test_delivery_merge.py b/tests/test_delivery_merge.py index 21617545d8..3e857e6646 100644 --- a/tests/test_delivery_merge.py +++ b/tests/test_delivery_merge.py @@ -21,6 +21,7 @@ def test_delivery_integration_members_keep_their_data_through_the_only_codec(): def test_named_forge_row_preserves_the_actual_nonmain_merge_target(): original = MergedListedPullRequest(42, 'fix/order', 'a' * 40, updated='2026-10-19T12:00:01Z', base='release', + reviewable_times=('2026-10-19T12:00:00Z',), merge=PullRequestCommit('b' * 40), merged='2026-10-19T12:00:00Z') row = FieldCodec.decode(ListedPullRequest, FieldCodec.encode(original)) request = row.parsed((), ('proof-order',)) @@ -34,7 +35,8 @@ def test_named_forge_row_preserves_the_actual_nonmain_merge_target(): def test_unmerged_query_row_has_no_merge_fields(): original = UnmergedListedPullRequest(43, 'fix/order', 'a' * 40, - updated='2026-10-19T12:00:01Z', base='main') + updated='2026-10-19T12:00:01Z', base='main', + reviewable_times=('2026-10-19T12:00:00Z',)) row = FieldCodec.decode(ListedPullRequest, FieldCodec.encode(original)) request = row.parsed((), ('proof-order',)) assert isinstance(request, UnmergedPullRequest) diff --git a/tests/test_fleet_replay.py b/tests/test_fleet_replay.py index 8ec7d7920f..30256001bb 100644 --- a/tests/test_fleet_replay.py +++ b/tests/test_fleet_replay.py @@ -1,7 +1,7 @@ """Replay a small fleet written through Core's own writers: each covered incident is raised once. Git is real (a temporary repository with an origin remote and one worktree per unit); only the -forge answer comes from the fleet, through ProgressRead's own query method. +forge answers come from the external gh fixture, through the unmodified reader. """ import os @@ -10,6 +10,7 @@ from abc import ABC, abstractmethod from collections import Counter from dataclasses import dataclass, field, replace +from datetime import UTC, datetime from pathlib import Path from typing import ClassVar @@ -38,11 +39,9 @@ from agent_comms.threads import Thread from agent_comms.todos import ( ClaimOrder, - ListedPullRequest, OrderClaim, OrderMessage, PendingProblemOrder, - ProgressRead, PullRequestLink, SharedWorktreeRefusal, Todo, @@ -52,6 +51,9 @@ from agent_comms.work_branches import UnpushedHead from conftest import declare_test_bus from source_revision_fixture import SourceRepository +from github_fixture import GitHubFixture + +pytest_plugins = ("github_fixture",) @dataclass @@ -63,7 +65,7 @@ class Fleet: worktrees: Path old_main: str request: str - pulls: dict[int, ListedPullRequest] = field(default_factory=dict) + github: GitHubFixture problems: dict[str, str] = field(default_factory=dict) commander: ClassVar[str] = "lead" @@ -92,21 +94,9 @@ def git(checkout: Path, *arguments: str, at: float | None = None) -> str: timeout=30, ).stdout.strip() - def reading(self) -> type[ProgressRead]: - """The live progress read, answered by this fleet's pull requests instead of GitHub.""" - pulls = self.pulls - - class FleetProgressRead(ProgressRead): - def query( - self, repo: RepositoryName, numbers: tuple[int, ...] - ) -> tuple[ListedPullRequest, ...]: - return tuple(pulls[number] for number in numbers if number in pulls) - - return FleetProgressRead - def observe(self) -> CommanderObservation: return CommanderObservation.read( - self.comms, self.comms.registry.require(self.commander), self.reading() + self.comms, self.comms.registry.require(self.commander) ) def thread(self, name: str) -> Thread: @@ -191,9 +181,10 @@ def claim(self, todo: Todo, number: int, head: str, *, at: float) -> OrderClaim: return claimed def pull_request(self, number: int, branch: str, head: str) -> None: - self.pulls[number] = UnmergedListedPullRequest( - number, branch, head, "2026-10-23T12:00:00Z", "main" - ) + stamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + self.github.put(self.repository, UnmergedListedPullRequest( + number, branch, head, stamp, "main", reviewable_times=(stamp,), + )) class IncidentSeed(ABC): @@ -381,7 +372,7 @@ def seed(cls, fleet: Fleet) -> StandingRule: @pytest.fixture -def fleet(comms, tmp_path, configured_repository, installed_runtime): +def fleet(comms, tmp_path, configured_repository, installed_runtime, github_cli): source = SourceRepository.create(tmp_path / "source") declare_test_bus( comms, @@ -393,9 +384,10 @@ def fleet(comms, tmp_path, configured_repository, installed_runtime): request = comms.goals.todos.add_request( comms.registry.require(Fleet.commander).incarnation, "Keep the fleet on its plans" ).id - configured_repository(comms) + project = configured_repository(comms) + github_cli.configure(source.github, *(repo.github for repo in project.repos)) old_main = source.main_commit - built = Fleet(comms, source, tmp_path / "worktrees", old_main, request) + built = Fleet(comms, source, tmp_path / "worktrees", old_main, request, github_cli) # Main moves past the oldest branch's base by exactly one commit more than the limit. for count in range(BranchBehindMain.limit + 1): built.commit(source.checkout, "main.py", f"count = {count}\n") diff --git a/tests/test_head_review.py b/tests/test_head_review.py index 1331fea8f2..487ac1bcde 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -1,46 +1,112 @@ -"""A head verdict goes only on the pull request's current head; a moved head posts nothing.""" +"""Head decisions through the native boundary, real stores, and the external gh process.""" -import subprocess +import time +from dataclasses import replace +from datetime import UTC, datetime import pytest -from agent_comms import repository_name -from agent_comms.errors import ToolRefusedError -from agent_comms.head_review import AcceptedHeadReview, PullRequestHead -from agent_comms.repository_name import RepositoryName +from agent_comms.field_codec import FieldCodec +from agent_comms.goal_actions import CreateProblemGoalAction +from agent_comms.head_review import PullRequestHead +from agent_comms.recorded_time import RecordedEventTime +from agent_comms.standing_rules import OrderRefusingStandingRule +from agent_comms.errors import RelationViolationError, ToolRefusedError +from agent_comms.tool_caller import ThreadCaller +from agent_comms.tools import CommsHeadVerdictTool +from agent_comms.todos import ( + HeadReviewOrder, NoHeadOrder, PullRequestLink, RecordPullRequest, + RejectedHeadVerdict, ReviewedHeadVerdict, StoredHeadReview, StrikeReason, + UnmergedListedPullRequest, +) -CURRENT = "c" * 40 +pytest_plugins = ("test_holder_standing_rules", "github_fixture") +HEAD = "c" * 40 +REASON = StrikeReason("This head repeats the same decision in two unrelated places instead of using the existing class.") -class Gh: - """Stands in for gh: answers the PR read and records every status post.""" - def __init__(self) -> None: - self.posts: list[tuple[str, ...]] = [] +@pytest.fixture +def head_bus(held_bus, github_cli): + todo = held_bus.goals.todos.get("P2") + github_cli.configure(todo.repo) + stamp = datetime.fromtimestamp(time.time() - 601, UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + github_cli.put(todo.repo, UnmergedListedPullRequest( + 7, "fix/head", HEAD, stamp, "main", reviewable_times=(stamp,), + )) + return held_bus, github_cli, PullRequestHead(todo.repo, 7, HEAD) - def __call__(self, command, **_options): - read = command[1] == "api" and command[2].endswith("/pulls/7") - self.posts.extend(() if read else (tuple(command),)) - return subprocess.CompletedProcess(command, 0, f"{CURRENT}\n" if read else "", "") +def invoke(comms, request, verdict, order=NoHeadOrder()): + tool = CommsHeadVerdictTool(request=request, operation=verdict, order=order) + return tool.invoke(comms, tool.arguments, caller=ThreadCaller("lead")) -@pytest.fixture -def gh(monkeypatch) -> Gh: - stub = Gh() - monkeypatch.setattr(repository_name.subprocess, "run", stub) - return stub + +def test_orderless_head_family_persists_both_outcomes_and_posts_each_exact_head(head_bus): + comms, github, request = head_bus + before = tuple(todo.strikes for todo in comms.goals.todos.list(request.repo)) + accepted, rejected = ReviewedHeadVerdict(), RejectedHeadVerdict(REASON) + invoke(comms, replace(request, head=HEAD[:9]), accepted) + invoke(comms, request, rejected) + with comms.goals.todos.snapshot() as db: + records = tuple(row.record for row in StoredHeadReview.select(db, order_by=("seq",))) + assert tuple(record.verdict for record in records) == (accepted, rejected) + assert all(record.request == request and record.order == NoHeadOrder() for record in records) + assert comms.goals.todos.head_reviews(request)[0].verdict == rejected + assert tuple(todo.strikes for todo in comms.goals.todos.list(request.repo)) == before + posts = tuple(call for call in github.calls() if f"statuses/{HEAD}" in call[1]) + assert len(posts) == 2 + assert "state=success" in posts[0] and "state=failure" in posts[1] + + +def test_moved_head_posts_and_records_nothing(head_bus): + comms, github, request = head_bus + moved = replace(request, head="a" * 40) + with pytest.raises(ToolRefusedError): + invoke(comms, moved, ReviewedHeadVerdict()) + assert not comms.goals.todos.head_reviews(moved) + assert not any("statuses/" in call[1] for call in github.calls()) -def test_a_moved_head_is_refused_and_posts_nothing(gh: Gh) -> None: - with pytest.raises(ToolRefusedError, match="moved"): - PullRequestHead(RepositoryName.from_wire("org/repo"), 7, "a" * 9).current() - assert gh.posts == [] +def test_ready_orderless_review_refuses_backend_orders_until_a_recorded_decision(head_bus): + comms, _, request = head_bus + todo = comms.goals.todos.get("P2") + assert OrderRefusingStandingRule.refusals(comms.goals, "lead") + action = CreateProblemGoalAction( + problem="P3", commander="lead", request=todo.request, + assigner=comms.registry.require("lead").incarnation, + repo=todo.repo, text="Repair the next owner", order=todo.order.checked(), + review_at=RecordedEventTime(time.time() + 3600), + ) + with pytest.raises(RelationViolationError): + comms.goals.update_goal("outsider", action) + assert all(current.id != "P3" for current in comms.goals.todos.list(request.repo)) + invoke(comms, request, RejectedHeadVerdict(REASON)) + assert not OrderRefusingStandingRule.refusals(comms.goals, "lead") + comms.goals.update_goal("outsider", action) + assert comms.goals.todos.get("P3").holder == "outsider" -def test_the_current_head_is_posted_in_full(gh: Gh) -> None: - repo = RepositoryName.from_wire("org/repo") - head = PullRequestHead(repo, 7, CURRENT[:9]).current() - AcceptedHeadReview().post(repo, head, "claude-ai", "#7") - (post,) = gh.posts - assert f"repos/org/repo/statuses/{CURRENT}" in post - assert "state=success" in post +def test_linked_head_rejection_strikes_revises_and_notifies_its_holder(head_bus): + comms, _, request = head_bus + todo = comms.goals.todos.get("P2") + held = todo.held() + RecordPullRequest(todo.revision, held.owner_identity, + PullRequestLink(request.repo, request.pr), held.generation).record(comms.goals, todo.id) + todo = comms.goals.todos.get(todo.id) + scope = HeadReviewOrder.for_request( + (todo,), request, "Repair the existing owner", (todo.order.checked().owner,), + ) + invoke(comms, request, ReviewedHeadVerdict(), scope) + todo = comms.goals.todos.get(todo.id) + scope = HeadReviewOrder.for_request( + (todo,), request, "Repair the existing owner", (todo.order.checked().owner,), + ) + before = comms.registry.require("worker").goal.require() + invoke(comms, request, RejectedHeadVerdict(REASON), scope) + changed = comms.goals.todos.get(todo.id) + assert changed.strike_count == todo.strike_count + 1 + assert changed.order.checked().id != todo.order.checked().id + assert REASON.text in changed.order.checked().edits + assert comms.registry.require("worker").goal.require().revision > before.revision + assert comms.goals.todos.head_reviews(request)[0].order.problem == todo.id diff --git a/tests/test_held_problem_orders.py b/tests/test_held_problem_orders.py index 95b9f7db96..df9c81ffb0 100644 --- a/tests/test_held_problem_orders.py +++ b/tests/test_held_problem_orders.py @@ -92,13 +92,14 @@ def merge_two_commit_fix(source, binaries, number): source.git(checkout, 'merge', '-q', '--no-ff', '-m', f'Merge pull request #{number}', f'fix/{number}') merge = source.git(checkout, 'rev-parse', 'HEAD').strip() source.git(checkout, 'push', '-q', 'origin', 'main', f'{commits[-1]}:refs/pull/{number}/head') - stub = binaries / 'pull_requests.json' - data = json.loads(stub.read_text()) - data['requests'][str(number)] = {'body': f'## fix {number}', 'listed': FieldCodec.encode( + from github_fixture import GitHubFixture + + GitHubFixture(binaries).put(source.github, MergedListedPullRequest(number=number, branch=f'fix/{number}', head=commits[-1], updated='2026-10-23T12:00:00Z', base=WorkBranches.main, - merge=PullRequestCommit(merge), merged='2026-10-23T12:00:00Z'))} - stub.write_text(json.dumps(data)) + reviewable_times=('2026-10-23T12:00:00Z',), + merge=PullRequestCommit(merge), merged='2026-10-23T12:00:00Z'), + body=f'## fix {number}') return base, commits[0], merge diff --git a/tests/test_todos.py b/tests/test_todos.py index 32e374148e..658ad9a07b 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -67,7 +67,7 @@ from agent_comms.work_branches import WorkBranches from source_revision_fixture import SourceRepository -pytest_plugins = ("test_holder_standing_rules", "test_unowned_checkout_work", "test_proof_build") +pytest_plugins = ("test_holder_standing_rules", "test_unowned_checkout_work", "test_proof_build", "github_fixture") def thread(name: str, root: Path, created: float) -> Thread: @@ -266,7 +266,7 @@ def test_merged_order_keeps_its_checked_fields(tmp_path: Path, source_repository @pytest.fixture -def pr_commander(tmp_path, monkeypatch, proof_build_checkouts, durable_home): +def pr_commander(tmp_path, monkeypatch, proof_build_checkouts, durable_home, github_cli): """One cited source, its real bare remote and installed history; only GitHub is synthetic.""" graph = next(graph for graph in proof_build_checkouts if graph.repository.github == CorePackage.repository) @@ -284,7 +284,7 @@ def pr_commander(tmp_path, monkeypatch, proof_build_checkouts, durable_home): for number in (1, 2): source.git(source.checkout, 'push', 'origin', f'{merged}:refs/pull/{number}/head') common = dict(branch="feature/orders", head=merged, updated="2026-10-20T12:00:00Z", - base=WorkBranches.main) + base=WorkBranches.main, reviewable_times=("2026-10-20T12:00:00Z",)) requests = ( (MergedListedPullRequest(number=1, merge=PullRequestCommit(merged), merged="2026-10-20T12:00:00Z", **common), "No order metadata."), @@ -292,15 +292,9 @@ def pr_commander(tmp_path, monkeypatch, proof_build_checkouts, durable_home): merged="2026-10-20T12:00:00Z", **common), "## P343\n\n## P344"), (ClosedListedPullRequest(number=3, **common), "Closed without merging."), ) - binaries = tmp_path / "bin" - binaries.mkdir() - (binaries / "gh").symlink_to(Path(__file__).with_name("github_cli_stub.py")) - (binaries / "pull_requests.json").write_text(json.dumps({ - "repo": source.github.to_wire(), - "requests": {str(row.number): {"body": body, "listed": FieldCodec.encode(row)} - for row, body in requests}, - })) - monkeypatch.setenv("PATH", os.pathsep.join((str(binaries), os.environ["PATH"]))) + github_cli.configure(source.github) + for row, body in requests: + github_cli.put(source.github, row, body) Bus.install(Bus.path_in(tmp_path)) lead = Thread( 'lead', frozenset(), str(source.checkout), created_at=1.0) From 412df2ba4421714d030119a3737b9f90402ef3b7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:08:29 -0400 Subject: [PATCH 12/15] Exercise orderless judge dispatch and current notification frames --- src/agent_comms/commander/judge.py | 22 +++++++++++----------- tests/test_commander_notifications.py | 6 +++--- tests/test_head_review.py | 22 +++++++++++++++++++++- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index 3350bb0e77..1f464d8910 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -194,7 +194,7 @@ class ReviewSubject(DeclaredFamily): def from_options(cls, options: HeadVerdictVerb, ctx: order.Commander) -> ReviewSubject: matches = tuple(member for member in cls.members_with(cls) if member.selected(options)) if len(matches) != 1: - sys.exit('Refused: select exactly one of --ids or --pr') + sys.exit('Refused: select either edit IDs or --pr') member, = matches return member.decode_options(options, ctx) @@ -221,12 +221,12 @@ def selected(cls, options: HeadVerdictVerb) -> bool: @classmethod def decode_options(cls, options: HeadVerdictVerb, ctx: order.Commander) -> EditReview: if options.head or options.repo: - sys.exit('Refused: --head and --repo belong to --pr, not --ids') + sys.exit('Refused: --head and --repo belong to --pr, not edit IDs') return cls(options.ids) def run(self, options: VerdictVerb, ctx: order.Commander) -> None: if not self.ids: - sys.exit('Refused: select at least one --ids ID:CODE') + sys.exit('Refused: select at least one edit ID:CODE') orders = {todo.id: todo for todo in ctx.held_orders() if not options.problem or todo.id == options.problem} edits = {edit.id: edit for edit in Edit.unjudged(ctx.comms, ctx.commander) @@ -282,7 +282,7 @@ class VerdictVerb(JudgeVerb): ids: Annotated[ tuple[str, ...], - Argument(flags=("ids",), options={"nargs": "+", "metavar": "ID:CODE"}), + Argument(flags=("ids",), options={"nargs": "*", "metavar": "ID:CODE"}), ] = () why: str problem: Annotated[ @@ -432,6 +432,12 @@ def slop(self) -> Slop: @cached_property def rejection_reason(self) -> StrikeReason: + if len(self.roast.split()) < 12 or not re.search(r'\bbecause\b', self.roast): + sys.exit('Refused: --roast needs 12+ words with because, naming the duplicated fact and its owner') + if len(self.fix.split()) < 8: + sys.exit('Refused: --fix needs 8+ words naming the owner and replacing shape') + order.refuse(self.roast, BluntText) + order.refuse(' '.join((self.why, self.roast, self.fix)), *DirectiveText.checks()) return StrikeReason("\n".join(( self.why, self.roast, *(f" {line.strip()}" for line in self.lines), @@ -450,13 +456,7 @@ def operation(self, ctx: order.Commander, todo: Todo, edit: Edit) -> SendBackVer evidence=self.head_evidence()) def check_edits(self, edits: list[Edit]) -> None: - if len(self.roast.split()) < 12 or not re.search(r'\bbecause\b', self.roast): - sys.exit('Refused: --roast needs 12+ words with because, naming the duplicated fact and its owner') - if len(self.fix.split()) < 8: - sys.exit('Refused: --fix needs 8+ words naming the owner and replacing shape') - _ = self.slop - order.refuse(self.roast, BluntText) - order.refuse(' '.join((self.why, self.roast, self.fix)), *DirectiveText.checks()) + _ = self.rejection_reason def main(args, ctx): JudgeVerb.parse(args or ['show']).apply(ctx) diff --git a/tests/test_commander_notifications.py b/tests/test_commander_notifications.py index 29670105f6..d51229a8ca 100644 --- a/tests/test_commander_notifications.py +++ b/tests/test_commander_notifications.py @@ -14,9 +14,9 @@ from agent_comms.read_basis import DMDisplayScope from agent_comms.service_passes import ServicePassStore from agent_comms.standing_rules import ServicePassFailing -from agent_comms.todos import ProgressRead from agent_comms.ui_model.unit_state import UnitState from agent_comms.wake_policy import PassiveWake +from test_holder_standing_rules import state_cards pytest_plugins = ("test_holder_standing_rules",) @@ -62,12 +62,12 @@ def test_quiet_completion_is_per_commander_even_when_a_later_recipient_fails(not def test_recreated_watcher_reads_original_committed_unit_observation(notification_bus): comms = notification_bus - states = UnitState.current(comms, "lead", ProgressRead()) + states = state_cards(comms, UnitState) beat(comms, states=states) first, = notifications(comms) assert all(state.line() in first.body for state in states) assert CommanderUpdate.latest(comms, comms.registry.require("lead").incarnation) == first.task - beat(comms, states=UnitState.current(comms, "lead", ProgressRead())) + beat(comms, states=state_cards(comms, UnitState)) unchanged, = notifications(comms) assert unchanged.reference == first.reference diff --git a/tests/test_head_review.py b/tests/test_head_review.py index c162641838..1210276fa9 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -6,7 +6,9 @@ import pytest -from agent_comms.field_codec import FieldCodec +from pathlib import Path + +from agent_comms.commander import judge, order from agent_comms.goal_actions import CreateProblemGoalAction from agent_comms.head_review import PullRequestHead from agent_comms.recorded_time import RecordedEventTime @@ -59,6 +61,24 @@ def test_orderless_head_family_persists_both_outcomes_and_posts_each_exact_head( assert "state=success" in posts[0] and "state=failure" in posts[1] +def test_judge_ok_and_back_dispatch_orderless_prs_without_positional_edit_ids(head_bus): + comms, github, request = head_bus + context = order.Commander.read_root(comms.root) + why = "The existing class owns this behavior without another copy" + common = ["--pr", str(request.pr), "--head", request.head, "--why", why] + judge.main(["ok", *common], context) + assert isinstance(comms.goals.todos.head_reviews(request)[0].verdict, ReviewedHeadVerdict) + todo = comms.goals.todos.get("P2") + location = f"{Path(todo.order.check.directory) / todo.order.owner.file}:{todo.order.owner.line}" + judge.main(["back", *common, + "--roast", "This is stupid because the same decision is written twice instead of belonging to its existing class.", + "--fix", "Put the shared behavior on the existing parent and delete both copies.", + "--slop", "IMPL-5", "--fact", "the same decision is written twice", + "--at", location, "--derive", "base-method", "--by", location], context) + assert isinstance(comms.goals.todos.head_reviews(request)[0].verdict, RejectedHeadVerdict) + assert len(tuple(call for call in github.calls() if f"statuses/{HEAD}" in call[1])) == 2 + + def test_moved_head_posts_and_records_nothing(head_bus): comms, github, request = head_bus moved = replace(request, head="a" * 40) From 9ca432f558d542059c02c00902a85859cd7b1b19 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:24:05 -0400 Subject: [PATCH 13/15] Carry stored PR-head reviews during install and gate phase four on their owner --- src/agent_comms/plan_phases.py | 5 +- tools/cutover/carry_head_reviews.py | 111 ++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 tools/cutover/carry_head_reviews.py diff --git a/src/agent_comms/plan_phases.py b/src/agent_comms/plan_phases.py index d15b67cbcf..0ae62be48b 100644 --- a/src/agent_comms/plan_phases.py +++ b/src/agent_comms/plan_phases.py @@ -166,7 +166,10 @@ class ReviewQueuePhase(CommanderNativePhase): """ after = (RecordPlanePhase,) - evidence = RulesetGates() + evidence = AllEvidence(( + ClassDeclared("HeadReviewRecord", ("src/agent_comms/todos.py",)), + RulesetGates(), + )) COMMANDER = "src/agent_comms/commander/" diff --git a/tools/cutover/carry_head_reviews.py b/tools/cutover/carry_head_reviews.py new file mode 100644 index 0000000000..dde3f3aff5 --- /dev/null +++ b/tools/cutover/carry_head_reviews.py @@ -0,0 +1,111 @@ +"""Carry embedded order-head reviews before the install reconciles TodoStore. + +The insert and removal share one transaction, so a completed carry has no old +review left to carry again. Delete this step after the live installation. +""" + +from __future__ import annotations + +import json +from contextlib import closing +from dataclasses import dataclass +from pathlib import Path +from typing import Annotated, ClassVar, Literal + +from pydantic import JsonValue + +from agent_comms.field_codec import FieldCodec, GitCommitText +from agent_comms.head_review import PullRequestHead +from agent_comms.installer.probes.carry import Carried, Carry +from agent_comms.locked_store import RootFile +from agent_comms.maintenance_barrier import MaintenanceBarrier, MaintenanceHolder +from agent_comms.recorded_time import EventTime +from agent_comms.registry_store import RegistryStore +from agent_comms.repository_name import RepositoryName +from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.todos import ( + Assignment, + CheckedProblemOrder, + HeadReviewRecord, + ProblemHeadReview, + ProblemOrder, + ReviewedHeadVerdict, + StoredHeadReview, + Todo, + TodoStore, +) +from agent_comms.typed_table import Column, TextStorage, TypedRow + + +@dataclass(frozen=True) +class EmbeddedHeadReview: + kind: Literal["reviewed_head_verdict"] + expected_revision: int + actor: ThreadIncarnation + previous: Assignment + at: EventTime + quote: str + head: Annotated[str, GitCommitText] + + def record(self, request: PullRequestHead, problem: str) -> HeadReviewRecord: + return HeadReviewRecord( + request, self.actor, self.at, ReviewedHeadVerdict(), + ProblemHeadReview(quote=self.quote, problem=problem, previous=self.previous), + ) + + +@dataclass(frozen=True) +class EmbeddedReviewSource(TypedRow): + id: str + repo: Annotated[RepositoryName, Column(storage=TextStorage)] + order: ProblemOrder + prior_orders: tuple[CheckedProblemOrder, ...] + verdicts: tuple[dict[str, JsonValue], ...] + + def records(self) -> tuple[HeadReviewRecord, ...]: + links = {link for order in (*self.prior_orders, *self.order.declarations) + for link in order.pull_requests} + if len(links) != 1: + raise ValueError(f"{self.id}: embedded head review needs one recorded PR, got {links}") + link, = links + if link.repo != self.repo: + raise ValueError(f"{self.id}: embedded head review names another repository") + reviews = (FieldCodec.decode(EmbeddedHeadReview, wire) for wire in self.verdicts + if wire["kind"] == "reviewed_head_verdict") + return tuple(review.record(PullRequestHead(self.repo, link.number, review.head), self.id) + for review in reviews) + + def remaining(self) -> tuple[dict[str, JsonValue], ...]: + return tuple(wire for wire in self.verdicts if wire["kind"] != "reviewed_head_verdict") + + +class HeadReviews(Carry): + opened_files: ClassVar[tuple[type[RootFile], ...]] = (TodoStore,) + + def carry(self, *, apply: bool) -> Carried: + path = TodoStore.path_in(self.root) + self.require_confined(self.root, path) + if not path.exists(): + return Carried(0) + if apply: + MaintenanceBarrier(RegistryStore.path_in(self.root)).read().require_held_by( + MaintenanceHolder.current().nonce, "Carry embedded PR-head reviews") + with TodoStore.observing(path) as db: + sources = EmbeddedReviewSource.read(db.execute( + f'SELECT id,repo,"order",prior_orders,verdicts FROM "{Todo.declared_name}" ' + "WHERE EXISTS (SELECT 1 FROM json_each(verdicts) " + "WHERE json_extract(value,'$.kind')='reviewed_head_verdict')")) + prepared = tuple((source, source.records()) for source in sources) + count = sum(len(records) for _, records in prepared) + if apply and count: + with closing(TodoStore.writer(path)) as db, db: + db.execute("PRAGMA foreign_keys=ON") + db.execute("BEGIN IMMEDIATE") + StoredHeadReview.create(db, if_not_exists=True) + for source, records in prepared: + for record in records: + StoredHeadReview.insert_values(db, record=record) + db.execute(f'UPDATE "{Todo.declared_name}" SET verdicts=? WHERE id=?', + (json.dumps(source.remaining()), source.id)) + return Carried(count, detail={"reviews": count, "todos": len(prepared)}, + rewritten=(path.relative_to(self.root),) if apply and count else ()) From ae734e304a6ae013c8838fc1976ff201e4ff5a64 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:27:27 -0400 Subject: [PATCH 14/15] Keep the one-shot head review carry imports minimal --- tools/cutover/carry_head_reviews.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/cutover/carry_head_reviews.py b/tools/cutover/carry_head_reviews.py index dde3f3aff5..5f4e614d77 100644 --- a/tools/cutover/carry_head_reviews.py +++ b/tools/cutover/carry_head_reviews.py @@ -9,7 +9,6 @@ import json from contextlib import closing from dataclasses import dataclass -from pathlib import Path from typing import Annotated, ClassVar, Literal from pydantic import JsonValue From 328a7ac3e73b99ee3a52fe44d7056dbc3ce2f8a4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:45:17 -0400 Subject: [PATCH 15/15] Publish every current commander observation and remove the duplicate head verb --- src/agent_comms/commander/judge.py | 33 --------------------------- src/agent_comms/commander/standing.py | 4 ++-- src/agent_comms/commander_pings.py | 22 ++++++++++-------- tests/test_commander_notifications.py | 12 ++++------ tests/test_proof_build.py | 4 ++-- 5 files changed, 21 insertions(+), 54 deletions(-) diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index 1f464d8910..a0a7425be0 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -46,9 +46,6 @@ RejectedHeadVerdict, HeadVerdict, HeadReviewOrder, - LinkedHeadReview, - NoHeadOrder, - OrderHeadReview, SendBackVerdict, StrikeReason, SupersededVerdict, @@ -157,36 +154,6 @@ def run(self, ctx: order.Commander) -> None: alarm(ctx) -@dataclass(frozen=True, kw_only=True) -class HeadVerb(JudgeVerb): - help = ("accept an order's reviewed PR head: every edit before it is judged and " - "agent-comms/review is posted on the commit") - problem: Annotated[str, Argument(flags=("problem",), options={"metavar": "PROBLEM"})] - head: Annotated[str, Argument(flags=("head",), options={"metavar": "SHA"})] - serves: Annotated[ - str, Argument(options={"help": "exact words of the order the head carries out"}) - ] - pr: int = 0 - - def run(self, ctx: order.Commander) -> None: - held = [todo for todo in ctx.held_orders() if todo.id == self.problem] - if not held: - sys.exit(f'Refused: {self.problem} is not an order this commander holds') - todo, = held - links = tuple(link for link in todo.order.checked().pull_requests - if not self.pr or link.number == self.pr) - if len(links) != 1: - sys.exit('Refused: select --pr from this order\'s linked pull requests') - link, = links - ctx.call(CommsHeadVerdictTool( - request=PullRequestHead(link.repo, link.number, self.head), - operation=ReviewedHeadVerdict(), - order=OrderHeadReview(todo.revision, LinkedHeadReview.of(todo, self.serves)), - )) - left = len(Edit.unjudged(ctx.comms, ctx.commander)) - print(f'head {self.head} accepted on {todo.id}; {left} still unjudged') - - class ReviewSubject(DeclaredFamily): """Decode the CLI's mutually exclusive selectors once, then keep the selected subject.""" diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index 5b620bfe7b..bf25a87e88 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -419,8 +419,8 @@ def line(self) -> str: @dataclass(frozen=True) class Unjudged(ReasonedStandingRule, UndatedStandingRule): limit: ClassVar[int] = 30 - text = ("UNJUDGED {why} edits -> accept each reviewed PR head with judge head (one verdict" - " judges every edit before it), or fork judges for the rest") + text = ("UNJUDGED {why} edits -> accept each reviewed PR head with judge ok --pr N --head SHA" + " (one verdict judges every edit before it), or fork judges for the rest") @classmethod def reason(cls, comms: Comms, commander: str) -> str | None: diff --git a/src/agent_comms/commander_pings.py b/src/agent_comms/commander_pings.py index 2500476f63..912f71cced 100644 --- a/src/agent_comms/commander_pings.py +++ b/src/agent_comms/commander_pings.py @@ -1,7 +1,7 @@ """Committed commander notifications own what the heartbeat has already handled. -Quiet state changes are passive notices. Unhandled urgent states remain eligible -for the next five-decision packet; no process-local cache or pending queue owns them. +Quiet state changes are passive notices. Urgent packets lead with five decisions +and publish every current observation; no process-local cache owns them. """ from __future__ import annotations @@ -89,13 +89,17 @@ class CommanderUpdate(TaskAttachment, declared_name=None): @abstractmethod def body(self, previous: CommanderUpdate | None) -> str: ... + def changed_pings(self, previous: CommanderUpdate | None, + presented: tuple[Ping, ...] = ()) -> tuple[Ping, ...]: + known = frozenset(previous.handled) if previous else frozenset() + return tuple(ping for ping in self.handled if ping not in known and ping not in presented) + def changed_lines(self, previous: CommanderUpdate | None, presented: tuple[Ping, ...] = ()) -> tuple[str, ...]: prior = previous.handled if previous else () - known = frozenset(prior) current = frozenset(ping.key for ping in self.handled) return ( - *(ping.text for ping in self.handled if ping not in known and ping not in presented), + *(ping.text for ping in self.changed_pings(previous, presented)), *(f"NO LONGER CURRENT: {ping.text}" for ping in prior if ping.key not in current), ) @@ -132,10 +136,7 @@ def derive(cls, commander: ThreadIncarnation, states: tuple[UnitState, ...], urgent = (*(UnitPing.capture(state) for state in states if state.attention()), *(RulePing.capture(rule) for rule in rules)) decisions = tuple(ping for ping in urgent if known.get(ping.key) != ping)[:5] - handled = (*quiet, *( - ping if ping in decisions else known[ping.key] - for ping in urgent if ping in decisions or ping.key in known - )) + handled = (*quiet, *urgent) if decisions: return CommanderDecisions(commander, handled, decisions) if previous is None or handled != previous.handled: @@ -171,5 +172,8 @@ class CommanderDecisions(CommanderUpdate): role = ConversationRole() def body(self, previous: CommanderUpdate | None) -> str: + also_current = self.changed_pings(previous, self.decisions) return "\n".join((*(ping.text for ping in self.decisions), - *self.changed_lines(previous, self.decisions))) + *(("ALSO CURRENT:", *(ping.text for ping in also_current)) + if also_current else ()), + *self.changed_lines(previous, self.handled))) diff --git a/tests/test_commander_notifications.py b/tests/test_commander_notifications.py index d51229a8ca..a28d4c02cd 100644 --- a/tests/test_commander_notifications.py +++ b/tests/test_commander_notifications.py @@ -72,7 +72,7 @@ def test_recreated_watcher_reads_original_committed_unit_observation(notificatio assert unchanged.reference == first.reference -def test_rule_packets_admit_five_and_leave_remaining_occurrences_unhandled(notification_bus): +def test_rule_packets_lead_with_five_and_publish_all_current_occurrences(notification_bus): comms = notification_bus rules = tuple(ServicePassFailing(f"watch-{index}", 1.0, "failed source read") for index in range(7)) @@ -80,16 +80,12 @@ def test_rule_packets_admit_five_and_leave_remaining_occurrences_unhandled(notif first, = notifications(comms) assert isinstance(first.task, CommanderDecisions) assert len(first.task.decisions) == 5 + assert {RulePing.capture(rule) for rule in rules} == set(first.task.handled) beat(comms, rules) - first, second = notifications(comms) - assert isinstance(second.task, CommanderDecisions) - assert len(second.task.decisions) == 2 - assert {RulePing.capture(rule) for rule in rules} == set(second.task.handled) - beat(comms, rules) - assert len(notifications(comms)) == 2 + assert len(notifications(comms)) == 1 # Changed diagnostic wording is not another failed occurrence. beat(comms, tuple(replace(rule, error="new diagnostic detail") for rule in rules)) - assert len(notifications(comms)) == 2 + assert len(notifications(comms)) == 1 def test_resolution_is_passive_and_same_rule_can_recur(notification_bus): diff --git a/tests/test_proof_build.py b/tests/test_proof_build.py index 61fafc2eff..1225406607 100644 --- a/tests/test_proof_build.py +++ b/tests/test_proof_build.py @@ -386,6 +386,6 @@ def test_install_waits_for_merges_to_settle_into_one_batch( monkeypatch.setattr(time, "time", lambda: merged + InstallDue.settle) (due,) = InstallDue.violations(bus, "lead") - assert graph.newer in due.why - assert "oldest 30 min" in due.why + assert due.main == graph.newer + assert due.oldest_minutes == 30