diff --git a/pyproject.toml b/pyproject.toml index 6f4624917a..332937a694 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "nominal-refactor-advisor @ git+https://github.com/OpenHCSDev/NominalRefactorAdvisor@main", "packaging>=24", "pydantic>=2.13", + "python-introspect @ git+https://github.com/OpenHCSDev/python-introspect@main", ] [project.optional-dependencies] diff --git a/src/agent_comms/activity.py b/src/agent_comms/activity.py index 845b2b305f..8cfe16af83 100644 --- a/src/agent_comms/activity.py +++ b/src/agent_comms/activity.py @@ -133,7 +133,7 @@ def from_error(cls, owner: OwnerIdentity, error: Exception) -> Self: def summary(self) -> str: return f"{self.headline} · {self.error_type}: {self.reason} · {self.recovery}" - @projected(view="artifact", name="kind") + @projected(view=ArtifactExclude, name="kind") def artifact_kind(self) -> str: return FieldCodec.encode(type(self)) diff --git a/src/agent_comms/child_process.py b/src/agent_comms/child_process.py index 2f14557a2f..070d2ec417 100644 --- a/src/agent_comms/child_process.py +++ b/src/agent_comms/child_process.py @@ -43,7 +43,7 @@ from .declared_family import DeclaredFamily from .errors import RelationViolationError -from .field_codec import FieldCodec, FieldDirective, Id128, NullMember, RecordMember, SubclassUnion, WireRequired +from .field_codec import DisclosedRecord, FieldCodec, FieldDirective, Id128, NullMember, RecordMember, SubclassUnion, WireRequired from .launch_runtime import INHERITED_ENVIRONMENT, LaunchRuntime, RestoredRuntime from .native_build_inputs import ( AsyncStdioOptions, ChildStdio, ConfiguredChildStdio, DetachedChildStdio, GitAcquisitionCancelled, @@ -1391,7 +1391,7 @@ class OrderCheckFailedError(ValueError): """A completed order check did not prove its expected result.""" -class CheckPrinted(DeclaredFamily, affix="Printed"): +class CheckPrinted(DisclosedRecord, DeclaredFamily, affix="Printed"): """The count printed by a child, or the absence of an integer.""" @staticmethod diff --git a/src/agent_comms/cli_bridges/claude.py b/src/agent_comms/cli_bridges/claude.py index 078b83f507..82d98e8337 100755 --- a/src/agent_comms/cli_bridges/claude.py +++ b/src/agent_comms/cli_bridges/claude.py @@ -5,11 +5,9 @@ from contextlib import aclosing from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, AsyncIterator, BinaryIO +from typing import AsyncIterator, BinaryIO from agent_comms.bus import Bus -from agent_comms.harness_delivery_states import Outcome -from agent_comms.messages import Message from agent_comms.private_path import FileRevision from agent_comms.runtime_updates import RuntimeUpdate from agent_comms.watched import FileSource @@ -21,19 +19,14 @@ from .claude_transcript import ClaudeJsonLines, RunningClaude from .shared import Enrol, LineMonitorBridge, Options, Watch, run_bridge -if TYPE_CHECKING: - from agent_comms.task_attachments.commander_pings import CommanderUpdate - - class ClaudeBridge(ClaudeJsonLines, ClaudeHistory, LineMonitorBridge): - """Follow this Claude session's file without submitting an input. + """Follow the Claude session and deliver the original waking HUD through its native frame. - Heartbeat packets reach the session through the board its tool-call hook prints, which - this bridge derives from Core whenever the hook asks, not through the Monitor: the harness - frames Monitor events as routine background, so a line said once there scrolls away. The - Monitor gets the packet itself too, since an idle session runs no tool calls. It is the - same message the delivery attempt was begun for: a rewritten body hashes to another - message_id, which no attempt was stored under.""" + Packet-owned wake behavior is shared with every bridge; tool-call board reads still derive + their current undisposed requests from Core rather than keeping another copy here. + The native frame receives the original message: rewriting its body changes the identity + the delivery attempt was stored under. + """ incoming_frame = ClaudeMonitorFrame window_bytes = ClaudeMonitorFrame.BATCH_CHARS * 2 @@ -49,13 +42,6 @@ async def watch_operation(self, operation: Watch) -> None: await asyncio.to_thread(state.rearm, partial(Connect().begin, self)) await super().watch_operation(operation) - async def deliver_board(self, update: CommanderUpdate, message: Message) -> type[Outcome]: - """The packet is on the bus, which is the board; only the session needs waking.""" - return await update.wake(self, message) - - async def wake(self, message: Message) -> type[Outcome]: - return await self.deliver(message) - def history_start(self, stream: BinaryIO, boundary: int) -> int: """Retain the recent window's owning occurrence, never an arbitrary mid-turn prefix.""" start = max(0, boundary - self.RECENT_BYTES) diff --git a/src/agent_comms/cli_bridges/shared.py b/src/agent_comms/cli_bridges/shared.py index 2c74db839e..662473c05a 100644 --- a/src/agent_comms/cli_bridges/shared.py +++ b/src/agent_comms/cli_bridges/shared.py @@ -1134,7 +1134,11 @@ async def prepare_delivery(self) -> None: """A transport may prepare its harness after the operation bound its reader.""" async def deliver_board(self, update: CommanderUpdate, message: messages.Message) -> type[Outcome]: - """A harness with no tool-call route takes a heartbeat packet like any message.""" + """Every transport honors the packet's wake decision; passive display is not input.""" + return await update.wake(self, message) + + async def wake(self, message: messages.Message) -> type[Outcome]: + """The packet already owns its HUD text; every harness receives that original message.""" return await self.deliver(message) async def accept(self, message: messages.Message) -> None: diff --git a/src/agent_comms/cli_options.py b/src/agent_comms/cli_options.py index 3e2c9cefca..47b0343ebd 100644 --- a/src/agent_comms/cli_options.py +++ b/src/agent_comms/cli_options.py @@ -7,10 +7,10 @@ from collections.abc import Sequence from typing import ClassVar, Self -from .field_codec import FieldCodec +from .field_codec import DisclosedRecord, FieldCodec -class JsonFlagRecord: +class JsonFlagRecord(DisclosedRecord): """A record supplies its JSON-value flag; FieldCodec owns its representation.""" __slots__ = () diff --git a/src/agent_comms/commander/publication_rules.py b/src/agent_comms/commander/publication_rules.py index cd9d4e379f..ee1357e367 100644 --- a/src/agent_comms/commander/publication_rules.py +++ b/src/agent_comms/commander/publication_rules.py @@ -43,7 +43,7 @@ def derive(cls, branches: tuple[HeldBranch, ...]) -> tuple[Self, ...]: def line(self) -> str: return (f"{self.holder.name} has {self.unpublished.commits} unpushed commits on" f" {self.problem} ({self.unpublished.head[:9]} in {self.worktree}, upstream" - f" {self.unpublished.upstream or 'not configured'}). waiting for the disk to die?" + f" {self.unpublished.upstream or 'not configured'}). these commits exist only locally" " -> push them") @@ -62,7 +62,7 @@ def derive(cls, branches: tuple[HeldBranch, ...]) -> tuple[Self, ...]: for held in branches: reader = held.repository.branches todo = held.todo - for pull in held.source.pulls.open_for(todo): + for pull in held.source.frame.pulls.open_for(todo): for compared in pull.empty_changes(reader): found.append(cls(held.problem, todo.order.checked().id, held.holder, PullRequestLink(todo.repo, pull.number), diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index 6937817bd6..f8d40ace58 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -178,10 +178,10 @@ def enforce(self, comms: Comms, commander: str) -> None: """Due is a disposition: the commander's `order install` launches.""" def line(self) -> str: - oldest = int((time.time() - min(merge.at for merge in self.merges)) // 60) + oldest = RecordedEventTime(min(merge.at for merge in self.merges)).stamp() landing = ", ".join(f"#{row.number} {row.branch}" for row in self.landing) or "none" return (f"{self.headline} {len(self.merges)} merges sitting on {self.repo.to_wire()} main {self.main[:12]}," - f" installed is {self.installed[:12]}, oldest {oldest} min" + f" installed is {self.installed[:12]}, oldest merge at {oldest}" f" ({'; '.join(merge.line for merge in self.merges)}; landing: {landing})." f" {self.question} -> {self.action}") @@ -331,9 +331,8 @@ def found(cls, reading: PlanReading) -> tuple[Self, ...]: ) def line(self) -> str: - hours = (time.time() - self.since) / 3600 return ( - f"UNSTALL {self.phase.plan.declared_name}/{self.phase.declared_name} held {hours:.1f}h" + f"UNSTALL {self.phase.plan.declared_name}/{self.phase.declared_name} held since {RecordedEventTime(self.since).stamp()}" f" and still not on main ({self.phase.plan.document}). what's actually holding it?" " -> find it and change that this wake: integrate steps that only land together," " take it over, or reassign it" @@ -385,8 +384,7 @@ def misaddressed(self, named: tuple[Message, ...], reached: frozenset[str]) -> s f"answer it in a message to {self.unit}") def line(self) -> str: - age = max(0, int((time.time() - self.message.timestamp) // 60)) - return (f"ANSWER {self.unit} asked {self.cited} {age} min ago. why is it still waiting on you?" + return (f"ANSWER {self.unit} asked {self.cited} at {RecordedEventTime(self.message.timestamp).stamp()}. why is it still waiting on you?" f" -> {self.clearing_action()}") diff --git a/src/agent_comms/commander/vibe_check.py b/src/agent_comms/commander/vibe_check.py index fe1eaa0585..48c4dbf148 100644 --- a/src/agent_comms/commander/vibe_check.py +++ b/src/agent_comms/commander/vibe_check.py @@ -8,6 +8,8 @@ from __future__ import annotations +from agent_comms.field_codec import WireExclude + import time from collections.abc import Mapping, Sequence from abc import abstractmethod @@ -25,6 +27,7 @@ OwnerInvocable, ) from agent_comms.messages import Message +from agent_comms.recorded_time import RecordedEventTime from agent_comms.registry_document import RegistrySnapshot from agent_comms.task_attachments.owner_notices import ChokeholdNotice, EscalationNotice, VibeCheckNotice from agent_comms.standing_rules import ( @@ -93,11 +96,11 @@ def from_orders(cls, orders: Sequence[Todo], timers_up: int, unjudged: int) -> S """One load projection shared by the heartbeat's HUD and standing rules.""" return cls(sum(todo.pending for todo in orders), timers_up, unjudged) - @projected(view="wire") + @projected(view=WireExclude) def total(self) -> float: return self.owed + 2 * self.timers_up + self.unjudged - @projected(view="wire") + @projected(view=WireExclude) def overloaded(self) -> bool: return self.total >= self.ceiling @@ -173,8 +176,8 @@ def due(self, now: float) -> bool: return self.message.timestamp <= now - self.seconds def line(self) -> str: - age = max(0, int((time.time() - self.message.timestamp) // 60)) - return f"vibe check {self.cited} is {age} min old. still ignoring it? -> answer it" + return (f"vibe check {self.cited} from {RecordedEventTime(self.message.timestamp).stamp()}." + " still ignoring it? -> answer it") class Indictment: diff --git a/src/agent_comms/commander_heartbeat.py b/src/agent_comms/commander_heartbeat.py index f38160539b..d28bd3273c 100644 --- a/src/agent_comms/commander_heartbeat.py +++ b/src/agent_comms/commander_heartbeat.py @@ -8,9 +8,11 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from agent_comms.commander_hud import CommanderFrame, CommanderHud +from agent_comms.commander_hud import CodexUsage, CommanderFrame, CommanderHud, HudUnavailableGroup from agent_comms.child_process import ReadWork -from agent_comms.task_attachments.commander_pings import CommanderUpdate, RulePing, UnitPing +from agent_comms.task_attachments.commander_pings import ( + AlarmGroup, CommanderUpdate, PublishedFacts, RulePing, UnitPing, +) from agent_comms.commander_service import CommanderService from agent_comms.heartbeat_frame import CommanderInputs, HeartbeatFrame from agent_comms.heartbeat_subjects import CommanderHistory @@ -20,7 +22,7 @@ from agent_comms.standing_rules import ServicePassFailing, StandingRule from agent_comms.threads import Thread from agent_comms.todos import ProgressRead, Todo -from agent_comms.ui_model.unit_state import AwaitingCommander, UnitState +from agent_comms.ui_model.unit_state import UnitState if TYPE_CHECKING: from agent_comms.comms import Comms @@ -71,13 +73,18 @@ def failed(self, opened_wire: Callable[[], AbstractContextManager[Comms]], failure = ServicePassFailing(watch=name, since=retained.since, error=retained.error) now = time.time() snapshot = comms.registry.snapshot() + actions = CommanderHistory.read(comms, self.actors(snapshot)) for thread in (recipient for lead in self.boards(comms.registry, comms.goals) for recipient in self.recipients(snapshot, lead)): for update in CommanderUpdate.report_failure( thread.incarnation, failure, CommanderUpdate.latest(comms, thread.incarnation), now=now, ): - update.send(comms, now, ()) + sections = AlarmGroup.sections((update,), update.open_pings(actions)) + update.send(comms, now, ( + HudUnavailableGroup.heading(), + *CommanderHud.alarm_text(sections, now), + )) def prepare(self, comms: Comms) -> Mapping[Thread, CommanderInputs]: return {thread: CommanderInputs.prepare(comms, thread.name, ProgressRead(), self) @@ -90,20 +97,17 @@ def observe(self, comms: Comms, prepared: Mapping[Thread, CommanderInputs]) -> t ) def pass_body(self, comms: Comms, observed: tuple[CommanderObservation, ...]) -> None: - """The committed packet survives connector replacement; local memory owns nothing. - - One board is read per registered commander; every commander it serves receives that - board under its own incarnation and completes its own pass.""" + """Read one shared board; each recipient keeps its own committed publication evidence.""" + published = {} completed = [] - huds = [] now = time.time() try: for item in observed: comms.goals.todos.record_merges(item.merges(comms)) recipients = self.recipients(comms.registry.snapshot(), item.thread) - held = {recipient: CommanderUpdate.latest(comms, recipient.incarnation) + held = {recipient.incarnation: CommanderUpdate.latest(comms, recipient.incarnation) for recipient in recipients} - previous = held[item.thread] + previous = held[item.thread.incarnation] known = frozenset(ping for packet in previous for ping in packet.handled) rejected = StandingRule.enforce_all( comms, item.thread.name, @@ -111,26 +115,30 @@ def pass_body(self, comms: Comms, observed: tuple[CommanderObservation, ...]) -> *(state for state in item.states if state.attention() and UnitPing.capture(state) not in known)), ) - # Board disposition reads CommanderHistory after enforcement. - hud = CommanderHud.of(comms, item.thread.name, item.frame, item.states) + usage = CodexUsage.read() + # Include acknowledgements committed during forge/provider reads and enforcement. history = CommanderHistory.read(comms, self.actors(comms.registry.snapshot())) states = tuple(state for state in item.states if state not in rejected) rules = tuple(rule for rule in item.rules if rule not in rejected) not_applied = tuple(rule.rejected_line(error) for rule, error in rejected.items()) - for recipient, latest in held.items(): - for update in CommanderUpdate.derive( - recipient.incarnation, states, rules, latest, now=now, - undisposed=tuple( - ping for packet in latest - for ping in packet.undisposed(history, now - AwaitingCommander.seconds)), - not_applied=not_applied, - ): - update.send(comms, now, hud.brief()) - completed.append(recipient.incarnation) - huds.append(hud) + deliveries = tuple(CommanderUpdate.derive( + recipient, states, rules, latest, now=now, history=history, + not_applied=not_applied, + ) for recipient, latest in held.items()) + board = CommanderHud.of(comms, item.thread.name, item.frame, item.states, + delivery=deliveries[0], usage=usage) + for delivery in deliveries: + hud = board.for_delivery(delivery) + recipient = delivery.packet.commander + published[recipient] = hud + facts = PublishedFacts.of(hud.display_facts) + latest = held[recipient] + if delivery.packet.should_publish(facts, latest): + delivery.packet.send(comms, now, hud.hud_text(now), publication=facts) + completed.append(recipient) finally: - # A later commander's failure does not erase passes already completed. - CommanderFrame.replace_all(comms.root, tuple(huds)) + # A later commander's failure does not erase frames/passes already completed. + CommanderFrame.replace_all(comms.root, tuple(published.values())) ServicePassStore.at(comms.root).heartbeat_passed( completed, (thread.incarnation for thread in comms.registry.all_threads().values()), diff --git a/src/agent_comms/commander_hud.py b/src/agent_comms/commander_hud.py index 441e58375d..9d3301c85f 100644 --- a/src/agent_comms/commander_hud.py +++ b/src/agent_comms/commander_hud.py @@ -2,12 +2,16 @@ from __future__ import annotations +from agent_comms.field_codec import WireExclude + import asyncio import time +from collections.abc import Iterable +from functools import cached_property from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path -from typing import TYPE_CHECKING, Annotated +from typing import TYPE_CHECKING, Annotated, ClassVar, Self from aiohttp import ClientError from pydantic import JsonValue @@ -18,20 +22,27 @@ from agent_comms.commander.vibe_check import OverloadDegradation from agent_comms.commander_frame_file import CommanderFrameFile from agent_comms.commander_service import CommanderService +from agent_comms.disclosure_text import ( + AccentTone, ErrorTone, ViewFacts, ViewSectionFacts, ViewTable, +) from agent_comms.errors import ToolRefusedError -from agent_comms.field_codec import DisclosureIdentity, OrderId, projected +from agent_comms.field_codec import ( + DisclosedRecord, DisclosureIdentity, HudExclude, OrderId, PublicationExclude, projected, +) from agent_comms.heartbeat_frame import HeartbeatFrame from agent_comms.locked_store import JsonDocument from agent_comms.plan_phases import Plan, PlanPhase from agent_comms.order_story import OrderStory from agent_comms.recorded_time import RecordedEventTime -from agent_comms.service_passes import BoardUnavailable, ServicePassStore, ServiceReading +from agent_comms.service_passes import BoardUnavailable, FailingPass, ServicePassStore, ServiceReading from agent_comms.thread_identity import ThreadIncarnation from agent_comms.todos import OrderProgress, ProgressPoint, ProgressRead, ProgressSource, PullRequest from agent_comms.tool_result import ToolResult from agent_comms.turn_digests import LastTurns, OrderEditCountRead -from agent_comms.ui_model.unit_state import UnitClock, UnitState -from agent_comms.task_attachments.commander_pings import CommanderUpdate +from agent_comms.ui_model.unit_state import ExpandedHudGroup, HudGroup, UnitClock, UnitState, UrgentGroup +from agent_comms.task_attachments.commander_pings import ( + AlarmGroup, AlarmSection, CommanderDelivery, CommanderUpdate, Ping, +) from agent_comms_hooks.board import BoardEntry if TYPE_CHECKING: @@ -39,7 +50,7 @@ @dataclass(frozen=True) -class CommanderOrder: +class CommanderOrder(DisclosedRecord): """Small order card; identifiers/text come from Todo, review rounds from TodoStore.""" problem: Annotated[str, DisclosureIdentity()] @@ -52,19 +63,31 @@ class CommanderOrder: unjudged_edits: int -class HudRow(ABC): - """One frame row below the load line. Each row says whether it needs the commander's hand, - and the frame puts every row that does above every row that does not.""" +class PlanPhasesGroup(HudGroup): + tone = AccentTone - def needs_hand(self) -> bool: - return False +class FinishedPlansGroup(ExpandedHudGroup, HudGroup): + pass + + +class HudUnavailableGroup(HudGroup): + tone = ErrorTone + + +class HudRecord(DisclosedRecord, ABC): + """Native rows expose their declared group, not another hand-joined renderer.""" + + @property @abstractmethod - def line(self) -> str: ... + def hud_group(self) -> type[HudGroup]: ... + + def needs_hand(self) -> bool: + return self.hud_group.needs_hand() @dataclass(frozen=True) -class CommanderUnit(HudRow): +class CommanderUnit(HudRecord): """One unit's state card: its orders, live strikes and GitHub's open ready/draft requests. ``state`` and ``clock`` come from the UnitState the heartbeat derived for the unit, so a @@ -73,47 +96,36 @@ class CommanderUnit(HudRow): unit: Annotated[ThreadIncarnation, DisclosureIdentity()] state: type[UnitState] - clock: UnitClock - orders: tuple[CommanderOrder, ...] - strikes: int - ready_prs: tuple[PullRequest, ...] - draft_prs: tuple[PullRequest, ...] + clock: Annotated[UnitClock, HudExclude()] = field(compare=False) + orders: Annotated[tuple[CommanderOrder, ...], HudExclude()] + strikes: Annotated[int, HudExclude()] + ready_prs: Annotated[tuple[PullRequest, ...], HudExclude()] + draft_prs: Annotated[tuple[PullRequest, ...], HudExclude()] - def line(self) -> str: - orders = "; ".join( - f"{order.problem}: {order.state}; {order.progress.line}" for order in self.orders - ) - return ( - f"{self.unit.name}: {self.state.question} orders={orders} " - f"review timers={sum(len(order.review_timers_up) for order in self.orders)} " - f"strikes={self.strikes} edits={sum(order.unjudged_edits for order in self.orders)} " - f"ready={','.join(str(pr.number) for pr in self.ready_prs)} " - f"draft={','.join(str(pr.number) for pr in self.draft_prs)}" - ) + @property + def hud_group(self) -> type[HudGroup]: + return self.state.hud_group - def brief_parts(self) -> tuple[str, ...]: - """The scan line's coherent pieces: the unit, each held order, its pull requests and - next action. The view owns wrapping, including sections wider than its viewport.""" - orders = tuple( - f"{order.problem} {order.state} · {order.progress.line}" for order in self.orders - ) or ("no held orders",) - pulls = ", ".join(f"#{pr.number}@{pr.head[:9]}" for pr in (*self.ready_prs, *self.draft_prs)) - return (self.unit.name, *orders, pulls or "no PR", f"→ {self.state.action}") + @projected(view=HudExclude) + def work(self) -> str: + return "\n".join(f"{order.problem} {order.state}" for order in self.orders) - def brief(self) -> str: - """A dense scan line; expansion reads the original order and activity owners.""" - return " | ".join(self.brief_parts()) + @projected(view=HudExclude) + def change(self) -> str: + return "\n".join(order.progress.line for order in self.orders) - def needs_hand(self) -> bool: - """Its UnitState asks for the commander, or a review timer on one of its orders is up.""" - return (self.state.attention() and self.state.commander_disposes) or any( - order.review_timers_up for order in self.orders - ) + @projected(view=HudExclude) + def next(self) -> str: + return self.state.action + + @property + def order_ids(self) -> tuple[OrderId, ...]: + return tuple(dict.fromkeys(card.order for card in self.orders)) def detail(self, comms: Comms, commander: ThreadIncarnation, orders: tuple[OrderId, ...]) -> CommanderUnitDetail: """Expand this published selection from its original record owners.""" - if tuple(dict.fromkeys(card.order for card in self.orders)) != orders: + if self.order_ids != orders: raise ToolRefusedError("The selected unit's orders changed; read its current card") store = comms.goals.todos held = store.held_by(commander, self.unit) @@ -136,7 +148,31 @@ def detail(self, comms: Comms, commander: ThreadIncarnation, @dataclass(frozen=True) -class CommanderOrderDetail: +class HudSection: + """One group heading, with native tables partitioned by their actual declarations.""" + + group: type[HudGroup] + tables: tuple[ViewTable[HudRecord], ...] + + @cached_property + def records(self) -> tuple[HudRecord, ...]: + return tuple(record for table in self.tables for record in table.records) + + def heading(self) -> str: + return self.group.count_heading(len(self.records)) + + @classmethod + def partition(cls, records: Iterable[HudRecord]) -> tuple[HudSection, ...]: + grouped: dict[type[HudGroup], dict[type[HudRecord], list[HudRecord]]] = {} + for record in records: + grouped.setdefault(record.hud_group, {}).setdefault(type(record), []).append(record) + return tuple(cls(group, tuple(ViewTable(declaration, HudExclude, tuple(rows)) + for declaration, rows in grouped[group].items())) + for group in HudGroup.members_with(HudGroup) if group in grouped) + + +@dataclass(frozen=True) +class CommanderOrderDetail(DisclosedRecord): """The selected card beside its original instructions, story and captured Git points.""" card: Annotated[CommanderOrder, DisclosureIdentity()] @@ -146,7 +182,7 @@ class CommanderOrderDetail: @dataclass(frozen=True) -class CommanderUnitDetail: +class CommanderUnitDetail(DisclosedRecord): """A lazy disclosure; activity uses the existing bounded turn wire projection.""" unit: Annotated[CommanderUnit, DisclosureIdentity()] @@ -155,10 +191,10 @@ class CommanderUnitDetail: @dataclass(frozen=True) -class CommanderPhase(HudRow): +class CommanderPhase(HudRecord): """PlanPhase supplies every answer; no HUD status taxonomy is maintained beside it.""" - phase: type[PlanPhase] + phase: Annotated[type[PlanPhase], DisclosureIdentity()] done: bool ready: bool holders: tuple[ThreadIncarnation, ...] @@ -172,48 +208,22 @@ def needs_hand(self) -> bool: """Ready with nobody holding it, or done while an order still holds it open.""" return (self.ready and not self.holders) or (self.done and bool(self.holders)) - def line(self) -> str: - return ( - f"{self.phase.plan.declared_name}/{self.phase.declared_name} " - f"done={self.done} ready={self.ready} " - f"holders={','.join(unit.name for unit in self.holders)}" - ) - - -@dataclass(frozen=True) -class BoardRow(HudRow): - """A committed board line, without another interpretation or ordering.""" - - entry: BoardEntry - - def needs_hand(self) -> bool: - return True - - def line(self) -> str: - return self.entry.aged(time.time()) - - -@dataclass(frozen=True) -class QuietUnits(HudRow): - """Units with no commander action share one roll call.""" - - units: tuple[CommanderUnit, ...] - - def line(self) -> str: - return f"{len(self.units)} units need no hand: " + ", ".join( - unit.unit.name for unit in self.units - ) + @property + def hud_group(self) -> type[HudGroup]: + return UrgentGroup if self.needs_hand() else PlanPhasesGroup @dataclass(frozen=True) -class FinishedPhases(HudRow): - """One plan's finished phases as one count: a graveyard needs no roll call.""" +class FinishedPhases(HudRecord): + """One declaration-derived finished count per plan, through the same table projection.""" - plan: type[Plan] - phases: tuple[type[PlanPhase], ...] + plan: Annotated[type[Plan], DisclosureIdentity()] + phases: Annotated[tuple[type[PlanPhase], ...], HudExclude()] + hud_group: ClassVar[type[HudGroup]] = FinishedPlansGroup - def line(self) -> str: - return f"{self.plan.declared_name}: {len(self.phases)} phases done" + @projected(view=HudExclude) + def done(self) -> int: + return len(self.phases) class UnreadableUsage(RateLimitReading): @@ -227,7 +237,7 @@ def needs_hand(self) -> bool: @dataclass(frozen=True) -class CodexUsage(HudRow): +class CodexUsage: reading: RateLimitReading @classmethod @@ -245,13 +255,24 @@ def needs_hand(self) -> bool: @dataclass(frozen=True) -class CommanderForks(HudRow): - """The commander's forks Core registered and still hears.""" +class CommanderStatus(DisclosedRecord): + load: float + review_up: int + unjudged: int + forks: int + usage: str + heartbeat: Annotated[str, PublicationExclude()] - forks: tuple[ThreadIncarnation, ...] - def line(self) -> str: - return f"forks: {', '.join(fork.name for fork in self.forks) or 'none registered'}" +@dataclass(frozen=True) +class HudDisplayFacts: + commander: ThreadIncarnation + status: ViewFacts + groups: tuple[ViewSectionFacts, ...] + alarms: tuple[ViewSectionFacts, ...] + alarm_membership: tuple[AlarmSection, ...] + not_applied: tuple[str, ...] + failures: tuple[tuple[str, FailingPass], ...] @dataclass(frozen=True) @@ -265,9 +286,12 @@ def retained_status(self) -> str: return 'Retained board rows and load:' return 'STALE retained board rows and load:' - @projected(view='wire') + def health_lines(self) -> tuple[str, ...]: + return (*self.service_status, self.retained_status()) + + @projected(view=WireExclude) def header(self) -> tuple[str, ...]: - return (*self.service_status, self.retained_status(), str(self.load)) + return (*self.health_lines(), str(self.load)) @dataclass(frozen=True) @@ -285,7 +309,8 @@ class CommanderHud(CommanderLoad): phases: tuple[CommanderPhase, ...] forks: tuple[ThreadIncarnation, ...] usage: CodexUsage - board: tuple[BoardEntry, ...] = () + packets: tuple[CommanderUpdate, ...] = () + open_pings: tuple[Ping, ...] = field(kw_only=True) @classmethod def read(cls, comms: Comms, commander: str) -> CommanderHud: @@ -293,16 +318,16 @@ def read(cls, comms: Comms, commander: str) -> CommanderHud: @classmethod def of(cls, comms: Comms, commander: str, frame: HeartbeatFrame, - states: tuple[UnitState, ...]) -> CommanderHud: + states: tuple[UnitState, ...], *, delivery: CommanderDelivery, + usage: CodexUsage) -> CommanderHud: """Cards follow ``states``, already in the UnitState family's declared order.""" - snapshot, now = comms.registry.snapshot(), time.time() - owner = snapshot.require(commander).incarnation - store = comms.goals.todos + snapshot = comms.registry.snapshot() + owner = snapshot.require(commander).incarnation.require_recorded() pulls, reading = frame.pulls, frame.plan - history = reading.todos + todo_history = reading.todos held = frame.todos progress = pulls.progress(held, comms.registry) - timers = store.timers_up_in(held, now, progress) + timers = frame.review_timers installed = InstalledBuild.read() edits = dict.fromkeys((todo.id for todo in held), 0) | { read.todo.id: read.read() @@ -325,7 +350,7 @@ def of(cls, comms: Comms, commander: str, frame: HeartbeatFrame, tuple(at for due, at in timers if due.id == todo.id), todo.strike_count, edits[todo.id], ) for todo in orders), - sum(todo.strikes_against(unit) for todo in history), + sum(todo.strikes_against(unit) for todo in todo_history), tuple(ready for request in requests.values() for ready in request.as_ready()), tuple(draft for request in requests.values() for draft in request.as_draft()), )) @@ -335,52 +360,89 @@ def of(cls, comms: Comms, commander: str, frame: HeartbeatFrame, ) for phase in PlanPhase.members_with(PlanPhase)) forks = tuple(fork.incarnation for fork in CommanderService.forks(snapshot, commander) if snapshot.status(fork.name).receiving) - board = tuple(entry for packet in CommanderUpdate.latest(comms, owner) - for entry in packet.board(comms)) + passes = ServicePassStore.at(comms.root).read() return cls(commander=owner, units=tuple(units), phases=phases, load=OverloadDegradation.from_orders(held, len(timers), sum(edits.values())), - forks=forks, usage=CodexUsage.read(), board=board) + forks=forks, usage=usage, packets=(delivery.packet,), + open_pings=delivery.open_pings, services=passes, + heartbeat=passes.heartbeat_completed_at(owner)) - def rows(self) -> tuple[HudRow, ...]: - """Committed board order, actionable cards, live phases, then folded quiet units.""" - live_phases = tuple(phase for phase in self.phases if not phase.finished) - live = (*self.units, *live_phases) - quiet = tuple(unit for unit in self.units if not unit.needs_hand()) - finished = tuple(phase.phase for phase in self.phases if phase.finished) - return ( - *(BoardRow(line) for line in self.board), - *(row for row in live if row.needs_hand()), - *(phase for phase in live_phases if not phase.needs_hand()), - *((QuietUnits(quiet),) if quiet else ()), - self.usage, - CommanderForks(self.forks), - *(FinishedPhases(plan, phases) for plan in Plan.members_with(Plan) - for phases in (tuple(phase for phase in finished if phase.plan is plan),) - if phases), - ) + def for_delivery(self, delivery: CommanderDelivery) -> CommanderHud: + """Keep the prepared shared board; packet history and completion belong to its recipient.""" + owner = delivery.packet.commander.require_recorded() + return replace(self, commander=owner, packets=(delivery.packet,), + open_pings=delivery.open_pings).with_services(self.services, owner) - def brief(self) -> tuple[str, ...]: - """Routine heartbeat telemetry; the full board retains completed plans.""" - plans = "; ".join( - f"{plan.declared_name} {sum(phase.finished for phase in phases)}/{len(phases)}" - for plan in Plan.members_with(Plan) - for phases in (tuple(phase for phase in self.phases if phase.phase.plan is plan),) - if any(not phase.finished for phase in phases) - ) - visible = tuple(unit for unit in self.units if unit.orders or unit.needs_hand()) - quiet = tuple(unit for unit in self.units if unit not in visible) - return ( - f"{self.load} | {len(visible)} active units | {len(self.forks)} forks", - self.usage.line(), - *(unit.brief() for unit in visible), - *((QuietUnits(quiet).line(),) if quiet else ()), - *((f"Plans · {plans}",) if plans else ()), + @property + def board(self) -> tuple[BoardEntry, ...]: + """The one action-filtered set captured from Ping.on_board for this publication.""" + return tuple(ping.board_entry() for ping in self.open_pings) + + @cached_property + def alarms(self) -> tuple[AlarmSection, ...]: + return AlarmGroup.sections(self.packets, self.open_pings) + + @cached_property + def groups(self) -> tuple[HudSection, ...]: + finished = tuple(phase.phase for phase in self.phases if phase.finished) + folded = tuple(FinishedPhases(plan, phases) for plan in Plan.members_with(Plan) + for phases in (tuple(phase for phase in finished if phase.plan is plan),) + if phases) + return HudSection.partition((*self.units, *(phase for phase in self.phases if not phase.finished), + *folded)) + + def status_table(self, now: float) -> ViewTable[CommanderStatus]: + return ViewTable(CommanderStatus, HudExclude, (CommanderStatus( + self.load.total, self.load.timers_up, self.load.unjudged, + len(self.forks), self.usage.line(), self.heartbeat.age(now), + ),)) + + def status_strip(self, now: float) -> str: + return "\n".join((*self.health_lines(), *self.status_table(now).labelled_lines())) + + @cached_property + def display_facts(self) -> HudDisplayFacts: + """Compare declared displayed cells, not excluded tool clocks or paint-time ages.""" + alarms = self.alarms + return HudDisplayFacts( + self.commander, self.status_table(0).facts, + tuple(ViewSectionFacts(section.heading(), tuple(table.facts for table in section.tables)) + for section in self.groups), + tuple(ViewSectionFacts(section.heading(), tuple(table.facts for table in section.tables(0))) + for section in alarms), + alarms, # Original ping equality retains hidden overflow membership, not paint-time age/text. + tuple(line for packet in self.packets for line in packet.not_applied), + tuple(sorted(self.services.failing.items())), ) - @projected(view="wire") + @staticmethod + def alarm_text(sections: tuple[AlarmSection, ...], now: float, *, + colour: bool = False) -> tuple[str, ...]: + return tuple(line for section in sections for line in ( + section.group.paint(section.heading(), colour), + *(line for table in section.tables(now) for line in table.labelled_lines()), + )) + + @staticmethod + def alarm_detail(sections: tuple[AlarmSection, ...], now: float) -> tuple[str, ...]: + """Every original line; the section title already owns its compact heading.""" + return tuple(ping.render(now) for section in sections for ping in section.pings) + + def hud_text(self, now: float, *, colour: bool = False) -> tuple[str, ...]: + """The heartbeat has no viewport; native readers own their concrete geometry.""" + result = list(self.status_strip(now).splitlines()) + for section in self.groups: + group = section.group + result.extend(("", group.paint(section.heading(), colour))) + result.extend(group.paint(line, colour) for line in group.table_lines( + table.lines() for table in section.tables)) + result.extend(("", *self.alarm_text(self.alarms, now, colour=colour), + *(line for packet in self.packets for line in packet.not_applied))) + return tuple(result) + + @projected(view=WireExclude) def frame(self) -> tuple[str, ...]: - """The load line, then one line per row in the order rows() declares.""" - return (*self.header, *(row.line() for row in self.rows())) + return self.hud_text(time.time()) def load_reading(self) -> CommanderLoad: return CommanderLoad(self.load, services=self.services, heartbeat=self.heartbeat) @@ -393,8 +455,8 @@ class CommanderFrame(CommanderFrameFile, JsonDocument[CommanderHud]): """The newest CommanderHud of each beating commander, for views that only show it. One writer, the heartbeat, replaces every commander's file each beat from the same - CommanderHud the heartbeat reads, and keeps no history. It never reads a frame back, - so a shape left by an older build cannot stop a beat; the next beat overwrites it. + CommanderHud the heartbeat reads, and keeps no history. It is a current view, never + proof that a packet was successfully published. """ path: Path @@ -402,6 +464,10 @@ class CommanderFrame(CommanderFrameFile, JsonDocument[CommanderHud]): record_type = CommanderHud + @classmethod + def at(cls, root: Path, commander: ThreadIncarnation) -> Self: + return cls(cls.path_in(root, lookup=str(commander.require_recorded().lookup))) + def empty(self) -> CommanderHud: raise BoardUnavailable(self.reading, f'Tristan frame missing: {self.path}') @@ -421,7 +487,7 @@ def replace_all(cls, root: Path, huds: tuple[CommanderHud, ...]) -> None: """Write one frame per commander; a commander no longer beating loses its frame.""" written = set() for hud in huds: - frame = cls(cls.path_in(root, lookup=str(hud.commander.require_recorded().lookup))) + frame = cls.at(root, hud.commander) frame.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) frame._publish_unlocked(hud) written.add(frame.path) diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index 6bb7a46f4f..dda4635406 100644 --- a/src/agent_comms/connector.py +++ b/src/agent_comms/connector.py @@ -18,6 +18,8 @@ from __future__ import annotations +from agent_comms.field_codec import WireExclude + import asyncio import base64 @@ -275,7 +277,7 @@ class InvalidRequestError(McpError): @dataclass(frozen=True) class MethodNotFoundError(McpError): - @projected(view="wire") + @projected(view=WireExclude) def code(self) -> int: return Refused.METHOD_NOT_FOUND diff --git a/src/agent_comms/context_store.py b/src/agent_comms/context_store.py index 864e9c07cf..c7d7439182 100644 --- a/src/agent_comms/context_store.py +++ b/src/agent_comms/context_store.py @@ -2,6 +2,8 @@ from __future__ import annotations +from agent_comms.field_codec import IdentityExclude + import json import sqlite3 from abc import ABC, abstractmethod @@ -224,7 +226,7 @@ def newest(cls, db: sqlite3.Connection, owner: ThreadIncarnation) -> list[Contex def append(cls, db: sqlite3.Connection, record: RecordedContextManifest) -> int: versions = tuple(ContextSegmentVersion.append(db, part) for part in record.segments) identity = FieldCodec.decode( - RecordedContextIdentity, FieldCodec.project(record, "identity") + RecordedContextIdentity, FieldCodec.project(record, IdentityExclude) ) identity.thread.require_recorded() return cls.insert_sequence( diff --git a/src/agent_comms/coordination_snapshot.py b/src/agent_comms/coordination_snapshot.py index a59eb9a015..3222c62764 100644 --- a/src/agent_comms/coordination_snapshot.py +++ b/src/agent_comms/coordination_snapshot.py @@ -2,6 +2,8 @@ from __future__ import annotations +from agent_comms.field_codec import SnapshotExclude + from agent_comms.lifecycle_capability import Active, Complete from dataclasses import dataclass @@ -234,7 +236,7 @@ def validate_publication_receipt(self) -> None: acknowledgement = ResponseObligation.response_record(self.publication_evidence, target) obligation.lifecycle.validate_publication(intent, acknowledgement) - @projected(view="snapshot") + @projected(view=SnapshotExclude) def can_retry(self) -> bool: return self.execution.lifecycle.can_retry( authorized=self.retry_authorized, diff --git a/src/agent_comms/coordination_tables/assignments.py b/src/agent_comms/coordination_tables/assignments.py index cc2d52689a..1deb2cc72f 100644 --- a/src/agent_comms/coordination_tables/assignments.py +++ b/src/agent_comms/coordination_tables/assignments.py @@ -157,24 +157,24 @@ def require_engaged_binding(self) -> AssignmentBinding: str, FrozenColumn(check="length(policy_version) BETWEEN 1 AND 256") ] = POLICY_VERSION - @projected(view="snapshot", name="disposition") + @projected(view=SnapshotExclude, name="disposition") def snapshot_disposition(self): return type(self.lifecycle) - @projected(view="snapshot", name="wake_mode") + @projected(view=SnapshotExclude, name="wake_mode") def snapshot_wake_mode(self): return type(self.lifecycle.mode) - @projected(view="snapshot", name="triage_verdict") + @projected(view=SnapshotExclude, name="triage_verdict") def snapshot_triage_verdict(self): verdict = self.lifecycle.verdict return verdict - @projected(view="snapshot", name="execution_id") + @projected(view=SnapshotExclude, name="execution_id") def snapshot_execution_id(self): return self.lifecycle.execution_id - @projected(view="snapshot", name="exact_target") + @projected(view=SnapshotExclude, name="exact_target") def snapshot_exact_target(self): return self.lifecycle.exact_target diff --git a/src/agent_comms/coordination_tables/attempts.py b/src/agent_comms/coordination_tables/attempts.py index 60e7980885..01be67d962 100644 --- a/src/agent_comms/coordination_tables/attempts.py +++ b/src/agent_comms/coordination_tables/attempts.py @@ -198,19 +198,19 @@ def authority(self) -> AttemptAuthority: created_at_ms: Annotated[int, FrozenColumn(check="created_at_ms >= 0")] updated_at_ms: Annotated[int, Column(check="updated_at_ms >= created_at_ms")] - @projected(view="snapshot", name="phase") + @projected(view=SnapshotExclude, name="phase") def snapshot_phase(self): return type(self.lifecycle) - @projected(view="snapshot", name="lease_expires_at_ms") + @projected(view=SnapshotExclude, name="lease_expires_at_ms") def snapshot_lease_expires_at_ms(self): return self.lifecycle.lease_expires_at_ms - @projected(view="snapshot", name="backend_done") + @projected(view=SnapshotExclude, name="backend_done") def snapshot_backend_done(self): return self.lifecycle.backend_done - @projected(view="snapshot", name="process_dead") + @projected(view=SnapshotExclude, name="process_dead") def snapshot_process_dead(self): return self.lifecycle.process_dead diff --git a/src/agent_comms/coordination_tables/executions.py b/src/agent_comms/coordination_tables/executions.py index 0f1e599ef0..fd9998d44b 100644 --- a/src/agent_comms/coordination_tables/executions.py +++ b/src/agent_comms/coordination_tables/executions.py @@ -354,11 +354,11 @@ def retry_authorized( created_at_ms: Annotated[int, FrozenColumn(check="created_at_ms >= 0")] updated_at_ms: Annotated[int, Column(check="updated_at_ms >= created_at_ms")] - @projected(view="snapshot", name="status") + @projected(view=SnapshotExclude, name="status") def snapshot_status(self): return type(self.lifecycle) - @projected(view="snapshot", name="current_attempt_ordinal") + @projected(view=SnapshotExclude, name="current_attempt_ordinal") def snapshot_current_attempt_ordinal(self): return self.lifecycle.current_attempt_ordinal diff --git a/src/agent_comms/coordination_tables/responses.py b/src/agent_comms/coordination_tables/responses.py index 7218bf745f..cf60ddaaec 100644 --- a/src/agent_comms/coordination_tables/responses.py +++ b/src/agent_comms/coordination_tables/responses.py @@ -97,15 +97,15 @@ def response_record(records, exact_target): updated_at_ms: Annotated[int, Column(check="updated_at_ms >= created_at_ms")] revision: Annotated[int, Column(check="revision > 0")] - @projected(view="snapshot", name="state") + @projected(view=SnapshotExclude, name="state") def snapshot_state(self): return type(self.lifecycle) - @projected(view="snapshot", name="receipt_message_id") + @projected(view=SnapshotExclude, name="receipt_message_id") def snapshot_receipt_message_id(self): return self.lifecycle.published_message_id - @projected(view="snapshot", name="receipt_seq") + @projected(view=SnapshotExclude, name="receipt_seq") def snapshot_receipt_seq(self): return self.lifecycle.publication_seq diff --git a/src/agent_comms/diagnostics.py b/src/agent_comms/diagnostics.py index c7bdf1a739..d2a682690b 100644 --- a/src/agent_comms/diagnostics.py +++ b/src/agent_comms/diagnostics.py @@ -2,6 +2,8 @@ from __future__ import annotations +from agent_comms.field_codec import ArtifactExclude + import hashlib import json import os @@ -164,7 +166,7 @@ def record_drain_failure( # file reference out of a previously presented diagnostic. The declared # artifact view excludes the activity-owned observation clock. "drain": FieldCodec.project( - replace(diagnostic, reason=str(source_error)), "artifact" + replace(diagnostic, reason=str(source_error)), ArtifactExclude ), "outcome": "inbox observation failed; this record grants no retry authority", **DiagnosticDocument.source_error_evidence(source_error), diff --git a/src/agent_comms/disclosure_text.py b/src/agent_comms/disclosure_text.py new file mode 100644 index 0000000000..0b5938e4f0 --- /dev/null +++ b/src/agent_comms/disclosure_text.py @@ -0,0 +1,390 @@ +"""Human-readable declared values shared by heartbeat text and native views.""" + +from __future__ import annotations + +from abc import abstractmethod +from collections.abc import Collection, Mapping +from dataclasses import dataclass +from functools import cached_property +from itertools import zip_longest +from textwrap import wrap +from types import MappingProxyType +from typing import ClassVar, Hashable + +from metaclass_registry.mro_dispatch import handles + +from agent_comms.declared_family import DeclaredFamily, FamilyMeta +from agent_comms.field_codec import FieldCodec, FieldPlan, PublicationExclude, ValueDescription, ViewColumn, ViewExclusion + + +class DisclosureKey: + """Semantic disclosure identity, independent of native widget allocation.""" + + +@dataclass(frozen=True) +class RecordKey(DisclosureKey): + owner: type + + +class RootKey(RecordKey): + """The whole record, not one of its fields.""" + + +@dataclass(frozen=True) +class FieldKey(RecordKey): + name: str + + +@dataclass(frozen=True) +class EntityKey(RecordKey): + parts: tuple[object, ...] + + +class AnonymousKey(DisclosureKey): + """An undeclared identity cannot inherit another item's selection.""" + + +@dataclass(frozen=True) +class MappingKey(DisclosureKey): + key: Hashable + + +@dataclass(frozen=True) +class DisclosureText(ValueDescription): + value: object + + @cached_property + def summary(self) -> str: + return str(self.value) + + @cached_property + def sequence_key(self) -> DisclosureKey: + return AnonymousKey() + + @property + def identity_part(self) -> object: + return self.value + + def column_value(self, column: ViewColumn) -> DisclosureText: + return DisclosureText.describe(column.current(self.value)) + + @handles(object, str, bytes) + @classmethod + def scalar(cls, value: object) -> DisclosureText: + return DisclosureText(value) + + @classmethod + def record(cls, value: object) -> DisclosureText: + return RecordText(value) + + @classmethod + def identified_record(cls, value: object, identity: tuple[FieldPlan, ...]) -> DisclosureText: + return IdentifiedRecordText(value, identity) + + @handles(type) + @classmethod + def class_value(cls, value: type) -> DisclosureText: + return ClassText(value) + + @handles(FamilyMeta) + @classmethod + def family_value(cls, value: type[DeclaredFamily]) -> DisclosureText: + return FamilyText(value) + + @handles(dict, MappingProxyType) + @classmethod + def mapping(cls, value: Mapping[Hashable, object]) -> DisclosureText: + return MappingText(value) + + @handles(list, tuple, frozenset) + @classmethod + def sequence(cls, value: Collection[object]) -> DisclosureText: + return SequenceText(value) + + +@dataclass(frozen=True) +class DisclosureEntry: + key: DisclosureKey + label: str + value: DisclosureText + + +class StructuredText(DisclosureText): + """An expanded branch borrows children prepared once by its source owner.""" + + @property + @abstractmethod + def children(self) -> tuple[DisclosureEntry, ...]: ... + + +@dataclass(frozen=True) +class ViewFacts: + """Declared displayed values; paint-time fields may exclude publication comparison.""" + + record: type + columns: tuple[str, ...] + rows: tuple[tuple[str, ...], ...] + + +@dataclass(frozen=True) +class ViewSectionFacts: + heading: str + tables: tuple[ViewFacts, ...] + + +@dataclass(frozen=True) +class ViewTable[R]: + """One declaration owns semantic cells and both wide and stacked presentation.""" + + record: type[R] + view: type[ViewExclusion] + records: tuple[R, ...] + show_header: ClassVar[bool] = True + row_separator: ClassVar[tuple[str, ...]] = () + + @cached_property + def declared_columns(self) -> tuple[ViewColumn, ...]: + return FieldCodec.view_columns(self.record, self.view) + + @property + def columns(self) -> tuple[ViewColumn, ...]: + return self.declared_columns + + @cached_property + def row_values(self) -> tuple[DisclosureText, ...]: + return tuple(DisclosureText.describe(record) for record in self.records) + + @cached_property + def entities(self) -> Mapping[DisclosureKey, R]: + entities = {text.sequence_key: record for text, record in zip(self.row_values, self.records)} + if len(entities) != len(self.records): + raise ValueError("A declared table requires unique record identities") + return MappingProxyType(entities) + + @cached_property + def cells(self) -> tuple[tuple[DisclosureText, ...], ...]: + return tuple(tuple(text.column_value(column) for column in self.declared_columns) + for text in self.row_values) + + @cached_property + def texts(self) -> tuple[tuple[str, ...], ...]: + return tuple(tuple(cell.summary for cell in row) for row in self.cells) + + @cached_property + def facts(self) -> ViewFacts: + retained = tuple(index for index, column in enumerate(self.declared_columns) + if not column.excluded_in(PublicationExclude)) + return ViewFacts(self.record, tuple(self.declared_columns[index].label for index in retained), + tuple(tuple(row[index].summary for index in retained) for row in self.cells)) + + @cached_property + def minimums(self) -> tuple[int, ...]: + return tuple(column.minimum_width(row[index] for row in self.texts) + for index, column in enumerate(self.columns)) + + def natural_widths(self) -> tuple[int, ...]: + return tuple(max(len(column.label), max( + (len(line) for row in self.texts for line in row[index].splitlines()), default=0, + )) for index, column in enumerate(self.columns)) + + def widths(self, available: int) -> tuple[int, ...]: + return ViewColumn.fit_widths(self.natural_widths(), available, self.minimums) + + def layout(self, available: int) -> ViewTable[R]: + """Geometry classification happens once here, never again in the native adapter.""" + if sum(self.minimums) > available: + return StackedViewTable(self.record, self.view, self.records) + return self + + def header_rows(self) -> tuple[tuple[str, ...], ...]: + return (tuple(column.label for column in self.columns),) + + def lines(self) -> tuple[str, ...]: + return self.render_lines(self.natural_widths()) + + def render_lines(self, widths: tuple[int, ...]) -> tuple[str, ...]: + lines: list[str] = [] + for row in (*self.header_rows(), *self.texts): + wrapped = tuple(tuple(part for line in text.splitlines() or ("",) + for part in wrap(line, max(1, cell_width), replace_whitespace=False, + break_long_words=False, break_on_hyphens=False) or ("",)) + for text, cell_width in zip(row, widths)) + lines.extend(" ".join(value.ljust(cell_width) for value, cell_width in zip(parts, widths)).rstrip() + for parts in zip_longest(*wrapped, fillvalue="")) + lines.extend(self.row_separator) + return tuple(lines) + + def labelled_lines(self) -> tuple[str, ...]: + """Compact strips use declared labels too, not another list of metric names.""" + return tuple(" · ".join(f"{column.label}: {cell}" + for column, cell in zip(self.columns, row)) for row in self.texts) + + +class StackedViewTable[R](ViewTable[R]): + """One native transport cell retains every declared label/value and original record.""" + + show_header = False + row_separator = ("",) + + @property + def columns(self) -> tuple[ViewColumn, ...]: + return self.declared_columns[:1] + + @cached_property + def texts(self) -> tuple[tuple[str, ...], ...]: + return tuple(("\n".join(f"{column.label}: {cell.summary}" + for column, cell in zip(self.declared_columns, row)),) + for row in self.cells) + + def widths(self, available: int) -> tuple[int, ...]: + return (max(1, available),) + + def layout(self, available: int) -> ViewTable[R]: + return self + + def header_rows(self) -> tuple[tuple[str, ...], ...]: + return () + + +class HudTone(DeclaredFamily, affix="Tone"): + terminal_colour: ClassVar[int] + + @classmethod + def paint(cls, text: str, colour: bool) -> str: + return f"\x1b[{cls.terminal_colour}m{text}\x1b[0m" if colour else text + + +class MutedTone(HudTone): + terminal_colour = 90 + + +class WarningTone(HudTone): + terminal_colour = 33 + + +class SuccessTone(HudTone): + terminal_colour = 32 + + +class AccentTone(HudTone): + terminal_colour = 36 + + +class ErrorTone(HudTone): + terminal_colour = 31 + + +class ViewHeading(DeclaredFamily, declared_name=None): + """Alarm sections and unit groups share paint/headings, not family membership.""" + + tone: ClassVar[type[HudTone]] = MutedTone + + @classmethod + def heading(cls) -> str: + return cls.declared_name.replace("_", " ").upper() + + @classmethod + def count_heading(cls, count: int) -> str: + return f"{cls.heading()} · {count}" + + @classmethod + def paint(cls, text: str, colour: bool) -> str: + return cls.tone.paint(text, colour) + + +class RecordText(StructuredText): + @cached_property + def fields(self) -> tuple[FieldPlan, ...]: + """Identity-only summaries never remove original detail children.""" + return tuple(plan for plan in FieldCodec.field_plans(type(self.value)) if plan.records) + + @cached_property + def _field_values(self) -> dict[str, DisclosureText]: + return {} + + def column_value(self, column: ViewColumn) -> DisclosureText: + if column.name not in self._field_values: + self._field_values[column.name] = super().column_value(column) + return self._field_values[column.name] + + @cached_property + def children(self) -> tuple[DisclosureEntry, ...]: + return tuple(DisclosureEntry(FieldKey(type(self.value), plan.name), plan.label, + self.column_value(plan)) for plan in self.fields) + + @cached_property + def summary(self) -> str: + return " · ".join(child.value.summary for child in self.children) + + +@dataclass(frozen=True) +class IdentifiedRecordText(RecordText): + identity: tuple[FieldPlan, ...] + + @cached_property + def identity_values(self) -> tuple[DisclosureText, ...]: + return tuple(self.column_value(plan) for plan in self.identity) + + @cached_property + def sequence_key(self) -> EntityKey: + return EntityKey(type(self.value), tuple(part.identity_part for part in self.identity_values)) + + @property + def identity_part(self) -> EntityKey: + return self.sequence_key + + @cached_property + def summary(self) -> str: + return " · ".join(part.summary for part in self.identity_values) + + +@dataclass(frozen=True) +class ClassText(DisclosureText): + value: type + + @cached_property + def summary(self) -> str: + return self.value.__name__ + + +@dataclass(frozen=True) +class FamilyText(ClassText): + value: type[DeclaredFamily] + + @cached_property + def summary(self) -> str: + return self.value.declared_name + + +class CollectionText(StructuredText): + value: Collection[object] + noun: ClassVar[str] = "item" + + @cached_property + def summary(self) -> str: + count = len(self.value) + return f"{count} {self.noun}{'s' if count != 1 else ''}" + + +@dataclass(frozen=True) +class SequenceText(CollectionText): + value: Collection[object] + + @cached_property + def children(self) -> tuple[DisclosureEntry, ...]: + entries = [] + for index, value in enumerate(self.value): + text = DisclosureText.describe(value) + entries.append(DisclosureEntry(text.sequence_key, str(index + 1), text)) + return tuple(entries) + + +@dataclass(frozen=True) +class MappingText(CollectionText): + value: Mapping[Hashable, object] + noun = "field" + + @cached_property + def children(self) -> tuple[DisclosureEntry, ...]: + return tuple(DisclosureEntry(MappingKey(key), str(key), DisclosureText.describe(value)) + for key, value in self.value.items()) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index db0eb0ab3f..a6dae25a28 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -5,13 +5,14 @@ import annotationlib import argparse import hashlib +import inspect import json import math import os import re import types from abc import ABC, abstractmethod -from collections.abc import Callable, Iterable, Iterator, Mapping +from collections.abc import Callable, Collection, Hashable, Iterable, Iterator, Mapping, Sequence from dataclasses import MISSING, Field, dataclass, field, fields, is_dataclass from datetime import datetime from enum import Enum, IntFlag @@ -44,9 +45,10 @@ ) from pydantic import BaseModel, GetCoreSchemaHandler, JsonValue from pydantic_core import core_schema +from python_introspect import declared_field_label from typing_extensions import TypeAliasType, TypeForm -from .declared_family import DeclaredFamily +from .declared_family import DeclaredFamily, FamilyMeta from .json_schema import JsonScalar, JsonSchemaOwner from .sealed import Sealed @@ -232,7 +234,11 @@ class ViewName(ValueDirective[str], DeclaredFamily, affix="Name"): class ViewExclusion(FlagDirective, DeclaredFamily, affix="Exclude"): - """Whether a field is absent from one encoded view.""" + """The nominal encoded view owns exclusion and optional field naming.""" + + @classmethod + def key_in(cls, annotation: object, default: str) -> str: + return default class WireName(ViewName): @@ -322,6 +328,24 @@ class ActivityExclude(ViewExclusion): class SnapshotExclude(ViewExclusion): + @classmethod + def key_in(cls, annotation: object, default: str) -> str: + return SnapshotName.read(annotation, default) + + +class HudExclude(ViewExclusion): + pass + + +class StatusExclude(ViewExclusion): + pass + + +class ReportExclude(ViewExclusion): + pass + + +class ArgumentsExclude(ViewExclusion): pass @@ -523,12 +547,58 @@ def add_to( owner.add_argument(*flags, **options) +class ViewColumn(ABC): + """Stored and computed fields expose one read-only presentation contract.""" + + @property + @abstractmethod + def name(self) -> str: ... + + @property + @abstractmethod + def annotation(self) -> object: ... + + @property + def label(self) -> str: + return declared_field_label(self.annotation) or self.name.replace("_", " ").title() + + @abstractmethod + def current(self, record: object) -> object: ... + + def excluded_in(self, view: type[ViewExclusion]) -> bool: + return view.read(self.annotation, False) + + def minimum_width(self, values: Iterable[str]) -> int: + """Keep headers and individual words intact while the viewport can afford them.""" + return max(len(self.label), max((len(word) for value in values for word in value.split()), default=0)) + + @staticmethod + def fit_widths(weights: Iterable[int], available: int, + minimums: Iterable[int]) -> tuple[int, ...]: + """Allocate measured content proportionally after retaining readable words.""" + weights = tuple(max(1, weight) for weight in weights) + available = min(available, sum(weights)) + minimums = tuple(minimums) + if sum(minimums) > available: + return minimums + flexible = max(0, available - sum(minimums)) + demands = tuple(max(0, weight - minimum) for weight, minimum in zip(weights, minimums)) + demands = demands if sum(demands) else weights + total, used = sum(demands), 0 + widths = [] + for demand, minimum in zip(demands, minimums): + end = used + demand + widths.append(minimum + flexible * end // total - flexible * used // total) + used = end + return tuple(widths) + + @dataclass(frozen=True) -class FieldPlan: +class FieldPlan(ViewColumn): """One field's wire keys, checks and compiled value decoder.""" declared: Field - annotation: object + _annotation: object directives: ClassVar[tuple[type[ValueDirective], ...]] = () records: ClassVar[bool] = True _plans: ClassVar[list[type[FieldPlan]]] = [] @@ -545,6 +615,10 @@ def claims(cls, declared: Field, annotation: object) -> bool: def name(self) -> str: return self.declared.name + @property + def annotation(self) -> object: + return self._annotation + @cached_property def key(self) -> str: return WireName.read(self.annotation, self.name) @@ -579,23 +653,8 @@ def choices(self) -> tuple[str, ...] | None: def omit_default(self) -> bool: return WireOmitDefault.read(self.annotation, False) - @cached_property - def view_names(self) -> tuple[ViewName, ...]: - return ViewName.on(self.annotation) - - @cached_property - def view_exclusions(self) -> tuple[ViewExclusion, ...]: - return ViewExclusion.on(self.annotation) - - def key_in(self, view: str) -> str: - for directive in self.view_names: - if type(directive).declared_name == view: - return directive.value - return self.key - - def excluded_in(self, view: str) -> bool: - return any(directive.value for directive in self.view_exclusions - if type(directive).declared_name == view) + def key_in(self, view: type[ViewExclusion]) -> str: + return view.key_in(self.annotation, self.key) @cached_property def value(self) -> ValueDecoder[object]: @@ -1256,7 +1315,7 @@ def for_value(cls, value: object) -> type[FieldRepresentation]: return cls @classmethod - def project_view(cls, value: object, view: str, annotation: object) -> JsonValue: + def project_view(cls, value: object, view: type[ViewExclusion], annotation: object) -> JsonValue: return RepresentedProjection.of(value, view, annotation) @classmethod @@ -1318,7 +1377,7 @@ def for_value(cls, value: object) -> type[FieldRepresentation]: return FieldCodec.representation(type(value))[1] @classmethod - def project_view(cls, value: object, view: str, annotation: object) -> JsonValue: + def project_view(cls, value: object, view: type[ViewExclusion], annotation: object) -> JsonValue: return FieldCodec._project_structure(value, view, annotation) @classmethod @@ -1627,17 +1686,17 @@ class RepresentedProjection(MroDispatch): """A represented value's view: its wire encoding, unless its declared record is its wire form.""" @classmethod - def of(cls, value: object, view: str, annotation: object) -> JsonValue: + def of(cls, value: object, view: type[ViewExclusion], annotation: object) -> JsonValue: return cls.handler_for(type(value))(value, view, annotation) @handles(RecordMember) @staticmethod - def record(value: RecordMember, view: str, annotation: object) -> JsonValue: + def record(value: RecordMember, view: type[ViewExclusion], annotation: object) -> JsonValue: return FieldCodec._project_record(value, view) @handles(object) @staticmethod - def encoded(value: object, view: str, annotation: object) -> JsonValue: + def encoded(value: object, view: type[ViewExclusion], annotation: object) -> JsonValue: return FieldCodec.encode(value, annotation) @@ -2205,20 +2264,92 @@ def minimum_digits(cls) -> int: return 7 -class Projected(property): +class DisclosedRecord: + """Records declare their disclosure capability in their own inheritance.""" + + __slots__ = () + + @classmethod + @cache + def disclosure_identity(cls) -> tuple[FieldPlan, ...]: + return tuple(plan for plan in FieldCodec.field_plans(cls) + if DisclosureIdentity.read(plan.annotation, False)) + + def disclose[T: ValueDescription](self, presentation: type[T]) -> T: + identity = type(self).disclosure_identity() + return (presentation.identified_record(self, identity) if identity + else presentation.record(self)) + + +class ValueDescription(MroDispatch, ABC): + """Presentation dispatch is independent of FieldCodec's external decoder dispatch.""" + + @classmethod + def describe(cls, value: object) -> Self: + handler: Callable[[object], Self] = cls.handler_for(type(value)) + return handler(value) + + @handles(DisclosedRecord) + @classmethod + def disclosed(cls, value: DisclosedRecord) -> Self: + return value.disclose(cls) + + @classmethod + @abstractmethod + def scalar(cls, value: object) -> Self: ... + + @classmethod + @abstractmethod + def record(cls, value: object) -> Self: ... + + @classmethod + @abstractmethod + def identified_record(cls, value: object, identity: tuple[FieldPlan, ...]) -> Self: ... + + @classmethod + @abstractmethod + def class_value(cls, value: type) -> Self: ... + + @classmethod + @abstractmethod + def family_value(cls, value: type[DeclaredFamily]) -> Self: ... + + @classmethod + @abstractmethod + def mapping(cls, value: Mapping[Hashable, object]) -> Self: ... + + @classmethod + @abstractmethod + def sequence(cls, value: Collection[object]) -> Self: ... + + +class Projected(ViewColumn, property): """A computed field declared by its owner for a named read-only view.""" _declared: ClassVar[WeakKeyDictionary[type, dict[str, Projected]]] = WeakKeyDictionary() + fget: Callable[..., object] - def __init__(self, getter, *, view: str, name: str | None = None): + @property + def name(self) -> str: + return self.__name__ + + def __init__(self, getter, *, view: type[ViewExclusion], name: str | None = None): super().__init__(getter) self.view = view self.wire_name = name or getter.__name__ def __set_name__(self, owner: type, name: str) -> None: """Every class body declaring this field records it, a slots rebuild included.""" + super().__set_name__(owner, name) Projected._declared.setdefault(owner, {})[name] = self + @cached_property + def annotation(self) -> object: + return inspect.signature(self.fget, eval_str=True).return_annotation + + def current(self, record: object) -> object: + return self.__get__(record, type(record)) + @staticmethod def of(declaration: type) -> Iterator[tuple[str, Projected]]: """Each projected field visible on ``declaration``, base declarations first.""" @@ -2226,7 +2357,7 @@ def of(declaration: type) -> Iterator[tuple[str, Projected]]: yield from Projected._declared.get(base, {}).items() -def projected(*, view: str, name: str | None = None): +def projected(*, view: type[ViewExclusion], name: str | None = None): return lambda getter: Projected(getter, view=view, name=name) @@ -2537,7 +2668,7 @@ def value_schema(cls, annotation: object) -> dict[str, JsonValue]: return representation.schema_for(annotation) @classmethod - def project(cls, value: object, view: str, annotation: object = None) -> JsonValue: + def project(cls, value: object, view: type[ViewExclusion], annotation: object = None) -> JsonValue: """Encode a redacted view using field exclusions and owned properties. A family record carries its tag at every depth, as in its wire form. @@ -2548,7 +2679,7 @@ def project(cls, value: object, view: str, annotation: object = None) -> JsonVal return cls._value_representation(value, annotation).project_view(value, view, annotation) @classmethod - def _project_structure(cls, value: object, view: str, annotation: object) -> JsonValue: + def _project_structure(cls, value: object, view: type[ViewExclusion], annotation: object) -> JsonValue: if is_dataclass(value) and not isinstance(value, type): return cls._project_record(value, view) if isinstance(value, (tuple, list)): @@ -2557,13 +2688,13 @@ def _project_structure(cls, value: object, view: str, annotation: object) -> Jso return cls.encode(value) @classmethod - def _project_record(cls, value: object, view: str) -> JsonValue: + def _project_record(cls, value: object, view: type[ViewExclusion]) -> JsonValue: """A record's view: its tag, its view's fields and the properties the view owns.""" result = cls._tag(value) result.update(cls._record_plan(type(value)).encoded( value, view, lambda entry, current: cls.project(current, view, entry.annotation) )) - properties = cls._projections(type(value), view) + properties = cls.projections(type(value), view) result.update( (member.wire_name, cls.project(getattr(value, name), view)) for name, member in properties.items() @@ -2617,7 +2748,7 @@ def _record_plan(cls, record: type) -> FieldCodec._RecordPlan: """Resolve every per-row type decision for one record declaration once.""" hints = cls._types(record) declarations = tuple( - cls._field_plan(item, hints[item.name])(declared=item, annotation=hints[item.name]) + cls._field_plan(item, hints[item.name])(declared=item, _annotation=hints[item.name]) for item in fields(record) ) declared = tuple(plan for plan in declarations if plan.declared.init and plan.records) @@ -2630,7 +2761,7 @@ def _record_plan(cls, record: type) -> FieldCodec._RecordPlan: key=lambda row: (WireOrder.read(row[1].annotation, row[0]), row[0]), ) ) - computed = {member.wire_name for member in cls._projections(record, "wire").values()} + computed = {member.wire_name for member in cls.projections(record, WireExclude).values()} wire_fields, required_keys = [], [] for entry in entries: keys = entry.contribute(wire_fields) @@ -2657,9 +2788,18 @@ def _field_plan(field: Field[object], annotation: object) -> type[FieldPlan]: raise TypeError(f"Field {field.name} must have exactly one declared field plan: {selected}") return selected.pop() + @classmethod + def view_columns(cls, declaration: type, view: type[ViewExclusion]) -> tuple[ViewColumn, ...]: + """One column source for native tables and plain-text views.""" + return ( + *(plan for plan in cls.field_plans(declaration) + if plan.records and not plan.excluded_in(view)), + *cls.projections(declaration, view).values(), + ) + @staticmethod @cache - def _projections(declaration: type, view: str) -> types.MappingProxyType[str, Projected]: + def projections(declaration: type, view: type[ViewExclusion]) -> types.MappingProxyType[str, Projected]: # Cache immutable declarations only. Each consumer still invokes the # original property on the current record; projected values are never cached. return types.MappingProxyType({ @@ -3432,11 +3572,11 @@ def encode(self, value: object) -> dict[str, object]: if not isinstance(value, self.target): raise MalformedRecord(f"Expected {self.target.__name__} record") result = self.plan.encoded( - value, "wire", lambda entry, current: entry.value.encode(current) + value, WireExclude, lambda entry, current: entry.value.encode(current) ) result.update( (member.wire_name, FieldCodec.encode(getattr(value, name))) - for name, member in FieldCodec._projections(self.target, "wire").items() + for name, member in FieldCodec.projections(self.target, WireExclude).items() ) return result diff --git a/src/agent_comms/heartbeat_frame.py b/src/agent_comms/heartbeat_frame.py index d8cc735265..c307e1c914 100644 --- a/src/agent_comms/heartbeat_frame.py +++ b/src/agent_comms/heartbeat_frame.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import time from dataclasses import dataclass from typing import TYPE_CHECKING, Self @@ -11,11 +12,13 @@ from agent_comms.phase_evidence import PlanReading, PlanReadings from agent_comms.plan_phases import PlanPhase from agent_comms.repo import Repo +from agent_comms.recorded_time import RecordedEventTime from agent_comms.standing_rules import OwedAnswer, StandingRule from agent_comms.thread_identity import ThreadIncarnation from agent_comms.storage_rules import EditWorkplaces from agent_comms.todos import ( ClosedHeadReading, HeadReviewRecord, ProgressRead, StoredClosedHead, StoredHeadReview, + Todo, TodoStore, ) if TYPE_CHECKING: @@ -56,6 +59,7 @@ class HeartbeatFrame(CommanderInputs): closed: ClosedHeadReading head_reviews: Mapping[PullRequestHead, tuple[HeadReviewRecord, ...]] workplaces: Mapping[ThreadIncarnation, EditWorkplaces] + review_timers: tuple[tuple[Todo, RecordedEventTime], ...] @classmethod def capture(cls, comms: Comms, commander: str, inputs: CommanderInputs, @@ -77,4 +81,5 @@ def capture(cls, comms: Comms, commander: str, inputs: CommanderInputs, closed, reviews, EditWorkplaces.capture(comms, snapshot, inputs.live, inputs.pulls), + TodoStore.timers_up_in(inputs.todos, time.time(), inputs.pulls.progress(inputs.todos, comms.registry)), ) diff --git a/src/agent_comms/heartbeat_subjects.py b/src/agent_comms/heartbeat_subjects.py index 4c57b7379f..a4e86a8a49 100644 --- a/src/agent_comms/heartbeat_subjects.py +++ b/src/agent_comms/heartbeat_subjects.py @@ -1,14 +1,13 @@ """What one heartbeat line is about, and the recorded Core action that disposes of it. -A line is disposed only by a verb Core recorded on its subject after the line began: a -commander or fork message reaching the unit, a verdict on the exact pull request head, or an -answer clearing the owed message. Nothing the commander says about the line counts. +Ordinary actions dispose only of lines that began before them. Exact-head supersession +retires that lost head itself, even when an older observation is published later. Nothing +the commander says about a line counts unless Core recorded the relevant subject action. """ from __future__ import annotations -from abc import abstractmethod -from dataclasses import dataclass +from dataclasses import dataclass, field from functools import cached_property from types import MappingProxyType from typing import TYPE_CHECKING, Mapping, Self @@ -16,10 +15,11 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import MessageId from agent_comms.head_review import PullRequestHead -from agent_comms.recorded_time import EventTime, RecordedEventTime -from agent_comms.service_passes import ServicePassStore +from agent_comms.recorded_time import EventTime, RecordedEventTime, UnrecordedEventTime +from agent_comms.registry_document import RegistrySnapshot +from agent_comms.service_passes import ServicePasses, ServicePassStore from agent_comms.thread_identity import RecordedThreadIncarnation, ThreadIncarnation -from agent_comms.todos import StoredHeadReview +from agent_comms.todos import HeadVerdict, StoredHeadReview if TYPE_CHECKING: from agent_comms.comms import Comms @@ -32,14 +32,26 @@ class CommanderHistory: record that disposes of it.""" comms: Comms + snapshot: RegistrySnapshot identities: frozenset[ThreadIncarnation] + _actions: dict[HeartbeatSubject, EventTime] = field(default_factory=dict, init=False, compare=False) + + def action_at(self, subject: HeartbeatSubject) -> EventTime: + """One original action per subject and captured history, independent of ping age.""" + if subject not in self._actions: + self._actions[subject] = subject.action_at(self) + return self._actions[subject] + + @cached_property + def passes(self) -> ServicePasses: + return ServicePassStore.at(self.comms.root).read() @classmethod def read(cls, comms: Comms, actors: frozenset[str]) -> Self: snapshot = comms.registry.snapshot() - return cls(comms, frozenset(snapshot.require(actor).incarnation for actor in actors)) + return cls(comms, snapshot, frozenset(snapshot.require(actor).incarnation for actor in actors)) - @property + @cached_property def actors(self) -> frozenset[str]: return frozenset(actor.name for actor in self.identities) @@ -53,39 +65,48 @@ def phases_checked(self) -> Mapping[type[OrderPhase], EventTime]: @dataclass(frozen=True) class HeartbeatSubject(DeclaredFamily, affix="Subject"): - @abstractmethod + def action_at(self, history: CommanderHistory) -> EventTime: + """Subjects without a disposing action never invent one.""" + return UnrecordedEventTime() + def disposed(self, history: CommanderHistory, since: float) -> bool: - """Whether Core recorded an action on this subject at or after ``since``.""" + """Original action evidence is shared, while each line retains its own start.""" + return history.action_at(self).since(since) @dataclass(frozen=True) class UnitSubject(HeartbeatSubject): unit: ThreadIncarnation - def disposed(self, history: CommanderHistory, since: float) -> bool: - return any(message.timestamp >= since for message in history.comms.bus.bus.latest_addressed( - history.actors, history.comms.registry.snapshot().names_for(self.unit.name), - )) + def action_at(self, history: CommanderHistory) -> EventTime: + return max((RecordedEventTime(message.timestamp) + for message in history.comms.bus.bus.latest_addressed( + history.actors, history.snapshot.names_for(self.unit.name))), + key=lambda at: at.latest_with(float('-inf')), default=UnrecordedEventTime()) @dataclass(frozen=True) class HeadSubject(HeartbeatSubject): request: PullRequestHead - def disposed(self, history: CommanderHistory, since: float) -> bool: - return any(review.at.since(since) and review.verdict.decides() - for review in StoredHeadReview.head_reviews(history.comms.goals.todos, self.request)) + def clears(self, verdict: HeadVerdict) -> bool: + return verdict.decides() + + def action_at(self, history: CommanderHistory) -> EventTime: + return max((review.at for review in StoredHeadReview.head_reviews( + history.comms.goals.todos, self.request) if self.clears(review.verdict)), + key=lambda at: at.latest_with(float('-inf')), default=UnrecordedEventTime()) @dataclass(frozen=True) class UnlandedHeadSubject(HeadSubject): - """Only the forge/Git condition clears this line; reviewing it does not land it.""" + """Exact-head supersession retires the lost work, not merely an older alarm.""" - def disposed(self, history: CommanderHistory, since: float) -> bool: - return False + def clears(self, verdict: HeadVerdict) -> bool: + return verdict.supersedes() - def acted_on(self, acted: frozenset[HeartbeatSubject]) -> bool: - return False + def disposed(self, history: CommanderHistory, since: float) -> bool: + return history.action_at(self).occurred() @dataclass(frozen=True) @@ -94,8 +115,8 @@ class PassSubject(HeartbeatSubject): commander: RecordedThreadIncarnation - def disposed(self, history: CommanderHistory, since: float) -> bool: - return ServicePassStore.at(history.comms.root).read().completed_since(self.commander, since) + def action_at(self, history: CommanderHistory) -> EventTime: + return history.passes.heartbeat_completed_at(self.commander) @dataclass(frozen=True) @@ -103,7 +124,3 @@ class AnswerSubject(HeartbeatSubject): """A message owed an answer: an action naming it with --answers acts on it.""" message: MessageId - - def disposed(self, history: CommanderHistory, since: float) -> bool: - """The answer clears the owed message itself, so the line leaves the next packet.""" - return False diff --git a/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index e5c1c1571e..6a8a0e0b7a 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -1,6 +1,8 @@ """History, display snapshots and viewer read-state ownership.""" from __future__ import annotations + +from agent_comms.field_codec import PresenceExclude from agent_comms.repo import Checkout from .activity import ActivityCapture, ObservedActivity @@ -113,7 +115,7 @@ class ThreadPoll(CliResult): peers: tuple[ThreadView, ...] def to_wire(self) -> object: - return FieldCodec.project(self, "presence") + return FieldCodec.project(self, PresenceExclude) @dataclass(frozen=True) diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index 8cc8461723..dd7dc51453 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -2,6 +2,8 @@ from __future__ import annotations +from agent_comms.field_codec import WireExclude + from .input_key import InputKey from .class_fact import ClassFact @@ -1026,7 +1028,7 @@ def view_key(self) -> tuple[str, int]: def view_order(self) -> tuple[int, int, int]: return 1, 0, self.seq - @projected(view="wire", name="id") + @projected(view=WireExclude, name="id") def message_id(self) -> MessageId: return MessageId.mint(f"{self.sender}:{self.target}:{self.timestamp}:{self.body}") diff --git a/src/agent_comms/native_tools.py b/src/agent_comms/native_tools.py index 4a2f8e5e30..86546f70e0 100644 --- a/src/agent_comms/native_tools.py +++ b/src/agent_comms/native_tools.py @@ -2,6 +2,8 @@ from __future__ import annotations +from agent_comms.field_codec import ArgumentsExclude + import json from abc import abstractmethod from dataclasses import dataclass, field @@ -119,7 +121,7 @@ class ArgumentTool: def arguments(self) -> dict[str, object]: # The native argument object has no family envelope. Its contents come # entirely from the record codec, never a retained raw dictionary. - encoded = FieldCodec.project(self, "arguments") + encoded = FieldCodec.project(self, ArgumentsExclude) del encoded[self.family_discriminator] return encoded diff --git a/src/agent_comms/order_story.py b/src/agent_comms/order_story.py index 7222ea54fa..4166e4581c 100644 --- a/src/agent_comms/order_story.py +++ b/src/agent_comms/order_story.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Annotated, Self from .build_identity import InstalledBuild -from .field_codec import DisclosureIdentity, OrderId +from .field_codec import DisclosedRecord, DisclosureIdentity, OrderId from .recorded_time import EventTime from .tool_result import ToolResult @@ -18,7 +18,7 @@ @dataclass(frozen=True) -class StoryLine: +class StoryLine(DisclosedRecord): at: EventTime text: str @@ -52,7 +52,7 @@ def story(self) -> tuple[StoryLine, ...]: @dataclass(frozen=True) -class ProblemStory: +class ProblemStory(DisclosedRecord): problem: Annotated[str, DisclosureIdentity()] state: str events: tuple[StoryLine, ...] diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 90613f2fca..59c21da202 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -703,17 +703,17 @@ def read(cls, snapshot: RegistrySnapshot, agents: AgentActivity, ) for thread in threads) def to_wire(self) -> object: - return FieldCodec.project(self, "presence") + return FieldCodec.project(self, PresenceExclude) - @projected(view="presence") + @projected(view=PresenceExclude) def is_fork(self) -> bool: return self.thread.is_fork - @projected(view="presence", name="activity") + @projected(view=PresenceExclude, name="activity") def activity_state(self) -> ActivityState: return self.activity.state - @projected(view="presence") + @projected(view=PresenceExclude) def activity_detail(self) -> str: shown = self.activity.presentation(self.thread.display_title) return " · ".join(part for part in (self.activity.detail, *shown.status.details) if part) @@ -757,19 +757,19 @@ def _idle_presentation(self, ordinary: ThreadPresentation) -> ThreadPresentation return ActivityState.WORKING.presentation(ordinary.title, "In a turn") return self.goal_execution.waiting_presentation(ordinary) - @projected(view="presence") + @projected(view=PresenceExclude) def resumable(self) -> bool: return self.thread.runtime.resumable - @projected(view="presence") + @projected(view=PresenceExclude) def last_activity(self) -> float: return self.activity.timestamp - @projected(view="presence") + @projected(view=PresenceExclude) def context_used(self) -> PiCount: return self.runtime.context_used - @projected(view="presence") + @projected(view=PresenceExclude) def context_size(self) -> PiCount: return self.runtime.context_size diff --git a/src/agent_comms/recorded_time.py b/src/agent_comms/recorded_time.py index 80f914c6ac..aaa110907b 100644 --- a/src/agent_comms/recorded_time.py +++ b/src/agent_comms/recorded_time.py @@ -9,7 +9,7 @@ from typing import ClassVar, Never from .declared_family import DeclaredFamily -from .field_codec import AcceptsNull, FieldCodec, FieldRepresentation, NullMember, boundary_decoder +from .field_codec import AcceptsNull, DisclosedRecord, FieldCodec, FieldRepresentation, NullMember, boundary_decoder class RecordedEventSeconds(FieldRepresentation): """The recorded-time value of an existing numeric-seconds contract.""" @@ -68,7 +68,7 @@ def encode(cls, value: object) -> Never: raise TypeError("A generated timestamp is read from its event, never written") -class EventTime(DeclaredFamily, affix="EventTime"): +class EventTime(DisclosedRecord, DeclaredFamily, affix="EventTime"): @classmethod def capture(cls, timestamp: float) -> EventTime: """Capture a recorded Unix-seconds fact, never the read time.""" @@ -78,6 +78,10 @@ def chronology(self) -> tuple[int, float]: """Undated facts sort after recorded events, without inventing a timestamp.""" return (1, 0.0) + def occurred(self) -> bool: + """Whether an event was recorded, independent of any later observation's age.""" + return False + def since(self, timestamp: float) -> bool: return False @@ -87,6 +91,12 @@ def before(self, timestamp: float) -> bool: def clock(self) -> str: return "-" + def stamp(self) -> str: + return "-" + + def age(self, now: float) -> str: + return "unrecorded" + def aged_line(self, label: str, now: float) -> tuple[str, ...]: """An undated observation cannot invent an age.""" return () @@ -132,6 +142,9 @@ def __post_init__(self): def chronology(self) -> tuple[int, float]: return (0, self.at) + def occurred(self) -> bool: + return True + def since(self, timestamp: float) -> bool: return self.at >= timestamp @@ -141,8 +154,21 @@ def before(self, timestamp: float) -> bool: def clock(self) -> str: return time.strftime("%H:%M:%S", time.localtime(self.at)) + def stamp(self) -> str: + """A stable dated fact, unlike paint-time elapsed wording.""" + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.at)) + + def age(self, now: float) -> str: + minutes, seconds = divmod(max(0, int(now - self.at)), 60) + hours, minutes = divmod(minutes, 60) + days, hours = divmod(hours, 24) + parts = (f"{amount}{unit}" for amount, unit in ( + (days, "d"), (hours, "h"), (minutes, "m"), (seconds, "s"), + ) if amount) + return f"{''.join(parts) or '0s'} ago" + def aged_line(self, label: str, now: float) -> tuple[str, ...]: - return (f"{label} {self.clock()} ({max(0, int(now - self.at))} s ago)",) + return (f"{label} {self.clock()} ({self.age(now)})",) def to_number(self, units_per_second: int) -> float: return self.at * units_per_second diff --git a/src/agent_comms/service_passes.py b/src/agent_comms/service_passes.py index 1e398d21be..884331483a 100644 --- a/src/agent_comms/service_passes.py +++ b/src/agent_comms/service_passes.py @@ -12,7 +12,7 @@ from .coordinator import Coordination from .locked_store import DocumentChange, DurableFile, LockedStore from .errors import ToolRefusedError -from .field_codec import WireTransient, projected +from .field_codec import WireExclude, WireTransient, projected from .tool_result import ToolResult from .recorded_time import EventTime, RecordedEventTime, UnrecordedEventTime from .store_files import DependentStateTier, SingleFileStore @@ -70,7 +70,7 @@ class ServiceReading(ToolResult): def with_services(self, passes: ServicePasses, owner: RecordedThreadIncarnation) -> Self: return replace(self, services=passes, heartbeat=passes.heartbeat_completed_at(owner)) - @projected(view='wire') + @projected(view=WireExclude) def service_status(self) -> tuple[str, ...]: clock = self.heartbeat.aged_line('Tristan last completed', time.time()) failures = self.services.failure_lines() diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index c7431f97db..284af273f0 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -237,6 +237,10 @@ def clearing_commands(self) -> str: def attention(self) -> bool: return True + @classmethod + def alarm_pings[P](cls, ping: P) -> tuple[P, ...]: + return (ping,) + def subjects(self) -> tuple[HeartbeatSubject, ...]: """What a recorded action must touch to dispose of this rule's heartbeat line; a rule about nothing a verb can act on clears only when its condition does.""" @@ -660,7 +664,7 @@ def derive(cls, branches: tuple[HeldBranch, ...]) -> tuple[Self, ...]: other_branch=second.difference.branch, repository=first.difference.repository, paths=conflicts) for first, second in HolderBranch.touching(branches) - if (conflicts := first.source.pulls.require_branch_conflicts( + if (conflicts := first.source.frame.pulls.require_branch_conflicts( first.difference.head, second.difference.head)) ) @@ -872,14 +876,15 @@ def command_values(self) -> Mapping[str, str]: class ReviewTimerUp(HeldOrderDue, FamilyScan): - """Each round of a held order's review timer is its own condition, so the heartbeat wakes - the commander once per round; the unit sees no timer and nothing is struck.""" + """The timer owns the request and wake; the unit's ReviewDue row owns its display.""" + + @classmethod + def alarm_pings[P](cls, ping: P) -> tuple[P, ...]: + return () @classmethod def conditions(cls, comms: Comms, frame: HeartbeatFrame) -> Iterable[tuple[Todo, EventTime]]: - return comms.goals.todos.timers_up_in( - frame.todos, time.time(), frame.pulls.progress(frame.todos, comms.registry), - ) + return frame.review_timers @classmethod def scan_observed(cls, comms: Comms, commander: str, @@ -1116,7 +1121,7 @@ def line(self) -> str: *(f"order {holder.name}" for holder in self.holders)) or (self.unowned,)) return (f"{self.tag} {self.request.repo.to_wire()}#{self.request.pr} {self.request.head[:9]} ({held}):" - f" {why}, idle {int((time.time() - self.moved) // 60)} min. who owns it, or are we" + f" {why}, unchanged since {RecordedEventTime(self.moved).stamp()}. who owns it, or are we" f" pretending it doesn't exist? -> {'; '.join(actions)}") @@ -1168,7 +1173,7 @@ def hunted(cls, comms: Comms, commander: str) -> float: @classmethod def reason(cls, comms: Comms, commander: str) -> str | None: age = time.time() - cls.hunted(comms, commander) - return f"last hunt {int(age // 60)} min ago" if age > cls.seconds else None + return f"no hunt in the last {cls.seconds // 60} min" if age > cls.seconds else None @classmethod def found(cls, why: str, comms: Comms, commander: str, diff --git a/src/agent_comms/task_attachments/commander_pings.py b/src/agent_comms/task_attachments/commander_pings.py index 48cbc74a29..2b2ee593b1 100644 --- a/src/agent_comms/task_attachments/commander_pings.py +++ b/src/agent_comms/task_attachments/commander_pings.py @@ -1,24 +1,23 @@ -"""Committed commander notifications own what the heartbeat has already handled. +"""Committed commander notifications own the heartbeat's retained observations. -A packet carries only lines that need the commander. New lines wake it; unchanged lines are -never restated; lines still undisposed past the answer window follow the new ones and refuse -the commander's writing verbs until a recorded action on their subject disposes of them. No -process-local cache owns any of it. +New observations and every still-undisposed request wake on each beat. A changed quiet frame +publishes once without waking; an unchanged quiet frame adds nothing to bus history. The +original subject actions dispose of requests, and the answer window only gates overdue +writing verbs. No process-local cache owns any of it. """ from __future__ import annotations from abc import abstractmethod from dataclasses import dataclass, field, replace -from datetime import datetime, timezone -from textwrap import shorten -from typing import TYPE_CHECKING, ClassVar, Self +from typing import TYPE_CHECKING, Annotated, ClassVar, Self from agent_comms.channel_targets import ThreadTarget from agent_comms.declared_family import DeclaredFamily from agent_comms.harness_delivery_states import DeliveredOutcome, Outcome from agent_comms.commander_service import CommanderService from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import FieldCodec, OrderId +from agent_comms.disclosure_text import AccentTone, ViewHeading, ViewTable, WarningTone +from agent_comms.field_codec import FieldCodec, HudExclude, OrderId, PublicationExclude from agent_comms.message_reference import MessageReference from agent_comms.heartbeat_subjects import CommanderHistory, HeartbeatSubject from agent_comms.messages import ( @@ -37,7 +36,6 @@ if TYPE_CHECKING: from collections.abc import Iterator - from agent_comms.cli_bridges.claude import ClaudeBridge from agent_comms.cli_bridges.shared import CliBridge from agent_comms.comms import Comms from agent_comms.registry_document import RegistrySnapshot @@ -63,11 +61,6 @@ class Ping(DeclaredFamily, affix="Ping"): subjects: tuple[HeartbeatSubject, ...] = field(default=(), compare=False, kw_only=True) since: EventTime = field(default_factory=UnrecordedEventTime, compare=False, kw_only=True) - @property - @abstractmethod - def group(self) -> str: - """Presentation grouping derived from the original rule or unit declaration.""" - @property def urgent(self) -> bool: return False @@ -76,14 +69,18 @@ def urgent(self) -> bool: @abstractmethod def key(self) -> object: ... + @property + @abstractmethod + def alarm_kind(self) -> type[DeclaredFamily]: ... + def acted(self, history: CommanderHistory) -> bool: - """Whether a subject has a recorded action since this line began.""" + """Each subject owns whether its original action clears this line.""" began = self.since.recorded_at("A line") return any(subject.disposed(history, began) for subject in self.subjects) def undisposed(self, history: CommanderHistory, cut: float) -> bool: - """Waiting on the commander, and not acted on since it began. Only such a line is - restated as waiting and locks the verbs.""" + """Waiting on the commander past the cut, without a recorded subject disposition. + Only such a line is restated as waiting and locks the verbs.""" return self.waits_on_commander(cut) and not self.acted(history) def waits_on_commander(self, cut: float) -> bool: @@ -110,11 +107,10 @@ def board_entry(self) -> BoardEntry: return BoardEntry(content.value, self.text, since, since + AwaitingCommander.seconds) def render(self, now: float) -> str: - return self.board_entry().aged(now) + return f"{self.text} ({self.since.age(now)})" - def preview(self, now: float) -> str: - """A bounded text view; the board and packet retain the complete original.""" - return replace(self.board_entry(), text=shorten(self.text, width=180, placeholder="…")).aged(now) + def alarm_pings(self) -> tuple[Ping, ...]: + return (self,) @dataclass(frozen=True) @@ -122,14 +118,14 @@ class RulePing(Ping): rule: type[StandingRule] observation: TextDigest - @property - def group(self) -> str: - return self.rule.declared_name - @property def urgent(self) -> bool: return self.rule.urgent + @property + def alarm_kind(self) -> type[StandingRule]: + return self.rule + @property def key(self) -> TextDigest: return self.observation @@ -138,6 +134,9 @@ def key(self) -> TextDigest: def commander_disposes(self) -> bool: return self.rule.disposes_subjects(self.subjects) + def alarm_pings(self) -> tuple[Ping, ...]: + return self.rule.alarm_pings(self) + @classmethod def capture(cls, rule: StandingRule) -> Self: return cls(type(rule), rule.observation_key(), text=rule.line(), subjects=rule.subjects()) @@ -153,8 +152,12 @@ class UnitPing(Ping): observation: TextDigest @property - def group(self) -> str: - return self.unit.name + def urgent(self) -> bool: + return self.state.hud_group.needs_hand() + + @property + def alarm_kind(self) -> type[UnitState]: + return self.state @property def key(self) -> ThreadIncarnation: @@ -164,6 +167,10 @@ def key(self) -> ThreadIncarnation: def commander_disposes(self) -> bool: return self.state.disposes_subjects(self.subjects) + def alarm_pings(self) -> tuple[Ping, ...]: + """The unit's row already carries this observation and its next action.""" + return () + @classmethod def capture(cls, state: UnitState) -> Self: return cls( @@ -175,13 +182,39 @@ def capture(cls, state: UnitState) -> Self: ) +@dataclass(frozen=True) +class CommanderDelivery: + """A derived packet and the one open set shared by publication and its disposable HUD.""" + + packet: CommanderUpdate + open_pings: tuple[Ping, ...] + + +class PublicationFacts(DeclaredFamily, affix="Facts"): + """The display evidence carried by an actual committed packet.""" + + +@dataclass(frozen=True) +class UnpublishedFacts(PublicationFacts): + """Historical and failure packets never claim display data they did not publish.""" + + +@dataclass(frozen=True) +class PublishedFacts(PublicationFacts): + digest: TextDigest + + @classmethod + def of(cls, facts: object) -> Self: + return cls(TextDigest(FieldCodec.comparison_digest(facts))) + + @dataclass(frozen=True) class CommanderUpdate(OwnerNotice, declared_name=None): commander: ThreadIncarnation handled: tuple[Ping, ...] not_applied: tuple[str, ...] = field(default=(), kw_only=True) + publication: PublicationFacts = field(default_factory=UnpublishedFacts, compare=False, kw_only=True) role: ClassVar[MessageRole] - header: ClassVar[str] = "COMMANDER" writes: ClassVar[tuple[type[ProducedRow], ...]] = (CommanderUpdatePosition,) @@ -196,40 +229,36 @@ def category(self, spoken: type[MessageCategory]) -> type[MessageCategory]: """A board packet speaks in the owner's voice yet is not his words: its own category.""" return HeartbeatCategory - def changed_from(self, handled: tuple[Ping, ...], not_applied: tuple[str, ...]) -> bool: - """A complete read that differs from this packet clears what it no longer holds.""" - return (self.handled, self.not_applied) != (handled, not_applied) + def should_publish(self, facts: PublishedFacts, previous: tuple[CommanderUpdate, ...]) -> bool: + return True - @abstractmethod - def lines(self, now: float) -> tuple[str, ...]: - """What this packet asks of the commander.""" + def new_pings(self) -> tuple[Ping, ...]: + return () - def body(self, now: float, brief: tuple[str, ...]) -> str: - """Scan first; original evidence remains in the same packet and expandable HUD.""" - stamp = datetime.fromtimestamp(now, timezone.utc).strftime("%H:%M:%S UTC") + def body(self, hud: tuple[str, ...]) -> str: + """The existing HUD owns presentation; this packet owns publication and history.""" return "\n".join(( - f"{self.header} · {self.commander.name} · {stamp} · {len(self.handled)} current", - *brief, "", *self.lines(now), "", - "Expand in Toad: F2 or /commander · units: original orders, story, commits, activity · board: every alert", + *hud, "", + "Full evidence: comms_hud board/open_pings, or Toad /commander original details", )) def board(self, comms: Comms) -> tuple[BoardEntry, ...]: - """This packet's lines a commander disposes of, through the same recorded actions the - gate reads: any commander's verdict drops its line on the next read, not the next - packet.""" + """Any commander's recorded action disposes of its subject on the shared board.""" history = CommanderHistory.read(comms, CommanderService.actors(comms.registry.snapshot())) - return tuple(ping.board_entry() for ping in self.handled - if ping.on_board(history)) + return tuple(ping.board_entry() for ping in self.open_pings(history)) + + def open_pings(self, history: CommanderHistory) -> tuple[Ping, ...]: + return tuple(ping for ping in self.handled if ping.on_board(history)) async def delivered_by(self, bridge: CliBridge, message: Message) -> type[Outcome]: return await bridge.deliver_board(self, message) @abstractmethod - async def wake(self, bridge: ClaudeBridge, message: Message) -> type[Outcome]: - """What reaches the session itself once its bridge has put this packet on its board.""" + async def wake(self, bridge: CliBridge, message: Message) -> type[Outcome]: + """The packet's wake decision is the same on every harness.""" def undisposed(self, history: CommanderHistory, cut: float) -> tuple[Ping, ...]: - """Lines older than ``cut`` that no recorded action on their subjects has disposed of.""" + """Read every subject through this pass's one captured actor/history reader.""" return tuple(ping for ping in self.handled if ping.undisposed(history, cut)) def require_target(self, target: str, registry: RegistrySnapshot) -> None: @@ -255,11 +284,10 @@ def current_board(cls, comms: Comms, commander: str) -> tuple[BoardEntry, ...]: def derive(cls, commander: ThreadIncarnation, states: tuple[UnitState, ...], rules: tuple[StandingRule, ...], previous: tuple[CommanderUpdate, ...], *, now: float, - undisposed: tuple[Ping, ...] = (), - not_applied: tuple[str, ...] = ()) -> tuple[CommanderUpdate, ...]: - """New lines wake the commander and unchanged lines are never restated. With nothing new, - cleared lines are committed by a passive notice so a recurrence is new again; with - nothing cleared either, nothing is sent.""" + history: CommanderHistory, + not_applied: tuple[str, ...] = ()) -> CommanderDelivery: + """New observations or any open request wake on every beat. Publication never disposes + of a ping; only a beat without unresolved work may be passive.""" known = {ping.key: ping for packet in previous for ping in packet.handled} # Equality ignores the start, so an unchanged observation keeps the start it had. began = {ping: ping.since for ping in known.values()} @@ -271,13 +299,16 @@ def derive(cls, commander: ThreadIncarnation, states: tuple[UnitState, ...], )), key=lambda ping: not ping.urgent, )) new = tuple(ping for ping in handled if known.get(ping.key) != ping) - if new: - return (CommanderDecisions( - commander, handled, new, - tuple(ping for ping in undisposed if ping in handled), not_applied=not_applied, - ),) - return tuple(CommanderNotice(commander, handled, not_applied=not_applied) - for packet in previous if packet.changed_from(handled, not_applied)) + notice = CommanderNotice(commander, handled, not_applied=not_applied) + opened = notice.open_pings(history) + open_members = frozenset(opened) + packet = CommanderDecisions( + commander, handled, new, + tuple(ping for old in previous for ping in old.handled + if ping in open_members and ping.waits_on_commander(now - AwaitingCommander.seconds)), + not_applied=not_applied, + ) if new or opened or not_applied else notice + return CommanderDelivery(packet, opened) @classmethod def report_failure(cls, commander: ThreadIncarnation, failure: ServicePassFailing, @@ -296,20 +327,24 @@ def report_failure(cls, commander: ThreadIncarnation, failure: ServicePassFailin not_applied=tuple(line for packet in previous for line in packet.not_applied), ),) - def send(self, comms: Comms, now: float, brief: tuple[str, ...]) -> Message: - return self.publish(comms.messaging, ThreadTarget(self.commander.name), self.body(now, brief)) + def send(self, comms: Comms, now: float, hud: tuple[str, ...], *, + publication: PublicationFacts = UnpublishedFacts()) -> Message: + return replace(self, publication=publication).publish( + comms.messaging, ThreadTarget(self.commander.name), self.body(hud)) @dataclass(frozen=True) class CommanderNotice(CommanderUpdate): + """Publish a changed quiet frame once, without waking for an identical copy.""" + role = NoticeRole() - async def wake(self, bridge: ClaudeBridge, message: Message) -> type[Outcome]: - """Cleared lines leave the board; nothing new needs the session.""" - return DeliveredOutcome + def should_publish(self, facts: PublishedFacts, previous: tuple[CommanderUpdate, ...]) -> bool: + return not any(packet.publication == facts for packet in previous) - def lines(self, now: float) -> tuple[str, ...]: - return (f"Cleared · {len(self.handled)} requests remain on the board.", *self.not_applied) + async def wake(self, bridge: CliBridge, message: Message) -> type[Outcome]: + """The current display has no unresolved request that needs model input.""" + return DeliveredOutcome @dataclass(frozen=True) @@ -320,20 +355,113 @@ class CommanderDecisions(CommanderUpdate): waiting: tuple[Ping, ...] role = ConversationRole() - async def wake(self, bridge: ClaudeBridge, message: Message) -> type[Outcome]: + async def wake(self, bridge: CliBridge, message: Message) -> type[Outcome]: return await bridge.wake(message) - def lines(self, now: float) -> tuple[str, ...]: - waiting = sorted(self.waiting, key=lambda ping: ping.since.chronology()) - groups: dict[str, list[Ping]] = {} - for ping in waiting: - groups.setdefault(ping.group, []).append(ping) - return ( - f"New · {len(self.decisions)}", - *(f"• {ping.preview(now)}" for ping in self.decisions), *self.not_applied, - *(line for name, pings in groups.items() for line in ( - f"Waiting · {name} · {len(pings)}", - *(f"• {ping.preview(now)}" for ping in pings[:2]), - *((f" +{len(pings) - 2} in the expandable board",) if len(pings) > 2 else ()), - )), - ) + def new_pings(self) -> tuple[Ping, ...]: + return self.decisions + + +class AlarmGroup(ViewHeading, affix="Alarms"): + """Alarm membership shares heading behavior, not the unit-group registry.""" + + @classmethod + @abstractmethod + def members(cls, packets: tuple[CommanderUpdate, ...], + opened: tuple[Ping, ...], new: tuple[Ping, ...]) -> tuple[Ping, ...]: ... + + @classmethod + def visible(cls, pings: tuple[Ping, ...]) -> tuple[Ping, ...]: + return pings + + @classmethod + def sections(cls, packets: tuple[CommanderUpdate, ...], + opened: tuple[Ping, ...]) -> tuple[AlarmSection, ...]: + new = tuple(line for packet in packets for ping in packet.new_pings() + for line in ping.alarm_pings()) + return tuple(AlarmSection(group, members) + for group in cls.members_with(cls) + for members in (group.members(packets, opened, new),) if members) + + +class CompactAlarms: + """Sections keep actionable representatives and explicit per-kind overflow.""" + + maximum_per_kind: ClassVar[int] = 1 + + @classmethod + def visible(cls, pings: tuple[Ping, ...]) -> tuple[Ping, ...]: + return pings[:cls.maximum_per_kind] + + +class NewAlarms(CompactAlarms, AlarmGroup): + tone = AccentTone + + @classmethod + def members(cls, packets: tuple[CommanderUpdate, ...], + opened: tuple[Ping, ...], new: tuple[Ping, ...]) -> tuple[Ping, ...]: + return new + + +class StillOpenAlarms(CompactAlarms, AlarmGroup): + tone = WarningTone + + @classmethod + def members(cls, packets: tuple[CommanderUpdate, ...], + opened: tuple[Ping, ...], new: tuple[Ping, ...]) -> tuple[Ping, ...]: + new_members = frozenset(new) + return tuple(line for ping in opened for line in ping.alarm_pings() if line not in new_members) + + +class WatchingAlarms(CompactAlarms, AlarmGroup): + """A still-failing source remains visible, but it has no commander disposal to wake for.""" + + @classmethod + def members(cls, packets: tuple[CommanderUpdate, ...], + opened: tuple[Ping, ...], new: tuple[Ping, ...]) -> tuple[Ping, ...]: + new_members = frozenset(new) + return tuple(line for packet in packets for ping in packet.handled + if not ping.commander_disposes for line in ping.alarm_pings() if line not in new_members) + + +@dataclass(frozen=True) +class AlarmLine: + kind: type[DeclaredFamily] + line: str + age: Annotated[str, PublicationExclude()] + + +@dataclass(frozen=True) +class AlarmOverflow: + kind: type[DeclaredFamily] + more: int + oldest_age: Annotated[str, PublicationExclude()] + + +@dataclass(frozen=True) +class AlarmSection: + group: type[AlarmGroup] + pings: tuple[Ping, ...] + + def heading(self) -> str: + return self.group.count_heading(len(self.pings)) + + def tables(self, now: float) -> tuple[ViewTable[AlarmLine] | ViewTable[AlarmOverflow], ...]: + grouped: dict[type[DeclaredFamily], list[Ping]] = {} + for ping in self.pings: + grouped.setdefault(ping.alarm_kind, []).append(ping) + lines: list[AlarmLine] = [] + overflow: list[AlarmOverflow] = [] + for kind, members in grouped.items(): + shown = self.group.visible(tuple(members)) + lines.extend(AlarmLine(kind, ping.text, ping.since.age(now)) for ping in shown) + rest = members[len(shown):] + if rest: + overflow.append(AlarmOverflow(kind, len(rest), min( + rest, key=lambda ping: ping.since.chronology()).since.age(now))) + tables: list[ViewTable[AlarmLine] | ViewTable[AlarmOverflow]] = [ + ViewTable(AlarmLine, HudExclude, tuple(lines)), + ] + if overflow: + tables.append(ViewTable(AlarmOverflow, HudExclude, tuple(overflow))) + return tuple(tables) diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index f013bc51d1..e67528d214 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -26,6 +26,7 @@ ) from .errors import RelationViolationError from .field_codec import ( + DisclosureIdentity, FieldCodec, TurnId, SingleFieldValue, @@ -174,7 +175,7 @@ class ThreadIncarnation(JsonFlagRecord, WireValue, ABC): value_flag: ClassVar[str] = "--thread-incarnation" hands_out_released_work: ClassVar[bool] = False """Whether work this actor released goes to an idle unit without a commander turn.""" - name: str + name: Annotated[str, DisclosureIdentity()] @property @abstractmethod @@ -273,7 +274,7 @@ class RecordedThreadIncarnation(ThreadIncarnation, ParticipantIdentity): still require ``current`` / ``resolved`` to prove the original alias lineage. """ - name: str = field(compare=False) + name: Annotated[str, DisclosureIdentity()] = field(compare=False) created_at: float def __eq__(self, other: object) -> bool: diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 3bd622bacd..5e23ed1ef7 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -76,6 +76,7 @@ from agent_comms.field_codec import ( DefaultNonNull, Description, + DisclosedRecord, DisclosureIdentity, FieldCodec, GitCommitText, @@ -671,7 +672,7 @@ class MergedLink: @dataclass(frozen=True) -class PullRequest(HeadReviewStatus, DeclaredFamily, affix="PullRequest"): +class PullRequest(HeadReviewStatus, DisclosedRecord, DeclaredFamily, affix="PullRequest"): number: int branch: str head: str @@ -1232,7 +1233,7 @@ def query(self, repo: RepositoryName, @dataclass(frozen=True) -class ProgressPoint: +class ProgressPoint(DisclosedRecord): """A revision and its time in the order's progress; Git rows use committer time.""" sha: Annotated[str, DisclosureIdentity()] at: float @@ -1245,14 +1246,14 @@ def listed(cls, line: str) -> ProgressPoint: @dataclass(frozen=True) -class DistanceReading: +class DistanceReading(DisclosedRecord): at: float distance: CheckPrinted command: str = "" @dataclass(frozen=True) -class DistanceReadings: +class DistanceReadings(DisclosedRecord): """The order's last two distance readings, oldest first: its base at the order, then its newest commit. Fewer than two read, or a last reading at done, project nothing.""" readings: tuple[DistanceReading, ...] = () @@ -1703,7 +1704,7 @@ def from_refs(cls, todo: Todo, pulls: ProgressRead, checkout: ProgressCheckout, @dataclass(frozen=True) -class OrderProgress(DeclaredFamily, affix="Progress"): +class OrderProgress(DisclosedRecord, DeclaredFamily, affix="Progress"): """Where an order's work stands, derived on each read from its unit's checkout and PRs. Never stored: the checkout and GitHub are its owners.""" diff --git a/src/agent_comms/tool_result.py b/src/agent_comms/tool_result.py index 19f194310a..300e578992 100644 --- a/src/agent_comms/tool_result.py +++ b/src/agent_comms/tool_result.py @@ -1,9 +1,9 @@ """Declared CLI and tool replies share their JSON behavior.""" -from .field_codec import FieldCodec +from .field_codec import DisclosedRecord, FieldCodec -class DeclaredReply: +class DeclaredReply(DisclosedRecord): """A declared reply record; the one sender that owns its transport encodes it.""" __slots__ = () diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index fcbdd926e4..b2e9a429ca 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -2,6 +2,8 @@ from __future__ import annotations +from agent_comms.field_codec import WireExclude + from .input_key import InputKeyText from .agent_inbox import TakenInputStart @@ -142,7 +144,7 @@ class OwnGoalPublication(ToolResult): __slots__ = () - @projected(view="wire", name="agentCommsKind") + @projected(view=WireExclude, name="agentCommsKind") def publication_kind(self) -> object: return FieldCodec.encode(PublishedGoalToolDetails) diff --git a/src/agent_comms/transcript_events.py b/src/agent_comms/transcript_events.py index a38b3c2a8b..6cfc031b51 100644 --- a/src/agent_comms/transcript_events.py +++ b/src/agent_comms/transcript_events.py @@ -215,11 +215,11 @@ class TextTranscript(TranscriptEvent): def activity_items(self) -> tuple[TranscriptEvent, ...]: return (self,) - @projected(view="activity", name="text") + @projected(view=ActivityExclude, name="text") def activity_text(self) -> str: return self.text[:TurnDigest.activity_excerpt_characters] - @projected(view="activity", name="text_characters") + @projected(view=ActivityExclude, name="text_characters") def activity_text_characters(self) -> int: return len(self.text) @@ -296,7 +296,7 @@ def category(self) -> type[MessageCategory]: def __post_init__(self) -> None: self.message.require_envelope() - @projected(view="activity") + @projected(view=ActivityExclude) def position(self) -> int: return self.message.seq @@ -304,11 +304,11 @@ def position(self) -> int: def text(self): return self.message.body - @projected(view="activity") + @projected(view=ActivityExclude) def timestamp(self): return self.message.timestamp - @projected(view="activity") + @projected(view=ActivityExclude) def source(self): return self.message.reference @@ -605,7 +605,7 @@ class TurnDigest: activity_text_limit: ClassVar[int] = 4 activity_call_limit: ClassVar[int] = 6 - @projected(view="activity", name="events") + @projected(view=ActivityExclude, name="events") def activity_events(self) -> tuple[TranscriptEvent, ...]: """Keep the request and the newest conversation text; omit raw tool results.""" items = tuple(item for event in self.events for item in event.activity_items()) @@ -613,7 +613,7 @@ def activity_events(self) -> tuple[TranscriptEvent, ...]: return items return items[:1] + items[-(self.activity_text_limit - 1):] - @projected(view="activity", name="calls_started") + @projected(view=ActivityExclude, name="calls_started") def activity_calls(self) -> tuple[OrderCall, ...]: """Use the existing call owner for digests and final recorded outcomes.""" calls: dict[ToolCallId, OrderCall] = {} @@ -622,7 +622,7 @@ def activity_calls(self) -> tuple[OrderCall, ...]: return tuple(replace(call, digest=call.digest[:self.activity_excerpt_characters]) for call in tuple(calls.values())[-self.activity_call_limit:]) - @projected(view="activity", name="original_event_count") + @projected(view=ActivityExclude, name="original_event_count") def activity_event_count(self) -> int: return len(self.events) diff --git a/src/agent_comms/transcripts.py b/src/agent_comms/transcripts.py index 41d6b6b2bd..d5222be4ce 100644 --- a/src/agent_comms/transcripts.py +++ b/src/agent_comms/transcripts.py @@ -289,7 +289,7 @@ def __post_init__(self) -> None: def transcript_root(self) -> Path: return Path(self.root) - @projected(view="content", name="thread") + @projected(view=ContentExclude, name="thread") def content_thread(self) -> ThreadIncarnation: return self.thread.incarnation @@ -320,7 +320,7 @@ def require_binding(self, owner: Transcripts, name: str) -> None: if self.thread.incarnation.resolved(registry) != requested.incarnation: raise StaleRevision("Transcript witness belongs to another original thread") - @projected(view="content", name="bus") + @projected(view=ContentExclude, name="bus") def content_bus(self) -> RecordedBusIdentity: # Appends beyond this page's assigned messages do not change its content. # Replacing the bus file does. @@ -343,7 +343,7 @@ def same_content(self, other: TranscriptReadIdentity) -> bool: Records appended beyond this page belong to a later update. """ return self.contains_records_cut(other) and ( - FieldCodec.project(self, "content") == FieldCodec.project(other, "content") + FieldCodec.project(self, ContentExclude) == FieldCodec.project(other, ContentExclude) ) def same_snapshot(self, other: TranscriptReadIdentity) -> bool: @@ -360,7 +360,7 @@ def require_draft_snapshot(self, current: TranscriptReadIdentity) -> None: class LiveTranscriptReadIdentity(TranscriptReadIdentity): thread: Annotated[Thread, ContentExclude()] - @projected(view="content", name="thread") + @projected(view=ContentExclude, name="thread") def content_thread(self) -> tuple[ThreadIncarnation, ThreadParent, ThreadText]: return self.thread.incarnation, self.thread.parent, self.thread.task @@ -394,7 +394,7 @@ def transcript_root(self) -> Path: def historical_source(self) -> str: return self.source.key - @projected(view="content", name="source") + @projected(view=ContentExclude, name="source") def content_source(self) -> tuple[str, str, RecordedBusIdentity, RecordedBusIdentity, int, FileObservation]: return (self.source.key, self.source.original_root, self.source.bus_identity, self.source.snapshot_bus_identity, self.source.snapshot_bus_revision, diff --git a/src/agent_comms/turn_digests.py b/src/agent_comms/turn_digests.py index c7311671db..a8e6769954 100644 --- a/src/agent_comms/turn_digests.py +++ b/src/agent_comms/turn_digests.py @@ -2,6 +2,8 @@ from __future__ import annotations +from agent_comms.field_codec import ActivityExclude + from abc import abstractmethod from collections.abc import Iterable, Iterator, Mapping from dataclasses import dataclass, field, replace @@ -15,7 +17,7 @@ from .declared_family import DeclaredFamily from .errors import ToolRefusedError from .external_transcript import ExternalTranscript, StoreRead -from .field_codec import FieldCodec, WireTransient, projected +from .field_codec import FieldCodec, WireExclude, WireTransient, projected from .held_orders import HeldOrders, LiveHold from .historical_native_inputs import HistoricalNativeAnswers from .message_reference import MessageReference @@ -55,7 +57,7 @@ class ActivityTurnView(TurnView): def project(cls, answer: ThreadTurns) -> JsonValue: selected = answer.turns[:cls.turn_limit] page = replace(answer, turns=selected, older=answer.older.older(selected)) - return FieldCodec.project(page, "activity") + return FieldCodec.project(page, ActivityExclude) class OriginalTurnView(TurnView): @@ -83,7 +85,7 @@ class UnavailableOrderEdits(ToolResult): owner: ThreadIncarnation cause: Annotated[WorkBranchError | OSError, WireTransient()] - @projected(view='wire') + @projected(view=WireExclude) def error(self) -> str: return str(self.cause) @@ -93,7 +95,7 @@ class UnjudgedEdits(ServiceReading): edits: tuple[EditCall, ...] unavailable: tuple[UnavailableOrderEdits, ...] = () - @projected(view='wire') + @projected(view=WireExclude) def complete(self) -> bool: return not self.unavailable diff --git a/src/agent_comms/ui_model/unit_state.py b/src/agent_comms/ui_model/unit_state.py index 02ad15f23f..6b3e8d3189 100644 --- a/src/agent_comms/ui_model/unit_state.py +++ b/src/agent_comms/ui_model/unit_state.py @@ -9,7 +9,7 @@ import time from abc import abstractmethod -from collections.abc import Iterator, Sequence +from collections.abc import Iterable, Iterator, Sequence from dataclasses import dataclass, field from functools import cached_property from subprocess import TimeoutExpired @@ -17,7 +17,9 @@ from agent_comms.comms import Comms from agent_comms.declared_family import DeclaredFamily +from agent_comms.disclosure_text import AccentTone, SuccessTone, ViewHeading, WarningTone from agent_comms.field_codec import ( + DisclosedRecord, MalformedRecord, ToolCallId, ) @@ -26,6 +28,7 @@ from agent_comms.messages import Message from agent_comms.native_failures import NativePiUnavailable from agent_comms.repo import GitCommandError +from agent_comms.recorded_time import RecordedEventTime from agent_comms.standing_rules import FamilyScan, UndatedStandingRule from agent_comms.thread_identity import ThreadIncarnation from agent_comms.thread_status import ThreadStatus @@ -34,7 +37,6 @@ MergedProgress, NoCommitProgress, OrderProgress, - ProgressRead, ProgressSource, Todo, ) @@ -46,7 +48,7 @@ from agent_comms.heartbeat_frame import HeartbeatFrame -class UnitClock(DeclaredFamily, affix="Clock"): +class UnitClock(DisclosedRecord, DeclaredFamily, affix="Clock"): """When a holder last acted and last committed on its pending orders.""" @abstractmethod @@ -86,12 +88,17 @@ class UnitObservation: comms: Comms thread: Thread todos: tuple[Todo, ...] - pulls: ProgressRead + frame: HeartbeatFrame questions: tuple[Message, ...] now: float presence: ThreadStatus working: bool + @cached_property + def review_timers(self) -> tuple[tuple[Todo, RecordedEventTime], ...]: + return tuple((todo, at) for todo, at in self.frame.review_timers + if todo.held().owner_identity == self.thread.incarnation) + @cached_property def goal(self) -> GoalSnapshot: return self.comms.goals.goal_snapshot(self.thread.name) @@ -152,19 +159,19 @@ def clock(self) -> UnitClock: @cached_property def sources(self) -> tuple[ProgressSource, ...]: return tuple( - ProgressSource.of(todo, self.comms.registry, self.pulls) for todo in self.todos + ProgressSource.of(todo, self.comms.registry, self.frame.pulls) for todo in self.todos ) def require_read(self) -> None: - for progress in self.pulls.progress(self.todos, self.comms.registry).values(): + for progress in self.frame.pulls.progress(self.todos, self.comms.registry).values(): progress.require_read() for todo in self.todos: - reader = self.pulls.checkout(todo.order.checked().check.directory).branches - for pull in self.pulls.open_for(todo): + reader = self.frame.pulls.checkout(todo.order.checked().check.directory).branches + for pull in self.frame.pulls.open_for(todo): pull.require_comparison(reader) if self.todos: for path in self.comms.registry.storage_rule.checkout_paths(self.thread): - self.pulls.checkout(path).require_read() + self.frame.pulls.checkout(path).require_read() for source in self.sources: source.checkout.require_read() @@ -178,7 +185,58 @@ def merges(self) -> dict[str, OrderProgress]: for origin in self.sources} -class NeedsCommanderAttention: +class ExpandedHudGroup: + """Expanded sections consume their tables; collapsed sections do not prepare them.""" + + @classmethod + def table_lines(cls, tables: Iterable[tuple[str, ...]]) -> tuple[str, ...]: + return tuple(line for table in tables for line in table) + + +class HudGroup(ViewHeading, affix="Group"): + """The declared order and headings shared by heartbeat and native table views.""" + + @classmethod + def table_lines(cls, tables: Iterable[tuple[str, ...]]) -> tuple[str, ...]: + return () + + @classmethod + def needs_hand(cls) -> bool: + return False + +class UrgentGroup(ExpandedHudGroup, HudGroup): + tone = WarningTone + + @classmethod + def needs_hand(cls) -> bool: + return True + + +class InProgressGroup(ExpandedHudGroup, HudGroup): + tone = SuccessTone + + +class ReadyToTestGroup(ExpandedHudGroup, HudGroup): + tone = AccentTone + + +class IdleGroup(HudGroup): + pass + + +class ReadyForApplication: + """Delivered and installed states share their declared presentation capability.""" + + hud_group: ClassVar[type[HudGroup]] = ReadyToTestGroup + + +class UrgentPresentation: + """A row can be urgent even when another rule already owns its request.""" + + hud_group: ClassVar[type[HudGroup]] = UrgentGroup + + +class NeedsCommanderAttention(UrgentPresentation): """Urgent siblings share their ping behavior without claiming each other's state.""" @classmethod @@ -193,6 +251,7 @@ class UnitState(UndatedStandingRule, FamilyScan, declared_name=None): presence: ThreadStatus source: UnitObservation = field(compare=False, repr=False) action: ClassVar[str] + hud_group: ClassVar[type[HudGroup]] = IdleGroup # What the owner would ask on reading this card; the action answers it. question: ClassVar[str] @@ -230,13 +289,12 @@ def observations( ) -> tuple[UnitObservation, ...]: snapshot, now = comms.registry.snapshot(), time.time() held = frame.holders - requests = frame.pulls readings = tuple( UnitObservation( comms, thread, tuple(held.get(thread.incarnation, ())), - requests, + frame, tuple( message for rule in frame.answers @@ -461,7 +519,18 @@ def matches(cls, source: UnitObservation) -> bool: ) -class DeliveredUnclosed(NeedsCommanderAttention, ConditionalState, UnitState): +class ReviewDue(UrgentPresentation, ConditionalState, UnitState): + """The frame's review rounds require the commander even while the unit keeps working.""" + + question = "its review timer is up. what did it actually do?" + action = "read its diff and judge the recorded work" + + @classmethod + def matches(cls, source: UnitObservation) -> bool: + return bool(source.review_timers) + + +class DeliveredUnclosed(ReadyForApplication, NeedsCommanderAttention, ConditionalState, UnitState): question = "delivered and still open. why isn't it closed?" action = "install the merge or close with the working application evidence" @@ -475,7 +544,7 @@ def matches(cls, source: UnitObservation) -> bool: ) -class AwaitingApplicationRun(NeedsCommanderAttention, UnitState): +class AwaitingApplicationRun(ReadyForApplication, NeedsCommanderAttention, UnitState): """Installed merged orders wait on their holder's application run, so no commander verb disposes of the line and it locks none. The line names only those orders; while the unit still writes source for another, that order's state is the unit's.""" @@ -495,6 +564,7 @@ def derive(cls, source: UnitObservation) -> Iterator[Self]: class Moving(ConditionalState, UnitState): + hud_group = InProgressGroup question = "it's moving. toward the order, or somewhere else?" action = "let it continue" diff --git a/src/agent_comms/work_branches.py b/src/agent_comms/work_branches.py index 7500b883e9..0497eba8c9 100644 --- a/src/agent_comms/work_branches.py +++ b/src/agent_comms/work_branches.py @@ -8,6 +8,8 @@ from __future__ import annotations +from agent_comms.field_codec import WireExclude + import subprocess from abc import abstractmethod from collections.abc import Mapping @@ -112,7 +114,7 @@ class WorkBranch: links: tuple[BranchLink, ...] checkouts: tuple[BranchCheckout, ...] - @projected(view="wire") + @projected(view=WireExclude) def linked(self) -> bool: return bool(self.links) @@ -676,7 +678,8 @@ def _checkout(snapshot: RegistrySnapshot, worktree: str) -> BranchCheckout: sorted( thread.name for thread in snapshot.threads.values() - if str(thread.worktree.path.expanduser().resolve()) == resolved + if not snapshot.statuses[thread.name].archived + and str(thread.worktree.path.expanduser().resolve()) == resolved ) ) if not threads: diff --git a/tests/commander_heartbeat_fixture.py b/tests/commander_heartbeat_fixture.py index 95dbb373bb..e6a72409da 100644 --- a/tests/commander_heartbeat_fixture.py +++ b/tests/commander_heartbeat_fixture.py @@ -5,6 +5,11 @@ from agent_comms.comms import Comms from agent_comms.commander_heartbeat import CommanderHeartbeat, CommanderObservation +from agent_comms.commander_hud import CodexUsage, CommanderHud +from agent_comms.commander_service import CommanderService +from agent_comms.heartbeat_subjects import CommanderHistory +from agent_comms.task_attachments.commander_pings import CommanderDelivery, CommanderNotice, CommanderUpdate +from agent_comms.ui_model.unit_state import UnitState from agent_comms.heartbeat_frame import CommanderInputs, HeartbeatFrame from agent_comms.phase_evidence import PlanReadings from agent_comms.threads import Thread @@ -18,6 +23,18 @@ def capture_frame(comms: Comms, commander: str, pulls: ProgressRead, return HeartbeatFrame.capture(comms, commander, inputs, plans) +def current_hud(comms: Comms, commander: str, frame: HeartbeatFrame, + states: tuple[UnitState, ...]) -> CommanderHud: + """Read explicit retained inputs for direct controls; production derives its delivery.""" + usage = CodexUsage.read() + owner = comms.registry.require(commander).incarnation + previous = CommanderUpdate.latest(comms, owner) + packet = next(iter(previous), CommanderNotice(owner, ())) + history = CommanderHistory.read(comms, CommanderService.actors(comms.registry.snapshot())) + return CommanderHud.of(comms, commander, frame, states, + delivery=CommanderDelivery(packet, packet.open_pings(history)), usage=usage) + + def observe_commander(comms: Comms, thread: Thread, pulls: ProgressRead, plans: PlanReadings = PlanReadings()) -> CommanderObservation: inputs = CommanderInputs.prepare(comms, thread.name, pulls, plans) diff --git a/tests/test_commander_hud_projection.py b/tests/test_commander_hud_projection.py new file mode 100644 index 0000000000..7183248ef8 --- /dev/null +++ b/tests/test_commander_hud_projection.py @@ -0,0 +1,141 @@ +"""Root-owned controls for the shared heartbeat and native HUD presentation.""" + +from agent_comms.field_codec import HudExclude + +from dataclasses import replace + +from agent_comms.commander.vibe_check import OverloadDegradation +from agent_comms.commander_hud import CommanderHud, CommanderOrder, CommanderUnit, CodexUsage, HudRecord, UnreadableUsage +from agent_comms.disclosure_text import ( + DisclosureText, EntityKey, FieldKey, IdentifiedRecordText, MappingText, SequenceText, ViewTable, +) +from agent_comms.field_codec import DisclosureIdentity, FieldCodec, OrderId +from agent_comms.recorded_time import RecordedEventTime +from agent_comms.standing_rules import ServicePassFailing +from agent_comms.task_attachments.commander_pings import CommanderDecisions, RulePing +from agent_comms.thread_identity import RecordedThreadIncarnation +from agent_comms.todos import OpenPrProgress +from agent_comms.ui_model.unit_state import ( + AwaitingApplicationRun, Idle, IdleGroup, InProgressGroup, Moving, NoPendingClock, + PendingClock, ReadyToTestGroup, Stuck, UrgentGroup, +) + + +def example_hud() -> CommanderHud: + """Controlled source example; no provider call or claim about the installed fleet.""" + order = CommanderOrder( + "P930", "Make the screen readable", OrderId("123456789abc"), + "pending", OpenPrProgress(3589, "8636a6f28", at=1.0), (), 0, 0, + ) + worker = CommanderUnit( + RecordedThreadIncarnation("screen", 2.0), Moving, NoPendingClock(), + (order,), 0, (), (), + ) + commander = RecordedThreadIncarnation("lead", 1.0) + old = replace(RulePing.capture(ServicePassFailing(watch="source", since=900, error="read refused")), + since=RecordedEventTime(900)) + new = replace(RulePing.capture(ServicePassFailing(watch="review", since=1000, error="head needs review")), + since=RecordedEventTime(1000)) + return CommanderHud( + commander=commander, units=( + worker, + replace(worker, unit=RecordedThreadIncarnation("blocked", 3.0), state=Stuck), + replace(worker, unit=RecordedThreadIncarnation("run-next", 4.0), state=AwaitingApplicationRun), + replace(worker, unit=RecordedThreadIncarnation("idle", 5.0), state=Idle, orders=()), + ), phases=(), load=OverloadDegradation(3, 1, 2), forks=(), + usage=CodexUsage(UnreadableUsage(reason="not requested in source example")), + packets=(CommanderDecisions(commander, (old, new), (new,), (old,)),), + heartbeat=RecordedEventTime(990), open_pings=(), + ) + + +def test_commander_hud_projection_has_one_progress_answer() -> None: + unit = example_hud().units[0] + assert tuple(FieldCodec.projections(CommanderUnit, HudExclude)) == ("work", "change", "next") + columns = FieldCodec.view_columns(CommanderUnit, HudExclude) + assert tuple(column.name for column in columns) == ("unit", "state", "work", "change", "next") + assert tuple(plan.name for plan in FieldCodec.field_plans(type(unit.unit)) + if DisclosureIdentity.read(plan.annotation, False)) == ("name",) + second = replace(unit.orders[0], problem="P931", order=OrderId("abcdef123456")) + table = ViewTable(CommanderUnit, HudExclude, (replace(unit, orders=(*unit.orders, second)),)) + assert "\n" in table.texts[0][2] + + +def test_shared_hud_groups_strip_full_alarms_and_plain_text() -> None: + hud = example_hud() + groups = hud.groups + assert tuple(section.group for section in groups) == ( + UrgentGroup, InProgressGroup, ReadyToTestGroup, IdleGroup, + ) + assert tuple(unit.unit.name for section in groups for unit in section.records) == ( + "blocked", "screen", "run-next", "idle", + ) + plain = hud.hud_text(1000) + status = tuple(hud.status_strip(1000).splitlines()) + assert plain[:len(status)] == status + assert "\x1b" not in "\n".join(plain) + headings = [section.heading() for section in groups] + assert [plain.index(heading) for heading in headings] == sorted(plain.index(heading) for heading in headings) + assert "CommanderUnit" not in "\n".join(plain) + assert "\x1b[" in "\n".join(hud.hud_text(1000, colour=True)) + assert sum(len(section.pings) for section in hud.alarms) == len(hud.packets[0].handled) + assert all(ping.text in "\n".join(hud.alarm_text(hud.alarms, 1000)) + for ping in hud.packets[0].handled) + assert hud.board == () + details = hud.alarm_detail(hud.alarms, 1000) + assert all(section.heading() not in details for section in hud.alarms) + assert all(ping.text in "\n".join(details) for ping in hud.packets[0].handled) + for section in groups: + for table in section.tables: + layout = table.layout(60) + narrow = "\n".join(layout.render_lines(layout.widths(60))) + assert all(unit.unit.name in narrow for unit in table.records) + assert tuple(unit.unit for unit in groups[0].records) == (hud.units[1].unit,) + + +def test_display_facts_ignore_only_hidden_clocks_and_paint_time() -> None: + hud = example_hud() + unit = replace(hud.units[0], clock=PendingClock(1, 2)) + hud = replace(hud, units=(unit, *hud.units[1:])) + later = replace(hud, units=(replace(unit, clock=PendingClock(1, 3)), *hud.units[1:]), + heartbeat=RecordedEventTime(999)) + assert hud.display_facts == later.display_facts + assert hud.status_table(1000).facts == hud.status_table(1060).facts + assert hud.display_facts != replace(hud, usage=CodexUsage( + UnreadableUsage(reason="a different visible usage observation"))).display_facts + assert hud.display_facts != replace(hud, units=(replace(unit, state=Stuck), *hud.units[1:])).display_facts + + +def test_disclosure_reuses_original_children_and_declared_identity() -> None: + unit = example_hud().units[0] + text = DisclosureText.describe(unit) + assert isinstance(text, IdentifiedRecordText) + assert text.summary == unit.unit.name + assert text.children is text.children + original_unit = next(child for child in text.children if child.key == FieldKey(CommanderUnit, "unit")) + assert original_unit.value is text.identity_values[0] + assert text.sequence_key is text.sequence_key + assert text.sequence_key == EntityKey(CommanderUnit, (EntityKey(type(unit.unit), (unit.unit.name,)),)) + # Identity-only text still discloses every original field, including hidden HUD columns. + assert tuple(child.key for child in text.children) == tuple( + FieldKey(CommanderUnit, plan.name) for plan in FieldCodec.field_plans(CommanderUnit) if plan.records) + assert next(child for child in text.children if child.key == FieldKey(CommanderUnit, "strikes")).value.value == unit.strikes + + mapping = DisclosureText.describe({"units": (unit,)}) + assert isinstance(mapping, MappingText) + assert mapping.children is mapping.children + sequence = mapping.children[0].value + assert isinstance(sequence, SequenceText) + assert sequence.children is sequence.children + assert sequence.children[0].key == text.sequence_key + assert sequence.children[0].value.value is unit + assert sequence.summary == "1 item" + table = ViewTable(CommanderUnit, HudExclude, (unit,)) + assert table.entities is table.entities + assert table.entities[text.sequence_key] is unit + + +def test_every_native_record_producer_declares_entity_identity() -> None: + for declaration in HudRecord.__subclasses__(): + assert any(DisclosureIdentity.read(plan.annotation, False) + for plan in FieldCodec.field_plans(declaration)), declaration diff --git a/tests/test_commander_notifications.py b/tests/test_commander_notifications.py index cbabc5067a..dfed0a93c2 100644 --- a/tests/test_commander_notifications.py +++ b/tests/test_commander_notifications.py @@ -4,6 +4,7 @@ import json import os import pickle +import sqlite3 import time from contextlib import nullcontext from dataclasses import dataclass, replace @@ -18,9 +19,9 @@ from agent_comms.thread_management import GivenTags from agent_comms.commander_heartbeat import CommanderHeartbeat, CommanderObservation from agent_comms.comms import Comms -from commander_heartbeat_fixture import ObservedHeartbeat, capture_frame +from commander_heartbeat_fixture import ObservedHeartbeat, capture_frame, current_hud from agent_comms.commander_hud import ( - BoardRow, CommanderFrame, CommanderHud, CommanderUnit, CodexUsage, QuietUnits, UnreadableUsage, + CommanderFrame, CommanderHud, CodexUsage, FinishedPlansGroup, UnreadableUsage, ) from agent_comms.commander.vibe_check import OverloadDegradation from agent_comms.task_attachments.commander_pings import CommanderDecisions, CommanderNotice, CommanderUpdate, RulePing @@ -29,17 +30,16 @@ from agent_comms.message_page import MessagePageRequest from agent_comms.read_basis import DMDisplayScope from agent_comms.service_passes import ServicePassStore +from agent_comms.recorded_time import RecordedEventTime from agent_comms.todos import ProgressRead, UnitOccupied from agent_comms.ui_model import observation from agent_comms.ui_model.observation import CommanderHudObserved, ObservationService, ObserveCommanderHud from agent_comms.ui_model.observation import ReadCommanderUnit, ReadRefused from agent_comms.cli_bridges.codex_connection import CodexConnection -from agent_comms.standing_rules import ServicePassFailing, StandingRule, UndatedStandingRule +from agent_comms.standing_rules import ReviewTimerUp, ServicePassFailing, StandingRule, UndatedStandingRule from agent_comms.ui_model.unit_state import ( - AwaitingApplicationRun, NoPendingClock, PendingClock, Spinning, Stuck, UnitState, + Spinning, UnitState, UrgentGroup, ) -from agent_comms.thread_identity import RecordedThreadIncarnation -from agent_comms_hooks.board import BoardEntry from agent_comms_hooks.watch import RecordedWatch, WatchClaim, WatchRecord from agent_comms.wake_policy import PassiveWake from agent_comms.bus_publication import AgentOrigin, HumanOrigin @@ -86,15 +86,21 @@ def owner(comms): return comms.messaging.user_identity(Checkout(comms.root)) -def notifications(comms): +def notifications(comms, commander="lead"): page = MessagePageRequest(DMDisplayScope.capture( - owner(comms).name, "lead", comms.registry.snapshot(), + owner(comms).name, commander, comms.registry.snapshot(), )).read_opened(comms.bus.bus) return tuple(message for message in page.messages if isinstance(message.task, CommanderUpdate)) def lead_hud(comms, states=()): - return CommanderHud.of(comms, "lead", capture_frame(comms, "lead", ProgressRead()), states) + return current_hud(comms, "lead", capture_frame(comms, "lead", ProgressRead()), states) + + +def assert_status_fields(message, hud): + facts = hud.status_table(time.time()).facts + assert all(f"{label}: {value}" in message.body + for row in facts.rows for label, value in zip(facts.columns, row)) def beat(comms, rules=(), *, states=()): @@ -215,7 +221,8 @@ def enforce(self, _comms, _commander): ServicePassStore.at(comms.root).passed(BrokenHeartbeat.__name__) beat(comms, rules, states=state_cards(comms, Spinning)) _, _, recovered = notifications(comms) - assert isinstance(recovered.task, CommanderNotice) + assert isinstance(recovered.task, CommanderDecisions) + assert recovered.task.decisions == () assert aged(recovered.task.handled) == aged(board.task.handled) # A new occurrence after the pass recovered is new, once. @@ -258,6 +265,47 @@ def everything_to_lead(comms): return {message.reference for message in page.messages} +def test_hidden_tool_time_does_not_append_quiet_bus_history(notification_bus): + comms = notification_bus + recorded_turn(comms, (False,)) + hud = beat(comms, states=state_cards(comms, UnitState)) + first, = notifications(comms) + assert isinstance(first.task, CommanderNotice) + for _ in range(2): + recorded_turn(comms, (False,)) + later = beat(comms, states=state_cards(comms, UnitState)) + assert tuple(card.clock for card in hud.units) != tuple(card.clock for card in later.units) + assert hud.display_facts == later.display_facts + assert tuple(message.reference for message in notifications(comms)) == (first.reference,) + + +def test_unchanged_open_alarm_wakes_each_beat_until_a_recorded_action(notification_bus): + from test_holder_standing_rules import publish + + comms = notification_bus + todo = comms.goals.todos.get("P1") + rule = ReviewTimerUp(problem=todo.id, holder=todo.held().owner_identity, + order=todo.order.checked().id, since=RecordedEventTime(time.time())) + beat(comms, (rule,)) + first, = notifications(comms) + beat(comms, (rule,)) + _, second = notifications(comms) + assert isinstance(second.task, CommanderDecisions) + assert second.task.decisions == () + assert second.task.handled[0].since == first.task.handled[0].since + hud = CommanderFrame.current(comms, "lead") + assert hud.board == second.task.board(comms) + assert len(hud.board) == 1 + + publish(comms, "lead", "worker", time.time()) + beat(comms, (rule,)) + _, _, cleared = notifications(comms) + assert isinstance(cleared.task, CommanderNotice) + assert CommanderFrame.current(comms, "lead").board == () + beat(comms, (rule,)) + assert len(notifications(comms)) == 3 + + def test_an_independent_heartbeat_message_never_replaces_the_board(notification_bus): comms = notification_bus failing = ServicePassFailing(watch="following-rule", since=time.time(), error="independent source") @@ -269,9 +317,11 @@ def test_an_independent_heartbeat_message_never_replaces_the_board(notification_ assert CommanderUpdate.current_board(comms, "lead") == board.task.board(comms) assert lead_hud(comms).board == board.task.board(comms) assert raw.reference in everything_to_lead(comms) - # The next pass derives from the typed board, so nothing is restated as new. + # The next pass clears NEW without promoting a subjectless observation to STILL OPEN. beat(comms, (failing,)) - assert len(notifications(comms)) == 1 + _, repeated = notifications(comms) + assert isinstance(repeated.task, CommanderNotice) + assert repeated.task.handled == board.task.handled def test_a_later_notice_is_the_baseline_and_keeps_the_lines_still_on_it(notification_bus): @@ -375,47 +425,52 @@ def test_frame_puts_hands_first_and_folds_finished_phases_per_plan(notification_ CommanderPhase(RecordPlanePhase, False, True, ()), CommanderPhase(ReviewQueuePhase, True, False, (worker,)), ), load=OverloadDegradation.from_orders((), 0, 0), forks=(worker,), - usage=CodexUsage(UnreadableUsage(reason='not requested'))) - assert hud.frame == ( - *hud.header, - "commander_native/record_plane done=False ready=True holders=", - "commander_native/review_queue done=True ready=False holders=worker", - "commander_native/design_reconciled done=False ready=False holders=worker", - "usage unreadable: not requested", - "forks: worker", - "commander_native: 2 phases done", - ) + usage=CodexUsage(UnreadableUsage(reason='not requested')), open_pings=()) + urgent = next(section for section in hud.groups if section.group is UrgentGroup) + assert tuple(row.phase for row in urgent.records) == (RecordPlanePhase, ReviewQueuePhase) + assert urgent.tables[0].record is type(hud.phases[0]) + folded, = next(section.records for section in hud.groups + if section.group is FinishedPlansGroup) + assert folded.phases == (RetainedStorePhase, OneOrderOwnerPhase) + assert folded.done == 2 + rendered = "\n".join(hud.hud_text(time.time())) + assert all(phase.declared_name in rendered for phase in (RecordPlanePhase, ReviewQueuePhase)) + + +def test_frame_keeps_original_ping_order_and_folded_idle_records(tmp_path): + from agent_comms.field_codec import OrderId + from agent_comms.thread_identity import RecordedThreadIncarnation + from agent_comms.ui_model.unit_state import Idle, IdleGroup, NoPendingClock, PendingClock, Stuck - -def test_frame_keeps_committed_board_order_and_folds_units_needing_no_hand(tmp_path): now = time.time() - awaiting = CommanderUnit( - RecordedThreadIncarnation("waiting-run", 1.0), AwaitingApplicationRun, - NoPendingClock(), (), 0, (), (), - ) - stuck = replace( - awaiting, unit=RecordedThreadIncarnation("stuck-worker", 2.0), - state=Stuck, clock=PendingClock(now - 3600, now - 3600), - ) - board = ( - BoardEntry("blocked", "BLOCKED waiting-run", now - 120, now + 3600), - BoardEntry("judge", "JUDGE #1", now - 240, now + 3600), + idle = CommanderUnit( + RecordedThreadIncarnation("idle-worker", 1.0), Idle, NoPendingClock(), (), 0, (), (), ) + stuck = replace(idle, unit=RecordedThreadIncarnation("stuck-worker", 2.0), + state=Stuck, clock=PendingClock(now - 3600, now - 3600)) + pings = tuple(replace(RulePing.capture(ReviewTimerUp( + problem=problem, holder=unit.unit, order=OrderId(order), + since=RecordedEventTime(now - age))), since=RecordedEventTime(now - age)) + for problem, unit, order, age in ( + ("P1", idle, "123456789abc", 120), ("P2", stuck, "abcdef123456", 240))) + owner = RecordedThreadIncarnation("lead", 3.0) + packet = CommanderDecisions(owner, pings, pings, ()) hud = CommanderHud( - commander=RecordedThreadIncarnation("lead", 3.0), units=(awaiting, stuck), phases=(), + commander=owner, units=(idle, stuck), phases=(), load=OverloadDegradation.from_orders((), 0, 0), forks=(), - usage=CodexUsage(UnreadableUsage(reason="not requested")), board=board, + usage=CodexUsage(UnreadableUsage(reason="not requested")), + packets=(packet,), open_pings=pings, ) - CommanderFrame.replace_all(tmp_path, (hud,)) restored, = CommanderFrame.read_all(tmp_path) assert restored == hud - rows = restored.rows() - assert rows[:4] == ( - BoardRow(board[0]), BoardRow(board[1]), stuck, QuietUnits((awaiting,)), - ) - assert awaiting.unit.name in rows[3].line() - assert all("AwaitingApplicationRun" not in line for line in restored.frame) + assert restored.open_pings == pings + assert restored.board == tuple(ping.board_entry() for ping in pings) + urgent, = (section for section in restored.groups if section.group is UrgentGroup) + quiet, = (section for section in restored.groups if section.group is IdleGroup) + assert urgent.records == (stuck,) + assert quiet.records == (idle,) + assert idle.unit.name in "\n".join(quiet.tables[0].lines()) def test_a_registered_commander_fork_is_on_the_hud(notification_bus): @@ -437,9 +492,9 @@ def test_quiet_completion_is_per_commander_even_when_a_later_recipient_fails(not completed = ServicePassStore.at(comms.root).read().commander_completed assert set(completed) == {lead.incarnation} assert missing.incarnation not in completed - # A quiet pass, its line unchanged, still completes after its original notice was committed. + # A quiet pass still completes and keeps the original alarm visibly open. beat(comms, (failing,)) - assert len(notifications(comms)) == 1 + assert len(notifications(comms)) == 2 # A pass that misses a still-registered commander retires nothing but its frame. asyncio.run(ObservedHeartbeat(()).run(lambda: nullcontext(comms))) assert CommanderFrame.read_all(comms.root) == () @@ -451,25 +506,31 @@ def test_quiet_completion_is_per_commander_even_when_a_later_recipient_fails(not assert not ServicePassStore.at(comms.root).read().commander_completed -def test_recreated_watcher_reads_original_committed_unit_observation(notification_bus): - """Quiet unit cards are not heartbeat lines; a fresh watcher reads the committed packet - and restates nothing it already carries.""" +def test_recreated_watcher_retains_non_disposable_observation_without_nagging(notification_bus): + """The original observation keeps its age, but is not an actionable open board line.""" comms = notification_bus states = state_cards(comms, UnitState) assert states and not any(state.attention() for state in states) failing = ServicePassFailing(watch="following-rule", since=time.time(), error="independent source") beat(comms, (failing,), states=states) first, = notifications(comms) - assert failing.line() in first.body + assert type(failing).declared_name in first.body assert not any(state.line() in first.body for state in states) hud = lead_hud(comms, states) - assert first.body.startswith(CommanderUpdate.header) - assert all(line in first.body for line in hud.brief()) - assert all(card.unit.name in first.body for card in hud.units) + assert_status_fields(first, hud) + assert all(section.heading() in first.body for section in hud.groups) assert CommanderUpdate.latest(comms, comms.registry.require("lead").incarnation) == (first.task,) beat(comms, (failing,), states=state_cards(comms, UnitState)) - unchanged, = notifications(comms) - assert unchanged.reference == first.reference + _, unchanged = notifications(comms) + assert unchanged.reference != first.reference + assert isinstance(unchanged.task, CommanderNotice) + assert aged(unchanged.task.handled) == aged(first.task.handled) + assert lead_hud(comms).board == () + from agent_comms.task_attachments.commander_pings import WatchingAlarms + + section, = CommanderFrame.current(comms, "lead").alarms + assert section.group is WatchingAlarms + assert tuple(ping.text for ping in section.pings) == (failing.line(),) def test_occupied_unit_refusal_is_published_and_later_rule_still_runs(notification_bus): @@ -501,7 +562,7 @@ def enforce(self, _comms, _commander): ServicePassStore.at(comms.root).read().commander_completed) -def test_rule_packets_list_every_new_line_and_never_restate_one(notification_bus): +def test_rule_packets_compact_new_lines_keep_originals_then_repeat_without_a_new_wake(notification_bus): comms = notification_bus rules = tuple(ServicePassFailing(watch=f"watch-{index}", since=1.0, error="failed source read") for index in range(7)) @@ -510,13 +571,21 @@ def test_rule_packets_list_every_new_line_and_never_restate_one(notification_bus assert isinstance(first.task, CommanderDecisions) assert set(first.task.decisions) == set(first.task.handled) == { RulePing.capture(rule) for rule in rules} - assert all(rule.line() in first.body for rule in rules) - assert all(line in first.body for line in lead_hud(comms).brief()) + assert sum(len(section.pings) for section in lead_hud(comms).alarms) == len(rules) + assert rules[0].line() in first.body + details = "\n".join(lead_hud(comms).alarm_detail(lead_hud(comms).alarms, time.time())) + assert all(rule.line() in details for rule in rules) + assert_status_fields(first, lead_hud(comms)) beat(comms, rules) - assert len(notifications(comms)) == 1 - # Changed diagnostic wording is not another failed occurrence. + _, unchanged = notifications(comms) + assert isinstance(unchanged.task, CommanderNotice) + assert CommanderFrame.current(comms, "lead").board == () + # Changed diagnostic wording is visible but not another failed occurrence. beat(comms, tuple(replace(rule, error="new diagnostic detail") for rule in rules)) - assert len(notifications(comms)) == 1 + assert len(notifications(comms)) == 3 + changed = CommanderFrame.current(comms, "lead") + assert aged(changed.packets[0].handled) == aged(first.task.handled) + assert all("new diagnostic detail" in ping.text for ping in changed.packets[0].handled) def test_resolution_is_passive_and_same_rule_can_recur(notification_bus): @@ -530,9 +599,9 @@ def test_resolution_is_passive_and_same_rule_can_recur(notification_bus): beat(comms, ServicePassFailing.violations(comms, "lead")) _, quiet = notifications(comms) assert isinstance(quiet.task, CommanderNotice) - assert all(line in quiet.body for line in lead_hud(comms).brief()) + assert_status_fields(quiet, lead_hud(comms)) assert all(isinstance(decision.wake_mode, PassiveWake) for decision in quiet.decisions) - # Nothing new and nothing cleared sends nothing. + # An identical quiet frame updates no bus history. beat(comms, ServicePassFailing.violations(comms, "lead")) assert len(notifications(comms)) == 2 # Use the same source occurrence to prove it was retired, not merely hidden. @@ -562,7 +631,10 @@ def test_new_local_commit_on_an_unpushed_branch_is_not_a_resolution(notification assert RulePing.capture(first) == RulePing.capture(moved) beat(comms, (first,)) beat(comms, (moved,)) - assert len(notifications(comms)) == 1 + original, repeated = notifications(comms) + assert isinstance(repeated.task, CommanderDecisions) + assert repeated.task.decisions == () + assert aged(repeated.task.handled) == aged(original.task.handled) def test_failed_rule_scan_raises_to_the_heartbeat(notification_bus, request): @@ -647,7 +719,7 @@ def test_codex_usage_decodes_window_and_keeps_refusal_visible(): assert FieldCodec.decode(CodexUsage, FieldCodec.encode(refused)) == refused -def test_waiting_notification_is_bounded_but_original_packet_keeps_every_alert(notification_bus): +def test_waiting_notification_keeps_actionable_subjects_overflow_and_original_evidence(notification_bus): comms = notification_bus from agent_comms.head_review import PullRequestHead from agent_comms.repository_name import RepositoryName @@ -662,12 +734,140 @@ def test_waiting_notification_is_bounded_but_original_packet_keeps_every_alert(n packet = CommanderDecisions( original.task.commander, original.task.handled, (), original.task.handled, ) - message = packet.send(comms, time.time(), lead_hud(comms).brief()) - assert sum(f"#{rule.request.pr} " in message.body for rule in rules) == 2 - assert max(map(len, message.body.splitlines())) < 300 + from agent_comms.task_attachments.commander_pings import AlarmOverflow + + hud = replace(lead_hud(comms), packets=(packet,), open_pings=packet.handled) + message = packet.send(comms, time.time(), hud.hud_text(time.time())) + for frame in (lead_hud(comms), hud): + assert len(frame.alarm_text(frame.alarms, time.time())) < len(rules) + overflow = tuple(row for section in frame.alarms for table in section.tables(time.time()) + if table.record is AlarmOverflow for row in table.records) + assert sum(row.more for row in overflow) == len(rules) - 1 + now = time.time() + original_lines = frame.alarm_detail(frame.alarms, now) + assert all(section.heading() not in original_lines for section in frame.alarms) + assert all(rule.line() in "\n".join(original_lines) for rule in rules) + assert rules[0].line() in original.body + assert rules[0].line() in message.body assert len(message.task.handled) == 12 assert {entry.text for entry in message.task.board(comms)} == {rule.line() for rule in rules} assert len(pickle.loads(pickle.dumps(message.task)).handled) == 12 + from agent_comms.text_digest import TextDigest + + swapped = (*packet.handled[:-1], replace(packet.handled[-1], observation=TextDigest.of("other observation"))) + replacement = replace(packet, handled=swapped, waiting=swapped) + changed = replace(hud, packets=(replacement,), open_pings=swapped) + assert hud.alarm_text(hud.alarms, 1000) == changed.alarm_text(changed.alarms, 1000) + assert hud.display_facts != changed.display_facts + + +def test_failed_quiet_publication_retries_identical_display_until_committed(notification_bus): + """A real SQLite refusal rolls back publication, not the disposable frame write.""" + from agent_comms.task_attachments.commander_pings import PublishedFacts + + comms = notification_bus + first_hud = beat(comms) + first, = notifications(comms) + assert isinstance(first.task, CommanderNotice) + assert first.task.publication == PublishedFacts.of(first_hud.display_facts) + comms.registry.register_child("quiet-fork", "lead", CommanderForkExecution, comms.goals) + bus = comms.bus.bus + with bus.writing() as db: + db.execute( + f'CREATE TRIGGER refuse_quiet BEFORE INSERT ON "{Message.table.declared_name}" ' + "BEGIN SELECT RAISE(ABORT, 'quiet publication refused'); END" + ) + try: + with pytest.raises(sqlite3.IntegrityError, match="quiet publication refused"): + beat(comms) + finally: + with bus.writing() as db: + db.execute("DROP TRIGGER refuse_quiet") + owner = comms.registry.require("lead").incarnation + unsent = CommanderFrame.at(comms.root, owner).read() + assert first_hud.display_facts != unsent.display_facts + assert CommanderUpdate.latest(comms, owner) == (first.task,) + assert len(notifications(comms)) == 1 + + retried = beat(comms) + assert retried.display_facts == unsent.display_facts + _, committed = notifications(comms) + assert committed.task.publication == PublishedFacts.of(retried.display_facts) + assert committed.reference != first.reference + beat(comms) + assert tuple(message.reference for message in notifications(comms)) == ( + first.reference, committed.reference, + ) + ServicePassStore.at(comms.root).failed("independent-watch", OSError("new recorded failure")) + failed = beat(comms) + assert failed.display_facts != retried.display_facts + assert "new recorded failure" in failed.status_strip(time.time()) + assert len(notifications(comms)) == 3 + + +def test_subject_evidence_is_shared_without_losing_each_pings_start(notification_bus, request): + from agent_comms.heartbeat_subjects import CommanderHistory, HeartbeatSubject, UnitSubject + from agent_comms.task_attachments.commander_pings import CommanderDelivery + from test_holder_standing_rules import publish + + queries = [] + + class CountedUnitSubject(UnitSubject): + def action_at(self, history): + queries.append(self) + return super().action_at(history) # Real addressed-message evidence, not a policy fake. + + request.addfinalizer(lambda: HeartbeatSubject.__registry__.pop(CountedUnitSubject.declared_name)) + comms = notification_bus + owner = comms.registry.require("lead").incarnation + worker = comms.registry.require("worker").incarnation + now = time.time() + action = publish(comms, "lead", "worker", now - 300) + subject = CountedUnitSubject(worker) + todo = comms.goals.todos.get("P1") + rule = ReviewTimerUp(problem=todo.id, holder=worker, order=todo.order.checked().id, + since=RecordedEventTime(now - 600)) + old = replace(RulePing.capture(rule), subjects=(subject,), since=RecordedEventTime(now - 600)) + new = replace(old, since=RecordedEventTime(now - 200)) + packet = CommanderDecisions(owner, (old, new), (), ()) + history = CommanderHistory.read(comms, frozenset({"lead"})) + assert history.action_at(subject) == RecordedEventTime(action.timestamp) + assert old.acted(history) and not new.acted(history) + opened = packet.open_pings(history) + assert opened == (new,) and opened[0].since == new.since + waiting = packet.undisposed(history, now) + assert tuple(ping.since for ping in waiting) == (new.since,) + hud = CommanderHud.of(comms, "lead", capture_frame(comms, "lead", ProgressRead()), (), + delivery=CommanderDelivery(packet, opened), usage=CodexUsage.read()) + assert hud.open_pings is opened + assert hud.board == (new.board_entry(),) + assert queries == [subject] + # A later original action changes a fresh beat, never the already captured evidence. + publish(comms, "lead", "worker", now) + assert not new.acted(history) + assert new.acted(CommanderHistory.read(comms, frozenset({"lead"}))) + assert queries == [subject, subject] + + +def test_retained_packet_without_display_witness_keeps_original_message_identity(notification_bus): + from agent_comms.field_codec import FieldCodec + from agent_comms.task_attachments.commander_pings import PublishedFacts, UnpublishedFacts + + comms = notification_bus + hud = beat(comms) + message, = notifications(comms) + encoded = FieldCodec.encode(message) + # The original retained packet has no evidence for display facts; the default says so. + del encoded["task"]["publication"] + retained = FieldCodec.decode(Message, encoded) + assert retained.message_id == message.message_id + assert retained.reference == message.reference + assert retained.body == message.body + assert retained.task.publication == UnpublishedFacts() + facts = PublishedFacts.of(hud.display_facts) + assert retained.task.should_publish(facts, (retained.task,)) + assert not message.task.should_publish(facts, (message.task,)) + assert FieldCodec.decode(Message, FieldCodec.encode(retained)).message_id == message.message_id def test_a_commander_that_stood_down_keeps_its_requests_but_gets_no_heartbeat(notification_bus): @@ -695,7 +895,7 @@ def test_one_pass_gives_every_commander_its_own_packet(notification_bus): fork = comms.registry.register_child("worker-fork", "worker", CommanderForkExecution, comms.goals) comms.registry.register(replace(fork, process_identity=live)) assert CommandersTag.declared_name in comms.registry.require("worker-fork").tags - failing = ServicePassFailing("following-rule", time.time(), "independent source") + failing = ServicePassFailing(watch="following-rule", since=time.time(), error="independent source") beat(comms, (failing,)) lead_packet, = CommanderUpdate.latest(comms, comms.registry.require("lead").incarnation) worker = comms.registry.require("worker").incarnation @@ -704,8 +904,22 @@ def test_one_pass_gives_every_commander_its_own_packet(notification_bus): assert worker_packet.handled == lead_packet.handled == (RulePing.capture(failing),) assert CommanderUpdate.latest(comms, comms.registry.require("outsider").incarnation) == () assert CommanderUpdate.latest(comms, comms.registry.require("worker-fork").incarnation) == () - assert set(ServicePassStore.at(comms.root).read().commander_completed) == { - comms.registry.require("lead").incarnation, worker} + served = (lead_packet.commander, worker) + assert set(ServicePassStore.at(comms.root).read().commander_completed) == set(served) + for recipient in served: + hud = CommanderFrame.current(comms, recipient.name) + assert hud.commander == recipient + assert hud.packets == CommanderUpdate.latest(comms, recipient) + assert len(notifications(comms, recipient.name)) == 1 + # Every recipient owns its quiet-publication receipt; the prepared board is shared. + beat(comms, (failing,)) + beat(comms, (failing,)) + for recipient in served: + first, quiet = notifications(comms, recipient.name) + assert isinstance(quiet.task, CommanderNotice) + assert quiet.task.commander == recipient + assert aged(quiet.task.handled) == aged(first.task.handled) + assert CommanderFrame.current(comms, recipient.name).packets == (quiet.task,) def test_fork_cli_default_inherits_audience_but_not_commander_role(notification_bus): diff --git a/tests/test_commander_skill_hook.py b/tests/test_commander_skill_hook.py index ec76cc9b63..c957182732 100644 --- a/tests/test_commander_skill_hook.py +++ b/tests/test_commander_skill_hook.py @@ -261,8 +261,8 @@ def test_the_board_prints_changes_once_then_an_unchanged_count(notification_bus, subjects=(UnitSubject(worker),), since=RecordedEventTime(began)) second = RulePing(ReviewTimerUp, TextDigest.of("second"), text="Review worker P2", subjects=(UnitSubject(worker),), since=RecordedEventTime(began)) - brief = lead_hud(comms).brief() - CommanderDecisions(lead, (first, second), (first, second), ()).send(comms, began + 90, brief) + hud = lead_hud(comms).hud_text(began + 90) + CommanderDecisions(lead, (first, second), (first, second), ()).send(comms, began + 90, hud) with (claim.held(record), claim.reader_held(), Binding.open(tmp_path / "delivery") as db, socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server): server.bind(str(claim.socket_path)) @@ -277,13 +277,13 @@ def test_the_board_prints_changes_once_then_an_unchanged_count(notification_bus, "BOARD: 2 unchanged (oldest: Read worker P1, seen 1 min)", ) changed = replace(first, observation=TextDigest.of("changed"), since=RecordedEventTime(began + 90)) - CommanderDecisions(lead, (changed, second), (changed,), ()).send(comms, began + 92, brief) + CommanderDecisions(lead, (changed, second), (changed,), ()).send(comms, began + 92, hud) assert tool_call(hook, server, comms, db, began + 92) == ( "BOARD: Read worker P1 (seen for 0 min)", "BOARD: 1 unchanged (oldest: Review worker P2, seen 1 min)", ) # A fresh packet does not reset delivery, and the answer window rings only once. - CommanderDecisions(lead, (changed, second), (), ()).send(comms, began + 93, brief) + CommanderDecisions(lead, (changed, second), (), ()).send(comms, began + 93, hud) alarm = began + AwaitingCommander.seconds assert tool_call(hook, server, comms, db, alarm + 1) == ( "BOARD: Review worker P2 (seen for 5 min)", @@ -293,7 +293,7 @@ def test_the_board_prints_changes_once_then_an_unchanged_count(notification_bus, "BOARD: 2 unchanged (oldest: Review worker P2, seen 5 min)", ) assert DeliveredBoardContent.count(db) == 2 - CommanderDecisions(lead, (), (), ()).send(comms, alarm + 3, brief) + CommanderDecisions(lead, (), (), ()).send(comms, alarm + 3, hud) assert tool_call(hook, server, comms, db, alarm + 3) == () assert DeliveredBoardContent.count(db) == 0 diff --git a/tests/test_coordination.py b/tests/test_coordination.py index 88b03bfc7f..393865bd6a 100644 --- a/tests/test_coordination.py +++ b/tests/test_coordination.py @@ -1,5 +1,7 @@ """Slice-1 store_files, direct-SQL authority and crash/reopen shape tests.""" +from agent_comms.field_codec import SnapshotExclude + import os import sqlite3 import subprocess @@ -326,7 +328,7 @@ def test_snapshot_projection_retry_and_inert_secrets(): attempt_record=failed_attempt, ) assert deferred.can_retry - projection = FieldCodec.project(deferred, "snapshot") + projection = FieldCodec.project(deferred, SnapshotExclude) assert projection["execution"]["current_attempt_ordinal"] == 1 assert projection["attempt"]["owner_generation"] == 1 assert "owner_token_digest" not in str(projection) @@ -370,8 +372,8 @@ def test_snapshot_projection_retry_and_inert_secrets(): def test_wire_claim_obligation_and_publication_contract(): record = execution(origin=WireOrigin) valid = snapshot(execution_record=record, response=obligation()) - assert FieldCodec.project(valid, "snapshot")["claims"][0]["claim_id"] == "claim-1" - primitive = FieldCodec.project(valid, "snapshot") + assert FieldCodec.project(valid, SnapshotExclude)["claims"][0]["claim_id"] == "claim-1" + primitive = FieldCodec.project(valid, SnapshotExclude) assert primitive["execution_claims"][0]["ordinal"] == 0 assert "claim_ids" not in primitive["execution"] assert [item["claim_id"] for item in primitive["claims"]] == [ @@ -967,7 +969,7 @@ def test_missing_replay_cannot_later_reauthorize_failed(db): pointer_revision=2, is_current=False, ) - assert FieldCodec.project(absent, "snapshot")["replay"] is None + assert FieldCodec.project(absent, SnapshotExclude)["replay"] is None assert not absent.can_retry with pytest.raises(IntegrityViolationError): replace( diff --git a/tests/test_coordination_store.py b/tests/test_coordination_store.py index 4ea3f63e08..4a8317aa25 100644 --- a/tests/test_coordination_store.py +++ b/tests/test_coordination_store.py @@ -2,6 +2,8 @@ from __future__ import annotations +from agent_comms.field_codec import SnapshotExclude + import hashlib import multiprocessing import sqlite3 @@ -399,7 +401,7 @@ def test_token_prepared_before_transaction_and_loss_replay_ignores_mutable(db_pa attempt.lifecycle.lease_expires_at_ms == attempt.created_at_ms + INITIAL_LEASE_DURATION_MS ) - assert token not in str(FieldCodec.project(first.value.snapshot, "snapshot")) + assert token not in str(FieldCodec.project(first.value.snapshot, SnapshotExclude)) assert token not in db_path.read_bytes().decode("latin1") duplicate = db.start( AttemptStart( diff --git a/tests/test_field_codec.py b/tests/test_field_codec.py index 854dc1833c..2ae4331652 100644 --- a/tests/test_field_codec.py +++ b/tests/test_field_codec.py @@ -1,3 +1,5 @@ + +from agent_comms.field_codec import ReportExclude, StatusExclude, WireExclude import json from dataclasses import dataclass, field from pathlib import Path @@ -119,7 +121,7 @@ def __getattribute__(self, name: str) -> object: record = Record("kept") assert [key for _, key in FieldCodec.record_fields(Record)] == ["value"] assert FieldCodec.encode(record) == {"value": "kept"} - assert FieldCodec.project(record, "status") == {"value": "kept"} + assert FieldCodec.project(record, StatusExclude) == {"value": "kept"} assert FieldCodec.decode(Record, {"value": "kept"}).value == "kept" properties = FieldCodec.value_schema(Record)["properties"] assert isinstance(properties, dict) @@ -133,7 +135,7 @@ def test_record_encoding_uses_the_declared_fields_and_projections() -> None: class Key: value: str - @projected(view="wire") + @projected(view=WireExclude) def uppercase(self) -> str: return self.value.upper() @@ -141,7 +143,7 @@ def uppercase(self) -> str: class ReadKey(Key): local: str - @projected(view="wire") + @projected(view=WireExclude) def local_uppercase(self) -> str: return self.local.upper() @@ -234,7 +236,7 @@ class Record: ): assert decoded.id is None assert FieldCodec.encode(decoded) == {} - assert FieldCodec.project(decoded, "status") == {} + assert FieldCodec.project(decoded, StatusExclude) == {} assert plan.read(raw) is None assert created == ["created"] with pytest.raises(MalformedRecord): @@ -306,12 +308,12 @@ def test_json_value_alias_uses_the_opaque_json_owner_everywhere() -> None: for annotation in annotations: assert FieldCodec.encode(payload, annotation) == payload assert FieldCodec.decode(annotation, payload) == payload - assert FieldCodec.project(payload, "report", annotation) == payload + assert FieldCodec.project(payload, ReportExclude, annotation) == payload assert FieldCodec.value_schema(annotation) == {} for invalid in ({1: "non-text key"}, {"nested": float("nan")}, {"nested": object()}): for operation in ( lambda: FieldCodec.encode(invalid, annotation), - lambda: FieldCodec.project(invalid, "report", annotation), + lambda: FieldCodec.project(invalid, ReportExclude, annotation), ): with pytest.raises(TypeError): operation() @@ -352,7 +354,7 @@ def test_new_value_owns_wire_form_everywhere_without_a_codec_adapter() -> None: encoded = {"value": "retained", "nested": ["nested"]} assert FieldCodec.encode(value) == encoded assert FieldCodec.decode(OwnedWireRecord, encoded) == value - assert FieldCodec.project(value, "status") == encoded + assert FieldCodec.project(value, StatusExclude) == encoded assert FieldCodec.value_schema(OwnedWireText) == {"type": "string"} assert FieldCodec.decode(OwnedWireRecord, {**encoded, "value": None}).value is None with pytest.raises(ValueError): @@ -458,7 +460,7 @@ class Scalars: "count": "0x1f", } assert FieldCodec.decode(Scalars, encoded) == value - assert FieldCodec.project(value, "status") == encoded + assert FieldCodec.project(value, StatusExclude) == encoded assert FieldCodec.value_schema(Annotated[Path | None, PathText]) == { "anyOf": [ {"type": "string", "description": "agent_comms.field_codec.PathText text"}, @@ -892,7 +894,7 @@ def test_inline_record_owns_keys_for_encode_decode_schema_and_projection(value: container, wire = type(value), {"number": 7, "text": "seven"} assert FieldCodec.encode(value) == wire assert FieldCodec.decode(container, wire) == value - assert FieldCodec.project(value, "report") == wire + assert FieldCodec.project(value, ReportExclude) == wire required = FieldCodec.record_schema(container)["required"] assert isinstance(required, list) assert set(required) == wire.keys() @@ -1168,5 +1170,5 @@ def from_wire(cls, data: object) -> Self: def test_represented_value_projects_as_its_record_only_when_its_record_is_its_wire_form() -> None: member = SecretMember("kept", "secret") assert FieldCodec.encode(member) == {"name": "kept", "token": "secret"} - assert FieldCodec.project(member, "snapshot") == {"name": "kept"} - assert FieldCodec.project(CountWire(3), "snapshot") == "count=3" + assert FieldCodec.project(member, SnapshotExclude) == {"name": "kept"} + assert FieldCodec.project(CountWire(3), SnapshotExclude) == "count=3" diff --git a/tests/test_fleet_replay.py b/tests/test_fleet_replay.py index a32804568c..5a3cf6cfa8 100644 --- a/tests/test_fleet_replay.py +++ b/tests/test_fleet_replay.py @@ -22,7 +22,7 @@ from agent_comms.commander.publication_rules import EmptyPullRequest, UnpushedBranch from agent_comms.commander.spinning import SpinningUnit from agent_comms.commander_heartbeat import CommanderHeartbeat, CommanderObservation -from commander_heartbeat_fixture import ObservedHeartbeat, observe_commander +from commander_heartbeat_fixture import ObservedHeartbeat, current_hud, observe_commander from agent_comms.commander_hud import CommanderHud from agent_comms.comms import Comms from agent_comms.external_transcript import ExternalTranscript @@ -706,7 +706,7 @@ def test_a_merged_order_running_its_proof_is_not_spinning(fleet): # This fixture's repository is no installed package: the HUD line says so, never raises. observed = built.observe() assert not any(type(rule) is SpinningUnit for rule in observed.rules) - hud = CommanderHud.of(built.comms, Fleet.commander, observed.frame, observed.states) + hud = current_hud(built.comms, Fleet.commander, observed.frame, observed.states) assert any(order.state == "Merged, no runtime install, waiting on the run" for unit in hud.units for order in unit.orders) diff --git a/tests/test_head_review.py b/tests/test_head_review.py index eb0a219229..71eb4f0b27 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -1,8 +1,10 @@ """Head decisions through the native boundary, real stores, and the external gh process.""" +import asyncio import subprocess import sys import time +from contextlib import nullcontext from dataclasses import replace from datetime import UTC, datetime from pathlib import Path @@ -11,27 +13,35 @@ from agent_comms.commander import judge, order +from agent_comms.closed_pull_requests import ClosedPullRequestWatch +from agent_comms.commander.publication_rules import ClosedUnmergedPullRequest +from agent_comms.commander_heartbeat import CommanderHeartbeat from agent_comms.goal_actions import CreateProblemGoalAction from commander_heartbeat_fixture import capture_frame from agent_comms.head_review import HeadReview, PullRequestHead, ReadPullRequestHead -from agent_comms.heartbeat_subjects import CommanderHistory, HeadSubject +from agent_comms.heartbeat_subjects import CommanderHistory, HeadSubject, UnlandedHeadSubject from agent_comms.recorded_time import RecordedEventTime from agent_comms.standing_rules import ( HeadReviewTimerUp, IdlePullRequest, OrderlessPullRequest, OrderlessUnreviewedHead, LinkedUnreviewedHead, ReviewTimerUp, StuckMerge, ) from agent_comms.repo import Checkout +from agent_comms.repository_name import RepositoryName +from agent_comms.task_attachments.commander_pings import CommanderDecisions, CommanderUpdate, RulePing +from agent_comms.thread_status import StoppedThreadStatus from agent_comms.threads import Thread from agent_comms.errors import ToolRefusedError from agent_comms.field_codec import RecordProblems from agent_comms.tool_caller import ThreadCaller -from agent_comms.work_branches import SourceGrowth, WorkBranches +from agent_comms.work_branches import ( + SourceGrowth, ThreadBranchCheckout, UnregisteredBranchCheckout, WorkBranches, +) from agent_comms.order_tools import CommsHeadVerdictTool from agent_comms.todos import ( - CarriedHeadVerdict, ConflictMergeBlock, DraftListedPullRequest, FailedCheckMergeBlock, GrownHeadVerdict, GrowthReason, OrderHeadReview, LinkedHeadReview, + CarriedHeadVerdict, ClosedListedPullRequest, ConflictMergeBlock, DraftListedPullRequest, FailedCheckMergeBlock, GrownHeadVerdict, GrowthReason, OrderHeadReview, LinkedHeadReview, NoHeadOrder, ProgressRead, PullRequestLink, RecordPullRequest, RejectedHeadVerdict, ReviewedHeadVerdict, StoredHeadReview, UnquotedHeadReview, - StrikeReason, UnderReviewHeadVerdict, UnmergedListedPullRequest, VerdictReason, + StrikeReason, SupersededHeadVerdict, UnderReviewHeadVerdict, UnmergedListedPullRequest, VerdictReason, ) from source_revision_fixture import SourceRepository @@ -368,10 +378,13 @@ def owed() -> list[PullRequestHead]: invoke(comms, request, UnderReviewHeadVerdict()) assert owed() == [request] - assert not subject.disposed(CommanderHistory.read(comms, frozenset()), since) + assert not CommanderHistory.read(comms, frozenset()).action_at(subject).since(since) invoke(comms, request, ReviewedHeadVerdict(ACCEPTED)) assert owed() == [] - assert subject.disposed(CommanderHistory.read(comms, frozenset()), since) + history = CommanderHistory.read(comms, frozenset()) + assert history.action_at(subject).since(since) + assert subject.disposed(history, since) + assert not subject.disposed(history, time.time() + 1) invoke(comms, request, UnderReviewHeadVerdict()) assert owed() == [request] @@ -546,15 +559,75 @@ def test_an_idle_draft_and_a_conflicting_ready_head_are_board_items(head_bus): for rule in IdlePullRequest.scan_pulls(comms.goals, ProgressRead(()), (), ordered)} assert 11 not in found assert found[9].startswith(f"IDLE {ready.repo.to_wire()}#9 {'c' * 9} (no live holder)") - assert "idle 60 min" in found[9] and found[9].endswith(" -> land or supersede it") - assert "idle 0 min" in found[7] and found[7].endswith(" -> merge main into it") + assert found[9].endswith(" -> land or supersede it") + assert found[7].endswith(" -> merge main into it") + + +@pytest.mark.parametrize(('verdict', 'cleared'), ( + (SupersededHeadVerdict(ACCEPTED, 'P2'), True), + (ReviewedHeadVerdict(ACCEPTED), False), + (RejectedHeadVerdict(VerdictReason('The declared owner still has a second competing decision.')), False), + (UnderReviewHeadVerdict(), False), +)) +def test_retained_head_verdict_precedes_the_published_lost_packet( + head_bus, reviewed_source, installed_runtime, verdict, cleared, +): + comms, github, request = head_bus + source = reviewed_source + stamp = datetime.fromtimestamp(time.time() - 601, UTC).strftime('%Y-%m-%dT%H:%M:%SZ') + github.put(request.repo, ClosedListedPullRequest( + request.pr, 'feature', request.head, closed_at=stamp, updated=stamp, + base='main', reviewable_times=(stamp,))) + asyncio.run(ClosedPullRequestWatch().run(lambda: nullcontext(comms))) + frame = capture_frame(comms, 'lead', ProgressRead(()), CommanderHeartbeat()) + lost, = ClosedUnmergedPullRequest.scan_observed(comms, 'lead', (), frame) + assert lost.request == request + + invoke(comms, request, verdict) + reviewed, = StoredHeadReview.head_reviews(comms.goals.todos, request) + published = time.time() + assert reviewed.at.before(published) + # A retained stale observation can be published after its exact-head verdict. + # Publish the actual typed packet; no board rows or history records are rewritten. + CommanderHeartbeat.register(comms.registry) + owner = comms.registry.require('lead').incarnation + ping = replace(RulePing.capture(lost), since=RecordedEventTime(published)) + packet = CommanderDecisions(owner, (ping,), (ping,), ()) + original = packet.send(comms, published, ()) + previous = CommanderUpdate.latest(comms, owner) + assert previous == (packet,) + assert isinstance(original.task, CommanderDecisions) + assert original.task == packet + history = CommanderHistory.read(comms, frozenset(('lead',))) + assert (CommanderUpdate.current_board(comms, 'lead') == ()) is cleared + assert (packet.open_pings(history) == ()) is cleared + if cleared: + subject, = ping.subjects + assert history.action_at(subject) == reviewed.at + assert history.action_at(subject).before(published) + + for unrelated in (replace(request, head=source.main_commit), + replace(request, repo=RepositoryName('other', request.repo.name)), + replace(request, pr=request.pr + 1)): + assert not UnlandedHeadSubject(unrelated).disposed(history, published) + assert not HeadSubject(request).disposed(history, published) + + # Derive the next real packet from that committed baseline and the same observation. + delivery = CommanderUpdate.derive(owner, (), (lost,), previous, + now=time.time(), history=history) + assert delivery.packet.handled[0].since == ping.since + assert (delivery.open_pings == ()) is cleared + delivery.packet.send(comms, time.time(), ()) + assert CommanderUpdate.latest(comms, owner) == (delivery.packet,) + assert (CommanderUpdate.current_board(comms, 'lead') == ()) is cleared + assert original.task.handled == (ping,) def working_on(comms, source, tmp_path, name, branch): """A registered thread whose own worktree has ``branch`` checked out.""" worktree = tmp_path / f"{name}-checkout" source.git(source.checkout, "worktree", "add", "-q", "-b", branch, str(worktree)) - comms.registry.declare(Thread(name, frozenset(), Checkout(worktree))) + comms.registry.declare(Thread(name, frozenset(), Checkout(worktree)), StoppedThreadStatus()) def test_a_request_no_order_links_reaches_the_thread_working_on_its_branch( @@ -594,6 +667,33 @@ def test_a_request_no_order_links_reaches_the_thread_working_on_its_branch( assert stuck.request.pr == 12 assert stuck.line().endswith(" -> fix debt-src at its owner at armer") + snapshot = comms.registry.snapshot() + assert snapshot.statuses["author"].stopped and not snapshot.statuses["author"].archived + reader = WorkBranches(reviewed_source.checkout) + worktrees = reader.worktrees() + path = str(snapshot.require("author").worktree.path) + assert reader.checkouts(snapshot, "fix/head-9", worktrees) == ( + ThreadBranchCheckout(("author",), path),) + comms.registry.archive("author") + archived = comms.registry.snapshot() + assert archived.statuses["author"].archived + # The same real Git worktree remains: only its registered holder changed. + assert reader.checkouts(archived, "fix/head-9", worktrees) == ( + UnregisteredBranchCheckout(path),) + pulls = ProgressRead(()) + rules = {rule.request.pr: rule for rule in OrderlessPullRequest.scan_orderless(comms.goals, pulls)} + assert set(rules) == {9, 11} + assert rules[9].holders == () + assert rules[9].line().startswith( + f"UNORDERED {ready.repo.to_wire()}#9 {'e' * 9} (no order, and no registered thread has" + " its branch checked out): required check debt-src failed") + assert rules[9].line().endswith(" -> order its author or close it with a reason") + row = next(row for row in pulls.open(ready.repo) if row.number == 9) + assert pulls.working_on(comms.goals, ready.repo, row) == () + still_stuck, = StuckMerge.scan_pulls(comms.goals, pulls) + assert still_stuck.request.pr == 12 + assert still_stuck.line().endswith(" -> fix debt-src at its owner at armer") + def test_another_commanders_linked_request_stays_off_the_orderless_board(head_bus): """The 2026-10-25 guarantee holds: a request an open order links is never orderless. diff --git a/tests/test_json_shape_field_codec.py b/tests/test_json_shape_field_codec.py index 1071afd8fa..dafdf9f8d6 100644 --- a/tests/test_json_shape_field_codec.py +++ b/tests/test_json_shape_field_codec.py @@ -1,5 +1,7 @@ """External JSON shape selection belongs to the existing codec boundary.""" +from agent_comms.field_codec import StatusExclude + from abc import abstractmethod from dataclasses import dataclass import json @@ -85,7 +87,7 @@ def test_external_shape_family_uses_one_codec_for_children_and_new_cases(): assert value.to_wire() == raw assert json.loads(json.dumps(value.to_wire())) == raw assert FieldCodec.encode(value) == raw - assert FieldCodec.project(value, "status") == raw + assert FieldCodec.project(value, StatusExclude) == raw assert ExternalValue.member_named("text") is TextValue assert FieldCodec.decode(type[ExternalValue], "text") is TextValue for invalid in (object(), {1: "bad key"}, float("nan")): diff --git a/tests/test_order_completion.py b/tests/test_order_completion.py index bf59c061fb..9332f41a49 100644 --- a/tests/test_order_completion.py +++ b/tests/test_order_completion.py @@ -12,6 +12,7 @@ from agent_comms.commander.standing import HolderGone from agent_comms.goals import CountCheckResult, OrderCheck from agent_comms.field_codec import RecordProblems +from commander_heartbeat_fixture import capture_frame from agent_comms.repo import Checkout, DetachedStart from agent_comms.installer.runtime_resolution import CorePackage, RuntimeResolution from agent_comms.launch_runtime import LaunchRuntime @@ -63,7 +64,8 @@ def test_await_run_names_only_its_orders_and_yields_to_source_work( def orders(*todos): unit = UnitObservation(ctx.comms, ctx.comms.registry.require('worker'), todos, - ProgressRead(), (), time.time(), + capture_frame(ctx.comms, 'lead', ProgressRead()), + (), time.time(), ctx.comms.registry.snapshot().status('worker'), False) return [state.orders for state in AwaitingApplicationRun.derive(unit)] diff --git a/tests/test_order_progress.py b/tests/test_order_progress.py index a4ab6563b2..6d34cc8a1d 100644 --- a/tests/test_order_progress.py +++ b/tests/test_order_progress.py @@ -16,7 +16,7 @@ from agent_comms.child_process import ProcessIdentity from agent_comms.commander_hud import CommanderFrame, CommanderHud from agent_comms.commander_heartbeat import CommanderHeartbeat -from commander_heartbeat_fixture import ObservedHeartbeat, observe_commander +from commander_heartbeat_fixture import ObservedHeartbeat, current_hud, observe_commander from agent_comms.comms import wire from agent_comms.goal_actions import CreateProblemGoalAction from agent_comms.launch_runtime import LaunchRoot @@ -178,8 +178,8 @@ def test_deleted_branch_marks_one_card_unreadable(held_bus, installed_runtime): broken.source.require_read() assert broken.reason == str(source_failure.value) assert tuple(card for card in frame.states if card.unit.name != 'worker') == healthy - expected = CommanderHud.of(comms, lead.name, frame.frame, frame.states) asyncio.run(ObservedHeartbeat((frame,)).run(lambda: nullcontext(comms))) + expected = current_hud(comms, lead.name, frame.frame, frame.states) assert CommanderFrame.read_all(comms.root) == (expected,) print(broken.line()) diff --git a/tests/test_standing_rules.py b/tests/test_standing_rules.py index 0e153ba4ff..7000394b6b 100644 --- a/tests/test_standing_rules.py +++ b/tests/test_standing_rules.py @@ -7,6 +7,7 @@ from agent_comms.commander.standing import GoalBlocked, RequestStalled from agent_comms.goal_actions import SetGoalAction +from agent_comms.heartbeat_subjects import CommanderHistory from agent_comms.goal_states import ActiveGoal, BlockedGoal, UnrecordedBlockGoal from agent_comms.task_attachments.commander_pings import CommanderDecisions, CommanderUpdate, RulePing from agent_comms.recorded_time import RecordedEventTime @@ -68,7 +69,10 @@ def test_blocked_goal_leads_the_board_and_clears_when_active(comms, tmp_path, st old = RulePing(RequestStalled, TextDigest.of("old housekeeping"), text="IDLE housekeeping", since=RecordedEventTime(now - 600)) previous = CommanderDecisions(thread.incarnation, (old,), (old,), ()) - update, = CommanderUpdate.derive(thread.incarnation, (), (rule,), (previous,), now=now) + history = CommanderHistory.read(comms, frozenset({thread.name})) + delivery = CommanderUpdate.derive(thread.incarnation, (), (rule,), (previous,), + now=now, history=history) + update = delivery.packet assert update.handled[0].urgent current = comms.registry.require("paper") comms.registry.register( @@ -76,7 +80,8 @@ def test_blocked_goal_leads_the_board_and_clears_when_active(comms, tmp_path, st comms.registry.status("paper"), ) assert GoalBlocked.violations(comms, "paper") == () - cleared, = CommanderUpdate.derive(thread.incarnation, (), (), (update,), now=now + 60) - assert cleared.handled == () + cleared = CommanderUpdate.derive(thread.incarnation, (), (), (update,), + now=now + 60, history=history) + assert cleared.packet.handled == () diff --git a/tests/test_unit_state.py b/tests/test_unit_state.py index d38064fb68..c6c4f70aff 100644 --- a/tests/test_unit_state.py +++ b/tests/test_unit_state.py @@ -14,7 +14,7 @@ from agent_comms.child_process import ProcessIdentity from agent_comms.commander_heartbeat import CommanderHeartbeat, CommanderObservation from agent_comms.commander_hud import CommanderFrame, CommanderHud -from commander_heartbeat_fixture import ObservedHeartbeat, capture_frame, observe_commander +from commander_heartbeat_fixture import ObservedHeartbeat, capture_frame, current_hud, observe_commander from agent_comms.external_transcript import ExternalTranscript from agent_comms.field_codec import OrderId, ToolCallId from agent_comms.goal_actions import CreateProblemGoalAction @@ -22,6 +22,7 @@ from agent_comms.launch_runtime import LaunchRoot from agent_comms.order_runs import ApplicationOrderRun from agent_comms.recorded_time import RecordedEventTime +from agent_comms.standing_rules import ReviewTimerUp from agent_comms.repo import Checkout, GitCommandError, Repo from agent_comms.session_file import RegistrySessionLocation from agent_comms.thread_execution import ExternalThreadExecution @@ -40,6 +41,7 @@ Detached, Idle, Moving, + ReviewDue, Spinning, StalledOnNothing, Stuck, @@ -156,6 +158,11 @@ def test_unit_state_family_uses_real_records(held_bus, tmp_path, request, member first_call_deadline=RecordedEventTime(time.time() - 1), ), ).upsert(db) + elif member is ReviewDue: + todo = comms.goals.todos.get("P1") + with comms.goals.todos.writing() as db: + replace(todo, review_at=RecordedEventTime(time.time() - 1), + revision=todo.revision + 1).upsert(db) elif member is Spinning: recorded_turn(comms, (True, True)) elif member is StalledOnNothing: @@ -195,8 +202,9 @@ def test_unit_state_family_uses_real_records(held_bus, tmp_path, request, 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", capture_frame(comms, "lead", ProgressRead())) + frame = capture_frame(comms, "lead", ProgressRead()) + assert tuple(ReviewTimerUp.conditions(comms, frame)) == frame.review_timers + states = UnitState.current(comms, "lead", frame) (state,) = (state for state in states if state.unit.name == unit) assert isinstance(state, member) assert state.unit == comms.registry.require(unit).incarnation @@ -378,8 +386,8 @@ def test_heartbeat_keeps_every_unit_when_one_checkout_is_unreadable( assert worker.reason == str(source_failure.value) else: assert not isinstance(worker, Unreadable) - expected = CommanderHud.of(held_bus, lead.name, observed.frame, cards) asyncio.run(ObservedHeartbeat((observed,)).run(lambda: nullcontext(held_bus))) + expected = current_hud(held_bus, lead.name, observed.frame, cards) published, = CommanderFrame.read_all(held_bus.root) assert published == expected assert tuple(unit.unit for unit in published.units) == tuple(card.unit for card in cards) diff --git a/uv.lock b/uv.lock index 06d7893b1e..44ffefd1c0 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,7 @@ dependencies = [ { name = "nominal-refactor-advisor" }, { name = "packaging" }, { name = "pydantic" }, + { name = "python-introspect" }, ] [package.optional-dependencies] @@ -54,6 +55,7 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.3" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.6" }, + { name = "python-introspect", git = "https://github.com/OpenHCSDev/python-introspect?rev=main" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.0" }, { name = "uv", marker = "extra == 'dev'", specifier = "==0.13.1" }, ] @@ -510,7 +512,7 @@ wheels = [ [[package]] name = "nominal-refactor-advisor" version = "0.1.0" -source = { git = "https://github.com/OpenHCSDev/NominalRefactorAdvisor?rev=main#595d19725deba0b37158c05cc05f1c7d8af6bec7" } +source = { git = "https://github.com/OpenHCSDev/NominalRefactorAdvisor?rev=main#d0667af3c2733b3de298be1af4bd69f963cb9e23" } dependencies = [ { name = "metaclass-registry" }, { name = "tree-sitter" }, @@ -730,6 +732,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] +[[package]] +name = "python-introspect" +version = "0.3.0" +source = { git = "https://github.com/OpenHCSDev/python-introspect?rev=main#0aff2a73bd24594d377d3f0ffa1464e12447c487" } +dependencies = [ + { name = "annotated-types" }, + { name = "metaclass-registry" }, +] + [[package]] name = "pytokens" version = "0.4.1"