From cde44c806b071002857bf1660be76457e9500f03 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 18:00:17 -0400 Subject: [PATCH 1/8] Derive unit cards once and keep rule scans separate --- plans/commander-native-plan.md | 2 +- src/agent_comms/active_route.py | 2 +- src/agent_comms/commander/capacity.py | 11 +- src/agent_comms/commander/config.py | 18 +- src/agent_comms/commander/order.py | 8 +- src/agent_comms/commander/standing.py | 160 ++---- src/agent_comms/commander/vibe_check.py | 38 +- src/agent_comms/commander_heartbeat.py | 67 +-- src/agent_comms/commander_parity/deletion.py | 6 +- src/agent_comms/messages.py | 10 +- .../skills/comms-commander/SKILL.md | 8 +- src/agent_comms/standing_rules.py | 115 ++--- src/agent_comms/storage_rules.py | 33 +- src/agent_comms/todos.py | 58 ++- src/agent_comms/tools.py | 5 +- src/agent_comms/ui_model/unit_state.py | 466 ++++++++++++++++++ tests/test_commander_capacity.py | 35 ++ tests/test_holder_standing_rules.py | 97 ++-- tests/test_install.py | 6 +- tests/test_proof_build.py | 2 +- tests/test_unit_state.py | 177 +++++++ tests/test_unowned_checkout_work.py | 32 +- 22 files changed, 1020 insertions(+), 336 deletions(-) create mode 100644 src/agent_comms/ui_model/unit_state.py create mode 100644 tests/test_unit_state.py diff --git a/plans/commander-native-plan.md b/plans/commander-native-plan.md index 3fff12ac45..f9fcec6bd9 100644 --- a/plans/commander-native-plan.md +++ b/plans/commander-native-plan.md @@ -157,7 +157,7 @@ Commanding is a high-tempo RTS. The fleet loses information because Core hands t The fix is one derived model with four renderers. **Core is the engine, Toad is the screen, the commander's context is the HUD feed, and the connector serves claude-ai the same frames.** Nothing computes fleet state twice. **Engine: `CommanderHud` in `ui_model`, derived on read, holding no state but one cursor.** -- **`UnitState` family.** The rotation tick's classification moves out of SKILL prose into Core: `Blocked` (goal execution blocked while holding an order), `Detached` (not receiving), `AwaitingCommander` (a question, alert or handoff with no commander answer, from `Message.answers`), `Stuck` (no tool call while holding an order), `Spinning` (send-backs or reruns with no new commit), `StalledOnNothing` (turn ended holding an order with nothing pending), `DeliveredUnclosed` (merged or installed, order open), `Moving` (fresh commit on the order's surface), `Idle` (no order). Each member derives itself from turn digests, git, goal execution and messages; owns its card line, its ping and its default action. Urgency order is the MRO, never a rank field. +- **`UnitState` family.** The rotation tick's classification moves out of SKILL prose into Core: `Unreadable` (a checkout or other unit source cannot be read; other units still get their cards), `Blocked` (goal execution blocked while holding an order), `Detached` (not receiving), `AwaitingCommander` (a question, alert or handoff with no commander answer, from `Message.answers`), `Stuck` (no tool call while holding an order), `Spinning` (send-backs or reruns with no new commit), `StalledOnNothing` (turn ended holding an order with nothing pending), `DeliveredUnclosed` (merged or installed, order open), `Moving` (fresh commit on the order's surface), `Idle` (no order). Each member derives itself from turn digests, git, goal execution and messages; owns its card line, its ping and its default action. States are direct `UnitState` members, sharing behavior through capability mixins rather than claiming to be other states. Urgency is their declaration order in the family's registry, never a rank field. - **Pings are decisions, each once.** A unit entering an urgent state, a review timer up, a check failed, a merge, an install, a run seen working, an owner message. Keyed by unit, state and order revision, so nothing re-fires. The `StandingRule` family is the ping source; `HolderCannotRun` (with `Detached` and `Blocked` as members) and `UnitAskUnanswered` join it. - **Frames.** `CommanderHud.frame()` is a snapshot; `frame.since(cursor)` is the delta: only changed cards and new pings, like a netcode delta. The one stored fact is each reader's cursor. - **Kill feed:** `comms_fleet_story` plus merges and installs, newest first. **Economy bar:** free disk against floor, merge-queue depth, units busy, idle and blocked, unjudged edits, model budget where the provider reports it. **Scoreboard:** per unit, delivered and installed orders, strikes, and self-inflicted wait minutes (`UnitTime`, row 32). diff --git a/src/agent_comms/active_route.py b/src/agent_comms/active_route.py index fd5dd0cb85..f861031e77 100644 --- a/src/agent_comms/active_route.py +++ b/src/agent_comms/active_route.py @@ -147,7 +147,7 @@ def wire_log(self) -> WireLog: def selects_its_root(self) -> bool: """Whether the root still exists and carries the marker this route was published for.""" return self.root.is_dir() and ( - self.wire_log().read_metadata_unlocked().wire_root_id == self.wire_root_id) + self.wire_log().read_metadata_unlocked().root_id == self.wire_root_id) def observe_root(self) -> Path: # The writer atomically replaces this marker. Reuse its owner/mode, diff --git a/src/agent_comms/commander/capacity.py b/src/agent_comms/commander/capacity.py index 4191ee9c07..8d260b3b33 100644 --- a/src/agent_comms/commander/capacity.py +++ b/src/agent_comms/commander/capacity.py @@ -2,12 +2,14 @@ from __future__ import annotations import time +from collections.abc import Sequence from dataclasses import dataclass from ..comms import Comms from ..field_codec import projected from ..tool_result import ToolResult from ..turn_digests import OrderEditCountRead +from ..ui_model.unit_state import UnitState @dataclass(frozen=True) @@ -22,14 +24,17 @@ class Load(ToolResult): @classmethod def of(cls, comms: Comms, commander: str) -> Load: + return cls.from_states(comms, commander, UnitState.current(comms, commander)) + + @classmethod + def from_states(cls, comms: Comms, commander: str, observed: Sequence[UnitState]) -> Load: snapshot, todos, now = comms.registry.snapshot(), comms.goals.todos, time.time() owner = snapshot.require(commander) held = todos.held_for_commander(owner.incarnation) owed = tuple(t for t in held if t.pending) forks = tuple(sorted( - thread.name for thread in snapshot.threads.values() - if thread.parent.named(owner.name) - and comms.agents.is_working(thread.incarnation, snapshot) + state.unit.name for state in observed + if state.source.thread.parent.named(owner.name) and state.source.working )) unjudged = sum( read.read() for read in OrderEditCountRead.for_orders(comms.root, snapshot, held) diff --git a/src/agent_comms/commander/config.py b/src/agent_comms/commander/config.py index dbca5bdbd0..1f237dd4ef 100644 --- a/src/agent_comms/commander/config.py +++ b/src/agent_comms/commander/config.py @@ -9,10 +9,11 @@ from dataclasses import dataclass from importlib.resources import files from pathlib import Path -from typing import TYPE_CHECKING, Annotated, Self +from typing import TYPE_CHECKING, Annotated, ClassVar, Self from pydantic import BaseModel, ConfigDict, ValidationInfo, field_validator +from agent_comms.locked_store import DurableFile from agent_comms.phase_evidence import MainTree, PlanReading from agent_comms.plan_phases import PlanPhase @@ -48,7 +49,8 @@ def require_commander(thread: Thread) -> None: ) -class CommanderConfig(CommanderRole, BaseModel): +class CommanderConfig(CommanderRole, BaseModel, DurableFile): + filename: ClassVar[str] = "commander.json" model_config = ConfigDict(frozen=True) commander: str @@ -62,7 +64,7 @@ class CommanderConfig(CommanderRole, BaseModel): @staticmethod def path(home: Path | None = None) -> Path: - return (home or Path.home()) / '.config/agent-comms/commander.json' + return (home or Path.home()) / '.config/agent-comms' / CommanderConfig.filename @staticmethod def packaged_rules() -> Path: @@ -91,14 +93,10 @@ def selected_root(cls, root: Path, info: ValidationInfo) -> Path: def read(cls, path: Path | str) -> Self: return cls.model_validate_json(Path(path).read_text()) - @staticmethod - def root_path(root: Path) -> Path: - return root / 'commander.json' - @classmethod def read_root(cls, root: Path | str) -> Self: root = Path(root).resolve() - return cls.model_validate_json(cls.root_path(root).read_text(), context=root) + return cls.model_validate_json(cls.path_in(root).read_text(), context=root) @classmethod def on_machine(cls, commander: str, repos: tuple[Repo, ...], root: Path, home: Path, @@ -143,7 +141,7 @@ def write(self, home: Path) -> Path: self.require_commander(Comms(self.root).registry.require(self.commander)) path = self.path(home) path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - record = self.root_path(self.root) + record = self.path_in(self.root) record.write_text(self.model_dump_json(indent=1) + '\n') path.unlink(missing_ok=True) path.symlink_to(record) @@ -160,7 +158,7 @@ class CommanderRecord(ABC): @boundary_decoder def read(root: Path) -> CommanderRecord: root = root.resolve() - if CommanderConfig.root_path(root).is_file(): + if CommanderConfig.path_in(root).is_file(): return RecordedCommander(CommanderConfig.read_root(root)) return NoCommanderRecord() diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index 5a04b5b97a..61ed32308c 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -102,6 +102,7 @@ CommsTodoListTool, TodoList, ) +from agent_comms.ui_model.unit_state import UnitState OUTGOING = DirectiveText.OUTGOING # every commander text passes each one @@ -804,7 +805,9 @@ def answer_references(self, ctx: Commander) -> tuple[MessageReference, ...]: def gate(self, ctx: Commander) -> None: named = self.named(ctx) - owed = tuple(rule for rule in OwedAnswer.broken(ctx.comms, self.who(ctx)) + states = UnitState.current(ctx.comms, self.who(ctx)) + owed = tuple(rule for rule in OwedAnswer.broken( + ctx.comms, self.who(ctx), observed=states) if not rule.answered_by(named)) if owed: lines = '\n'.join(f'{rule.line()}: {rule.message.body[:80]}' for rule in owed) @@ -1208,7 +1211,8 @@ class NextHuntVerb(OrderVerb, declared_name="next-hunt"): help = "print the next place to hunt" def run(self, ctx: Commander) -> None: - for path in HuntDue.next_hunt(ctx.comms, ctx.commander): + for path in HuntDue.next_hunt(ctx.comms, ctx.commander, + HuntDue.todos(ctx.comms, ctx.commander)): print(path) diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index 6e542af419..3cd0ffd1c5 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -3,12 +3,10 @@ from __future__ import annotations import time -from abc import abstractmethod from collections.abc import Iterable, Sequence from contextlib import closing from dataclasses import dataclass, field -from pathlib import Path -from typing import ClassVar, Self +from typing import TYPE_CHECKING, ClassVar, Self from agent_comms.build_identity import RecordedBuildIdentity from agent_comms.bus_page_index import BusPageIndex @@ -17,18 +15,18 @@ from agent_comms.commander_heartbeat import CommanderHeartbeat from agent_comms.comms import Comms from agent_comms.goal_actions import ReissuedOrderGoalAction -from agent_comms.goal_presentation import BlockedExecution from agent_comms.goals import Goal from agent_comms.message_bus import WatchPass from agent_comms.message_page import MessageReadRequest, PageBounds from agent_comms.message_reference import MessageReference -from agent_comms.messages import Message +from agent_comms.messages import Message, NeedsAnswerKind from agent_comms.phase_evidence import PlanReading from agent_comms.plan_phases import PlanPhase from agent_comms.read_basis import NamedViewer, RepliesDisplayScope from agent_comms.recorded_time import RecordedEventTime from agent_comms.standing_rules import ( DatedStandingRule, + IndependentStandingRule, OwedAnswer, ReasonedStandingRule, StandingRule, @@ -39,12 +37,14 @@ from agent_comms.todos import Todo from agent_comms.turn_digests import OrderEditCountRead, TurnDigestRead, TurnsSince from agent_comms.wire_log import WireSnapshot -from agent_comms.work_branches import WorkBranches from agent_comms.work_dispatch import ReusedWorkDispatch, WorkDispatch +if TYPE_CHECKING: + from agent_comms.ui_model.unit_state import UnitState + @dataclass(frozen=True) -class InstallDue(UndatedStandingRule): +class InstallDue(IndependentStandingRule, UndatedStandingRule): """A configured main not contained in the installed build needs an install once its oldest uninstalled merge has stood for the settle window. @@ -82,77 +82,7 @@ def line(self) -> str: @dataclass(frozen=True) -class HolderCannotRun(UndatedStandingRule, declared_name=None): - unit: str - orders: tuple[str, ...] - - @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 GoalCannotRun(UndatedStandingRule): - """Report blocked work without rewriting the unit's goal or orders.""" - - unit: str - orders: tuple[str, ...] - execution: BlockedExecution - - @classmethod - def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: - return tuple( - cls(owner.name, tuple(todo.id for todo in todos), block) - for owner, todos in cls.holders(comms, commander).items() - for view in (comms.goals.goal_snapshot(owner.name),) - for block in view.execution.unit_blocks() - ) - - def line(self) -> str: - return self.execution.unit_block_line(self.unit, self.orders) - - -@dataclass(frozen=True) -class RequestStalled(UndatedStandingRule): +class RequestStalled(IndependentStandingRule, UndatedStandingRule): goal: Goal @classmethod @@ -199,6 +129,22 @@ class UnansweredUnitAsks(MessageReadRequest[tuple[Message, ...]]): before: float empty: ClassVar[tuple[Message, ...]] = () + @classmethod + def for_orders( + cls, comms: Comms, commander: str, todos: Sequence[Todo], before: float + ) -> tuple[Message, ...]: + if not todos: + return () + snapshot = comms.registry.snapshot() + peers = frozenset(name for todo in todos for name in snapshot.names_for(todo.held().owner)) + since = min( + order.at.recorded_at("A held order") for todo in todos for order in todo.order_history + ) + questions = comms.bus.log.read_page(cls( + RepliesDisplayScope(snapshot.names_for(commander), peers), since, before, + )) + return tuple(message for message in questions if issubclass(message.type, NeedsAnswerKind)) + def indexed(self, index: BusPageIndex, source: WireSnapshot) -> tuple[Message, ...]: answered: set[MessageReference] = set() found: list[Message] = [] @@ -250,7 +196,7 @@ def indexed(self, index: BusPageIndex, source: WireSnapshot) -> tuple[Message, . return tuple(m for m in sent if covered < m.timestamp <= self.before) -class PlanRule(UndatedStandingRule, declared_name=None): +class PlanRule(IndependentStandingRule, UndatedStandingRule, declared_name=None): """A rule read from one look at the plans and the commander's orders.""" @staticmethod @@ -320,32 +266,21 @@ def line(self) -> str: @dataclass(frozen=True) class UnitAskUnanswered(OwedAnswer, UndatedStandingRule): - unit: str - seconds: ClassVar[int] = 5 * 60 + """Per-message gate entries derived from the unit frame, not another unit scan.""" - @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 = StandingRule.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, - )) + unit: str @classmethod - def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: - snapshot = comms.registry.snapshot() - return tuple(cls(message, snapshot.canonical_name(message.sender)) - for message in cls.asks(comms, commander, time.time() - cls.seconds)) + def scan_observed(cls, comms: Comms, commander: str, + observed: Sequence[UnitState]) -> tuple[Self, ...]: + return tuple(cls(message, state.unit.name) for state in observed for message in state.owed) @classmethod def open_messages(cls, comms: Comms, commander: str) -> tuple[Message, ...]: - return cls.asks(comms, commander, time.time()) + from agent_comms.ui_model.unit_state import UnitState + + return tuple(message for state in UnitState.current(comms, commander) + for message in state.open_questions()) def line(self) -> str: age = max(0, int((time.time() - self.message.timestamp) // 60)) @@ -354,7 +289,7 @@ def line(self) -> str: @dataclass(frozen=True) -class UnownedWheelSpin(UndatedStandingRule): +class UnownedWheelSpin(IndependentStandingRule, UndatedStandingRule): """A unit holds no next order while the board has unowned rows; Core hands the row to it without a commander turn, restarting the row's review window at the handoff.""" @@ -484,30 +419,7 @@ def heartbeat_line(self, current: Sequence[WaitingUnownedWheelSpin]) -> str: @dataclass(frozen=True) -class CheckoutUnreadable(UndatedStandingRule): - """A holder's checkout git cannot open: that unit's state, while every other unit reads on.""" - - unit: str - checkout: Path - error: str = field(compare=False) - - @classmethod - def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: - storage = comms.registry.storage_rule - holders = sorted({todo.held().owner for todo in cls.pending_todos(comms, commander)}) - return tuple( - cls(holder, path, error) - for holder in holders - for path in storage.checkout_paths(comms.registry.require(holder)) - for error in WorkBranches(path).read_checkout().errors() - ) - - def line(self) -> str: - return (f"CHECKOUT UNREADABLE: {self.unit} {self.checkout}: {self.error}" - " -> order the unit to repair its checkout; its work stays retained") - -@dataclass(frozen=True) -class HeartbeatSilent(DatedStandingRule): +class HeartbeatSilent(IndependentStandingRule, DatedStandingRule): """A commander the heartbeat serves whose newest beat is older than the cadence allows. The heartbeat beats on every pass, so its silence is the failure; this rule reads the diff --git a/src/agent_comms/commander/vibe_check.py b/src/agent_comms/commander/vibe_check.py index bcbc03f1d8..dcfd7136cd 100644 --- a/src/agent_comms/commander/vibe_check.py +++ b/src/agent_comms/commander/vibe_check.py @@ -12,7 +12,7 @@ from abc import abstractmethod from collections.abc import Sequence from dataclasses import dataclass, field -from typing import ClassVar, Self +from typing import TYPE_CHECKING, ClassVar, Self from agent_comms.commander.capacity import Load from agent_comms.commander.config import CommanderConfig @@ -27,7 +27,12 @@ from agent_comms.messages import Message from agent_comms.read_basis import RepliesDisplayScope from agent_comms.service_sender import ServiceSender -from agent_comms.standing_rules import OwedAnswer, StandingRule, UndatedStandingRule +from agent_comms.standing_rules import ( + IndependentStandingRule, OwedAnswer, StandingRule, UndatedStandingRule, +) + +if TYPE_CHECKING: + from agent_comms.ui_model.unit_state import UnitState class EscalationSender(ServiceSender): @@ -106,7 +111,8 @@ class Degradation(DeclaredFamily, affix="Degradation"): @classmethod @abstractmethod - def found(cls, comms: Comms, commander: str) -> tuple[Sign, ...]: + def found(cls, comms: Comms, commander: str, + observed: Sequence[UnitState]) -> tuple[Sign, ...]: """How the signal shows now, one sign per occurrence; empty while it does not.""" @classmethod @@ -132,10 +138,11 @@ class AnswersOwedDegradation(Degradation): """Messages to the commander are waiting past their window; each message is one sign.""" @classmethod - def found(cls, comms: Comms, commander: str) -> tuple[Sign, ...]: + def found(cls, comms: Comms, commander: str, + observed: Sequence[UnitState]) -> tuple[Sign, ...]: return tuple( Sign(cls, (rule.cited,), rule.line()) - for rule in OwedAnswer.broken(comms, commander) + for rule in OwedAnswer.broken(comms, commander, observed=observed) if not isinstance(rule, Escalation) ) @@ -145,7 +152,8 @@ class PlanStalledDegradation(Degradation): phase stops the commander when it crosses the window and again each time its age doubles.""" @classmethod - def found(cls, comms: Comms, commander: str) -> tuple[Sign, ...]: + def found(cls, comms: Comms, commander: str, + observed: Sequence[UnitState]) -> tuple[Sign, ...]: now = time.time() return tuple( Sign(cls, (rule.phase, rule.since, cls.doublings(now - rule.since)), rule.line()) @@ -166,8 +174,9 @@ class OverloadDegradation(Degradation): round_seconds: ClassVar[int] = 30 * 60 @classmethod - def found(cls, comms: Comms, commander: str) -> tuple[Sign, ...]: - load = Load.of(comms, commander) + def found(cls, comms: Comms, commander: str, + observed: Sequence[UnitState]) -> tuple[Sign, ...]: + load = Load.from_states(comms, commander, observed) if load.share < cls.ceiling: return () return (Sign(cls, (int(time.time() // cls.round_seconds),), @@ -183,11 +192,12 @@ class VibeCheck(UndatedStandingRule): sign: Sign @classmethod - def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: + def scan_observed(cls, comms: Comms, commander: str, + observed: Sequence[UnitState]) -> tuple[Self, ...]: return tuple( cls(sign) for member in Degradation.members_with(Degradation) - for sign in member.found(comms, commander) + for sign in member.found(comms, commander, observed) ) def heartbeat_group(self) -> object: @@ -218,7 +228,7 @@ def line(self) -> str: @dataclass(frozen=True) -class VibeCheckUnanswered(Escalation, OwedAnswer, UndatedStandingRule): +class VibeCheckUnanswered(Escalation, IndependentStandingRule, OwedAnswer, UndatedStandingRule): """A vibe check is owed an answer like a directive: the gate refuses until it gets one.""" seconds: ClassVar[int] = 5 * 60 @@ -262,7 +272,7 @@ def quoted(cls, body: str) -> bool: @dataclass(frozen=True) -class Chokehold(UndatedStandingRule): +class Chokehold(IndependentStandingRule, UndatedStandingRule): """A vibe check ignored past its window: every unit under the commander is paused and the commander is told, in a burst it cannot skim, what brings them back.""" @@ -311,7 +321,7 @@ def line(self) -> str: @dataclass(frozen=True) -class ChokeholdUnanswered(Escalation, OwedAnswer, UndatedStandingRule): +class ChokeholdUnanswered(Escalation, IndependentStandingRule, OwedAnswer, UndatedStandingRule): """Every chokehold message gates the commander from the moment it lands.""" @classmethod @@ -327,7 +337,7 @@ def line(self) -> str: @dataclass(frozen=True) -class ChokeholdAnswer(UndatedStandingRule, declared_name=None): +class ChokeholdAnswer(IndependentStandingRule, UndatedStandingRule, declared_name=None): """The commander's newest answer to a chokehold, once every chokehold message is answered.""" answer: Message diff --git a/src/agent_comms/commander_heartbeat.py b/src/agent_comms/commander_heartbeat.py index 4cfb9a28c6..848507cdc6 100644 --- a/src/agent_comms/commander_heartbeat.py +++ b/src/agent_comms/commander_heartbeat.py @@ -11,6 +11,8 @@ 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 PullRequests +from agent_comms.ui_model.unit_state import UnitState if TYPE_CHECKING: from agent_comms.comms import Comms @@ -20,45 +22,41 @@ @dataclass class CommanderReview: - """One commander's handled observations, its last changed beat and next review position. + """One commander's handled observations and its last changed beat. Seq 0 precedes every published message: no changed beat has been sent yet. """ - violations: frozenset[StandingRule] + rules: frozenset[StandingRule] + states: frozenset[UnitState] = frozenset() changed: int = 0 - turn: int = 0 def wake(self, comms: Comms, sender: str, commander: Thread, - rules: tuple[StandingRule, ...]) -> None: + rules: tuple[StandingRule, ...], states: tuple[UnitState, ...]) -> None: """Every pass beats, so silence is a failure; an unchanged beat is one short line.""" - found = frozenset(rules) - previous = self.violations - if found == previous and self.changed: - comms.messaging.send(sender, commander.name, ( - f"COMMANDER HEARTBEAT: unchanged since seq {self.changed}," - f" {len(found)} violations.")) + 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: + lines = [f"COMMANDER HEARTBEAT: unchanged since seq {self.changed}," + f" {len(actionable)} violations."] + lines.extend(state.line() for state in states) + comms.messaging.send(sender, commander.name, "\n".join(lines)) return # Mark the observation before any effect; an uncertain publication is not replayed. - self.violations = found + self.rules, self.states = found_rules, found_states rejected = StandingRule.enforce_all( - comms, commander.name, (rule for rule in rules if rule not in previous) + 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.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: + 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 @@ -68,6 +66,15 @@ class CommanderObservation: thread: Thread merged: tuple[Todo, ...] rules: tuple[StandingRule, ...] + states: tuple[UnitState, ...] + + @classmethod + def read(cls, comms: Comms, thread: Thread) -> CommanderObservation: + pulls = PullRequests(StandingRule.todos(comms, thread.name)) + states = UnitState.current(comms, thread.name, pulls) + rules = StandingRule.broken(comms, thread.name, observed=states) + merged = tuple(todo for state in states for todo in state.merged_orders(comms, pulls)) + return cls(thread, merged, rules, states) @dataclass @@ -89,11 +96,7 @@ def commanders(cls, registry: Registration, goals: Goals) -> tuple[Thread, ...]: 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), - ) + CommanderObservation.read(comms, thread) for thread in self.commanders(comms.registry, comms.goals) ) @@ -107,4 +110,4 @@ def pass_body(self, comms: Comms, observed: tuple[CommanderObservation, ...]) -> 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) + review.wake(comms, self.sender, item.thread, item.rules, item.states) diff --git a/src/agent_comms/commander_parity/deletion.py b/src/agent_comms/commander_parity/deletion.py index ce2a1f6073..194bfeaa6c 100644 --- a/src/agent_comms/commander_parity/deletion.py +++ b/src/agent_comms/commander_parity/deletion.py @@ -15,6 +15,7 @@ from ..change_report import FileLine from ..child_process import CountPrinted, ExitedOutcome +from ..commander.config import CommanderConfig from ..commander.order import OrderFile from ..field_codec import FieldCodec from ..goal_actions import CreateProblemGoalAction @@ -22,6 +23,7 @@ from ..progress_claims import MergedClaim from ..recorded_time import RecordedEventTime from ..repo import Repo +from ..storage_rules import StorageRule from ..todos import ( CheckedCommitDelivery, CommitDelivery, @@ -85,10 +87,10 @@ def read(cls) -> LifecycleObservation: prefix="parity-deletion-", dir=rule.checkout_base ) as checkouts: root = original.commander.root - (root / "storage.json").write_text( + StorageRule.path_in(root).write_text( json.dumps(FieldCodec.encode(replace(rule, checkout_base=Path(checkouts)))) ) - (root / "commander.json").write_text(original.commander.model_dump_json()) + CommanderConfig.path_in(root).write_text(original.commander.model_dump_json()) run = replace(original, commander=MeasuredCommander.read_root(root)) return cls.exercise(run) diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index c7bf2bb939..503d3c5ff8 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): + """Questions, alerts and handoffs ask their 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 89f82b997b..9674e6f918 100644 --- a/src/agent_comms/skills/comms-commander/SKILL.md +++ b/src/agent_comms/skills/comms-commander/SKILL.md @@ -72,13 +72,7 @@ Priority: the owner's latest message; a broken install, lost or replayed input, ## 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, with a strike (rules/send-backs-and-strikes.md). -- **Idle:** give it the top unowned plan step if your load has room to review it; otherwise leave it stopped. -- **Adding slop:** send it back with the result it violates. +Read the heartbeat's unit cards in their urgency order. Each `UnitState` member owns its classification and default action. Read the selected unit's diff and newest turn to judge whether its work is correct; send back added slop with the result it violates. 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 f2867f8d62..0c0f685c63 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -28,12 +28,14 @@ from agent_comms.storage_rules import MemoryUse, StorageRule from agent_comms.thread_identity import ThreadIncarnation from agent_comms.todos import Todo + from agent_comms.ui_model.unit_state import UnitState class StandingRule(DeclaredFamily, affix="StandingRule", discovery_package="agent_comms.commander"): @classmethod @abstractmethod - def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: ... + def scan_observed(cls, comms: Comms, commander: str, + observed: Sequence[UnitState]) -> tuple[Self, ...]: ... @abstractmethod def line(self) -> str: ... @@ -41,6 +43,9 @@ def line(self) -> str: ... @abstractmethod def broke_after(self, after: float) -> bool: ... + def attention(self) -> bool: + return True + def rejected_line(self) -> str: return f"Not applied (goal changed): {self.line()}" @@ -98,9 +103,9 @@ def pending_todos(comms: Comms, commander: str) -> tuple[Todo, ...]: @staticmethod def holders(comms: Comms, commander: str) -> dict[ThreadIncarnation, list[Todo]]: - """Every unit holding a pending order, with its orders.""" + """Every unit holding an open order, with its orders.""" held: dict[ThreadIncarnation, list[Todo]] = {} - for todo in StandingRule.pending_todos(comms, commander): + for todo in StandingRule.todos(comms, commander): held.setdefault(todo.held().owner_identity, []).append(todo) return held @@ -110,11 +115,26 @@ def scan_owner(cls) -> type[StandingRule]: return cls @classmethod - def broken(cls, comms: Comms, commander: str, after: float = 0.0) -> tuple[Self, ...]: - """Read each inherited scan owner once, preserving the requested member scope.""" + def broken(cls, comms: Comms, commander: str, after: float = 0.0, *, + observed: Sequence[UnitState]) -> tuple[StandingRule, ...]: + """Read each rule owner once against the supplied unit frame, including an empty one.""" owners = dict.fromkeys(rule.scan_owner() for rule in cls.members_with(cls)) - found = (v for owner in owners for v in owner.violations(comms, commander)) - return tuple(rule for rule in found if isinstance(rule, cls) and rule.broke_after(after)) + return tuple(rule for owner in owners + for rule in owner.scan_observed(comms, commander, observed) + if rule.attention() and rule.broke_after(after)) + + +class IndependentStandingRule(StandingRule, declared_name=None): + """A rule whose source read does not consume the unit observation frame.""" + + @classmethod + @abstractmethod + def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: ... + + @classmethod + def scan_observed(cls, comms: Comms, commander: str, + observed: Sequence[UnitState]) -> tuple[Self, ...]: + return cls.violations(comms, commander) class UndatedStandingRule(StandingRule, declared_name=None): @@ -175,20 +195,22 @@ class ReasonedStandingRule(StandingRule, declared_name=None): def reason(cls, comms: Comms, commander: str) -> str | None: ... @classmethod - def found(cls, why: str, comms: Comms, commander: str) -> Self: + def found(cls, why: str, comms: Comms, commander: str, + observed: Sequence[UnitState]) -> Self: return cls(why) @classmethod - def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: + def scan_observed(cls, comms: Comms, commander: str, + observed: Sequence[UnitState]) -> tuple[Self, ...]: why = cls.reason(comms, commander) - return (cls.found(why, comms, commander),) if why else () + return (cls.found(why, comms, commander, observed),) if why else () def line(self) -> str: return self.text.format(why=self.why) @dataclass(frozen=True) -class HeldOrderDue(DatedStandingRule, declared_name=None): +class HeldOrderDue(IndependentStandingRule, DatedStandingRule, declared_name=None): """Each held order supplies one dated condition; the rule owns its wording.""" problem: str @@ -224,52 +246,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 @@ -287,13 +263,18 @@ def reason(cls, comms: Comms, commander: str) -> str | None: return f"last hunt {int(age // 60)} min ago" if age > cls.seconds else None @classmethod - def found(cls, why: str, comms: Comms, commander: str) -> HuntDue: + def found(cls, why: str, comms: Comms, commander: str, + observed: Sequence[UnitState]) -> HuntDue: since = cls.hunted(comms, commander) + cls.seconds + readable = {unit for state in observed for unit in state.readable_units()} + todos = tuple(todo for todo in cls.todos(comms, commander) + if todo.held().owner_identity in readable) return next((LocatedHuntDue(why, since, path) - for path in cls.next_hunt(comms, commander)), cls(why, since)) + for path in cls.next_hunt(comms, commander, todos)), cls(why, since)) @classmethod - def next_hunt(cls, comms: Comms, commander: str) -> tuple[str, ...]: + def next_hunt(cls, comms: Comms, commander: str, + todos: Iterable[Todo]) -> tuple[str, ...]: """Least recently hunted hot file in the held orders' declared check repositories.""" actor = comms.registry.require(commander).incarnation hunted: dict[str, float] = {} @@ -301,7 +282,7 @@ def next_hunt(cls, comms: Comms, commander: str) -> tuple[str, ...]: hunted[hunt.where.file] = max(hunted.get(hunt.where.file, 0), hunt.at.recorded_at("A hunt")) repos = {Repo(checkout=Path(todo.order.checked().check.directory), github=todo.repo) - for todo in cls.todos(comms, commander)} + for todo in todos} candidates: list[str] = [] for repo in repos: log = repo.git(repo.checkout, 'log', 'origin/main', '--since=7.days', @@ -329,7 +310,7 @@ def line(self) -> str: @dataclass(frozen=True) -class DirectiveUnacted(OwedAnswer, DatedStandingRule): +class DirectiveUnacted(IndependentStandingRule, OwedAnswer, DatedStandingRule): """An owner directive recorded on an open request, past its deadline with no order since.""" request: str @@ -388,7 +369,7 @@ def reason(cls, comms: Comms, commander: str) -> str | None: @dataclass(frozen=True) -class ServicePassFailing(DatedStandingRule): +class ServicePassFailing(IndependentStandingRule, DatedStandingRule): """A Core service watch whose newest pass failed; the connector's log reaches nobody.""" watch: str @@ -410,7 +391,7 @@ def line(self) -> str: " -> fix the owner of the failing read this wake") @dataclass(frozen=True) -class BoardDisagrees(UndatedStandingRule): +class BoardDisagrees(IndependentStandingRule, UndatedStandingRule): """An open order reads as no commit despite its linked pull request having merged.""" problem: str @@ -420,7 +401,7 @@ class BoardDisagrees(UndatedStandingRule): @classmethod def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: return tuple( - cls(todo.id, todo.order.id, merged.sha) + cls(todo.id, todo.order.checked().id, merged.sha) for todo, merged in comms.goals.todos.board_disagreements( comms.registry.require(commander).incarnation, comms.registry ) @@ -434,7 +415,7 @@ def line(self) -> str: @dataclass(frozen=True) -class MemoryLow(UndatedStandingRule): +class MemoryLow(IndependentStandingRule, UndatedStandingRule): """Under the memory threshold a cleanup owner is due, told what holds the memory.""" rule: StorageRule @@ -458,7 +439,7 @@ def line(self) -> str: @dataclass(frozen=True) -class DiskLow(UndatedStandingRule): +class DiskLow(IndependentStandingRule, UndatedStandingRule): """Under the cleanup threshold a cleanup owner is due; under the floor nothing allocates.""" rule: StorageRule diff --git a/src/agent_comms/storage_rules.py b/src/agent_comms/storage_rules.py index f2263d6258..b022ee4162 100644 --- a/src/agent_comms/storage_rules.py +++ b/src/agent_comms/storage_rules.py @@ -9,7 +9,7 @@ import subprocess import time from abc import abstractmethod -from collections.abc import Iterator, Sequence +from collections.abc import Iterable, Iterator, Sequence from dataclasses import dataclass, replace from enum import StrEnum from functools import cached_property @@ -17,11 +17,12 @@ from typing import TYPE_CHECKING, Annotated, ClassVar, Self from agent_comms.field_codec import FieldCodec, PathText +from agent_comms.locked_store import DurableFile from agent_comms.native_tool_call import SelectedToolDenied from agent_comms.problem_number import ProblemNumber from agent_comms.repo import Repo from agent_comms.repository_name import RepositoryName -from agent_comms.standing_rules import UndatedStandingRule +from agent_comms.standing_rules import StandingRule, UndatedStandingRule from agent_comms.storage_room import ( AllocationRoomWork, BuildRoomWork, @@ -34,8 +35,10 @@ if TYPE_CHECKING: from agent_comms.comms import Comms from agent_comms.registration import Registration + from agent_comms.thread_identity import ThreadIncarnation from agent_comms.threads import Thread from agent_comms.todos import Todo, TodoStore + from agent_comms.ui_model.unit_state import UnitState StoragePath = Annotated[Path, PathText] @@ -70,7 +73,8 @@ class MemoryUse: @dataclass(frozen=True) -class StorageRule(RoomAllocation): +class StorageRule(RoomAllocation, DurableFile): + filename: ClassVar[str] = "storage.json" floor_gib: float = 5.0 # under this nothing that allocates may run cleanup_gib: float = 30.0 # under this a cleanup owner is due; it does not stop builds memory_gib: float = 3.0 # available memory under this makes a cleanup owner due @@ -93,7 +97,7 @@ def __post_init__(self): @classmethod def load(cls, root: Path): - path = root / 'storage.json' + path = cls.path_in(root) return FieldCodec.decode(cls, json.loads(path.read_text()) if path.exists() else {}) def initial_roots(self, name): @@ -342,13 +346,26 @@ def scan_owner(cls) -> type[StorageViolation]: @classmethod def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: - todos = cls.todos(comms, commander) - units = sorted({todo.holder for todo in todos if todo.state.open and todo.holder}) + units = {todo.held().owner_identity for todo in cls.todos(comms, commander)} + return cls.for_units(comms, units) + + @classmethod + def scan_observed(cls, comms: Comms, commander: str, + observed: Sequence[UnitState]) -> tuple[Self, ...]: + held = StandingRule.holders(comms, commander) + return cls.for_units(comms, (unit for state in observed + for unit in state.readable_units() if unit in held)) + + @classmethod + def for_units(cls, comms: Comms, units: Iterable[ThreadIncarnation]) -> tuple[Self, ...]: registry = comms.registry members = cls.members_with(cls) found = [] - for unit in units: - storage = UnitStorage.read(registry.storage_rule, registry.require(unit)) + for unit in sorted(set(units), key=lambda unit: unit.name): + thread = registry.require(unit.name) + if thread.incarnation != unit: + raise ValueError("Observed unit incarnation changed") + storage = UnitStorage.read(registry.storage_rule, thread) found.extend(member(storage) for member in members if member.violated(storage)) return tuple(found) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 64a8df8811..f032a3265d 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -71,7 +71,7 @@ WireValue, boundary_decoder, ) -from agent_comms.goals import CheckedCommit, OrderCheck, ProblemGoalOrder +from agent_comms.goals import CheckedCommit, GitCommandFailed, OrderCheck, ProblemGoalOrder from agent_comms.locked_store import RegistryFile from agent_comms.maintenance_barrier import MaintenanceBarrier from agent_comms.message_reference import MessageReference @@ -552,8 +552,13 @@ class PullRequests: def __init__(self, todos: Sequence[Todo] = ()) -> None: self._read: dict[tuple[RepositoryName, type[ListedPullRequest], str], tuple[PullRequest, ...]] = {} self._todos: dict[RepositoryName, dict[str, Todo]] = {} + self(todos) + + def __call__(self, todos: Sequence[Todo]) -> Self: + """Register a frame's orders while preserving this read's existing GitHub facts.""" for todo in todos: self._todos.setdefault(todo.repo, {})[todo.id] = todo + return self def listed(self, repo: RepositoryName, kind: type[ListedPullRequest], todo: Todo) -> tuple[PullRequest, ...]: @@ -723,6 +728,9 @@ def readings(self, source: ProgressSource) -> DistanceReadings: ... @abstractmethod def pushes(self, pattern: str) -> Iterator[tuple[str, float]]: ... + def require_read(self) -> None: + """A retired checkout needs no local read; present and unread ones decide.""" + def checked_commits(self, order: CheckedProblemOrder, refs: tuple[str, ...], *excluded: str) -> Iterator[ProgressPoint]: return iter(()) @@ -748,6 +756,29 @@ def pushes(self, pattern: str) -> Iterator[tuple[str, float]]: return iter(()) +@dataclass(frozen=True) +class UnreadCheckout(ProgressCheckout): + """A failed source read is data, never a missing checkout or no progress.""" + + reason: str + + def require_read(self) -> None: + raise WorkBranchError(self.reason) + + def commits(self, pattern: str, refs: tuple[str, ...], + *excluded: str) -> Iterator[ProgressPoint]: + raise WorkBranchError(self.reason) + + def references(self, storage: StorageRule, owner: Thread) -> tuple[str, ...]: + return () + + def readings(self, source: ProgressSource) -> DistanceReadings: + raise WorkBranchError(self.reason) + + def pushes(self, pattern: str) -> Iterator[tuple[str, float]]: + raise WorkBranchError(self.reason) + + class PresentCheckout(ProgressCheckout): default_reference: ClassVar[str] = "refs/remotes/origin/HEAD" @@ -758,6 +789,9 @@ def default_refs(self) -> tuple[str, ...]: def git(self, *args: str) -> str: return CheckedCommit.git(str(self.path), *args) + def require_read(self) -> None: + self.git("rev-parse", "--git-dir") + @cached_property def git_refs(self) -> tuple[str, ...]: return tuple(self.git("for-each-ref", "--format=%(refname)", @@ -849,8 +883,13 @@ def of(cls, todo: Todo, registry: Registration, pulls: PullRequests) -> Self: candidates = (Path(todo.order.checked().check.directory), *owned) path = next((path for path in candidates if (path / ".git").exists()), next(iter(owned), candidates[0])) - checkout = PresentCheckout(path) if (path / ".git").exists() else MissingCheckout(path) - return cls(todo, checkout, pulls, checkout.references(storage, owner), + checkout: ProgressCheckout = ( + PresentCheckout(path) if (path / ".git").exists() else MissingCheckout(path)) + try: + references = checkout.references(storage, owner) + except (OSError, GitCommandFailed, WorkBranchError, subprocess.TimeoutExpired) as failure: + checkout, references = UnreadCheckout(path, str(failure)), () + return cls(todo, checkout, pulls, references, tuple(PresentCheckout(path) for path in owned if (path / ".git").exists())) @property @@ -917,7 +956,7 @@ def from_source(cls, source: ProgressSource) -> OrderProgress: try: return next((progress for member in members for progress in member.observations(source)), NoCommitProgress()) - except (WorkBranchError, subprocess.TimeoutExpired) as unread: + except (OSError, GitCommandFailed, WorkBranchError, subprocess.TimeoutExpired) as unread: return UnreadProgress(str(unread)) @property @@ -1474,6 +1513,9 @@ def withdrawals(self, todo: Todo) -> tuple[str, ...]: def waiting_on_install(self, todo: Todo, read: dict[str, OrderProgress]) -> tuple[Todo, ...]: return () + def commander_questions(self, comms: Comms) -> tuple[Message, ...]: + return () + def closing_artifact(self) -> str: raise ValueError("Only a delivered order has closing evidence.") @@ -1927,6 +1969,11 @@ def __post_init__(self) -> None: if not self.message.body.strip(): raise TodoError("A delivery claim needs its report text.") + def original(self, comms: Comms) -> Message: + with comms.bus.log.checked_read() as source: + row = PublicMessages.require(source.connection, message_id=self.message.message_id) + return replace(self.message, seq=row.seq) + @classmethod def for_order( cls, todo: Todo, actor: ThreadIncarnation, number: int, head: str, @@ -1948,6 +1995,9 @@ def require_reassignment(self, todo: Todo, proposed: Assignment) -> None: if proposed.owner_identity != todo.holder_state.previous_assignment().owner_identity: self.require_transfer(todo) + def commander_questions(self, comms: Comms) -> tuple[Message, ...]: + return (self.claim.original(comms),) + def observed_merges(self, repo: Repo, source: ProgressSource) -> tuple[MergedProblemOrder, ...]: return () diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index 2443b2a817..a2c19f81fc 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -40,6 +40,7 @@ ) from .cli_result import ToolCatalogResult from .commander.capacity import Load +from .ui_model.unit_state import UnitState from .coordination_tables.refusals import SendRefusal from .declared_family import DeclaredFamily from .display_order import ChannelSort, ThreadSort @@ -1005,7 +1006,9 @@ class CommsStandingRulesTool(ThreadToolRequest): ] = 0.0 def apply(self, comms: Comms, caller: ThreadCaller) -> StandingRulesRead: - broken = StandingRule.broken(comms, caller.name) + states = UnitState.current(comms, caller.name) + broken = (*StandingRule.broken(comms, caller.name, observed=states), + *(state for state in states if state.attention())) rejected = StandingRule.enforce_all(comms, caller.name, broken) return StandingRulesRead( tuple(rule.line() for rule in broken 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..e0731bb038 --- /dev/null +++ b/src/agent_comms/ui_model/unit_state.py @@ -0,0 +1,466 @@ +"""One derived card per unit, ordered by the family's declaration order. + +States are siblings, not increasingly urgent subtypes of one another. The +StandingRule registry supplies both membership and urgency; no second roster +or independently writable unit status is retained. +""" + +from __future__ import annotations + +import time +from abc import abstractmethod +from collections.abc import Iterator, Sequence +from dataclasses import dataclass, field +from functools import cached_property +from subprocess import TimeoutExpired +from typing import TYPE_CHECKING, ClassVar, Self + +from agent_comms.field_codec import MalformedRecord +from agent_comms.goal_presentation import GoalSnapshot, RunnableExecution +from agent_comms.goals import GitCommandFailed +from agent_comms.messages import Message +from agent_comms.native_failures import NativePiUnavailable +from agent_comms.standing_rules import StandingRule, UndatedStandingRule +from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.thread_status import ThreadStatus +from agent_comms.todos import ( + MergedProgress, + NoCommitProgress, + OrderProgress, + PresentCheckout, + ProgressSource, + PullRequests, + SendBackVerdict, + Todo, +) +from agent_comms.transcript_events import OrderCall, TurnDigest +from agent_comms.turn_digests import LastTurns, TurnDigestRead +from agent_comms.work_branches import WorkBranchError + +if TYPE_CHECKING: + from agent_comms.comms import Comms + from agent_comms.threads import Thread + + +@dataclass(frozen=True) +class UnitObservation: + """Original sources shared by candidate states within this one read.""" + + comms: Comms + thread: Thread + todos: tuple[Todo, ...] + pulls: PullRequests + messages: tuple[Message, ...] + now: float + presence: ThreadStatus + + @cached_property + def goal(self) -> GoalSnapshot: + return self.comms.goals.goal_snapshot(self.thread.name) + + @cached_property + def questions(self) -> tuple[Message, ...]: + claims = tuple( + message for todo in self.todos for message in todo.order.commander_questions(self.comms) + ) + return (*self.messages, *claims) + + @cached_property + def due_questions(self) -> tuple[Message, ...]: + return tuple( + message for message in self.questions + if message.timestamp <= self.now - AwaitingCommander.seconds + ) + + @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 + ) + + def require_read(self) -> None: + self.questions + if self.todos: + for path in self.comms.registry.storage_rule.checkout_paths(self.thread): + PresentCheckout(path).require_read() + for source in self.sources: + source.checkout.require_read() + + @property + def orders(self) -> tuple[str, ...]: + return tuple(todo.id for todo in self.todos) + + +class NeedsCommanderAttention: + """Urgent siblings share their ping behavior without claiming each other's state.""" + + def attention(self) -> bool: + return True + + +@dataclass(frozen=True) +class UnitState(UndatedStandingRule, declared_name=None): + unit: ThreadIncarnation + orders: tuple[str, ...] + presence: ThreadStatus + source: UnitObservation = field(compare=False, repr=False) + action: ClassVar[str] + + @classmethod + def worst_first(cls) -> tuple[type[UnitState], ...]: + return UnitState.members_with(UnitState) + + @classmethod + def scan_owner(cls) -> type[StandingRule]: + return UnitState + + @classmethod + def scan_observed( + cls, comms: Comms, commander: str, observed: Sequence[UnitState] + ) -> tuple[Self, ...]: + # States are already read; ordinary scans consume them rather than emit them again. + return () + + @classmethod + def asks(cls, comms: Comms, commander: str, before: float) -> tuple[Message, ...]: + from agent_comms.commander.standing import UnansweredUnitAsks + + return UnansweredUnitAsks.for_orders( + comms, + commander, + StandingRule.todos(comms, commander), + before, + ) + + @classmethod + def read(cls, source: UnitObservation) -> UnitState: + try: + return next(state for member in cls.worst_first() for state in member.derive(source)) + except ( + OSError, + GitCommandFailed, + WorkBranchError, + TimeoutExpired, + NativePiUnavailable, + MalformedRecord, + ) as failure: + return Unreadable( + source.thread.incarnation, source.orders, source.presence, source, str(failure) + ) + + @classmethod + def observations( + cls, comms: Comms, commander: str, pulls: PullRequests | type[PullRequests] = PullRequests + ) -> tuple[UnitObservation, ...]: + snapshot, now = comms.registry.snapshot(), time.time() + held = StandingRule.holders(comms, commander) + for owner in held: + if snapshot.require(owner.name).incarnation != owner: + raise ValueError("Held order owner incarnation changed") + requests = pulls(tuple(todo for todos in held.values() for todo in todos)) + questions = cls.asks(comms, commander, now) + readings = tuple( + UnitObservation( + comms, + thread, + tuple(held.get(thread.incarnation, ())), + requests, + tuple( + message + for message in questions + if snapshot.canonical_name(message.sender) == thread.name + ), + now, + snapshot.status(thread.name), + ) + for thread in snapshot.threads.values() + ) + candidates = tuple(reading for reading in readings if reading.thread.name != commander) + eligible = set(held) + eligible.update( + reading.thread.incarnation + for reading in candidates + if reading.thread.parent.named(commander) + ) + eligible.update( + reading.thread.incarnation + for reading in candidates + if reading.thread.role.executor(reading.thread) and reading.presence.receiving + ) + return tuple(reading for reading in readings if reading.thread.incarnation in eligible) + + @classmethod + def current( + cls, comms: Comms, commander: str, pulls: PullRequests | type[PullRequests] = PullRequests + ) -> tuple[UnitState, ...]: + states = tuple(cls.read(source) for source in cls.observations(comms, commander, pulls)) + return tuple( + state for member in cls.worst_first() for state in states if isinstance(state, member) + ) + + @classmethod + def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: + return tuple( + state + for state in cls.current(comms, commander) + if isinstance(state, cls) and state.attention() + ) + + @classmethod + @abstractmethod + def derive(cls, source: UnitObservation) -> Iterator[Self]: ... + + def open_questions(self) -> tuple[Message, ...]: + return self.source.questions + + @property + def owed(self) -> tuple[Message, ...]: + return self.source.due_questions + + def readable_units(self) -> tuple[ThreadIncarnation, ...]: + return (self.unit,) + + def attention(self) -> bool: + return False + + def merged_orders(self, comms: Comms, pulls: PullRequests) -> tuple[Todo, ...]: + return tuple( + closed + for problem in self.orders + for todo in (comms.goals.todos.get(problem),) + for closed in todo.order.closed_by_merge(todo, comms.registry, pulls) + ) + + @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"{type(self).__name__} {self.unit.name}: {self.detail()} -> {self.default_action}" + + +class ConditionalState(UnitState, declared_name=None): + """Abstract card construction shared by siblings, not a separate unit state.""" + + @classmethod + def derive(cls, source: UnitObservation) -> Iterator[Self]: + if cls.matches(source): + yield cls(source.thread.incarnation, source.orders, source.presence, source) + + @classmethod + @abstractmethod + def matches(cls, source: UnitObservation) -> bool: ... + + +class PendingWorkState(ConditionalState, declared_name=None): + """These sibling states require pending work; declare that condition once.""" + + @classmethod + def derive(cls, source: UnitObservation) -> Iterator[Self]: + if source.pending: + yield from super().derive(source) + + +@dataclass(frozen=True) +class Unreadable(NeedsCommanderAttention, UnitState): + reason: str + action = "repair the unreadable source; do not infer that the unit is idle" + + @classmethod + def derive(cls, source: UnitObservation) -> Iterator[Self]: + # Validate the checkout reads before a higher-level label can hide a failure. + source.require_read() + return iter(()) + + def open_questions(self) -> tuple[Message, ...]: + return () + + @property + def owed(self) -> tuple[Message, ...]: + return () + + def readable_units(self) -> tuple[ThreadIncarnation, ...]: + return () + + def merged_orders(self, comms: Comms, pulls: PullRequests) -> tuple[Todo, ...]: + # The card reports the failure; a merge read must not retry that source. + return () + + def detail(self) -> str: + return f"{super().detail()}; {self.reason}" + + +@dataclass(frozen=True) +class Blocked(NeedsCommanderAttention, UnitState): + goal: GoalSnapshot + action = "follow the blocked goal's declared response" + + @classmethod + def derive(cls, source: UnitObservation) -> Iterator[Self]: + if not source.pending: + return + if source.goal.execution.unit_blocks(): + yield cls( + source.thread.incarnation, + tuple(todo.id for todo in source.pending), + source.presence, + source, + source.goal, + ) + + def line(self) -> str: + return "\n".join( + block.unit_block_line(self.unit.name, self.orders) + for block in self.goal.execution.unit_blocks() + ) + + +@dataclass(frozen=True) +class Detached(NeedsCommanderAttention, UnitState): + unread: int + action = "wake it or reassign the order" + + @classmethod + def derive(cls, source: UnitObservation) -> Iterator[Self]: + if source.pending and not source.presence.receiving: + yield cls( + source.thread.incarnation, + source.orders, + source.presence, + source, + source.comms.bus.pending_count(source.thread.name), + ) + + def detail(self) -> str: + return f"{self.unread} unread; {super().detail()}" + + +@dataclass(frozen=True) +class AwaitingCommander(NeedsCommanderAttention, UnitState): + seconds: ClassVar[int] = 5 * 60 + action = "answer the original message" + + @classmethod + def derive(cls, source: UnitObservation) -> Iterator[Self]: + if source.due_questions: + yield cls(source.thread.incarnation, source.orders, source.presence, source) + + def detail(self) -> str: + return ", ".join( + f"message {message.reference.seq}:{message.message_id}" for message in self.owed + ) + + +class Stuck(NeedsCommanderAttention, PendingWorkState, UnitState): + seconds: ClassVar[int] = 20 * 60 + action = "inspect its last turn and restart only known-safe work" + + @classmethod + def matches(cls, source: UnitObservation) -> bool: + calls = source.calls + latest = max( + (event.timestamp for turn in source.turns for event in turn.events), + default=source.since, + ) + return bool( + not calls + and any( + todo.order.checked().first_call_deadline.before(source.now) + for todo in source.pending + ) + or calls + and latest < source.now - cls.seconds + ) + + +class Spinning(NeedsCommanderAttention, PendingWorkState, UnitState): + action = "read the failed approach and change it before another rerun" + + @classmethod + def matches(cls, source: UnitObservation) -> bool: + sends = sorted( + verdict.at.recorded_at("A send-back") + for todo in source.pending + for verdict in todo.verdicts + if isinstance(verdict, SendBackVerdict) + ) + calls = source.calls + repeats = len(calls) >= 2 and calls[-1].digest == calls[-2].digest + if len(sends) < 2 and not repeats: + return False + commit_at = max( + (commit.at for origin in source.sources for commit in origin.commits()), + default=source.since, + ) + return ( + len(sends) >= 2 + and sends[-2] > commit_at + or repeats + and source.turns[-1].start.since(commit_at) + ) + + +class StalledOnNothing(NeedsCommanderAttention, PendingWorkState, UnitState): + action = "resume its work or give it the missing next step" + + @classmethod + def matches(cls, source: UnitObservation) -> bool: + return bool( + isinstance(source.goal.execution, RunnableExecution) + and source.turns + and not source.comms.bus.pending_count(source.thread.name) + ) + + +class DeliveredUnclosed(NeedsCommanderAttention, ConditionalState, UnitState): + action = "install the merge or close with the working application evidence" + + @classmethod + def matches(cls, source: UnitObservation) -> bool: + progress: dict[str, OrderProgress] = { + origin.todo.id: next(MergedProgress.found(origin), NoCommitProgress()) + for origin in source.sources + } + return any( + todo.order.accepted or todo.order.waiting_on_install(todo, progress) + for todo in source.todos + ) + + +class Moving(ConditionalState, UnitState): + action = "let it continue" + + @classmethod + def matches(cls, source: UnitObservation) -> bool: + return bool(source.todos) + + +class Idle(ConditionalState, UnitState): + action = "give it the next useful order" + + @classmethod + def matches(cls, source: UnitObservation) -> bool: + return not source.todos diff --git a/tests/test_commander_capacity.py b/tests/test_commander_capacity.py index 15af72baf9..dd8a334e5b 100644 --- a/tests/test_commander_capacity.py +++ b/tests/test_commander_capacity.py @@ -1,9 +1,18 @@ """Capacity reports the existing load formula and working forks without policy advice.""" +from dataclasses import replace + import pytest from agent_comms.commander.capacity import Load from agent_comms.field_codec import FieldCodec +from agent_comms.goal_actions import CreateProblemGoalAction, SetGoalAction +from agent_comms.goals import RequestGoalOrder +from agent_comms.launch_root import LaunchRoot +from agent_comms.thread_status import IdleThreadStatus, RunningThreadStatus, StoppedThreadStatus +from agent_comms.thread_text import ForkedParent + +pytest_plugins = ("test_holder_standing_rules",) @pytest.mark.parametrize("unjudged", [0, 1, 395, 1_000_000]) @@ -18,3 +27,29 @@ def test_capacity_reports_load_and_forks(unjudged): assert load.share == load.total / 2 +@pytest.mark.parametrize("presence,expected", ( + (RunningThreadStatus(), ("worker",)), + (IdleThreadStatus(), ("worker",)), + (StoppedThreadStatus(), ()), +)) +def test_claimed_child_capacity_uses_receiving_presence(held_bus, claim_order, presence, expected): + comms = held_bus + template = comms.goals.todos.get("P1") + for problem in ("P1", "P2"): + claim_order(problem) + comms.goals.update_goal("worker", SetGoalAction( + text="Delegate the review", goal_id="review-request", order=RequestGoalOrder())) + child = replace(comms.registry.require("worker"), parent=ForkedParent("lead")) + comms.registry.register(child, presence) + comms.goals.update_goal("outsider", CreateProblemGoalAction( + problem="P3", commander=child.name, request=child.goal.require().id, + repo=template.repo, text="Review the child commander's work", + review_at=template.review_at, + order=replace(template.order.checked(), id="order-P3"), + )) + assert not any(todo.pending for todo in comms.goals.todos.held_for_commander( + comms.registry.require("lead").incarnation)) + with LaunchRoot.from_path(comms.root).selected(): + assert Load.of(comms, "lead").forks == expected + + diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index 5884abafcd..426d538e34 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -17,10 +17,7 @@ from agent_comms.commander.config import CommanderConfig from agent_comms.commander.order import Commander, OrderFile, SayVerb, SendVerb from agent_comms.commander.standing import ( - Detached, - GoalCannotRun, HeartbeatSilent, - HolderCannotRun, UnitAskUnanswered, ) from agent_comms.commander_heartbeat import CommanderHeartbeat @@ -38,7 +35,7 @@ from agent_comms.installer.backup import StoreBackup from agent_comms.launch_root import LaunchRoot from agent_comms.message_page import MessagePageRequest -from agent_comms.messages import InfoKind, Message +from agent_comms.messages import InfoKind, Message, QuestionKind from agent_comms.native_arguments import GivenBinary from agent_comms.order_runs import MechanicalOrderRun from agent_comms.read_basis import DMDisplayScope @@ -49,12 +46,14 @@ from agent_comms.thread_model import DeclaredModel from agent_comms.thread_status import StoppedThreadStatus from agent_comms.todos import ( + ClaimOrder, OrderClaim, OrderMessage, PendingProblemOrder, TodoConflict, ) from conftest import declare_test_bus from native_backend_fixture import native_backend_fixture, wait_for from test_goal_execution import turn_on +from agent_comms.ui_model.unit_state import AwaitingCommander, Blocked, Detached, UnitState @pytest.fixture @@ -106,6 +105,28 @@ def test_stop_rejects_a_human_before_any_registry_change(comms, tmp_path): assert comms.registry.store.path.read_bytes() == before +@pytest.fixture +def claim_order(held_bus): + """Publish the real order and claim messages, using their retained identities.""" + def claim(problem): + todo = held_bus.goals.todos.get(problem) + lead = held_bus.registry.require("lead").incarnation + worker = held_bus.registry.require("worker").incarnation + original = held_bus.messaging.send("lead", "worker", todo.order.instructions(todo)).message + todo = held_bus.goals.todos.apply(todo.id, OrderMessage(todo.revision, lead, original.message_id)) + claimed = OrderClaim.for_order(todo, worker, 1, "a" * 40, + "Review the published repair", original.reference) + claimed = replace(claimed, message=replace( + claimed.message, timestamp=time.time() - AwaitingCommander.seconds - 1)) + ClaimOrder(todo.revision, worker, todo.held().generation, claimed).record(held_bus.goals, todo.id) + return claimed + return claim + + +def unanswered(comms): + return UnitAskUnanswered.broken(comms, "lead", observed=UnitState.current(comms, "lead")) + + def blocked_holder(comms): """Settle a real failed turn; never manufacture a stored backend block.""" owner = comms.registry.require("worker") @@ -127,7 +148,7 @@ 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) - violation, = GoalCannotRun.violations(held_bus, "lead") + violation, = Blocked.violations(held_bus, "lead") assert violation.orders == ("P1", "P2") violation.enforce(held_bus, "lead") assert held_bus.registry.require("worker").goal.require() == goal @@ -137,7 +158,7 @@ def test_an_active_goal_with_an_unresolved_attempt_is_reported_never_retried(hel 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="Inspect the pending work", timestamp=timestamp, answers=answers), origin=AgentOrigin(), ) @@ -150,14 +171,14 @@ def test_failed_holder_is_reported_without_reissuing_orders_or_replaying_unknown orders = held_bus.goals.todos.held_orders() inputs = InputDispositions(held_bus.root) unresolved = inputs.lookup("unresolved-input") - violation, = GoalCannotRun.violations(held_bus, "lead") + violation, = Blocked.violations(held_bus, "lead") assert violation.orders == ("P1", "P2") violation.enforce(held_bus, "lead") assert held_bus.registry.require("worker").goal == goal assert held_bus.goals.todos.held_orders() == orders assert store.require_generation(goal.id) == generation assert inputs.lookup("unresolved-input") == unresolved - assert GoalCannotRun.violations(held_bus, "lead") == (violation,) + assert Blocked.violations(held_bus, "lead") == (violation,) def test_an_order_to_a_blocked_goal_activates_it_and_keeps_other_orders(held_bus): @@ -194,7 +215,7 @@ async def test_sigterm_group_preserves_active_goal_and_never_reissues_orders( await Coordination.run_worker(partial(held_bus.owners.stop, "worker")) assert held_bus.registry.require("worker").goal == goal assert isinstance(held_bus.goals.goal_execution("worker"), RunnableExecution) - for violation in GoalCannotRun.violations(held_bus, "lead"): + for violation in Blocked.violations(held_bus, "lead"): violation.enforce(held_bus, "lead") assert held_bus.goals.todos.held_orders() == orders assert InputDispositions(held_bus.root).lookup("unresolved-input") == unresolved @@ -223,7 +244,7 @@ def test_a_running_turn_is_not_a_stalled_holder(held_bus): lease = owner.thread.turn_lease assert lease is not None try: - assert GoalCannotRun.violations(held_bus, "lead") == () + assert Blocked.violations(held_bus, "lead") == () finally: held_bus.agents.finish_turn(lease) @@ -231,21 +252,21 @@ def test_a_running_turn_is_not_a_stalled_holder(held_bus): def test_unanswered_uses_age_direct_target_and_later_answer_references(held_bus): now = time.time() publish(held_bus, "worker", "lead", now - 7200) # before every held order - publish(held_bus, "worker", "lead", now - UnitAskUnanswered.seconds + 30) + publish(held_bus, "worker", "lead", now - AwaitingCommander.seconds + 30) publish(held_bus, "outsider", "lead", now - 1800) # not a holder publish(held_bus, "worker", "outsider", now - 1800) # not its commander 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") + violation, = unanswered(held_bus) assert violation.message.reference == question.reference print(violation.line()) held_bus.messaging.send("lead", "worker", "Responding", answers=(question.reference,)) - assert UnitAskUnanswered.violations(held_bus, "lead") == () + assert unanswered(held_bus) == () 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 unanswered(held_bus) == () @pytest.mark.parametrize('target,body', [ @@ -254,15 +275,15 @@ def test_unanswered_uses_age_direct_target_and_later_answer_references(held_bus) ]) def test_commander_say_answers_an_unanswered_unit_message(held_bus, target, body, tmp_path): question = held_bus.bus.publisher.publish_ordinary( - Message(sender='worker', target=target, type=InfoKind, body=body, - timestamp=time.time() - UnitAskUnanswered.seconds - 1), origin=AgentOrigin(), + Message(sender='worker', target=target, type=QuestionKind, body=body, + timestamp=time.time() - AwaitingCommander.seconds - 1), origin=AgentOrigin(), ) ctx = Commander.read_root(held_bus.root) reply = tmp_path / 'reply.txt' reply.write_text('Responding to the worker with the actual commander command') say = SayVerb(units="worker", body_file=str(reply), answers=(question.message_id,)) say.apply(ctx) - assert UnitAskUnanswered.violations(held_bus, "lead") == () + assert unanswered(held_bus) == () # A name that is not open needs no answer: it is dropped, never a refusal of the verb. assert SayVerb( units="worker", body_file="unused", answers=("not-a-message",) @@ -270,7 +291,7 @@ def test_commander_say_answers_an_unanswered_unit_message(held_bus, target, body def test_an_unanswered_unit_message_gates_send_and_say_until_answered(held_bus, tmp_path): - question = publish(held_bus, "worker", "lead", time.time() - UnitAskUnanswered.seconds - 1) + question = publish(held_bus, "worker", "lead", time.time() - AwaitingCommander.seconds - 1) ctx = Commander.read_root(held_bus.root) reply = tmp_path / "reply.txt" reply.write_text("Waiting on the merge queue, owner unit-goalblock") @@ -279,7 +300,7 @@ def test_an_unanswered_unit_message_gates_send_and_say_until_answered(held_bus, with pytest.raises(SystemExit, match=question.message_id): verb.apply(ctx) SayVerb(units="worker", body_file=str(reply), answers=(question.message_id,)).apply(ctx) - assert UnitAskUnanswered.violations(held_bus, "lead") == () + assert unanswered(held_bus) == () # Naming it again after it was answered names nothing open, so it answers nothing. assert SendVerb( unit="worker", body_file="unused", answers=(question.message_id,) @@ -287,18 +308,18 @@ def test_an_unanswered_unit_message_gates_send_and_say_until_answered(held_bus, def test_answering_one_owed_message_leaves_another_owed(held_bus, tmp_path): - first = publish(held_bus, "worker", "lead", time.time() - UnitAskUnanswered.seconds - 2) - second = publish(held_bus, "worker", "lead", time.time() - UnitAskUnanswered.seconds - 1) + first = publish(held_bus, "worker", "lead", time.time() - AwaitingCommander.seconds - 2) + second = publish(held_bus, "worker", "lead", time.time() - AwaitingCommander.seconds - 1) ctx = Commander.read_root(held_bus.root) reply = tmp_path / "reply.txt" reply.write_text("Waiting on the merge queue, owner unit-goalblock") with pytest.raises(SystemExit, match=second.message_id): SayVerb(units="worker", body_file=str(reply), answers=(first.message_id,)).apply(ctx) - assert {owed.message.message_id for owed in UnitAskUnanswered.violations(held_bus, "lead")} == { + assert {owed.message.message_id for owed in unanswered(held_bus)} == { first.message_id, second.message_id} SayVerb(units="worker", body_file=str(reply), answers=(first.message_id, second.message_id)).apply(ctx) - assert UnitAskUnanswered.violations(held_bus, "lead") == () + assert unanswered(held_bus) == () def test_an_overdue_owner_directive_gates_until_answered(held_bus, tmp_path): @@ -319,7 +340,7 @@ def test_an_overdue_owner_directive_gates_until_answered(held_bus, tmp_path): @pytest.mark.parametrize('body', ['@outsider This question is yours', 'The worker broadcasts its result']) def test_channel_messages_without_a_commander_addressee_are_not_a_question(held_bus, body): - held_bus.messaging.send('worker', '#comms', body) + held_bus.messaging.send('worker', '#comms', body, type=QuestionKind) assert UnitAskUnanswered.asks(held_bus, 'lead', time.time()) == () @@ -388,24 +409,22 @@ def cli(*arguments): print('Deletion expected 0, remote main reads 4: send and check accepted; stale dirty tree unchanged.') -def test_detached_and_goal_blocks_are_both_in_standing_rule_scan(held_bus, installed_runtime): - blocked_holder(held_bus) +def test_one_state_per_unit_even_when_blocked_and_detached(held_bus, installed_runtime): 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, = GoalCannotRun.violations(held_bus, "lead") - broken = StandingRule.broken(held_bus, "lead") - assert blocked in broken - assert all(rule in broken for rule in family) + assert detached.unit.name == "worker" and set(detached.orders) == {"P1", "P2"} + blocked_holder(held_bus) + blocked, = Blocked.violations(held_bus, "lead") + states = UnitState.current(held_bus, "lead") + assert tuple(state for state in states if state.unit == blocked.unit) == (blocked,) + assert blocked not in StandingRule.broken(held_bus, "lead", observed=states) def test_startup_heartbeat_enforces_and_publishes_both_new_rule_lines(held_bus, installed_runtime): blocked_holder(held_bus) question = publish(held_bus, "worker", "lead", time.time() - 901) expected = tuple(rule.line() for rule in ( - *GoalCannotRun.violations(held_bus, "lead"), *UnitAskUnanswered.violations(held_bus, "lead"), + *Blocked.violations(held_bus, "lead"), *unanswered(held_bus), )) heartbeat = CommanderHeartbeat() commander = held_bus.registry.require("lead") @@ -432,16 +451,20 @@ def test_every_pass_beats_and_a_stale_beat_is_heartbeat_silent(held_bus, monkeyp review = CommanderReview(frozenset()) CommanderHeartbeat.register(held_bus.registry) assert HeartbeatSilent.violations(held_bus, "lead") == () + states = UnitState.current(held_bus, "lead") with held_bus.bus.locked(): - review.wake(held_bus, CommanderHeartbeat.sender, commander, ()) - review.wake(held_bus, CommanderHeartbeat.sender, commander, ()) + review.wake(held_bus, CommanderHeartbeat.sender, commander, (), states) + review.wake(held_bus, CommanderHeartbeat.sender, commander, (), states) page = held_bus.bus.log.read_page(MessagePageRequest(DMDisplayScope.capture( CommanderHeartbeat.sender, "lead", held_bus.registry.snapshot(), ))) 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." + assert unchanged.body.splitlines() == [ + f"COMMANDER HEARTBEAT: unchanged since seq {changed.seq}, 0 violations.", + *(state.line() for state in states), + ] assert HeartbeatSilent.violations(held_bus, "lead") == () later = unchanged.timestamp + HeartbeatSilent.seconds + 1 monkeypatch.setattr(time, "time", lambda: later) diff --git a/tests/test_install.py b/tests/test_install.py index 92c826f279..2f4cb84253 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -169,7 +169,7 @@ def test_an_install_on_a_bus_with_no_commander_writes_no_commander_config(privat home = _harnessless_home(private_directory, monkeypatch) result = InstallCliCommand(project=private_directory).apply(comms) assert result.config == NoConfigFile() - assert not CommanderConfig.root_path(root).exists() + assert not CommanderConfig.path_in(root).exists() assert not CommanderConfig.path(home).exists() and not CommanderConfig.path(home).is_symlink() @@ -187,7 +187,7 @@ def test_an_install_refuses_flags_a_bus_without_a_commander_record_cannot_use( home = _harnessless_home(private_directory, monkeypatch) with pytest.raises(UpgradeRefused): InstallCliCommand(project=private_directory, **flags).apply(Comms(root)) - assert not CommanderConfig.root_path(root).exists() and not CommanderConfig.path(home).is_symlink() + assert not CommanderConfig.path_in(root).exists() and not CommanderConfig.path(home).is_symlink() def test_an_install_without_flags_keeps_the_recorded_commander_and_rehomes_its_paths( @@ -206,7 +206,7 @@ def test_an_install_without_flags_keeps_the_recorded_commander_and_rehomes_its_p result = InstallCliCommand(project=private_directory).apply(comms) expected = CommanderConfig.on_machine("lead", (), root, home, frozenset({"parked-unit"})) assert result.config == WrittenConfigFile(str(CommanderConfig.path(home))) - assert CommanderConfig.root_path(root).read_text() == expected.model_dump_json(indent=1) + "\n" + assert CommanderConfig.path_in(root).read_text() == expected.model_dump_json(indent=1) + "\n" assert CommanderConfig.read(CommanderConfig.path(home)) == expected diff --git a/tests/test_proof_build.py b/tests/test_proof_build.py index f6e8c01fbf..c3deab478c 100644 --- a/tests/test_proof_build.py +++ b/tests/test_proof_build.py @@ -358,7 +358,7 @@ def test_install_due_compares_fetched_main_to_installed_build( before = Repo.git(checkout, "rev-parse", "HEAD") bus = Comms(tmp_path / "bus") config = CommanderConfig.on_machine("lead", (graph.repository,), bus.root, tmp_path / "home") - CommanderConfig.root_path(bus.root).write_text(config.model_dump_json()) + CommanderConfig.path_in(bus.root).write_text(config.model_dump_json()) due = InstallDue.violations(bus, "lead") diff --git a/tests/test_unit_state.py b/tests/test_unit_state.py new file mode 100644 index 0000000000..3044be3198 --- /dev/null +++ b/tests/test_unit_state.py @@ -0,0 +1,177 @@ +"""Unit cards from real records, with unread failures isolated to their unit.""" +import json +import time +from dataclasses import replace +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from agent_comms.commander_heartbeat import CommanderHeartbeat +from agent_comms.launch_runtime import LaunchRoot +from agent_comms.message_page import MessagePageRequest +from agent_comms.read_basis import DMDisplayScope +from agent_comms.recorded_time import RecordedEventTime +from agent_comms.thread_status import StoppedThreadStatus +from agent_comms.thread_text import ForkedParent, 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, + Unreadable, +) +from test_holder_standing_rules import blocked_holder, publish + +pytest_plugins = ("test_holder_standing_rules",) + + +def recorded_turn(comms, calls): + stamp = datetime.fromtimestamp(time.time(), UTC).isoformat() + rows = [dict(type="session", id="state-source", version=3, timestamp=stamp, + cwd=str(comms.root)), + 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)))) + + +def unread_checkout(comms, tmp_path): + checkout = tmp_path / "unread-worker" + checkout.mkdir() + (checkout / ".git").write_text("gitdir: ./removed-git-directory\n") + comms.registry.register(replace(comms.registry.require("worker"), worktree=str(checkout))) + + +@pytest.mark.parametrize("member", UnitState.worst_first(), ids=lambda member: member.__name__) +def test_unit_state_family_uses_real_records(held_bus, tmp_path, member): + comms = held_bus + unit = "worker" + with LaunchRoot.from_path(comms.root).selected(): + if member is Unreadable: + unread_checkout(comms, tmp_path) + elif 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 # Assigned work, before its first-call deadline. + else: + pytest.fail(f"New UnitState needs its real-record scenario: {member}") + states = UnitState.current(comms, "lead") + state, = (state for state in states if state.unit.name == unit) + assert isinstance(state, member) + assert state.unit == comms.registry.require(unit).incarnation + assert state.default_action and state.unit.name in state.line() + assert not any(isinstance(state, sibling) for sibling in UnitState.worst_first() + if sibling is not member) + assert states == tuple( + state for expected in UnitState.worst_first() + for state in states if isinstance(state, expected)) + print(state.line()) + + +def test_claim_is_a_commander_question_not_a_verified_delivery(held_bus, claim_order): + comms = held_bus + claim = claim_order("P1") + with LaunchRoot.from_path(comms.root).selected(): + state, = AwaitingCommander.violations(comms, "lead") + question, = state.questions + assert question.message_id == claim.message.message_id + assert question.seq > claim.message.answers[0].seq + assert DeliveredUnclosed.violations(comms, "lead") == () + + +def test_stopped_child_without_lead_orders_remains_in_the_frame(held_bus): + child = replace(held_bus.registry.require("outsider"), parent=ForkedParent("lead")) + held_bus.registry.register(child, StoppedThreadStatus()) + with LaunchRoot.from_path(held_bus.root).selected(): + state, = (state for state in UnitState.current(held_bus, "lead") + if state.unit == child.incarnation) + assert isinstance(state, Idle) + assert state.presence == StoppedThreadStatus() + + +def test_malformed_native_source_is_unreadable_not_idle(held_bus): + recorded_turn(held_bus, 1) + path = held_bus.root / "state-source.jsonl" + rows = path.read_text().splitlines() + header = json.loads(rows[0]) + del header["timestamp"] + path.write_text("\n".join((json.dumps(header), *rows[1:])) + "\n") + with LaunchRoot.from_path(held_bus.root).selected(): + state, = Unreadable.violations(held_bus, "lead") + assert state.unit.name == "worker" + assert "timestamp" in state.reason + + +@pytest.mark.parametrize("primary,broken_metadata", ((False, True), (True, True), (False, False)), + ids=("home", "order", "nongit-home")) +def test_heartbeat_keeps_every_unit_when_one_checkout_is_unreadable( + held_bus, tmp_path, primary, broken_metadata, installed_runtime): + import os + + from agent_comms.child_process import ProcessIdentity + + if primary: + checkout = Path(held_bus.goals.todos.get("P1").order.checked().check.directory) + (checkout / ".git").rename(checkout / "saved-git") + (checkout / ".git").write_text("gitdir: ./removed-git-directory\n") + else: + unread_checkout(held_bus, tmp_path) + if not broken_metadata: + (held_bus.registry.require("worker").project_path / ".git").unlink() + lead = held_bus.registry.require("lead") + held_bus.registry.register(replace(lead, process_identity=ProcessIdentity.capture(os.getpid()))) + heartbeat = CommanderHeartbeat() + with LaunchRoot.from_path(held_bus.root).selected(): + observed = heartbeat.observe(held_bus) + cards = observed[0].states + assert isinstance(cards[0], Unreadable) + assert {card.unit.name for card in cards} >= {"worker", "outsider"} + heartbeat.pass_body(held_bus, observed) + page = held_bus.bus.log.read_page(MessagePageRequest(DMDisplayScope.capture( + heartbeat.sender, "lead", held_bus.registry.snapshot(), + ))) + message = next(row for row in page.messages if row.sender == heartbeat.sender) + assert all(card.line() in message.body for card in cards) + assert message.body.index(cards[0].line()) < message.body.index(cards[-1].line()) + print(message.body) diff --git a/tests/test_unowned_checkout_work.py b/tests/test_unowned_checkout_work.py index e84d4583eb..138724301d 100644 --- a/tests/test_unowned_checkout_work.py +++ b/tests/test_unowned_checkout_work.py @@ -17,13 +17,12 @@ from agent_comms.commander.config import CommanderConfig from agent_comms.commander.standing import ( CheckoutHeldWheelSpin, - CheckoutUnreadable, ReassignedWheelSpin, UnownedOrderWatch, UnownedWheelSpin, WaitingUnownedWheelSpin, ) -from agent_comms.commander_heartbeat import CommanderHeartbeat, CommanderReview +from agent_comms.commander_heartbeat import CommanderHeartbeat, CommanderObservation, CommanderReview from agent_comms.comms import Comms from agent_comms.field_codec import FieldCodec from agent_comms.goal_actions import SetGoalAction @@ -40,6 +39,7 @@ from agent_comms.todos import GoalRef, PendingProblemOrder, ReleaseTodo, StrikeReason from agent_comms.transcript_events import AssistantTranscript from agent_comms.work_branches import UnreadableCheckoutRead, WorkBranches +from agent_comms.ui_model.unit_state import Unreadable from order_scope_fixture import FIX_SCOPE @@ -104,7 +104,7 @@ def decide(command: str, cwd: Path) -> None: @pytest.fixture -def checkout_bus(tmp_path: Path) -> Comms: +def checkout_bus(tmp_path: Path, configured_repository, installed_runtime) -> Comms: home = tmp_path / 'declared-project' home.mkdir() git(home, 'init', '-q', '--initial-branch=main') @@ -167,10 +167,7 @@ def checkout_bus(tmp_path: Path) -> Comms: turn_id='available', item_id='answer', events=(AssistantTranscript( 'Ready for work', position='answer', timestamp=time.time()),)) CommanderHeartbeat.register(c.registry) - CommanderConfig( - commander='lead', root=root, state=tmp_path / 'state', repos=(), - worktrees=rule.checkout_base, scratch=rule.scratch_base, disk=tmp_path, - ).write(tmp_path / 'config-home') + configured_repository(c) return c @@ -197,11 +194,15 @@ def test_dirty_then_unpushed_then_pushed_linked_checkout(checkout_bus: Comms, tm with practice_copy(original.root, tmp_path / 'practice') as (root, _measurement): c = Comms(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) + 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')) + beat() git(checkout, 'add', 'work.py') git(checkout, 'commit', '-q', '-m', 'Unpushed work') assert git(checkout, 'status', '--porcelain') == '' @@ -214,20 +215,18 @@ 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')) + beat() 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) + assert set(available).isdisjoint(review.rules) 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')) + beat() drain_candidate_catchup(root) history = subprocess.run( (sys.executable, '-m', 'agent_comms.cli', '--root', str(root), 'history', @@ -254,6 +253,7 @@ def test_unreadable_checkout_is_its_units_state_and_the_pass_carries_on( assert isinstance(read, UnreadableCheckoutRead) and read.holds_work UnownedOrderWatch().watch_pass(lambda: nullcontext(c)) assert c.goals.todos.get('P2') == before - unreadable, = CheckoutUnreadable.violations(c, 'lead') - assert (unreadable.unit, unreadable.checkout) == ('worker', checkout) + unreadable, = Unreadable.violations(c, 'lead') + assert unreadable.unit == c.registry.require('worker').incarnation + assert str(checkout) in unreadable.reason assert 'not a git repository' in unreadable.line() From 1c66e3e5c2c2ad8cf59bd5138ca565f6a23618fb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 19:10:07 -0400 Subject: [PATCH 2/8] Reuse captured fork membership and keep answer reads independent --- src/agent_comms/commander/order.py | 5 +---- src/agent_comms/commander/standing.py | 12 ++++++++++-- src/agent_comms/commander/vibe_check.py | 4 ++-- src/agent_comms/standing_rules.py | 16 +++++++++++----- src/agent_comms/ui_model/unit_state.py | 6 ------ tests/test_commander_capacity.py | 17 ++++++++++++++--- tests/test_holder_standing_rules.py | 2 +- tests/test_todos.py | 5 +++-- tests/test_unit_state.py | 2 +- 9 files changed, 43 insertions(+), 26 deletions(-) diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index 61ed32308c..d751cd8533 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -102,7 +102,6 @@ CommsTodoListTool, TodoList, ) -from agent_comms.ui_model.unit_state import UnitState OUTGOING = DirectiveText.OUTGOING # every commander text passes each one @@ -805,9 +804,7 @@ def answer_references(self, ctx: Commander) -> tuple[MessageReference, ...]: def gate(self, ctx: Commander) -> None: named = self.named(ctx) - states = UnitState.current(ctx.comms, self.who(ctx)) - owed = tuple(rule for rule in OwedAnswer.broken( - ctx.comms, self.who(ctx), observed=states) + owed = tuple(rule for rule in OwedAnswer.overdue(ctx.comms, self.who(ctx)) if not rule.answered_by(named)) if owed: lines = '\n'.join(f'{rule.line()}: {rule.message.body[:80]}' for rule in owed) diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index 3cd0ffd1c5..638baf5f59 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -275,12 +275,20 @@ def scan_observed(cls, comms: Comms, commander: str, observed: Sequence[UnitState]) -> tuple[Self, ...]: return tuple(cls(message, state.unit.name) for state in observed for message in state.owed) + @classmethod + def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: + from agent_comms.ui_model.unit_state import UnitState + + return tuple(cls(message, source.thread.name) + for source in UnitState.observations(comms, commander) + for message in source.due_questions) + @classmethod def open_messages(cls, comms: Comms, commander: str) -> tuple[Message, ...]: from agent_comms.ui_model.unit_state import UnitState - return tuple(message for state in UnitState.current(comms, commander) - for message in state.open_questions()) + return tuple(message for source in UnitState.observations(comms, commander) + for message in source.questions) def line(self) -> str: age = max(0, int((time.time() - self.message.timestamp) // 60)) diff --git a/src/agent_comms/commander/vibe_check.py b/src/agent_comms/commander/vibe_check.py index dcfd7136cd..b7354449d6 100644 --- a/src/agent_comms/commander/vibe_check.py +++ b/src/agent_comms/commander/vibe_check.py @@ -228,7 +228,7 @@ def line(self) -> str: @dataclass(frozen=True) -class VibeCheckUnanswered(Escalation, IndependentStandingRule, OwedAnswer, UndatedStandingRule): +class VibeCheckUnanswered(Escalation, OwedAnswer, UndatedStandingRule): """A vibe check is owed an answer like a directive: the gate refuses until it gets one.""" seconds: ClassVar[int] = 5 * 60 @@ -321,7 +321,7 @@ def line(self) -> str: @dataclass(frozen=True) -class ChokeholdUnanswered(Escalation, IndependentStandingRule, OwedAnswer, UndatedStandingRule): +class ChokeholdUnanswered(Escalation, OwedAnswer, UndatedStandingRule): """Every chokehold message gates the commander from the moment it lands.""" @classmethod diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index 0c0f685c63..f88d1d7aa3 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -116,12 +116,12 @@ def scan_owner(cls) -> type[StandingRule]: @classmethod def broken(cls, comms: Comms, commander: str, after: float = 0.0, *, - observed: Sequence[UnitState]) -> tuple[StandingRule, ...]: - """Read each rule owner once against the supplied unit frame, including an empty one.""" + observed: Sequence[UnitState]) -> tuple[Self, ...]: + """Read each owner once against the supplied frame, preserving the requested family.""" owners = dict.fromkeys(rule.scan_owner() for rule in cls.members_with(cls)) return tuple(rule for owner in owners for rule in owner.scan_observed(comms, commander, observed) - if rule.attention() and rule.broke_after(after)) + if isinstance(rule, cls) and rule.attention() and rule.broke_after(after)) class IndependentStandingRule(StandingRule, declared_name=None): @@ -155,7 +155,7 @@ def broke_after(self, after: float) -> bool: @dataclass(frozen=True) -class OwedAnswer(StandingRule, declared_name=None): +class OwedAnswer(IndependentStandingRule, declared_name=None): """A message to the commander owed an answer past its window. Every member gates the commander's answering verbs until an action names it with --answers, so an answer cannot be held: one that must wait is sent now as "waiting on X, owner Y".""" @@ -172,6 +172,12 @@ def cited(self) -> str: def open_messages(cls, comms: Comms, commander: str) -> tuple[Message, ...]: """This member's messages an action may answer now, inside their window or past it.""" + @classmethod + def overdue(cls, comms: Comms, commander: str) -> tuple[Self, ...]: + """The message gate reads message debt, without classifying checkouts or runtimes.""" + return tuple(rule for member in cls.members_with(cls) + for rule in member.violations(comms, commander)) + def answered_by(self, named: tuple[Message, ...]) -> bool: """Whether an action naming these open messages answers this one.""" return any(message.message_id == self.message.message_id for message in named) @@ -310,7 +316,7 @@ def line(self) -> str: @dataclass(frozen=True) -class DirectiveUnacted(IndependentStandingRule, OwedAnswer, DatedStandingRule): +class DirectiveUnacted(OwedAnswer, DatedStandingRule): """An owner directive recorded on an open request, past its deadline with no order since.""" request: str diff --git a/src/agent_comms/ui_model/unit_state.py b/src/agent_comms/ui_model/unit_state.py index e0731bb038..b4c07c87a0 100644 --- a/src/agent_comms/ui_model/unit_state.py +++ b/src/agent_comms/ui_model/unit_state.py @@ -230,9 +230,6 @@ def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: @abstractmethod def derive(cls, source: UnitObservation) -> Iterator[Self]: ... - def open_questions(self) -> tuple[Message, ...]: - return self.source.questions - @property def owed(self) -> tuple[Message, ...]: return self.source.due_questions @@ -295,9 +292,6 @@ def derive(cls, source: UnitObservation) -> Iterator[Self]: source.require_read() return iter(()) - def open_questions(self) -> tuple[Message, ...]: - return () - @property def owed(self) -> tuple[Message, ...]: return () diff --git a/tests/test_commander_capacity.py b/tests/test_commander_capacity.py index dd8a334e5b..3e82d90a6f 100644 --- a/tests/test_commander_capacity.py +++ b/tests/test_commander_capacity.py @@ -42,14 +42,25 @@ def test_claimed_child_capacity_uses_receiving_presence(held_bus, claim_order, p child = replace(comms.registry.require("worker"), parent=ForkedParent("lead")) comms.registry.register(child, presence) comms.goals.update_goal("outsider", CreateProblemGoalAction( - problem="P3", commander=child.name, request=child.goal.require().id, + problem="P3", commander="lead", request=comms.registry.require("lead").goal.require().id, + assigner=child.incarnation, repo=template.repo, text="Review the child commander's work", review_at=template.review_at, order=replace(template.order.checked(), id="order-P3"), )) - assert not any(todo.pending for todo in comms.goals.todos.held_for_commander( - comms.registry.require("lead").incarnation)) + assert not any(todo.pending for todo in comms.goals.todos.held_by( + comms.registry.require("lead").incarnation, child.incarnation)) with LaunchRoot.from_path(comms.root).selected(): assert Load.of(comms, "lead").forks == expected +def test_capacity_does_not_mix_new_children_into_a_captured_frame(held_bus): + from agent_comms.ui_model.unit_state import UnitState + + states = UnitState.current(held_bus, "lead") + child = held_bus.registry.new_thread( + "late-fork", frozenset(), str(held_bus.root), parent=ForkedParent("lead")) + held_bus.registry.register(child) + assert "late-fork" not in Load.from_states(held_bus, "lead", states).forks + + diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index 426d538e34..2b07e83cb6 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -341,7 +341,7 @@ def test_an_overdue_owner_directive_gates_until_answered(held_bus, tmp_path): @pytest.mark.parametrize('body', ['@outsider This question is yours', 'The worker broadcasts its result']) def test_channel_messages_without_a_commander_addressee_are_not_a_question(held_bus, body): held_bus.messaging.send('worker', '#comms', body, type=QuestionKind) - assert UnitAskUnanswered.asks(held_bus, 'lead', time.time()) == () + assert UnitState.asks(held_bus, 'lead', time.time()) == () def test_send_and_check_accept_a_deletion_check_on_fresh_main( diff --git a/tests/test_todos.py b/tests/test_todos.py index 11a60931fa..cd86897239 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -236,7 +236,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): +def pr_commander(tmp_path, monkeypatch, proof_build_checkouts, configured_repository): """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) @@ -267,8 +267,9 @@ def pr_commander(tmp_path, monkeypatch, proof_build_checkouts): rule = StorageRule(checkout_base=source.checkout.parent) (tmp_path / "storage.json").write_text(json.dumps(FieldCodec.encode(rule))) Registration(tmp_path / "registry.json").register(lead) + config = configured_repository(Comms(tmp_path), source) return Commander(commander=lead.name, root=tmp_path, state=tmp_path / "state", - repos=(source,), worktrees=source.checkout.parent, + repos=config.repos, worktrees=source.checkout.parent, scratch=tmp_path, disk=tmp_path) diff --git a/tests/test_unit_state.py b/tests/test_unit_state.py index 3044be3198..d10fa5efa8 100644 --- a/tests/test_unit_state.py +++ b/tests/test_unit_state.py @@ -114,7 +114,7 @@ def test_claim_is_a_commander_question_not_a_verified_delivery(held_bus, claim_o claim = claim_order("P1") with LaunchRoot.from_path(comms.root).selected(): state, = AwaitingCommander.violations(comms, "lead") - question, = state.questions + question, = state.owed assert question.message_id == claim.message.message_id assert question.seq > claim.message.answers[0].seq assert DeliveredUnclosed.violations(comms, "lead") == () From c7fdbad63e7f67b8feca425c6df3c8ba3ac3a036 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 19:27:44 -0400 Subject: [PATCH 3/8] Share question readers without a circular rule dependency --- src/agent_comms/commander/questions.py | 93 +++++++++++++++++++ src/agent_comms/commander/standing.py | 86 +---------------- src/agent_comms/commander/vibe_check.py | 3 +- .../commander_parity/measurements.py | 8 +- src/agent_comms/ui_model/unit_state.py | 8 +- tests/test_commander_capacity.py | 15 +-- tests/test_storage_violations.py | 4 +- tests/test_todos.py | 12 ++- 8 files changed, 123 insertions(+), 106 deletions(-) create mode 100644 src/agent_comms/commander/questions.py diff --git a/src/agent_comms/commander/questions.py b/src/agent_comms/commander/questions.py new file mode 100644 index 0000000000..e704e9654a --- /dev/null +++ b/src/agent_comms/commander/questions.py @@ -0,0 +1,93 @@ +"""Message reads shared by unit observations and commander answer rules.""" + +from __future__ import annotations + +from collections.abc import Sequence +from contextlib import closing +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar + +from agent_comms.bus_page_index import BusPageIndex +from agent_comms.message_page import MessageReadRequest +from agent_comms.message_reference import MessageReference +from agent_comms.messages import Message, NeedsAnswerKind +from agent_comms.read_basis import RepliesDisplayScope +from agent_comms.wire_log import WireSnapshot + +if TYPE_CHECKING: + from agent_comms.comms import Comms + from agent_comms.todos import Todo + + +@dataclass(frozen=True, slots=True) +class UnansweredUnitAsks(MessageReadRequest[tuple[Message, ...]]): + scope: RepliesDisplayScope + since: float + before: float + empty: ClassVar[tuple[Message, ...]] = () + + @classmethod + def for_orders( + cls, comms: Comms, commander: str, todos: Sequence[Todo], before: float + ) -> tuple[Message, ...]: + if not todos: + return () + snapshot = comms.registry.snapshot() + peers = frozenset(name for todo in todos for name in snapshot.names_for(todo.held().owner)) + since = min( + order.at.recorded_at("A held order") for todo in todos for order in todo.order_history + ) + questions = comms.bus.log.read_page(cls( + RepliesDisplayScope(snapshot.names_for(commander), peers), since, before, + )) + return tuple(message for message in questions if issubclass(message.type, NeedsAnswerKind)) + + 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, slots=True) +class SenderMessages(UnansweredUnitAsks): + """Everything the other side sent the commander in the window, answered or not.""" + + def indexed(self, index: BusPageIndex, source: WireSnapshot) -> tuple[Message, ...]: + with closing(index.recent_offsets( + self.since, predicate=self.scope.index_predicate, before_offset=source.boundary, + )) as offsets: + return tuple(row.message for row in self.indexed_records(source, offsets) + if row.message.sender not in self.scope.first_names + and row.message.timestamp <= self.before) + + +@dataclass(frozen=True, slots=True) +class UnansweredBurst(UnansweredUnitAsks): + """A sender whose messages arrive as one burst: an answer to any of its messages covers + everything it sent before that answer, so the commander answers a burst once, not once per + line. Only what it sent after the newest such answer is still open.""" + + def indexed(self, index: BusPageIndex, source: WireSnapshot) -> tuple[Message, ...]: + replied: dict[MessageReference, float] = {} + sent: 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: + for answered in message.answers: + replied[answered] = max(replied.get(answered, 0.0), message.timestamp) + else: + sent.append(message) + covered = max((replied[m.reference] for m in sent if m.reference in replied), default=0.0) + return tuple(m for m in sent if covered < m.timestamp <= self.before) diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index 638baf5f59..f78c1c1167 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -6,7 +6,7 @@ from collections.abc import Iterable, Sequence from contextlib import closing from dataclasses import dataclass, field -from typing import TYPE_CHECKING, ClassVar, Self +from typing import ClassVar, Self from agent_comms.build_identity import RecordedBuildIdentity from agent_comms.bus_page_index import BusPageIndex @@ -19,7 +19,7 @@ from agent_comms.message_bus import WatchPass from agent_comms.message_page import MessageReadRequest, PageBounds from agent_comms.message_reference import MessageReference -from agent_comms.messages import Message, NeedsAnswerKind +from agent_comms.messages import Message from agent_comms.phase_evidence import PlanReading from agent_comms.plan_phases import PlanPhase from agent_comms.read_basis import NamedViewer, RepliesDisplayScope @@ -36,12 +36,10 @@ from agent_comms.threads import Thread from agent_comms.todos import Todo from agent_comms.turn_digests import OrderEditCountRead, TurnDigestRead, TurnsSince +from agent_comms.ui_model.unit_state import UnitState from agent_comms.wire_log import WireSnapshot from agent_comms.work_dispatch import ReusedWorkDispatch, WorkDispatch -if TYPE_CHECKING: - from agent_comms.ui_model.unit_state import UnitState - @dataclass(frozen=True) class InstallDue(IndependentStandingRule, UndatedStandingRule): @@ -122,80 +120,6 @@ def indexed(self, index: BusPageIndex, source: WireSnapshot) -> tuple[Message, . return tuple(reply for reply in replies if asked.intersection(reply.answers)) -@dataclass(frozen=True, slots=True) -class UnansweredUnitAsks(MessageReadRequest[tuple[Message, ...]]): - scope: RepliesDisplayScope - since: float - before: float - empty: ClassVar[tuple[Message, ...]] = () - - @classmethod - def for_orders( - cls, comms: Comms, commander: str, todos: Sequence[Todo], before: float - ) -> tuple[Message, ...]: - if not todos: - return () - snapshot = comms.registry.snapshot() - peers = frozenset(name for todo in todos for name in snapshot.names_for(todo.held().owner)) - since = min( - order.at.recorded_at("A held order") for todo in todos for order in todo.order_history - ) - questions = comms.bus.log.read_page(cls( - RepliesDisplayScope(snapshot.names_for(commander), peers), since, before, - )) - return tuple(message for message in questions if issubclass(message.type, NeedsAnswerKind)) - - 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, slots=True) -class SenderMessages(UnansweredUnitAsks): - """Everything the other side sent the commander in the window, answered or not.""" - - def indexed(self, index: BusPageIndex, source: WireSnapshot) -> tuple[Message, ...]: - with closing(index.recent_offsets( - self.since, predicate=self.scope.index_predicate, before_offset=source.boundary, - )) as offsets: - return tuple(row.message for row in self.indexed_records(source, offsets) - if row.message.sender not in self.scope.first_names - and row.message.timestamp <= self.before) - - -@dataclass(frozen=True, slots=True) -class UnansweredBurst(UnansweredUnitAsks): - """A sender whose messages arrive as one burst: an answer to any of its messages covers - everything it sent before that answer, so the commander answers a burst once, not once per - line. Only what it sent after the newest such answer is still open.""" - - def indexed(self, index: BusPageIndex, source: WireSnapshot) -> tuple[Message, ...]: - replied: dict[MessageReference, float] = {} - sent: 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: - for answered in message.answers: - replied[answered] = max(replied.get(answered, 0.0), message.timestamp) - else: - sent.append(message) - covered = max((replied[m.reference] for m in sent if m.reference in replied), default=0.0) - return tuple(m for m in sent if covered < m.timestamp <= self.before) - - class PlanRule(IndependentStandingRule, UndatedStandingRule, declared_name=None): """A rule read from one look at the plans and the commander's orders.""" @@ -277,16 +201,12 @@ def scan_observed(cls, comms: Comms, commander: str, @classmethod def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: - from agent_comms.ui_model.unit_state import UnitState - return tuple(cls(message, source.thread.name) for source in UnitState.observations(comms, commander) for message in source.due_questions) @classmethod def open_messages(cls, comms: Comms, commander: str) -> tuple[Message, ...]: - from agent_comms.ui_model.unit_state import UnitState - return tuple(message for source in UnitState.observations(comms, commander) for message in source.questions) diff --git a/src/agent_comms/commander/vibe_check.py b/src/agent_comms/commander/vibe_check.py index b7354449d6..7afd65ace8 100644 --- a/src/agent_comms/commander/vibe_check.py +++ b/src/agent_comms/commander/vibe_check.py @@ -16,7 +16,8 @@ from agent_comms.commander.capacity import Load from agent_comms.commander.config import CommanderConfig -from agent_comms.commander.standing import AnswersTo, PlanStalled, SenderMessages, UnansweredBurst +from agent_comms.commander.questions import SenderMessages, UnansweredBurst +from agent_comms.commander.standing import AnswersTo, PlanStalled from agent_comms.comms import Comms from agent_comms.declared_family import DeclaredFamily from agent_comms.goal_actions import ( diff --git a/src/agent_comms/commander_parity/measurements.py b/src/agent_comms/commander_parity/measurements.py index 7ebbf4f03c..f661cb1d31 100644 --- a/src/agent_comms/commander_parity/measurements.py +++ b/src/agent_comms/commander_parity/measurements.py @@ -10,8 +10,9 @@ from agent_comms.commander_parity import ParityComparison, UnverifiedParity from agent_comms.commander_parity.load import LoadComparison, state_files from agent_comms.comms import Comms -from agent_comms.standing_rules import DiskLow, StandingRule +from agent_comms.standing_rules import DiskLow, IndependentStandingRule, StandingRule from agent_comms.storage_rules import StorageViolation +from agent_comms.ui_model.unit_state import UnitState @dataclass(frozen=True) @@ -35,13 +36,14 @@ def describe(self) -> str: ) @classmethod - def read(cls, owner: type[StandingRule]) -> ParityComparison: + def read[Rule: IndependentStandingRule](cls, owner: type[Rule]) -> ParityComparison: method = owner.violations if not isinstance(method, MethodType): return UnverifiedParity(f"{owner.__qualname__}.violations is not a bound classmethod") code = method.__func__.__code__ config = CommanderConfig.read(CommanderConfig.path()) comms = Comms(config.root) + states = UnitState.current(comms, config.commander) before = state_files(config.state) observed: list[tuple[StandingRule, ...]] = [] @@ -55,7 +57,7 @@ def observe(_code: CodeType, _offset: int, value: tuple[StandingRule, ...]) -> N monitoring.PROFILER_ID, monitoring.events.PY_RETURN, observe ) monitoring.set_local_events(monitoring.PROFILER_ID, code, monitoring.events.PY_RETURN) - current = owner.broken(comms, config.commander) + current = owner.broken(comms, config.commander, observed=states) finally: monitoring.free_tool_id(monitoring.PROFILER_ID) if len(observed) != 1: diff --git a/src/agent_comms/ui_model/unit_state.py b/src/agent_comms/ui_model/unit_state.py index b4c07c87a0..f1ffe57d80 100644 --- a/src/agent_comms/ui_model/unit_state.py +++ b/src/agent_comms/ui_model/unit_state.py @@ -15,6 +15,7 @@ from subprocess import TimeoutExpired from typing import TYPE_CHECKING, ClassVar, Self +from agent_comms.commander.questions import UnansweredUnitAsks from agent_comms.field_codec import MalformedRecord from agent_comms.goal_presentation import GoalSnapshot, RunnableExecution from agent_comms.goals import GitCommandFailed @@ -68,7 +69,8 @@ def questions(self) -> tuple[Message, ...]: @cached_property def due_questions(self) -> tuple[Message, ...]: return tuple( - message for message in self.questions + message + for message in self.questions if message.timestamp <= self.now - AwaitingCommander.seconds ) @@ -99,7 +101,7 @@ def sources(self) -> tuple[ProgressSource, ...]: ) def require_read(self) -> None: - self.questions + _ = self.questions if self.todos: for path in self.comms.registry.storage_rule.checkout_paths(self.thread): PresentCheckout(path).require_read() @@ -143,8 +145,6 @@ def scan_observed( @classmethod def asks(cls, comms: Comms, commander: str, before: float) -> tuple[Message, ...]: - from agent_comms.commander.standing import UnansweredUnitAsks - return UnansweredUnitAsks.for_orders( comms, commander, diff --git a/tests/test_commander_capacity.py b/tests/test_commander_capacity.py index 3e82d90a6f..69f57cc0ad 100644 --- a/tests/test_commander_capacity.py +++ b/tests/test_commander_capacity.py @@ -6,8 +6,6 @@ from agent_comms.commander.capacity import Load from agent_comms.field_codec import FieldCodec -from agent_comms.goal_actions import CreateProblemGoalAction, SetGoalAction -from agent_comms.goals import RequestGoalOrder from agent_comms.launch_root import LaunchRoot from agent_comms.thread_status import IdleThreadStatus, RunningThreadStatus, StoppedThreadStatus from agent_comms.thread_text import ForkedParent @@ -34,20 +32,10 @@ def test_capacity_reports_load_and_forks(unjudged): )) def test_claimed_child_capacity_uses_receiving_presence(held_bus, claim_order, presence, expected): comms = held_bus - template = comms.goals.todos.get("P1") for problem in ("P1", "P2"): claim_order(problem) - comms.goals.update_goal("worker", SetGoalAction( - text="Delegate the review", goal_id="review-request", order=RequestGoalOrder())) child = replace(comms.registry.require("worker"), parent=ForkedParent("lead")) comms.registry.register(child, presence) - comms.goals.update_goal("outsider", CreateProblemGoalAction( - problem="P3", commander="lead", request=comms.registry.require("lead").goal.require().id, - assigner=child.incarnation, - repo=template.repo, text="Review the child commander's work", - review_at=template.review_at, - order=replace(template.order.checked(), id="order-P3"), - )) assert not any(todo.pending for todo in comms.goals.todos.held_by( comms.registry.require("lead").incarnation, child.incarnation)) with LaunchRoot.from_path(comms.root).selected(): @@ -60,7 +48,8 @@ def test_capacity_does_not_mix_new_children_into_a_captured_frame(held_bus): states = UnitState.current(held_bus, "lead") child = held_bus.registry.new_thread( "late-fork", frozenset(), str(held_bus.root), parent=ForkedParent("lead")) - held_bus.registry.register(child) + held_bus.registry.register(child, IdleThreadStatus()) assert "late-fork" not in Load.from_states(held_bus, "lead", states).forks + assert "late-fork" in Load.of(held_bus, "lead").forks diff --git a/tests/test_storage_violations.py b/tests/test_storage_violations.py index 1dd225f9bb..7d09c85a40 100644 --- a/tests/test_storage_violations.py +++ b/tests/test_storage_violations.py @@ -29,6 +29,7 @@ ) from agent_comms.todos import GoalRef, PendingProblemOrder, PresentCheckout from agent_comms.order_runs import MechanicalOrderRun +from agent_comms.ui_model.unit_state import UnitState @pytest.mark.parametrize( @@ -129,7 +130,8 @@ def observed_read(_code: CodeType, _offset: int, value: UnitStorage) -> None: monitoring.set_local_events( monitoring.PROFILER_ID, UnitStorage.read.__func__.__code__, monitoring.events.PY_RETURN ) - found = StorageViolation.broken(comms, "lead") + found = StorageViolation.broken( + comms, "lead", observed=UnitState.current(comms, "lead")) finally: monitoring.free_tool_id(monitoring.PROFILER_ID) assert {type(violation) for violation in found} == expected diff --git a/tests/test_todos.py b/tests/test_todos.py index cd86897239..60ac176649 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -267,9 +267,19 @@ def pr_commander(tmp_path, monkeypatch, proof_build_checkouts, configured_reposi rule = StorageRule(checkout_base=source.checkout.parent) (tmp_path / "storage.json").write_text(json.dumps(FieldCodec.encode(rule))) Registration(tmp_path / "registry.json").register(lead) + from agent_comms.plan_phases import PlanPhase + config = configured_repository(Comms(tmp_path), source) + declared = source.module_file(PlanPhase.__module__) + plans = config.module_repo(PlanPhase.__module__) + target = source.checkout / declared + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes((plans.checkout / plans.module_file(PlanPhase.__module__)).read_bytes()) + source.git(source.checkout, 'add', str(declared)) + source.git(source.checkout, 'commit', '-qm', 'Declare the fixture plan owners') + source.git(source.checkout, 'push', 'origin', 'main') return Commander(commander=lead.name, root=tmp_path, state=tmp_path / "state", - repos=config.repos, worktrees=source.checkout.parent, + repos=(source,), worktrees=source.checkout.parent, scratch=tmp_path, disk=tmp_path) From 8e45cc3fcf650403dd019c7b13a59040dc3fcbc2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 19:36:57 -0400 Subject: [PATCH 4/8] Measure the explicit scan and initialize copied bus fixtures --- src/agent_comms/commander/questions.py | 49 +++++++++++++------ src/agent_comms/commander/vibe_check.py | 5 +- .../commander_parity/measurements.py | 14 +++--- tests/test_commander_capacity.py | 6 +-- 4 files changed, 48 insertions(+), 26 deletions(-) diff --git a/src/agent_comms/commander/questions.py b/src/agent_comms/commander/questions.py index e704e9654a..1d172da33e 100644 --- a/src/agent_comms/commander/questions.py +++ b/src/agent_comms/commander/questions.py @@ -37,17 +37,25 @@ def for_orders( since = min( order.at.recorded_at("A held order") for todo in todos for order in todo.order_history ) - questions = comms.bus.log.read_page(cls( - RepliesDisplayScope(snapshot.names_for(commander), peers), since, before, - )) + questions = comms.bus.log.read_page( + cls( + RepliesDisplayScope(snapshot.names_for(commander), peers), + since, + before, + ) + ) return tuple(message for message in questions if issubclass(message.type, NeedsAnswerKind)) 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: + 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: @@ -62,12 +70,19 @@ class SenderMessages(UnansweredUnitAsks): """Everything the other side sent the commander in the window, answered or not.""" def indexed(self, index: BusPageIndex, source: WireSnapshot) -> tuple[Message, ...]: - with closing(index.recent_offsets( - self.since, predicate=self.scope.index_predicate, before_offset=source.boundary, - )) as offsets: - return tuple(row.message for row in self.indexed_records(source, offsets) - if row.message.sender not in self.scope.first_names - and row.message.timestamp <= self.before) + with closing( + index.recent_offsets( + self.since, + predicate=self.scope.index_predicate, + before_offset=source.boundary, + ) + ) as offsets: + return tuple( + row.message + for row in self.indexed_records(source, offsets) + if row.message.sender not in self.scope.first_names + and row.message.timestamp <= self.before + ) @dataclass(frozen=True, slots=True) @@ -79,9 +94,13 @@ class UnansweredBurst(UnansweredUnitAsks): def indexed(self, index: BusPageIndex, source: WireSnapshot) -> tuple[Message, ...]: replied: dict[MessageReference, float] = {} sent: list[Message] = [] - with closing(index.recent_offsets( - self.since, predicate=self.scope.index_predicate, before_offset=source.boundary, - )) as offsets: + 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: diff --git a/src/agent_comms/commander/vibe_check.py b/src/agent_comms/commander/vibe_check.py index 7afd65ace8..f806326bc6 100644 --- a/src/agent_comms/commander/vibe_check.py +++ b/src/agent_comms/commander/vibe_check.py @@ -29,7 +29,10 @@ from agent_comms.read_basis import RepliesDisplayScope from agent_comms.service_sender import ServiceSender from agent_comms.standing_rules import ( - IndependentStandingRule, OwedAnswer, StandingRule, UndatedStandingRule, + IndependentStandingRule, + OwedAnswer, + StandingRule, + UndatedStandingRule, ) if TYPE_CHECKING: diff --git a/src/agent_comms/commander_parity/measurements.py b/src/agent_comms/commander_parity/measurements.py index f661cb1d31..6353fcd85a 100644 --- a/src/agent_comms/commander_parity/measurements.py +++ b/src/agent_comms/commander_parity/measurements.py @@ -10,7 +10,7 @@ from agent_comms.commander_parity import ParityComparison, UnverifiedParity from agent_comms.commander_parity.load import LoadComparison, state_files from agent_comms.comms import Comms -from agent_comms.standing_rules import DiskLow, IndependentStandingRule, StandingRule +from agent_comms.standing_rules import DiskLow, StandingRule from agent_comms.storage_rules import StorageViolation from agent_comms.ui_model.unit_state import UnitState @@ -36,10 +36,12 @@ def describe(self) -> str: ) @classmethod - def read[Rule: IndependentStandingRule](cls, owner: type[Rule]) -> ParityComparison: - method = owner.violations + def read(cls, owner: type[StandingRule]) -> ParityComparison: + method = owner.scan_observed if not isinstance(method, MethodType): - return UnverifiedParity(f"{owner.__qualname__}.violations is not a bound classmethod") + return UnverifiedParity( + f"{owner.__qualname__}.scan_observed is not a bound classmethod" + ) code = method.__func__.__code__ config = CommanderConfig.read(CommanderConfig.path()) comms = Comms(config.root) @@ -62,7 +64,7 @@ def observe(_code: CodeType, _offset: int, value: tuple[StandingRule, ...]) -> N monitoring.free_tool_id(monitoring.PROFILER_ID) if len(observed) != 1: return UnverifiedParity( - f"Expected one {owner.__qualname__}.violations call: {len(observed)}" + f"Expected one {owner.__qualname__}.scan_observed call: {len(observed)}" ) native = tuple(rule for rule in observed[0] if rule.broke_after(0.0)) return cls( @@ -88,7 +90,7 @@ def describe(self) -> str: @classmethod def read(cls) -> StorageComparison: - return cls(RuleComparison.read(StorageViolation), RuleComparison.read(DiskLow)) + return cls(RuleComparison.read(StorageViolation.scan_owner()), RuleComparison.read(DiskLow)) def measured_load() -> ParityComparison: diff --git a/tests/test_commander_capacity.py b/tests/test_commander_capacity.py index 69f57cc0ad..c172004134 100644 --- a/tests/test_commander_capacity.py +++ b/tests/test_commander_capacity.py @@ -18,11 +18,9 @@ def test_capacity_reports_load_and_forks(unjudged): load = Load(24, 14, unjudged, ("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), " - f"1 working forks ['judge'], {load.total / 2:.0f} per reader" - ) assert load.share == load.total / 2 + assert FieldCodec.encode(load)["share"] == load.share + assert replace(load, forks=("judge", "reviewer")).share < load.share @pytest.mark.parametrize("presence,expected", ( From 66102aae9da86a0e06ae2b166eb2ff5cfca8eaba Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 20:01:52 -0400 Subject: [PATCH 5/8] Ask execution ownership and require a captured pull-request read --- src/agent_comms/commander/capacity.py | 3 +- src/agent_comms/commander/standing.py | 6 +-- src/agent_comms/commander_heartbeat.py | 2 +- .../commander_parity/measurements.py | 3 +- src/agent_comms/goal_presentation.py | 6 +++ src/agent_comms/todos.py | 4 +- src/agent_comms/tools.py | 2 +- src/agent_comms/ui_model/unit_state.py | 22 +++------ tests/test_commander_capacity.py | 45 ++++++++++++++----- tests/test_goal_execution.py | 5 ++- tests/test_holder_standing_rules.py | 29 +++++++----- tests/test_storage_violations.py | 4 +- tests/test_unit_state.py | 14 +++--- tests/test_unowned_checkout_work.py | 9 ++-- 14 files changed, 93 insertions(+), 61 deletions(-) diff --git a/src/agent_comms/commander/capacity.py b/src/agent_comms/commander/capacity.py index 8d260b3b33..da13ad3562 100644 --- a/src/agent_comms/commander/capacity.py +++ b/src/agent_comms/commander/capacity.py @@ -8,6 +8,7 @@ from ..comms import Comms from ..field_codec import projected from ..tool_result import ToolResult +from ..todos import PullRequests from ..turn_digests import OrderEditCountRead from ..ui_model.unit_state import UnitState @@ -24,7 +25,7 @@ class Load(ToolResult): @classmethod def of(cls, comms: Comms, commander: str) -> Load: - return cls.from_states(comms, commander, UnitState.current(comms, commander)) + return cls.from_states(comms, commander, UnitState.current(comms, commander, PullRequests())) @classmethod def from_states(cls, comms: Comms, commander: str, observed: Sequence[UnitState]) -> Load: diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index f78c1c1167..fa64eadf09 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -34,7 +34,7 @@ ) from agent_comms.storage_rules import OrderCheckoutWork from agent_comms.threads import Thread -from agent_comms.todos import Todo +from agent_comms.todos import PullRequests, Todo from agent_comms.turn_digests import OrderEditCountRead, TurnDigestRead, TurnsSince from agent_comms.ui_model.unit_state import UnitState from agent_comms.wire_log import WireSnapshot @@ -202,12 +202,12 @@ def scan_observed(cls, comms: Comms, commander: str, @classmethod def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: return tuple(cls(message, source.thread.name) - for source in UnitState.observations(comms, commander) + for source in UnitState.observations(comms, commander, PullRequests()) for message in source.due_questions) @classmethod def open_messages(cls, comms: Comms, commander: str) -> tuple[Message, ...]: - return tuple(message for source in UnitState.observations(comms, commander) + return tuple(message for source in UnitState.observations(comms, commander, PullRequests()) for message in source.questions) def line(self) -> str: diff --git a/src/agent_comms/commander_heartbeat.py b/src/agent_comms/commander_heartbeat.py index 848507cdc6..163ed86aab 100644 --- a/src/agent_comms/commander_heartbeat.py +++ b/src/agent_comms/commander_heartbeat.py @@ -33,7 +33,7 @@ class CommanderReview: 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.""" + """Every pass beats with every unit's card, even when the frame is unchanged.""" found_rules, found_states = frozenset(rules), frozenset(states) actionable = (*rules, *(state for state in states if state.attention())) old_rules = self.rules diff --git a/src/agent_comms/commander_parity/measurements.py b/src/agent_comms/commander_parity/measurements.py index 6353fcd85a..c4f8dc2efb 100644 --- a/src/agent_comms/commander_parity/measurements.py +++ b/src/agent_comms/commander_parity/measurements.py @@ -12,6 +12,7 @@ from agent_comms.comms import Comms from agent_comms.standing_rules import DiskLow, StandingRule from agent_comms.storage_rules import StorageViolation +from agent_comms.todos import PullRequests from agent_comms.ui_model.unit_state import UnitState @@ -45,7 +46,7 @@ def read(cls, owner: type[StandingRule]) -> ParityComparison: code = method.__func__.__code__ config = CommanderConfig.read(CommanderConfig.path()) comms = Comms(config.root) - states = UnitState.current(comms, config.commander) + states = UnitState.current(comms, config.commander, PullRequests()) before = state_files(config.state) observed: list[tuple[StandingRule, ...]] = [] diff --git a/src/agent_comms/goal_presentation.py b/src/agent_comms/goal_presentation.py index 20584dac9e..fa76b14ad5 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -80,6 +80,9 @@ def details_heading(self, heading: str) -> str: def waiting_presentation(self, ordinary: ThreadPresentation) -> ThreadPresentation: return ordinary + def runnable(self) -> bool: + return False + def standby(self) -> bool: return False @@ -162,6 +165,9 @@ def toggle(self) -> type[GoalAction]: class RunnableExecution(ActiveExecution): label = "Active" + def runnable(self) -> bool: + return True + @dataclass(frozen=True) class RunningExecution(ActiveExecution): diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index f032a3265d..de9d505895 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -552,9 +552,9 @@ class PullRequests: def __init__(self, todos: Sequence[Todo] = ()) -> None: self._read: dict[tuple[RepositoryName, type[ListedPullRequest], str], tuple[PullRequest, ...]] = {} self._todos: dict[RepositoryName, dict[str, Todo]] = {} - self(todos) + self.register(todos) - def __call__(self, todos: Sequence[Todo]) -> Self: + def register(self, todos: Sequence[Todo]) -> Self: """Register a frame's orders while preserving this read's existing GitHub facts.""" for todo in todos: self._todos.setdefault(todo.repo, {})[todo.id] = todo diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index a2c19f81fc..d9cc18cf0f 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -1006,7 +1006,7 @@ class CommsStandingRulesTool(ThreadToolRequest): ] = 0.0 def apply(self, comms: Comms, caller: ThreadCaller) -> StandingRulesRead: - states = UnitState.current(comms, caller.name) + states = UnitState.current(comms, caller.name, PullRequests()) broken = (*StandingRule.broken(comms, caller.name, observed=states), *(state for state in states if state.attention())) rejected = StandingRule.enforce_all(comms, caller.name, broken) diff --git a/src/agent_comms/ui_model/unit_state.py b/src/agent_comms/ui_model/unit_state.py index f1ffe57d80..7fc242f645 100644 --- a/src/agent_comms/ui_model/unit_state.py +++ b/src/agent_comms/ui_model/unit_state.py @@ -17,7 +17,7 @@ from agent_comms.commander.questions import UnansweredUnitAsks from agent_comms.field_codec import MalformedRecord -from agent_comms.goal_presentation import GoalSnapshot, RunnableExecution +from agent_comms.goal_presentation import GoalSnapshot from agent_comms.goals import GitCommandFailed from agent_comms.messages import Message from agent_comms.native_failures import NativePiUnavailable @@ -54,6 +54,7 @@ class UnitObservation: messages: tuple[Message, ...] now: float presence: ThreadStatus + working: bool @cached_property def goal(self) -> GoalSnapshot: @@ -170,14 +171,14 @@ def read(cls, source: UnitObservation) -> UnitState: @classmethod def observations( - cls, comms: Comms, commander: str, pulls: PullRequests | type[PullRequests] = PullRequests + cls, comms: Comms, commander: str, pulls: PullRequests ) -> tuple[UnitObservation, ...]: snapshot, now = comms.registry.snapshot(), time.time() held = StandingRule.holders(comms, commander) for owner in held: if snapshot.require(owner.name).incarnation != owner: raise ValueError("Held order owner incarnation changed") - requests = pulls(tuple(todo for todos in held.values() for todo in todos)) + requests = pulls.register(tuple(todo for todos in held.values() for todo in todos)) questions = cls.asks(comms, commander, now) readings = tuple( UnitObservation( @@ -192,6 +193,7 @@ def observations( ), now, snapshot.status(thread.name), + comms.agents.is_working(thread.incarnation, snapshot), ) for thread in snapshot.threads.values() ) @@ -210,22 +212,12 @@ def observations( return tuple(reading for reading in readings if reading.thread.incarnation in eligible) @classmethod - def current( - cls, comms: Comms, commander: str, pulls: PullRequests | type[PullRequests] = PullRequests - ) -> tuple[UnitState, ...]: + def current(cls, comms: Comms, commander: str, pulls: PullRequests) -> tuple[UnitState, ...]: states = tuple(cls.read(source) for source in cls.observations(comms, commander, pulls)) return tuple( state for member in cls.worst_first() for state in states if isinstance(state, member) ) - @classmethod - def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: - return tuple( - state - for state in cls.current(comms, commander) - if isinstance(state, cls) and state.attention() - ) - @classmethod @abstractmethod def derive(cls, source: UnitObservation) -> Iterator[Self]: ... @@ -423,7 +415,7 @@ class StalledOnNothing(NeedsCommanderAttention, PendingWorkState, UnitState): @classmethod def matches(cls, source: UnitObservation) -> bool: return bool( - isinstance(source.goal.execution, RunnableExecution) + source.goal.execution.runnable() and source.turns and not source.comms.bus.pending_count(source.thread.name) ) diff --git a/tests/test_commander_capacity.py b/tests/test_commander_capacity.py index c172004134..44916c7dc4 100644 --- a/tests/test_commander_capacity.py +++ b/tests/test_commander_capacity.py @@ -1,9 +1,11 @@ -"""Capacity reports the existing load formula and working forks without policy advice.""" +"""Capacity reports the existing load formula and captured working forks.""" +import os from dataclasses import replace import pytest +from agent_comms.child_process import ProcessIdentity from agent_comms.commander.capacity import Load from agent_comms.field_codec import FieldCodec from agent_comms.launch_root import LaunchRoot @@ -23,30 +25,51 @@ def test_capacity_reports_load_and_forks(unjudged): assert replace(load, forks=("judge", "reviewer")).share < load.share -@pytest.mark.parametrize("presence,expected", ( - (RunningThreadStatus(), ("worker",)), - (IdleThreadStatus(), ("worker",)), - (StoppedThreadStatus(), ()), +@pytest.fixture +def working_turn(held_bus): + leases = [] + + def start(name): + owner = held_bus.agents.begin_turn(name, f"capacity-{name}") + leases.append(owner.thread.require_turn_lease()) + + yield start + for lease in reversed(leases): + held_bus.agents.finish_turn(lease) + + +@pytest.mark.parametrize("presence,working,expected", ( + (RunningThreadStatus(), True, ("worker",)), + (RunningThreadStatus(), False, ()), + (IdleThreadStatus(), False, ()), + (StoppedThreadStatus(), False, ()), )) -def test_claimed_child_capacity_uses_receiving_presence(held_bus, claim_order, presence, expected): +def test_claimed_child_capacity_uses_working_activity( + held_bus, claim_order, working_turn, presence, working, expected): comms = held_bus for problem in ("P1", "P2"): claim_order(problem) - child = replace(comms.registry.require("worker"), parent=ForkedParent("lead")) + child = replace(comms.registry.require("worker"), parent=ForkedParent("lead"), + process_identity=ProcessIdentity.capture(os.getpid())) comms.registry.register(child, presence) + if working: + working_turn(child.name) assert not any(todo.pending for todo in comms.goals.todos.held_by( comms.registry.require("lead").incarnation, child.incarnation)) with LaunchRoot.from_path(comms.root).selected(): assert Load.of(comms, "lead").forks == expected -def test_capacity_does_not_mix_new_children_into_a_captured_frame(held_bus): +def test_capacity_does_not_mix_new_children_into_a_captured_frame(held_bus, working_turn): + from agent_comms.todos import PullRequests from agent_comms.ui_model.unit_state import UnitState - states = UnitState.current(held_bus, "lead") + states = UnitState.current(held_bus, "lead", PullRequests()) child = held_bus.registry.new_thread( - "late-fork", frozenset(), str(held_bus.root), parent=ForkedParent("lead")) - held_bus.registry.register(child, IdleThreadStatus()) + "late-fork", frozenset(), str(held_bus.root), parent=ForkedParent("lead"), + process_identity=ProcessIdentity.capture(os.getpid())) + held_bus.registry.register(child, RunningThreadStatus()) + working_turn(child.name) assert "late-fork" not in Load.from_states(held_bus, "lead", states).forks assert "late-fork" in Load.of(held_bus, "lead").forks diff --git a/tests/test_goal_execution.py b/tests/test_goal_execution.py index 5e9fe3b4d7..54c1ba3a4b 100644 --- a/tests/test_goal_execution.py +++ b/tests/test_goal_execution.py @@ -47,8 +47,11 @@ def execution_bus(copied_goal_bus): def test_execution_identity_survives_wire_and_change_detection(execution_bus, execution_type): goal = execution_bus.registry.require("worker").goal execution = execution_type(goal.id) - assert FieldCodec.decode(GoalExecution, FieldCodec.encode(execution)) == execution + decoded = FieldCodec.decode(GoalExecution, FieldCodec.encode(execution)) + assert decoded == execution + assert decoded.runnable() == execution.runnable() current = GoalSnapshot(goal, RunnableExecution(goal.id)) + assert current.execution.runnable() assert current.changed_by(GoalSnapshot(goal, execution)) == (current.execution != execution) diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index 2b07e83cb6..206f189726 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -48,6 +48,7 @@ from agent_comms.todos import ( ClaimOrder, OrderClaim, OrderMessage, PendingProblemOrder, + PullRequests, TodoConflict, ) from conftest import declare_test_bus @@ -123,8 +124,14 @@ def claim(problem): return claim +def state_cards(comms, member): + return tuple(state for state in UnitState.current(comms, "lead", PullRequests()) + if isinstance(state, member)) + + def unanswered(comms): - return UnitAskUnanswered.broken(comms, "lead", observed=UnitState.current(comms, "lead")) + return UnitAskUnanswered.broken( + comms, "lead", observed=UnitState.current(comms, "lead", PullRequests())) def blocked_holder(comms): @@ -148,7 +155,7 @@ 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) - violation, = Blocked.violations(held_bus, "lead") + violation, = state_cards(held_bus, Blocked) assert violation.orders == ("P1", "P2") violation.enforce(held_bus, "lead") assert held_bus.registry.require("worker").goal.require() == goal @@ -171,14 +178,14 @@ def test_failed_holder_is_reported_without_reissuing_orders_or_replaying_unknown orders = held_bus.goals.todos.held_orders() inputs = InputDispositions(held_bus.root) unresolved = inputs.lookup("unresolved-input") - violation, = Blocked.violations(held_bus, "lead") + violation, = state_cards(held_bus, Blocked) assert violation.orders == ("P1", "P2") violation.enforce(held_bus, "lead") assert held_bus.registry.require("worker").goal == goal assert held_bus.goals.todos.held_orders() == orders assert store.require_generation(goal.id) == generation assert inputs.lookup("unresolved-input") == unresolved - assert Blocked.violations(held_bus, "lead") == (violation,) + assert state_cards(held_bus, Blocked) == (violation,) def test_an_order_to_a_blocked_goal_activates_it_and_keeps_other_orders(held_bus): @@ -215,7 +222,7 @@ async def test_sigterm_group_preserves_active_goal_and_never_reissues_orders( await Coordination.run_worker(partial(held_bus.owners.stop, "worker")) assert held_bus.registry.require("worker").goal == goal assert isinstance(held_bus.goals.goal_execution("worker"), RunnableExecution) - for violation in Blocked.violations(held_bus, "lead"): + for violation in state_cards(held_bus, Blocked): violation.enforce(held_bus, "lead") assert held_bus.goals.todos.held_orders() == orders assert InputDispositions(held_bus.root).lookup("unresolved-input") == unresolved @@ -244,7 +251,7 @@ def test_a_running_turn_is_not_a_stalled_holder(held_bus): lease = owner.thread.turn_lease assert lease is not None try: - assert Blocked.violations(held_bus, "lead") == () + assert state_cards(held_bus, Blocked) == () finally: held_bus.agents.finish_turn(lease) @@ -411,11 +418,11 @@ def cli(*arguments): def test_one_state_per_unit_even_when_blocked_and_detached(held_bus, installed_runtime): held_bus.registry.register(held_bus.registry.require("worker"), StoppedThreadStatus()) - detached, = Detached.violations(held_bus, "lead") + detached, = state_cards(held_bus, Detached) assert detached.unit.name == "worker" and set(detached.orders) == {"P1", "P2"} blocked_holder(held_bus) - blocked, = Blocked.violations(held_bus, "lead") - states = UnitState.current(held_bus, "lead") + blocked, = state_cards(held_bus, Blocked) + states = UnitState.current(held_bus, "lead", PullRequests()) assert tuple(state for state in states if state.unit == blocked.unit) == (blocked,) assert blocked not in StandingRule.broken(held_bus, "lead", observed=states) @@ -424,7 +431,7 @@ def test_startup_heartbeat_enforces_and_publishes_both_new_rule_lines(held_bus, 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"), *unanswered(held_bus), + *state_cards(held_bus, Blocked), *unanswered(held_bus), )) heartbeat = CommanderHeartbeat() commander = held_bus.registry.require("lead") @@ -451,7 +458,7 @@ def test_every_pass_beats_and_a_stale_beat_is_heartbeat_silent(held_bus, monkeyp review = CommanderReview(frozenset()) CommanderHeartbeat.register(held_bus.registry) assert HeartbeatSilent.violations(held_bus, "lead") == () - states = UnitState.current(held_bus, "lead") + states = UnitState.current(held_bus, "lead", PullRequests()) with held_bus.bus.locked(): review.wake(held_bus, CommanderHeartbeat.sender, commander, (), states) review.wake(held_bus, CommanderHeartbeat.sender, commander, (), states) diff --git a/tests/test_storage_violations.py b/tests/test_storage_violations.py index 7d09c85a40..687d7c0378 100644 --- a/tests/test_storage_violations.py +++ b/tests/test_storage_violations.py @@ -27,7 +27,7 @@ StorageViolation, UnitStorage, ) -from agent_comms.todos import GoalRef, PendingProblemOrder, PresentCheckout +from agent_comms.todos import GoalRef, PendingProblemOrder, PresentCheckout, PullRequests from agent_comms.order_runs import MechanicalOrderRun from agent_comms.ui_model.unit_state import UnitState @@ -131,7 +131,7 @@ def observed_read(_code: CodeType, _offset: int, value: UnitStorage) -> None: monitoring.PROFILER_ID, UnitStorage.read.__func__.__code__, monitoring.events.PY_RETURN ) found = StorageViolation.broken( - comms, "lead", observed=UnitState.current(comms, "lead")) + comms, "lead", observed=UnitState.current(comms, "lead", PullRequests())) finally: monitoring.free_tool_id(monitoring.PROFILER_ID) assert {type(violation) for violation in found} == expected diff --git a/tests/test_unit_state.py b/tests/test_unit_state.py index d10fa5efa8..94108bb5c7 100644 --- a/tests/test_unit_state.py +++ b/tests/test_unit_state.py @@ -14,7 +14,7 @@ from agent_comms.recorded_time import RecordedEventTime from agent_comms.thread_status import StoppedThreadStatus from agent_comms.thread_text import ForkedParent, SavedSessionFile -from agent_comms.todos import DeliverTodo, OperationDelivery +from agent_comms.todos import DeliverTodo, OperationDelivery, PullRequests from agent_comms.ui_model.unit_state import ( AwaitingCommander, Blocked, @@ -28,7 +28,7 @@ UnitState, Unreadable, ) -from test_holder_standing_rules import blocked_holder, publish +from test_holder_standing_rules import blocked_holder, publish, state_cards pytest_plugins = ("test_holder_standing_rules",) @@ -96,7 +96,7 @@ def test_unit_state_family_uses_real_records(held_bus, tmp_path, member): pass # Assigned work, before its first-call deadline. else: pytest.fail(f"New UnitState needs its real-record scenario: {member}") - states = UnitState.current(comms, "lead") + states = UnitState.current(comms, "lead", PullRequests()) state, = (state for state in states if state.unit.name == unit) assert isinstance(state, member) assert state.unit == comms.registry.require(unit).incarnation @@ -113,18 +113,18 @@ def test_claim_is_a_commander_question_not_a_verified_delivery(held_bus, claim_o comms = held_bus claim = claim_order("P1") with LaunchRoot.from_path(comms.root).selected(): - state, = AwaitingCommander.violations(comms, "lead") + state, = state_cards(comms, AwaitingCommander) question, = state.owed assert question.message_id == claim.message.message_id assert question.seq > claim.message.answers[0].seq - assert DeliveredUnclosed.violations(comms, "lead") == () + assert state_cards(comms, DeliveredUnclosed) == () def test_stopped_child_without_lead_orders_remains_in_the_frame(held_bus): child = replace(held_bus.registry.require("outsider"), parent=ForkedParent("lead")) held_bus.registry.register(child, StoppedThreadStatus()) with LaunchRoot.from_path(held_bus.root).selected(): - state, = (state for state in UnitState.current(held_bus, "lead") + state, = (state for state in UnitState.current(held_bus, "lead", PullRequests()) if state.unit == child.incarnation) assert isinstance(state, Idle) assert state.presence == StoppedThreadStatus() @@ -138,7 +138,7 @@ def test_malformed_native_source_is_unreadable_not_idle(held_bus): del header["timestamp"] path.write_text("\n".join((json.dumps(header), *rows[1:])) + "\n") with LaunchRoot.from_path(held_bus.root).selected(): - state, = Unreadable.violations(held_bus, "lead") + state, = state_cards(held_bus, Unreadable) assert state.unit.name == "worker" assert "timestamp" in state.reason diff --git a/tests/test_unowned_checkout_work.py b/tests/test_unowned_checkout_work.py index 138724301d..7f3d8f733b 100644 --- a/tests/test_unowned_checkout_work.py +++ b/tests/test_unowned_checkout_work.py @@ -33,14 +33,12 @@ from agent_comms.recorded_time import RecordedEventTime from agent_comms.repository_name import RepositoryName from agent_comms.storage_rules import StorageDenied, StorageRule, shell_storage_effects -from agent_comms.standing_rules import StandingRule from agent_comms.thread_execution import ExternalThreadExecution from agent_comms.thread_identity import CoreWatcherIncarnation -from agent_comms.todos import GoalRef, PendingProblemOrder, ReleaseTodo, StrikeReason +from agent_comms.todos import GoalRef, PendingProblemOrder, PullRequests, ReleaseTodo, StrikeReason from agent_comms.transcript_events import AssistantTranscript from agent_comms.work_branches import UnreadableCheckoutRead, WorkBranches -from agent_comms.ui_model.unit_state import Unreadable -from order_scope_fixture import FIX_SCOPE +from agent_comms.ui_model.unit_state import UnitState, Unreadable def git(directory: Path, *arguments: str) -> str: @@ -253,7 +251,8 @@ def test_unreadable_checkout_is_its_units_state_and_the_pass_carries_on( assert isinstance(read, UnreadableCheckoutRead) and read.holds_work UnownedOrderWatch().watch_pass(lambda: nullcontext(c)) assert c.goals.todos.get('P2') == before - unreadable, = Unreadable.violations(c, 'lead') + unreadable, = (state for state in UnitState.current(c, 'lead', PullRequests()) + if isinstance(state, Unreadable)) assert unreadable.unit == c.registry.require('worker').incarnation assert str(checkout) in unreadable.reason assert 'not a git repository' in unreadable.line() From 933cf97debdfaa585fbd59d568b1614f46f73d33 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 20:11:19 -0400 Subject: [PATCH 6/8] Send the unchanged heartbeat as the one line its docstring declares An unchanged frame carries no new card, so repeating every unit's card restated the previous changed beat. The unchanged beat is now one line, and the docstring keeps its original contract instead of being rewritten to excuse the repetition. Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/commander_heartbeat.py | 9 ++++----- tests/test_holder_standing_rules.py | 5 +---- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/agent_comms/commander_heartbeat.py b/src/agent_comms/commander_heartbeat.py index 163ed86aab..728d8afa9a 100644 --- a/src/agent_comms/commander_heartbeat.py +++ b/src/agent_comms/commander_heartbeat.py @@ -33,16 +33,15 @@ class CommanderReview: def wake(self, comms: Comms, sender: str, commander: Thread, rules: tuple[StandingRule, ...], states: tuple[UnitState, ...]) -> None: - """Every pass beats with every unit's card, even when the frame is unchanged.""" + """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: - lines = [f"COMMANDER HEARTBEAT: unchanged since seq {self.changed}," - f" {len(actionable)} violations."] - lines.extend(state.line() for state in states) - comms.messaging.send(sender, commander.name, "\n".join(lines)) + 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 diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index 206f189726..886a874405 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -468,10 +468,7 @@ def test_every_pass_beats_and_a_stale_beat_is_heartbeat_silent(held_bus, monkeyp changed, unchanged = (message for message in page.messages if message.sender == CommanderHeartbeat.sender) assert "Standing rules clear." in changed.body - assert unchanged.body.splitlines() == [ - f"COMMANDER HEARTBEAT: unchanged since seq {changed.seq}, 0 violations.", - *(state.line() for state in states), - ] + assert unchanged.body == f"COMMANDER HEARTBEAT: unchanged since seq {changed.seq}, 0 violations." assert HeartbeatSilent.violations(held_bus, "lead") == () later = unchanged.timestamp + HeartbeatSilent.seconds + 1 monkeypatch.setattr(time, "time", lambda: later) From 0070017e5bf3567066174aff3d2cca9f216675cb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 20:14:16 -0400 Subject: [PATCH 7/8] Sort the capacity imports and wrap its long line The debt-src ratchet grew two lint findings in capacity.py: the todos import out of order and a 101-character return line. Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/commander/capacity.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/commander/capacity.py b/src/agent_comms/commander/capacity.py index da13ad3562..bf1a95ae5b 100644 --- a/src/agent_comms/commander/capacity.py +++ b/src/agent_comms/commander/capacity.py @@ -7,8 +7,8 @@ from ..comms import Comms from ..field_codec import projected -from ..tool_result import ToolResult from ..todos import PullRequests +from ..tool_result import ToolResult from ..turn_digests import OrderEditCountRead from ..ui_model.unit_state import UnitState @@ -25,7 +25,8 @@ class Load(ToolResult): @classmethod def of(cls, comms: Comms, commander: str) -> Load: - return cls.from_states(comms, commander, UnitState.current(comms, commander, PullRequests())) + return cls.from_states( + comms, commander, UnitState.current(comms, commander, PullRequests())) @classmethod def from_states(cls, comms: Comms, commander: str, observed: Sequence[UnitState]) -> Load: From a1c3884d5fbb5617f1f084154e1b008df2665701 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 20:33:17 -0400 Subject: [PATCH 8/8] Read claim originals once per observation and drop dead unit-state paths Detached.unread no longer takes part in equality, so a new unread message does not make every card look changed and resend the frame. Each order now declares its claims without reading the bus, and UnitState.observations reads every claim original in one checked read, opened only when a claim exists. UnitObservation.questions is that captured tuple, so one unit's questions can no longer fail on their own, and Unreadable keeps the owed questions instead of overriding them to nothing. The heartbeat and the answer gate now read owed questions from the same place. Deleted StorageViolation.violations (no caller; parity reads scan_observed), the parity row wording that named it, and the one-caller UnitState.asks forwarder. Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/commander_parity/rows.py | 2 +- src/agent_comms/storage_rules.py | 5 --- src/agent_comms/todos.py | 26 +++++++++----- src/agent_comms/ui_model/unit_state.py | 46 ++++++++++-------------- tests/test_holder_standing_rules.py | 4 ++- 5 files changed, 40 insertions(+), 43 deletions(-) diff --git a/src/agent_comms/commander_parity/rows.py b/src/agent_comms/commander_parity/rows.py index da3e35de71..4f2a5f89f5 100644 --- a/src/agent_comms/commander_parity/rows.py +++ b/src/agent_comms/commander_parity/rows.py @@ -197,7 +197,7 @@ class StorageHygieneParityRow(ParityRow): number = 19 capability = "Storage hygiene" current = "`StandingRule.broken` storage delivery (formerly `storage.py`)" - native = "`StorageViolation.violations`, `DiskLow.violations`; live `StorageRule` thresholds" + native = "`StorageViolation.scan_observed`, `DiskLow.violations`; live `StorageRule` thresholds" phases = (ControlPlanePhase,) @classmethod diff --git a/src/agent_comms/storage_rules.py b/src/agent_comms/storage_rules.py index b022ee4162..503f0ef3e9 100644 --- a/src/agent_comms/storage_rules.py +++ b/src/agent_comms/storage_rules.py @@ -344,11 +344,6 @@ class StorageViolation(UndatedStandingRule, declared_name=None): def scan_owner(cls) -> type[StorageViolation]: return __class__ - @classmethod - def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: - units = {todo.held().owner_identity for todo in cls.todos(comms, commander)} - return cls.for_units(comms, units) - @classmethod def scan_observed(cls, comms: Comms, commander: str, observed: Sequence[UnitState]) -> tuple[Self, ...]: diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 8d01db8b39..299d7b33f0 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -16,7 +16,7 @@ import sys import time from abc import ABC, abstractmethod -from collections.abc import Callable, Iterator, Sequence +from collections.abc import Callable, Iterator, Mapping, Sequence from contextlib import contextmanager from dataclasses import dataclass, field, fields, replace from functools import cache, cached_property, partial @@ -79,7 +79,7 @@ from agent_comms.order_runs import OrderRun 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 +from agent_comms.private_bus_checkpoint import CheckedRead, PublicMessages from agent_comms.problem_number import ProblemNumber from agent_comms.progress_claims import MergedClaim, ProgressClaim, WorkingClaim from agent_comms.recorded_time import ( @@ -1545,7 +1545,7 @@ def withdrawals(self, todo: Todo) -> tuple[str, ...]: def waiting_on_install(self, todo: Todo, read: dict[str, OrderProgress]) -> tuple[Todo, ...]: return () - def commander_questions(self, comms: Comms) -> tuple[Message, ...]: + def commander_claims(self) -> tuple[OrderClaim, ...]: return () def closing_artifact(self) -> str: @@ -2039,11 +2039,21 @@ def __post_init__(self) -> None: if not self.message.body.strip(): raise TodoError("A delivery claim needs its report text.") - def original(self, comms: Comms) -> Message: - with comms.bus.log.checked_read() as source: - row = PublicMessages.require(source.connection, message_id=self.message.message_id) + def original(self, source: CheckedRead) -> Message: + row = PublicMessages.require(source.connection, message_id=self.message.message_id) return replace(self.message, seq=row.seq) + @staticmethod + def originals[K]( + comms: Comms, claims: Mapping[K, tuple[OrderClaim, ...]] + ) -> dict[K, tuple[Message, ...]]: + """Every claim's original from one checked read; no claim opens no read.""" + if not any(claims.values()): + return {} + with comms.bus.log.checked_read() as source: + return {key: tuple(claim.original(source) for claim in held) + for key, held in claims.items()} + @classmethod def for_order( cls, todo: Todo, actor: ThreadIncarnation, number: int, head: str, @@ -2068,8 +2078,8 @@ def require_reassignment(self, todo: Todo, proposed: Assignment) -> None: if proposed.owner_identity != todo.holder_state.previous_assignment().owner_identity: self.require_transfer(todo) - def commander_questions(self, comms: Comms) -> tuple[Message, ...]: - return (self.claim.original(comms),) + def commander_claims(self) -> tuple[OrderClaim, ...]: + return (self.claim,) def observed_merges(self, repo: Repo, source: ProgressSource) -> tuple[MergedProblemOrder, ...]: return () diff --git a/src/agent_comms/ui_model/unit_state.py b/src/agent_comms/ui_model/unit_state.py index 7fc242f645..9819e28d6d 100644 --- a/src/agent_comms/ui_model/unit_state.py +++ b/src/agent_comms/ui_model/unit_state.py @@ -27,6 +27,7 @@ from agent_comms.todos import ( MergedProgress, NoCommitProgress, + OrderClaim, OrderProgress, PresentCheckout, ProgressSource, @@ -51,7 +52,7 @@ class UnitObservation: thread: Thread todos: tuple[Todo, ...] pulls: PullRequests - messages: tuple[Message, ...] + questions: tuple[Message, ...] now: float presence: ThreadStatus working: bool @@ -60,13 +61,6 @@ class UnitObservation: def goal(self) -> GoalSnapshot: return self.comms.goals.goal_snapshot(self.thread.name) - @cached_property - def questions(self) -> tuple[Message, ...]: - claims = tuple( - message for todo in self.todos for message in todo.order.commander_questions(self.comms) - ) - return (*self.messages, *claims) - @cached_property def due_questions(self) -> tuple[Message, ...]: return tuple( @@ -102,7 +96,6 @@ def sources(self) -> tuple[ProgressSource, ...]: ) def require_read(self) -> None: - _ = self.questions if self.todos: for path in self.comms.registry.storage_rule.checkout_paths(self.thread): PresentCheckout(path).require_read() @@ -144,15 +137,6 @@ def scan_observed( # States are already read; ordinary scans consume them rather than emit them again. return () - @classmethod - def asks(cls, comms: Comms, commander: str, before: float) -> tuple[Message, ...]: - return UnansweredUnitAsks.for_orders( - comms, - commander, - StandingRule.todos(comms, commander), - before, - ) - @classmethod def read(cls, source: UnitObservation) -> UnitState: try: @@ -179,17 +163,27 @@ def observations( if snapshot.require(owner.name).incarnation != owner: raise ValueError("Held order owner incarnation changed") requests = pulls.register(tuple(todo for todos in held.values() for todo in todos)) - questions = cls.asks(comms, commander, now) + asks = UnansweredUnitAsks.for_orders( + comms, commander, StandingRule.todos(comms, commander), now + ) + # Claim originals come from one read here, so no unit's questions read can fail alone. + claims = OrderClaim.originals(comms, { + owner: tuple(claim for todo in todos for claim in todo.order.commander_claims()) + for owner, todos in held.items() + }) readings = tuple( UnitObservation( comms, thread, tuple(held.get(thread.incarnation, ())), requests, - tuple( - message - for message in questions - if snapshot.canonical_name(message.sender) == thread.name + ( + *( + message + for message in asks + if snapshot.canonical_name(message.sender) == thread.name + ), + *claims.get(thread.incarnation, ()), ), now, snapshot.status(thread.name), @@ -284,10 +278,6 @@ def derive(cls, source: UnitObservation) -> Iterator[Self]: source.require_read() return iter(()) - @property - def owed(self) -> tuple[Message, ...]: - return () - def readable_units(self) -> tuple[ThreadIncarnation, ...]: return () @@ -326,7 +316,7 @@ def line(self) -> str: @dataclass(frozen=True) class Detached(NeedsCommanderAttention, UnitState): - unread: int + unread: int = field(compare=False) action = "wake it or reassign the order" @classmethod diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index 886a874405..5af92104fe 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -42,6 +42,7 @@ from agent_comms.recorded_time import RecordedEventTime from agent_comms.repository_name import RepositoryName from agent_comms.standing_rules import StandingRule +from agent_comms.commander.questions import UnansweredUnitAsks from agent_comms.thread_identity import CoreWatcherIncarnation from agent_comms.thread_model import DeclaredModel from agent_comms.thread_status import StoppedThreadStatus @@ -348,7 +349,8 @@ def test_an_overdue_owner_directive_gates_until_answered(held_bus, tmp_path): @pytest.mark.parametrize('body', ['@outsider This question is yours', 'The worker broadcasts its result']) def test_channel_messages_without_a_commander_addressee_are_not_a_question(held_bus, body): held_bus.messaging.send('worker', '#comms', body, type=QuestionKind) - assert UnitState.asks(held_bus, 'lead', time.time()) == () + todos = StandingRule.todos(held_bus, 'lead') + assert UnansweredUnitAsks.for_orders(held_bus, 'lead', todos, time.time()) == () def test_send_and_check_accept_a_deletion_check_on_fresh_main(