From 832a8dbcdc391f375a87004ec7ac3a19ca1f3836 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 02:02:20 -0400 Subject: [PATCH 01/16] Declare commander HUD columns on the unit record --- src/agent_comms/commander_hud.py | 41 +++++++++++++++----------- src/agent_comms/field_codec.py | 12 +++++--- src/agent_comms/thread_identity.py | 5 ++-- tests/test_commander_hud_projection.py | 28 ++++++++++++++++++ 4 files changed, 63 insertions(+), 23 deletions(-) create mode 100644 tests/test_commander_hud_projection.py diff --git a/src/agent_comms/commander_hud.py b/src/agent_comms/commander_hud.py index 2f9342db0d..a881e16f97 100644 --- a/src/agent_comms/commander_hud.py +++ b/src/agent_comms/commander_hud.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field, replace from pathlib import Path -from typing import TYPE_CHECKING, Annotated +from typing import TYPE_CHECKING, Annotated, cast from aiohttp import ClientError from pydantic import JsonValue @@ -20,7 +20,9 @@ from agent_comms.commander_service import CommanderService from agent_comms.coordinator import Coordination from agent_comms.errors import ToolRefusedError -from agent_comms.field_codec import DisclosureIdentity, OrderId, WireTransient, projected +from agent_comms.field_codec import ( + DisclosureIdentity, FieldCodec, HudExclude, OrderId, WireTransient, projected, +) from agent_comms.heartbeat_frame import HeartbeatFrame from agent_comms.locked_store import JsonDocument from agent_comms.plan_phases import Plan, PlanPhase @@ -74,11 +76,11 @@ 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()] + 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( @@ -92,18 +94,23 @@ def line(self) -> str: f"draft={','.join(str(pr.number) for pr in self.draft_prs)}" ) - 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="hud") + def work(self) -> str: + return "\n".join(f"{order.problem} {order.state}" for order in self.orders) + + @projected(view="hud") + def change(self) -> str: + return "\n".join(order.progress.line for order in self.orders) + + @projected(view="hud") + def next(self) -> str: + return self.state.action def brief(self) -> str: - """A dense scan line; expansion reads the original order and activity owners.""" - return " | ".join(self.brief_parts()) + """The same declared HUD view supplies the heartbeat's text scan line.""" + return " | ".join(str(value) for value in cast( + dict[str, JsonValue], FieldCodec.project(self, "hud"), + ).values()) def needs_hand(self) -> bool: """Its UnitState asks for the commander, or a review timer on one of its orders is up.""" diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index db0eb0ab3f..f28de0e47a 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -325,6 +325,10 @@ class SnapshotExclude(ViewExclusion): pass +class HudExclude(ViewExclusion): + pass + + class ContentExclude(ViewExclusion): pass @@ -2563,7 +2567,7 @@ def _project_record(cls, value: object, view: str) -> JsonValue: 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() @@ -2630,7 +2634,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, "wire").values()} wire_fields, required_keys = [], [] for entry in entries: keys = entry.contribute(wire_fields) @@ -2659,7 +2663,7 @@ def _field_plan(field: Field[object], annotation: object) -> type[FieldPlan]: @staticmethod @cache - def _projections(declaration: type, view: str) -> types.MappingProxyType[str, Projected]: + def projections(declaration: type, view: str) -> 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({ @@ -3436,7 +3440,7 @@ def encode(self, value: object) -> dict[str, object]: ) 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, "wire").items() ) return result diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 880aef1716..05af12cd34 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 @@ -269,7 +270,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/tests/test_commander_hud_projection.py b/tests/test_commander_hud_projection.py new file mode 100644 index 0000000000..0f9690fd58 --- /dev/null +++ b/tests/test_commander_hud_projection.py @@ -0,0 +1,28 @@ +"""The heartbeat and table borrow the same declared HUD projection.""" + +from agent_comms.commander_hud import CommanderOrder, CommanderUnit +from agent_comms.field_codec import DisclosureIdentity, FieldCodec, OrderId +from agent_comms.thread_identity import RecordedThreadIncarnation +from agent_comms.todos import OpenPrProgress +from agent_comms.ui_model.unit_state import Moving, NoPendingClock + + +def test_commander_hud_projection_has_one_progress_answer() -> None: + progress = OpenPrProgress(3572, "87dabd883", at=1.0) + order = CommanderOrder( + "P927", "Make the screen readable", OrderId("123456789abc"), + "pending", progress, (), 0, 0, + ) + unit = CommanderUnit( + RecordedThreadIncarnation("worker", 2.0), Moving, NoPendingClock(), + (order,), 0, (), (), + ) + assert tuple(FieldCodec.projections(CommanderUnit, "hud")) == ("work", "change", "next") + assert tuple(plan.name for plan in FieldCodec.field_plans(CommanderUnit) + if plan.records and not plan.excluded_in("hud")) == ("unit", "state") + assert unit.work == "P927 pending" + assert unit.change == progress.line + assert unit.next == Moving.action + assert unit.brief().count("#3572") == 1 + assert tuple(plan.name for plan in FieldCodec.field_plans(type(unit.unit)) + if DisclosureIdentity.read(plan.annotation, False)) == ("name",) From 4125c2585abb8d43bddf212121373cc8e376c653 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 02:05:35 -0400 Subject: [PATCH 02/16] Share declared identity text between heartbeat and native views --- src/agent_comms/commander_hud.py | 9 ++-- src/agent_comms/disclosure_text.py | 61 ++++++++++++++++++++++++++ tests/test_commander_hud_projection.py | 4 +- 3 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 src/agent_comms/disclosure_text.py diff --git a/src/agent_comms/commander_hud.py b/src/agent_comms/commander_hud.py index a881e16f97..b7caa0f045 100644 --- a/src/agent_comms/commander_hud.py +++ b/src/agent_comms/commander_hud.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field, replace from pathlib import Path -from typing import TYPE_CHECKING, Annotated, cast +from typing import TYPE_CHECKING, Annotated from aiohttp import ClientError from pydantic import JsonValue @@ -19,9 +19,10 @@ from agent_comms.commander_frame_file import CommanderFrameFile from agent_comms.commander_service import CommanderService from agent_comms.coordinator import Coordination +from agent_comms.disclosure_text import DisclosureText from agent_comms.errors import ToolRefusedError from agent_comms.field_codec import ( - DisclosureIdentity, FieldCodec, HudExclude, OrderId, WireTransient, projected, + DisclosureIdentity, HudExclude, OrderId, WireTransient, projected, ) from agent_comms.heartbeat_frame import HeartbeatFrame from agent_comms.locked_store import JsonDocument @@ -108,9 +109,7 @@ def next(self) -> str: def brief(self) -> str: """The same declared HUD view supplies the heartbeat's text scan line.""" - return " | ".join(str(value) for value in cast( - dict[str, JsonValue], FieldCodec.project(self, "hud"), - ).values()) + return " | ".join(DisclosureText.view(self, "hud")) def needs_hand(self) -> bool: """Its UnitState asks for the commander, or a review timer on one of its orders is up.""" diff --git a/src/agent_comms/disclosure_text.py b/src/agent_comms/disclosure_text.py new file mode 100644 index 0000000000..48a1b639f2 --- /dev/null +++ b/src/agent_comms/disclosure_text.py @@ -0,0 +1,61 @@ +"""Human-readable declared values shared by heartbeat text and native views.""" + +from __future__ import annotations + +from dataclasses import dataclass, is_dataclass +from functools import singledispatchmethod + +from agent_comms.declared_family import FamilyMeta +from agent_comms.field_codec import DisclosureIdentity, FieldCodec + + +@dataclass(frozen=True) +class DisclosureText: + value: object + + @singledispatchmethod + @classmethod + def of(cls, value: object) -> DisclosureText: + """Recover foreign record shape once, at this presentation boundary.""" + if is_dataclass(value): + return RecordText(value) + return cls(value) + + @of.register(FamilyMeta) + @classmethod + def _family(cls, value: FamilyMeta) -> DisclosureText: + return FamilyText(value) + + def summary(self, width: int | None = None) -> str: + return str(self.value)[:width].partition("\n")[0] + + @classmethod + def view(cls, value: object, view: str) -> tuple[str, ...]: + """Read the original values, not their flattened JSON representations.""" + return ( + *(cls.of(plan.current(value)).summary() + for plan in FieldCodec.field_plans(type(value)) + if plan.records and not plan.excluded_in(view)), + *(cls.of(getattr(value, name)).summary() + for name in FieldCodec.projections(type(value), view)), + ) + + +class RecordText(DisclosureText): + def summary(self, width: int | None = None) -> str: + plans = tuple(plan for plan in FieldCodec.field_plans(type(self.value)) if plan.records) + identity = tuple(plan for plan in plans if DisclosureIdentity.read(plan.annotation, False)) + parts: list[str] = [] + for plan in identity or plans: + parts.append(DisclosureText.of(plan.current(self.value)).summary(width)) + if width is not None and sum(map(len, parts)) >= width: + break + return " · ".join(parts)[:width] + + +@dataclass(frozen=True) +class FamilyText(DisclosureText): + value: FamilyMeta + + def summary(self, width: int | None = None) -> str: + return self.value.declared_name[:width] diff --git a/tests/test_commander_hud_projection.py b/tests/test_commander_hud_projection.py index 0f9690fd58..62048515f1 100644 --- a/tests/test_commander_hud_projection.py +++ b/tests/test_commander_hud_projection.py @@ -23,6 +23,8 @@ def test_commander_hud_projection_has_one_progress_answer() -> None: assert unit.work == "P927 pending" assert unit.change == progress.line assert unit.next == Moving.action - assert unit.brief().count("#3572") == 1 + assert unit.brief() == ( + "worker | moving | P927 pending | PR #3572 open at 87dabd883 | let it continue" + ) assert tuple(plan.name for plan in FieldCodec.field_plans(type(unit.unit)) if DisclosureIdentity.read(plan.annotation, False)) == ("name",) From dea927107116407abd9824b33ca1c0f3a501c577 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 02:11:19 -0400 Subject: [PATCH 03/16] Decode disclosure shape once for heartbeat and native consumers --- src/agent_comms/disclosure_text.py | 85 +++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 8 deletions(-) diff --git a/src/agent_comms/disclosure_text.py b/src/agent_comms/disclosure_text.py index 48a1b639f2..ad774a787c 100644 --- a/src/agent_comms/disclosure_text.py +++ b/src/agent_comms/disclosure_text.py @@ -2,11 +2,13 @@ from __future__ import annotations +from collections.abc import Collection, Mapping, Sequence from dataclasses import dataclass, is_dataclass from functools import singledispatchmethod +from typing import ClassVar, Hashable from agent_comms.declared_family import FamilyMeta -from agent_comms.field_codec import DisclosureIdentity, FieldCodec +from agent_comms.field_codec import DisclosureIdentity, FieldCodec, FieldPlan @dataclass(frozen=True) @@ -16,16 +18,43 @@ class DisclosureText: @singledispatchmethod @classmethod def of(cls, value: object) -> DisclosureText: - """Recover foreign record shape once, at this presentation boundary.""" + """Recover foreign shape once, before heartbeat and UI consumers borrow it.""" if is_dataclass(value): + identity = tuple( + plan for plan in FieldCodec.field_plans(type(value)) + if DisclosureIdentity.read(plan.annotation, False) + ) + if identity: + return IdentifiedRecordText(value, identity) return RecordText(value) return cls(value) + @of.register(type) + @classmethod + def _class(cls, value: type) -> DisclosureText: + return ClassText(value) + @of.register(FamilyMeta) @classmethod def _family(cls, value: FamilyMeta) -> DisclosureText: return FamilyText(value) + @of.register(Mapping) + @classmethod + def _mapping(cls, value: Mapping[Hashable, object]) -> DisclosureText: + return MappingText(value) + + @of.register(Sequence) + @classmethod + def _sequence(cls, value: Sequence[object]) -> DisclosureText: + return SequenceText(value) + + @of.register(str) + @of.register(bytes) + @classmethod + def _scalar(cls, value: str | bytes) -> DisclosureText: + return cls(value) + def summary(self, width: int | None = None) -> str: return str(self.value)[:width].partition("\n")[0] @@ -42,20 +71,60 @@ def view(cls, value: object, view: str) -> tuple[str, ...]: class RecordText(DisclosureText): + def summary_fields(self) -> tuple[FieldPlan, ...]: + return tuple(plan for plan in FieldCodec.field_plans(type(self.value)) if plan.records) + def summary(self, width: int | None = None) -> str: - plans = tuple(plan for plan in FieldCodec.field_plans(type(self.value)) if plan.records) - identity = tuple(plan for plan in plans if DisclosureIdentity.read(plan.annotation, False)) parts: list[str] = [] - for plan in identity or plans: - parts.append(DisclosureText.of(plan.current(self.value)).summary(width)) - if width is not None and sum(map(len, parts)) >= width: + length = 0 + for plan in self.summary_fields(): + part = DisclosureText.of(plan.current(self.value)).summary(width) + parts.append(part) + length += len(part) + len(" · ") + if width is not None and length >= width: break return " · ".join(parts)[:width] @dataclass(frozen=True) -class FamilyText(DisclosureText): +class IdentifiedRecordText(RecordText): + identity: tuple[FieldPlan, ...] + + def summary_fields(self) -> tuple[FieldPlan, ...]: + return self.identity + + +@dataclass(frozen=True) +class ClassText(DisclosureText): + value: type + + def summary(self, width: int | None = None) -> str: + return self.value.__name__[:width] + + +@dataclass(frozen=True) +class FamilyText(ClassText): value: FamilyMeta def summary(self, width: int | None = None) -> str: return self.value.declared_name[:width] + + +class CollectionText(DisclosureText): + value: Collection[object] + noun: ClassVar[str] = "item" + + def summary(self, width: int | None = None) -> str: + count = len(self.value) + return f"{count} {self.noun}{'s' if count != 1 else ''}"[:width] + + +@dataclass(frozen=True) +class SequenceText(CollectionText): + value: Sequence[object] + + +@dataclass(frozen=True) +class MappingText(CollectionText): + value: Mapping[Hashable, object] + noun = "field" From 1dae3a369eb4250cf4959fe05561b0992e1e289b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:04:53 -0400 Subject: [PATCH 04/16] Share commander HUD columns, family groups and retained alarms --- pyproject.toml | 1 + src/agent_comms/commander_heartbeat.py | 14 ++- src/agent_comms/commander_hud.py | 104 +++++++++++++----- src/agent_comms/disclosure_text.py | 12 +- src/agent_comms/field_codec.py | 60 +++++++++- src/agent_comms/recorded_time.py | 8 +- .../task_attachments/commander_pings.py | 80 ++++---------- src/agent_comms/ui_model/unit_state.py | 38 +++++++ tests/test_commander_hud_projection.py | 87 ++++++++++++--- tests/test_commander_notifications.py | 85 ++++++++------ tests/test_commander_skill_hook.py | 10 +- uv.lock | 13 ++- 12 files changed, 353 insertions(+), 159 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fc53e7492c..2c30e35c8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "metaclass-registry @ git+https://github.com/OpenHCSDev/metaclass-registry@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/commander_heartbeat.py b/src/agent_comms/commander_heartbeat.py index d795fe40c2..72a384bb3a 100644 --- a/src/agent_comms/commander_heartbeat.py +++ b/src/agent_comms/commander_heartbeat.py @@ -5,7 +5,7 @@ import time from collections.abc import Callable from contextlib import AbstractContextManager -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import TYPE_CHECKING from agent_comms.commander_hud import CommanderFrame, CommanderHud @@ -76,7 +76,10 @@ def failed(self, opened_wire: Callable[[], AbstractContextManager[Comms]], thread.incarnation, failure, CommanderUpdate.latest(comms, thread.incarnation), now=now, ): - update.send(comms, now, ()) + update.send(comms, now, ( + "HUD unavailable: heartbeat failed", + *CommanderHud.alarm_text((update,), now), + )) def observe(self, comms: Comms) -> tuple[CommanderObservation, ...]: return tuple( @@ -87,7 +90,7 @@ def observe(self, comms: Comms) -> tuple[CommanderObservation, ...]: def pass_body(self, comms: Comms, observed: tuple[CommanderObservation, ...]) -> None: """The committed packet survives connector replacement; local memory owns nothing.""" self.register(comms.registry) - CommanderFrame.replace_all(comms.root, tuple(item.hud for item in observed)) + published = {item.thread.incarnation: item.hud for item in observed} completed = [] now = time.time() try: @@ -113,9 +116,12 @@ def pass_body(self, comms: Comms, observed: tuple[CommanderObservation, ...]) -> not_applied=tuple( rule.rejected_line(error) for rule, error in rejected.items()), ): - update.send(comms, now, item.hud.brief()) + hud = replace(item.hud, packets=(update,)) + published[item.thread.incarnation] = hud + update.send(comms, now, hud.hud_text(now)) completed.append(item.thread.incarnation) finally: + CommanderFrame.replace_all(comms.root, tuple(published.values())) # A later commander's failure does not erase passes already completed. ServicePassStore.at(comms.root).heartbeat_passed( completed, diff --git a/src/agent_comms/commander_hud.py b/src/agent_comms/commander_hud.py index b7caa0f045..b6797dd38e 100644 --- a/src/agent_comms/commander_hud.py +++ b/src/agent_comms/commander_hud.py @@ -4,6 +4,9 @@ import asyncio import time +from itertools import zip_longest +from shutil import get_terminal_size +from textwrap import wrap from abc import ABC, abstractmethod from dataclasses import dataclass, field, replace from pathlib import Path @@ -22,7 +25,7 @@ from agent_comms.disclosure_text import DisclosureText from agent_comms.errors import ToolRefusedError from agent_comms.field_codec import ( - DisclosureIdentity, HudExclude, OrderId, WireTransient, projected, + DisclosureIdentity, FieldCodec, HudExclude, OrderId, ViewColumn, WireTransient, projected, ) from agent_comms.heartbeat_frame import HeartbeatFrame from agent_comms.locked_store import JsonDocument @@ -34,7 +37,7 @@ 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.ui_model.unit_state import HudGroup, UnitClock, UnitState from agent_comms.task_attachments.commander_pings import CommanderUpdate from agent_comms_hooks.board import BoardEntry @@ -107,10 +110,6 @@ def change(self) -> str: def next(self) -> str: return self.state.action - def brief(self) -> str: - """The same declared HUD view supplies the heartbeat's text scan line.""" - return " | ".join(DisclosureText.view(self, "hud")) - 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( @@ -142,6 +141,14 @@ def detail(self, comms: Comms, commander: ThreadIncarnation, return CommanderUnitDetail(self, details, turns.to_wire()) +@dataclass(frozen=True) +class CommanderGroup: + """A stable partition of the current units, not another unit-state owner.""" + + group: type[HudGroup] + units: tuple[CommanderUnit, ...] + + @dataclass(frozen=True) class CommanderOrderDetail: """The selected card beside its original instructions, story and captured Git points.""" @@ -277,7 +284,7 @@ class CommanderHud(ToolResult): load: OverloadDegradation forks: tuple[ThreadIncarnation, ...] usage: CodexUsage - board: tuple[BoardEntry, ...] = () + packets: tuple[CommanderUpdate, ...] = () heartbeat: Annotated[EventTime, WireTransient()] = field( default_factory=UnrecordedEventTime, compare=False, kw_only=True, ) @@ -331,11 +338,10 @@ 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, commander)) return cls(owner, tuple(units), phases, OverloadDegradation.from_orders(held, len(timers), sum(edits.values())), - forks, CodexUsage.read(), board) + forks, CodexUsage.read(), CommanderUpdate.latest(comms, owner), + heartbeat=ServicePassStore.at(comms.root).read().heartbeat_completed_at(owner)) def rows(self) -> tuple[HudRow, ...]: """Committed board order, actionable cards, live phases, then folded quiet units.""" @@ -355,29 +361,73 @@ def rows(self) -> tuple[HudRow, ...]: if phases), ) - 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) + @property + def board(self) -> tuple[BoardEntry, ...]: + """The current packet's original alarms; the frame keeps no second alert state.""" + return tuple(ping.board_entry() for packet in self.packets for ping in packet.handled) + + def groups(self) -> tuple[CommanderGroup, ...]: + """Stable partition by the state family's declared groups, never a UI sort.""" + grouped: dict[type[HudGroup], list[CommanderUnit]] = {} + for unit in self.units: + grouped.setdefault(unit.state.hud_group, []).append(unit) + return tuple(CommanderGroup(group, tuple(grouped.get(group, ()))) + for group in HudGroup.members_with(HudGroup) + if grouped.get(group) or group.show_empty) + + def status_strip(self, now: float) -> str: + return ( + f"LOAD {self.load.total:.0f} · REVIEW UP {self.load.timers_up} · " + f"UNJUDGED {self.load.unjudged} · FORKS {len(self.forks)} · " + f"{self.usage.line()} · heartbeat {self.heartbeat.age(now)}" ) - 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) + + @staticmethod + def alarm_text(packets: tuple[CommanderUpdate, ...], now: float) -> tuple[str, ...]: + """New membership belongs to the recorded packet, age to the original BoardEntry.""" + new = tuple(ping for packet in packets for ping in packet.new_pings()) 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 ()), + "NEW", + *(f" {ping.render(now)}" for ping in new), + "STILL OPEN", + *(f" {ping.render(now)}" for packet in packets + for ping in packet.handled if ping not in new), + *(line for packet in packets for line in packet.not_applied), ) + def hud_text(self, now: float, *, width: int | None = None, + colour: bool = False) -> tuple[str, ...]: + """The heartbeat and the native page borrow the same columns, groups and strip.""" + columns = FieldCodec.view_columns(CommanderUnit, "hud") + headings = tuple(column.label for column in columns) + groups = self.groups() + cells = {unit.unit: DisclosureText.view(unit, "hud") for unit in self.units} + weights = tuple(max(len(label), max( + (len(line) for row in cells.values() for line in row[index].splitlines()), default=0, + )) for index, label in enumerate(headings)) + available = get_terminal_size().columns if width is None else width + minimums = tuple(column.minimum_width(row[index] for row in cells.values()) + for index, column in enumerate(columns)) + widths = ViewColumn.fit_widths(weights, available - len(" ") * (len(columns) - 1), minimums) + + def lines(row: tuple[str, ...]) -> tuple[str, ...]: + wrapped = tuple(tuple(part for line in text.splitlines() or ("",) + for part in wrap(line, cell_width, replace_whitespace=False) or ("",)) + for text, cell_width in zip(row, widths)) + return tuple(" ".join(value.ljust(cell_width) for value, cell_width in zip(parts, widths)).rstrip() + for parts in zip_longest(*wrapped, fillvalue="")) + + result = [self.status_strip(now)] + for section in groups: + result.extend(("", section.group.paint(section.group.heading(), colour), *lines(headings))) + for unit in section.units: + result.extend(section.group.paint(line, colour) for line in lines(cells[unit.unit])) + result.extend(("", *self.alarm_text(self.packets, now))) + return tuple(result) + @projected(view="wire") def frame(self) -> tuple[str, ...]: - """The load line, then one line per row in the order rows() declares.""" - return (str(self.load), *self.heartbeat.aged_line("heartbeat", time.time()), - *(row.line() for row in self.rows())) + return self.hud_text(time.time()) diff --git a/src/agent_comms/disclosure_text.py b/src/agent_comms/disclosure_text.py index ad774a787c..f209ae8882 100644 --- a/src/agent_comms/disclosure_text.py +++ b/src/agent_comms/disclosure_text.py @@ -56,18 +56,14 @@ def _scalar(cls, value: str | bytes) -> DisclosureText: return cls(value) def summary(self, width: int | None = None) -> str: - return str(self.value)[:width].partition("\n")[0] + text = str(self.value)[:width] + return text if width is None else text.partition("\n")[0] @classmethod def view(cls, value: object, view: str) -> tuple[str, ...]: """Read the original values, not their flattened JSON representations.""" - return ( - *(cls.of(plan.current(value)).summary() - for plan in FieldCodec.field_plans(type(value)) - if plan.records and not plan.excluded_in(view)), - *(cls.of(getattr(value, name)).summary() - for name in FieldCodec.projections(type(value), view)), - ) + return tuple(cls.of(column.current(value)).summary() + for column in FieldCodec.view_columns(type(value), view)) class RecordText(DisclosureText): diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index f28de0e47a..0d3109f2d8 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -44,6 +44,7 @@ ) 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 @@ -527,8 +528,45 @@ def add_to( owner.add_argument(*flags, **options) +class ViewColumn: + """Stored and computed fields expose one read-only presentation contract.""" + + name: str + annotation: object + + @property + def label(self) -> str: + return declared_field_label(self.annotation) or self.name.replace("_", " ").title() + + def current(self, record: object) -> object: + raise NotImplementedError + + 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) + minimums = tuple(minimums) + if sum(minimums) > available: + minimums = (1,) * len(weights) + 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 @@ -2209,7 +2247,7 @@ def minimum_digits(cls) -> int: return 7 -class Projected(property): +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() @@ -2221,8 +2259,16 @@ def __init__(self, getter, *, view: str, name: str | None = None): def __set_name__(self, owner: type, name: str) -> None: """Every class body declaring this field records it, a slots rebuild included.""" + self.name = name Projected._declared.setdefault(owner, {})[name] = self + @cached_property + def annotation(self) -> object: + return get_type_hints(self.fget, include_extras=True)["return"] + + 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.""" @@ -2661,6 +2707,16 @@ 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 + @cache + def view_columns(cls, declaration: type, view: str) -> 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]: diff --git a/src/agent_comms/recorded_time.py b/src/agent_comms/recorded_time.py index 80f914c6ac..21a632e1cb 100644 --- a/src/agent_comms/recorded_time.py +++ b/src/agent_comms/recorded_time.py @@ -87,6 +87,9 @@ def before(self, timestamp: float) -> bool: def clock(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 () @@ -141,8 +144,11 @@ def before(self, timestamp: float) -> bool: def clock(self) -> str: return time.strftime("%H:%M:%S", time.localtime(self.at)) + def age(self, now: float) -> str: + return f"{max(0, int(now - self.at))} s 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/task_attachments/commander_pings.py b/src/agent_comms/task_attachments/commander_pings.py index e3a35ec684..67eb76b965 100644 --- a/src/agent_comms/task_attachments/commander_pings.py +++ b/src/agent_comms/task_attachments/commander_pings.py @@ -1,7 +1,7 @@ -"""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 +A packet carries only lines that need the commander. New lines wake it; unchanged lines stay +visible in passive heartbeat notices; lines still undisposed past the answer window refuse the commander's writing verbs until a recorded action on their subject disposes of them. No process-local cache owns any of it. """ @@ -9,8 +9,6 @@ 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 agent_comms.bus_publication import AgentOrigin @@ -65,11 +63,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 @@ -114,20 +107,12 @@ def board_entry(self) -> BoardEntry: def render(self, now: float) -> str: return self.board_entry().aged(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) - @dataclass(frozen=True) 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 @@ -154,10 +139,6 @@ class UnitPing(Ping): questions: tuple[MessageReference, ...] observation: TextDigest - @property - def group(self) -> str: - return self.unit.name - @property def key(self) -> ThreadIncarnation: return self.unit @@ -183,7 +164,6 @@ class CommanderUpdate(TaskAttachment, declared_name=None): handled: tuple[Ping, ...] not_applied: tuple[str, ...] = field(default=(), kw_only=True) role: ClassVar[MessageRole] - header: ClassVar[str] = "COMMANDER" writes: ClassVar[tuple[type[ProducedRow], ...]] = (CommanderUpdatePosition,) @@ -194,21 +174,14 @@ def projections(self, message: Message) -> Iterator[TypedTable]: def commander_update(self, commander: ThreadIncarnation) -> tuple[CommanderUpdate, ...]: return (self,) if self.commander == commander else () - 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) - - @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, "", + "Expand in Toad: F2 or /commander · original orders, story, commits, activity and every alert", )) def board(self, comms: Comms, commander: str) -> tuple[BoardEntry, ...]: @@ -268,9 +241,8 @@ def derive(cls, commander: ThreadIncarnation, states: tuple[UnitState, ...], 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.""" + """New observations wake the commander. Every beat also publishes unchanged alarms + through a passive notice; publication never counts as disposing of an alarm.""" 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()} @@ -287,8 +259,7 @@ def derive(cls, commander: ThreadIncarnation, states: tuple[UnitState, ...], 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)) + return (CommanderNotice(commander, handled, not_applied=not_applied),) @classmethod def report_failure(cls, commander: ThreadIncarnation, failure: ServicePassFailing, @@ -307,12 +278,12 @@ 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: + def send(self, comms: Comms, now: float, hud: tuple[str, ...]) -> Message: from agent_comms.commander_heartbeat import CommanderHeartbeat message = Message( sender=CommanderHeartbeat.sender, target=self.commander.name, - body=self.body(now, brief), + body=self.body(hud), type=InfoKind, role=self.role, task=self, ) return comms.bus.publisher.publish_ordinary( @@ -322,15 +293,14 @@ def send(self, comms: Comms, now: float, brief: tuple[str, ...]) -> Message: @dataclass(frozen=True) class CommanderNotice(CommanderUpdate): + """The current HUD without new requests: publish it, never wake for it.""" + role = NoticeRole() async def wake(self, bridge: ClaudeBridge, message: Message) -> type[Outcome]: - """Cleared lines leave the board; nothing new needs the session.""" + """Unchanged alarms remain visible; nothing new needs the session.""" return DeliveredOutcome - def lines(self, now: float) -> tuple[str, ...]: - return (f"Cleared · {len(self.handled)} requests remain on the board.", *self.not_applied) - @dataclass(frozen=True) class CommanderDecisions(CommanderUpdate): @@ -343,17 +313,5 @@ class CommanderDecisions(CommanderUpdate): async def wake(self, bridge: ClaudeBridge, 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 diff --git a/src/agent_comms/ui_model/unit_state.py b/src/agent_comms/ui_model/unit_state.py index 44e79cf5b5..13dc098b75 100644 --- a/src/agent_comms/ui_model/unit_state.py +++ b/src/agent_comms/ui_model/unit_state.py @@ -174,9 +174,43 @@ def merges(self) -> dict[str, OrderProgress]: for origin in self.sources} +class HudGroup(DeclaredFamily, affix="Group"): + """The declared order and headings shared by heartbeat and native table views.""" + + terminal_colour: ClassVar[int] + show_empty: ClassVar[bool] = True + + @classmethod + def heading(cls) -> str: + return cls.declared_name.replace("_", " ").upper() + + @classmethod + def paint(cls, text: str, colour: bool) -> str: + return f"\x1b[{cls.terminal_colour}m{text}\x1b[0m" if colour else text + + +class UrgentGroup(HudGroup): + terminal_colour = 33 + + +class InProgressGroup(HudGroup): + terminal_colour = 32 + + +class ReadyToTestGroup(HudGroup): + terminal_colour = 36 + + +class IdleGroup(HudGroup): + terminal_colour = 90 + show_empty = False + + class NeedsCommanderAttention: """Urgent siblings share their ping behavior without claiming each other's state.""" + hud_group: ClassVar[type[HudGroup]] = UrgentGroup + @classmethod def attention(cls) -> bool: return True @@ -189,6 +223,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] @@ -458,6 +493,7 @@ def matches(cls, source: UnitObservation) -> bool: class DeliveredUnclosed(NeedsCommanderAttention, ConditionalState, UnitState): + hud_group = ReadyToTestGroup question = "delivered and still open. why isn't it closed?" action = "install the merge or close with the working application evidence" @@ -476,6 +512,7 @@ class AwaitingApplicationRun(NeedsCommanderAttention, UnitState): 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.""" + hud_group = ReadyToTestGroup question = "installed. did its holder actually run it yet?" action = "its holder runs the application proof on the installed build; close it on its evidence" commander_disposes = False @@ -491,6 +528,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/tests/test_commander_hud_projection.py b/tests/test_commander_hud_projection.py index 62048515f1..fa4c5fb656 100644 --- a/tests/test_commander_hud_projection.py +++ b/tests/test_commander_hud_projection.py @@ -1,30 +1,85 @@ -"""The heartbeat and table borrow the same declared HUD projection.""" +"""Root-owned controls for the shared heartbeat and native HUD presentation.""" -from agent_comms.commander_hud import CommanderOrder, CommanderUnit +from dataclasses import replace + +from agent_comms.commander.vibe_check import OverloadDegradation +from agent_comms.commander_hud import CommanderHud, CommanderOrder, CommanderUnit, CodexUsage, UnreadableUsage +from agent_comms.disclosure_text import DisclosureText 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 Moving, NoPendingClock +from agent_comms.ui_model.unit_state import ( + AwaitingApplicationRun, Idle, IdleGroup, InProgressGroup, Moving, NoPendingClock, + ReadyToTestGroup, Stuck, UrgentGroup, +) -def test_commander_hud_projection_has_one_progress_answer() -> None: - progress = OpenPrProgress(3572, "87dabd883", at=1.0) +def example_hud() -> CommanderHud: + """Controlled source example; no provider call or claim about the installed fleet.""" order = CommanderOrder( - "P927", "Make the screen readable", OrderId("123456789abc"), - "pending", progress, (), 0, 0, + "P930", "Make the screen readable", OrderId("123456789abc"), + "pending", OpenPrProgress(3589, "8636a6f28", at=1.0), (), 0, 0, ) - unit = CommanderUnit( - RecordedThreadIncarnation("worker", 2.0), Moving, NoPendingClock(), + worker = CommanderUnit( + RecordedThreadIncarnation("screen", 2.0), Moving, NoPendingClock(), (order,), 0, (), (), ) + commander = RecordedThreadIncarnation("lead", 1.0) + old = replace(RulePing.capture(ServicePassFailing("source", 900, "read refused")), + since=RecordedEventTime(900)) + new = replace(RulePing.capture(ServicePassFailing("review", 1000, "head needs review")), + since=RecordedEventTime(1000)) + return CommanderHud( + commander, ( + 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=()), + ), (), OverloadDegradation(3, 1, 2), (), + CodexUsage(UnreadableUsage(reason="not requested in source example")), + (CommanderDecisions(commander, (old, new), (new,), (old,)),), + heartbeat=RecordedEventTime(990), + ) + + +def test_commander_hud_projection_has_one_progress_answer() -> None: + unit = example_hud().units[0] assert tuple(FieldCodec.projections(CommanderUnit, "hud")) == ("work", "change", "next") - assert tuple(plan.name for plan in FieldCodec.field_plans(CommanderUnit) - if plan.records and not plan.excluded_in("hud")) == ("unit", "state") - assert unit.work == "P927 pending" - assert unit.change == progress.line - assert unit.next == Moving.action - assert unit.brief() == ( - "worker | moving | P927 pending | PR #3572 open at 87dabd883 | let it continue" + columns = FieldCodec.view_columns(CommanderUnit, "hud") + assert tuple(column.name for column in columns) == ("unit", "state", "work", "change", "next") + assert DisclosureText.view(unit, "hud") == ( + "screen", "moving", "P930 pending", "PR #3589 open at 8636a6f28", "let it continue", ) 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")) + assert "\n" in DisclosureText.view(replace(unit, orders=(*unit.orders, second)), "hud")[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.units) == ( + "blocked", "screen", "run-next", "idle", + ) + plain = hud.hud_text(1000, width=120) + assert plain[0] == hud.status_strip(1000) + assert "LOAD 7 · REVIEW UP 1 · UNJUDGED 2 · FORKS 0" in plain[0] + assert plain[0].endswith("heartbeat 10 s ago") + assert "\x1b" not in "\n".join(plain) + headings = [section.group.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, width=120, colour=True)) + alarms = hud.alarm_text(hud.packets, 1000) + assert alarms == ( + "NEW", f" {hud.packets[0].new_pings()[0].render(1000)}", "STILL OPEN", + f" {hud.packets[0].handled[0].render(1000)}", + ) + assert tuple(unit.unit for unit in groups[0].units) == (hud.units[1].unit,) diff --git a/tests/test_commander_notifications.py b/tests/test_commander_notifications.py index 4aecf0582b..6a0dd2cc45 100644 --- a/tests/test_commander_notifications.py +++ b/tests/test_commander_notifications.py @@ -25,6 +25,7 @@ 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 from agent_comms.ui_model import observation from agent_comms.ui_model.observation import CommanderHudObserved, ObservationService, ObserveCommanderHud @@ -35,7 +36,6 @@ AwaitingApplicationRun, NoPendingClock, PendingClock, Spinning, Stuck, UnitState, ) from agent_comms.thread_identity import RecordedThreadIncarnation -from agent_comms_hooks.board import BoardEntry from agent_comms.wake_policy import PassiveWake from agent_comms.bus_publication import AgentOrigin from agent_comms.channel_targets import ThreadTarget @@ -76,7 +76,8 @@ def beat(comms, rules=(), *, states=()): comms.registry.require("lead"), (), rules, states, hud=lead_hud(comms, states)) # Deliberately construct a fresh watcher on every pass, never reuse a cache. CommanderHeartbeat().pass_body(comms, (frame,)) - return frame.hud + return next(hud for hud in CommanderFrame.read_all(comms.root) + if hud.commander == frame.hud.commander) def test_expanded_unit_reads_original_orders_and_bounded_activity_then_refuses_stale_selection( @@ -244,11 +245,15 @@ def test_an_independent_heartbeat_message_never_replaces_the_board(notification_ lead = comms.registry.require("lead").incarnation assert CommanderUpdate.latest(comms, lead) == (board.task,) assert CommanderUpdate.current_board(comms, "lead") == board.task.board(comms, "lead") - assert lead_hud(comms).board == board.task.board(comms, "lead") + assert tuple(entry.text for entry in lead_hud(comms).board) == tuple( + ping.text for ping in board.task.handled) + 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 derives from the typed board and passively repeats the open alarm. 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): @@ -352,7 +357,7 @@ def test_frame_puts_hands_first_and_folds_finished_phases_per_plan(notification_ CommanderPhase(RecordPlanePhase, False, True, ()), CommanderPhase(ReviewQueuePhase, True, False, (worker,)), ), OverloadDegradation.from_orders((), 0, 0), (worker,), CodexUsage(UnreadableUsage(reason='not requested'))) - assert hud.frame == ( + assert (str(hud.load), *(row.line() for row in hud.rows())) == ( str(hud.load), "commander_native/record_plane done=False ready=True holders=", "commander_native/review_queue done=True ready=False holders=worker", @@ -373,14 +378,16 @@ def test_frame_keeps_committed_board_order_and_folds_units_needing_no_hand(tmp_p 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), - ) + pings = tuple(replace(RulePing.capture(ServicePassFailing(name, began, text)), + since=RecordedEventTime(began)) + for name, began, text in (("blocked", now - 120, "BLOCKED waiting-run"), + ("judge", now - 240, "JUDGE #1"))) + board = tuple(ping.board_entry() for ping in pings) hud = CommanderHud( RecordedThreadIncarnation("lead", 3.0), (awaiting, stuck), (), OverloadDegradation.from_orders((), 0, 0), (), - CodexUsage(UnreadableUsage(reason="not requested")), board, + CodexUsage(UnreadableUsage(reason="not requested")), + (CommanderNotice(RecordedThreadIncarnation("lead", 3.0), pings),), ) CommanderFrame.replace_all(tmp_path, (hud,)) @@ -412,9 +419,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. CommanderHeartbeat().pass_body(comms, ()) assert CommanderFrame.read_all(comms.root) == () @@ -426,9 +433,8 @@ 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_keeps_original_alarm_visible_without_a_new_wake(notification_bus): + """A fresh watcher retains the original alarm age and repeats it as a passive notice.""" comms = notification_bus states = state_cards(comms, UnitState) assert states and not any(state.attention() for state in states) @@ -438,13 +444,16 @@ def test_recreated_watcher_reads_original_committed_unit_observation(notificatio assert failing.line() 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 first.body.startswith("LOAD ") + assert f"UNJUDGED {hud.load.unjudged}" in first.body assert all(card.unit.name in first.body for card in hud.units) 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 failing.line() in unchanged.body.split("STILL OPEN", 1)[1] def test_overdue_head_refusal_is_published_and_later_rule_still_runs(notification_bus): @@ -474,7 +483,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_list_every_new_line_then_repeat_it_without_a_new_wake(notification_bus): comms = notification_bus rules = tuple(ServicePassFailing(f"watch-{index}", 1.0, "failed source read") for index in range(7)) @@ -484,12 +493,17 @@ def test_rule_packets_list_every_new_line_and_never_restate_one(notification_bus 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 first.body.startswith("LOAD ") 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 all(rule.line() in unchanged.body.split("STILL OPEN", 1)[1] for rule in rules) + # 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 + _, _, changed = notifications(comms) + assert isinstance(changed.task, CommanderNotice) + assert aged(changed.task.handled) == aged(first.task.handled) + assert "new diagnostic detail" in changed.body def test_resolution_is_passive_and_same_rule_can_recur(notification_bus): @@ -503,14 +517,14 @@ 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 quiet.body.startswith("LOAD ") assert all(isinstance(decision.wake_mode, PassiveWake) for decision in quiet.decisions) - # Nothing new and nothing cleared sends nothing. + # Even an empty board publishes the current HUD without waking its commander. beat(comms, ServicePassFailing.violations(comms, "lead")) - assert len(notifications(comms)) == 2 + assert len(notifications(comms)) == 3 # Use the same source occurrence to prove it was retired, not merely hidden. beat(comms, rules) - _, _, repeated = notifications(comms) + _, _, _, repeated = notifications(comms) assert repeated.task.decisions == first.task.decisions @@ -536,7 +550,9 @@ 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, CommanderNotice) + assert aged(repeated.task.handled) == aged(original.task.handled) def test_failed_rule_scan_raises_to_the_heartbeat(notification_bus, request): @@ -621,7 +637,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_every_open_alarm_and_its_original_evidence(notification_bus): comms = notification_bus from agent_comms.head_review import PullRequestHead from agent_comms.repository_name import RepositoryName @@ -636,9 +652,10 @@ 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 + hud = replace(lead_hud(comms), packets=(packet,)) + message = packet.send(comms, time.time(), hud.hud_text(time.time())) + assert sum(f"#{rule.request.pr} " in message.body for rule in rules) == len(rules) + assert all(rule.line() in message.body.split("STILL OPEN", 1)[1] for rule in rules) assert len(message.task.handled) == 12 assert {entry.text for entry in message.task.board(comms, "lead")} == {rule.line() for rule in rules} assert len(pickle.loads(pickle.dumps(message.task)).handled) == 12 diff --git a/tests/test_commander_skill_hook.py b/tests/test_commander_skill_hook.py index bc4740d5a0..472dedab0b 100644 --- a/tests/test_commander_skill_hook.py +++ b/tests/test_commander_skill_hook.py @@ -252,8 +252,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)) @@ -268,13 +268,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)", @@ -284,7 +284,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/uv.lock b/uv.lock index 8248960e0e..60a79668b5 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.14.8, <3.15" [[package]] @@ -23,6 +23,7 @@ dependencies = [ { name = "metaclass-registry" }, { 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" }, ] @@ -729,6 +731,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" From c3c13dcb58ca480846f717373bbd0469cdc6956d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 04:26:41 -0400 Subject: [PATCH 05/16] Keep commander alarms actionable and share HUD tables and publication cadence --- src/agent_comms/cli_bridges/claude.py | 30 +- src/agent_comms/cli_bridges/shared.py | 6 +- .../commander/publication_rules.py | 2 +- src/agent_comms/commander_heartbeat.py | 22 +- src/agent_comms/commander_hud.py | 284 ++++++++---------- src/agent_comms/disclosure_text.py | 51 +++- src/agent_comms/field_codec.py | 6 +- src/agent_comms/heartbeat_frame.py | 5 + src/agent_comms/standing_rules.py | 4 +- .../task_attachments/commander_pings.py | 140 +++++++-- src/agent_comms/ui_model/unit_state.py | 60 +++- tests/test_commander_hud_projection.py | 16 +- tests/test_commander_notifications.py | 144 ++++----- tests/test_order_completion.py | 4 +- tests/test_standing_rules.py | 11 +- tests/test_unit_state.py | 12 +- 16 files changed, 477 insertions(+), 320 deletions(-) diff --git a/src/agent_comms/cli_bridges/claude.py b/src/agent_comms/cli_bridges/claude.py index f862f1c15e..d631868cf1 100755 --- a/src/agent_comms/cli_bridges/claude.py +++ b/src/agent_comms/cli_bridges/claude.py @@ -3,14 +3,11 @@ import asyncio from contextlib import aclosing -from dataclasses import replace from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, AsyncIterator, BinaryIO, ClassVar +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 @@ -22,22 +19,12 @@ 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. - - 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 only gets a wake line, since an idle session runs no tool calls.""" + """Follow the Claude session and deliver the original waking HUD through its native frame. - board_wake: ClassVar[str] = ( - "Heartbeat: your board changed. New and changed lines print once as BOARD: on your next " - "tool call; later calls print one unchanged count." - ) + 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. + """ incoming_frame = ClaudeMonitorFrame window_bytes = ClaudeMonitorFrame.BATCH_CHARS * 2 @@ -53,13 +40,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(replace(message, body=self.board_wake)) - def history_start(self, stream: BinaryIO, boundary: int) -> int: """On restart only the last 4 MiB is published again; Core's finish is idempotent, so completed items may overlap. The first record starts after a newline in that window.""" diff --git a/src/agent_comms/cli_bridges/shared.py b/src/agent_comms/cli_bridges/shared.py index ad1b83fa99..f7f41dd1a6 100644 --- a/src/agent_comms/cli_bridges/shared.py +++ b/src/agent_comms/cli_bridges/shared.py @@ -1131,7 +1131,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/commander/publication_rules.py b/src/agent_comms/commander/publication_rules.py index c1ec057086..2083b45ba1 100644 --- a/src/agent_comms/commander/publication_rules.py +++ b/src/agent_comms/commander/publication_rules.py @@ -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_heartbeat.py b/src/agent_comms/commander_heartbeat.py index 72a384bb3a..b3ba2e1014 100644 --- a/src/agent_comms/commander_heartbeat.py +++ b/src/agent_comms/commander_heartbeat.py @@ -8,10 +8,11 @@ from dataclasses import dataclass, replace from typing import TYPE_CHECKING -from agent_comms.commander_hud import CommanderFrame, CommanderHud -from agent_comms.task_attachments.commander_pings import CommanderUpdate, RulePing, UnitPing +from agent_comms.commander_hud import CommanderFrame, CommanderHud, HudUnavailableGroup +from agent_comms.task_attachments.commander_pings import AlarmGroup, CommanderUpdate, RulePing, UnitPing from agent_comms.commander_service import CommanderService from agent_comms.heartbeat_frame import HeartbeatFrame +from agent_comms.heartbeat_subjects import CommanderHistory from agent_comms.message_bus import WatchPass from agent_comms.phase_evidence import PlanReadings, RetainedPlanReadings from agent_comms.service_passes import ServicePassStore @@ -76,9 +77,11 @@ def failed(self, opened_wire: Callable[[], AbstractContextManager[Comms]], thread.incarnation, failure, CommanderUpdate.latest(comms, thread.incarnation), now=now, ): + actions = CommanderHistory.read(comms, self.actors(comms.registry.snapshot(), thread.name)) + sections = AlarmGroup.sections((update,), update.open_pings(actions)) update.send(comms, now, ( - "HUD unavailable: heartbeat failed", - *CommanderHud.alarm_text((update,), now), + HudUnavailableGroup.heading(), failure.line(), + *CommanderHud.alarm_text(sections, now), )) def observe(self, comms: Comms) -> tuple[CommanderObservation, ...]: @@ -104,11 +107,12 @@ 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)), ) - for update in CommanderUpdate.derive( + actions = CommanderHistory.read(comms, self.actors(comms.registry.snapshot(), item.thread.name)) + for delivery in CommanderUpdate.derive( item.thread.incarnation, tuple(state for state in item.states if state not in rejected), tuple(rule for rule in item.rules if rule not in rejected), previous, - now=now, + now=now, history=actions, undisposed=tuple( ping for packet in previous for ping in packet.undisposed(comms, item.thread.name, @@ -116,9 +120,11 @@ def pass_body(self, comms: Comms, observed: tuple[CommanderObservation, ...]) -> not_applied=tuple( rule.rejected_line(error) for rule, error in rejected.items()), ): - hud = replace(item.hud, packets=(update,)) + hud = replace(item.hud, packets=(delivery.packet,), open_pings=delivery.open_pings) published[item.thread.incarnation] = hud - update.send(comms, now, hud.hud_text(now)) + changed = CommanderFrame.at(comms.root, hud.commander).changed(hud) + if delivery.packet.should_publish(changed): + delivery.packet.send(comms, now, hud.hud_text(now)) completed.append(item.thread.incarnation) finally: CommanderFrame.replace_all(comms.root, tuple(published.values())) diff --git a/src/agent_comms/commander_hud.py b/src/agent_comms/commander_hud.py index b6797dd38e..a520a66552 100644 --- a/src/agent_comms/commander_hud.py +++ b/src/agent_comms/commander_hud.py @@ -4,13 +4,12 @@ import asyncio import time -from itertools import zip_longest -from shutil import get_terminal_size -from textwrap import wrap +from collections.abc import Iterable +from functools import cached_property from abc import ABC, abstractmethod 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 @@ -22,12 +21,13 @@ from agent_comms.commander_frame_file import CommanderFrameFile from agent_comms.commander_service import CommanderService from agent_comms.coordinator import Coordination -from agent_comms.disclosure_text import DisclosureText +from agent_comms.disclosure_text import ViewTable from agent_comms.errors import ToolRefusedError from agent_comms.field_codec import ( - DisclosureIdentity, FieldCodec, HudExclude, OrderId, ViewColumn, WireTransient, projected, + DisclosureIdentity, HudExclude, MalformedRecord, OrderId, WireTransient, projected, ) from agent_comms.heartbeat_frame import HeartbeatFrame +from agent_comms.heartbeat_subjects import CommanderHistory from agent_comms.locked_store import JsonDocument from agent_comms.plan_phases import Plan, PlanPhase from agent_comms.order_story import OrderStory @@ -37,8 +37,10 @@ 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 HudGroup, 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, AlarmSummary, CommanderUpdate, Ping, +) from agent_comms_hooks.board import BoardEntry if TYPE_CHECKING: @@ -59,19 +61,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): + terminal_colour = 36 - def needs_hand(self) -> bool: - return False +class FinishedPlansGroup(ExpandedHudGroup, HudGroup): + terminal_colour = 90 + + +class HudUnavailableGroup(HudGroup): + terminal_colour = 31 + + +class HudRecord(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 @@ -86,17 +100,9 @@ class CommanderUnit(HudRow): 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 @projected(view="hud") def work(self) -> str: @@ -110,12 +116,6 @@ def change(self) -> str: def next(self) -> str: return self.state.action - 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 - ) - def detail(self, comms: Comms, commander: ThreadIncarnation, orders: tuple[OrderId, ...]) -> CommanderUnitDetail: """Expand this published selection from its original record owners.""" @@ -142,11 +142,27 @@ def detail(self, comms: Comms, commander: ThreadIncarnation, @dataclass(frozen=True) -class CommanderGroup: - """A stable partition of the current units, not another unit-state owner.""" +class HudSection: + """One group heading, with native tables partitioned by their actual declarations.""" group: type[HudGroup] - units: tuple[CommanderUnit, ...] + 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, "hud", tuple(rows)) + for declaration, rows in grouped[group].items())) + for group in HudGroup.members_with(HudGroup) if group in grouped) @dataclass(frozen=True) @@ -169,7 +185,7 @@ 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] @@ -186,48 +202,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], ...] + 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="hud") + def done(self) -> int: + return len(self.phases) class UnreadableUsage(RateLimitReading): @@ -241,7 +231,7 @@ def needs_hand(self) -> bool: @dataclass(frozen=True) -class CodexUsage(HudRow): +class CodexUsage: reading: RateLimitReading @classmethod @@ -258,16 +248,6 @@ def needs_hand(self) -> bool: return self.reading.needs_hand() -@dataclass(frozen=True) -class CommanderForks(HudRow): - """The commander's forks Core registered and still hears.""" - - 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 CommanderHud(ToolResult): """One disposable view of native records, shared by comms_hud and the commander board. @@ -285,6 +265,7 @@ class CommanderHud(ToolResult): forks: tuple[ThreadIncarnation, ...] usage: CodexUsage packets: tuple[CommanderUpdate, ...] = () + open_pings: tuple[Ping, ...] = field(default=(), kw_only=True) heartbeat: Annotated[EventTime, WireTransient()] = field( default_factory=UnrecordedEventTime, compare=False, kw_only=True, ) @@ -298,14 +279,13 @@ def read(cls, comms: Comms, commander: str) -> CommanderHud: def of(cls, comms: Comms, commander: str, frame: HeartbeatFrame, states: tuple[UnitState, ...]) -> CommanderHud: """Cards follow ``states``, already in the UnitState family's declared order.""" - snapshot, now = comms.registry.snapshot(), time.time() + snapshot = comms.registry.snapshot() owner = snapshot.require(commander).incarnation - store = comms.goals.todos 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() @@ -328,7 +308,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()), )) @@ -338,42 +318,30 @@ 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) + packets = CommanderUpdate.latest(comms, owner) + actions = CommanderHistory.read(comms, CommanderService.actors(snapshot, commander)) + opened = tuple(ping for packet in packets for ping in packet.open_pings(actions)) return cls(owner, tuple(units), phases, OverloadDegradation.from_orders(held, len(timers), sum(edits.values())), - forks, CodexUsage.read(), CommanderUpdate.latest(comms, owner), + forks, CodexUsage.read(), packets, open_pings=opened, heartbeat=ServicePassStore.at(comms.root).read().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), - ) - @property def board(self) -> tuple[BoardEntry, ...]: - """The current packet's original alarms; the frame keeps no second alert state.""" - return tuple(ping.board_entry() for packet in self.packets for ping in packet.handled) - - def groups(self) -> tuple[CommanderGroup, ...]: - """Stable partition by the state family's declared groups, never a UI sort.""" - grouped: dict[type[HudGroup], list[CommanderUnit]] = {} - for unit in self.units: - grouped.setdefault(unit.state.hud_group, []).append(unit) - return tuple(CommanderGroup(group, tuple(grouped.get(group, ()))) - for group in HudGroup.members_with(HudGroup) - if grouped.get(group) or group.show_empty) + """The one action-filtered set captured from Ping.on_board for this publication.""" + return tuple(ping.board_entry() for ping in self.open_pings) + + @property + def alarms(self) -> tuple[AlarmSection, ...]: + return AlarmGroup.sections(self.packets, self.open_pings) + + 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_strip(self, now: float) -> str: return ( @@ -383,46 +351,30 @@ def status_strip(self, now: float) -> str: ) @staticmethod - def alarm_text(packets: tuple[CommanderUpdate, ...], now: float) -> tuple[str, ...]: - """New membership belongs to the recorded packet, age to the original BoardEntry.""" - new = tuple(ping for packet in packets for ping in packet.new_pings()) - return ( - "NEW", - *(f" {ping.render(now)}" for ping in new), - "STILL OPEN", - *(f" {ping.render(now)}" for packet in packets - for ping in packet.handled if ping not in new), - *(line for packet in packets for line in packet.not_applied), - ) - - def hud_text(self, now: float, *, width: int | None = None, - colour: bool = False) -> tuple[str, ...]: - """The heartbeat and the native page borrow the same columns, groups and strip.""" - columns = FieldCodec.view_columns(CommanderUnit, "hud") - headings = tuple(column.label for column in columns) - groups = self.groups() - cells = {unit.unit: DisclosureText.view(unit, "hud") for unit in self.units} - weights = tuple(max(len(label), max( - (len(line) for row in cells.values() for line in row[index].splitlines()), default=0, - )) for index, label in enumerate(headings)) - available = get_terminal_size().columns if width is None else width - minimums = tuple(column.minimum_width(row[index] for row in cells.values()) - for index, column in enumerate(columns)) - widths = ViewColumn.fit_widths(weights, available - len(" ") * (len(columns) - 1), minimums) - - def lines(row: tuple[str, ...]) -> tuple[str, ...]: - wrapped = tuple(tuple(part for line in text.splitlines() or ("",) - for part in wrap(line, cell_width, replace_whitespace=False) or ("",)) - for text, cell_width in zip(row, widths)) - return tuple(" ".join(value.ljust(cell_width) for value, cell_width in zip(parts, widths)).rstrip() - for parts in zip_longest(*wrapped, fillvalue="")) + def alarm_text(sections: tuple[AlarmSection, ...], now: float, *, + width: int | None = None) -> tuple[str, ...]: + return tuple(line for section in sections for line in ( + section.heading(), *ViewTable(AlarmSummary, "hud", section.summaries(now)).lines(width), + )) + @staticmethod + def alarm_detail(sections: tuple[AlarmSection, ...], now: float) -> tuple[str, ...]: + """The expandable board borrows the same sections but retains every original line.""" + return tuple(line for section in sections for line in ( + section.heading(), *(ping.render(now) for ping in section.pings), + )) + + def hud_text(self, now: float, *, colour: bool = False, + width: int | None = None) -> tuple[str, ...]: + """The producer passes no width; an actual reader may supply its own viewport.""" result = [self.status_strip(now)] - for section in groups: - result.extend(("", section.group.paint(section.group.heading(), colour), *lines(headings))) - for unit in section.units: - result.extend(section.group.paint(line, colour) for line in lines(cells[unit.unit])) - result.extend(("", *self.alarm_text(self.packets, now))) + 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(width) for table in section.tables)) + result.extend(("", *self.alarm_text(self.alarms, now, width=width), + *(line for packet in self.packets for line in packet.not_applied))) return tuple(result) @projected(view="wire") @@ -437,17 +389,29 @@ 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. A previous frame is only a + quiet-publication baseline: an older shape is replaced, never treated as current evidence. """ path: Path 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 ToolRefusedError(f"heartbeat frame missing: {self.path}") + def changed(self, hud: CommanderHud) -> bool: + """Only the disposable frame is compared; a prior schema cannot block its replacement.""" + try: + previous = self.read() + except (MalformedRecord, ToolRefusedError): + return True + return previous != hud + @classmethod def current(cls, comms: Comms, commander: str) -> CommanderHud: """Read the heartbeat's publication, never repeat its forge and history work.""" @@ -457,7 +421,7 @@ def current(cls, comms: Comms, commander: str) -> CommanderHud: raise ToolRefusedError( f"heartbeat frame stale for {owner.name} since {completed.clock()}" ) - hud = cls(cls.path_in(comms.root, lookup=str(owner.lookup))).read() + hud = cls.at(comms.root, owner).read() if hud.commander != owner: raise ToolRefusedError(f"heartbeat frame belongs to another incarnation of {owner.name}") return replace(hud, heartbeat=completed) @@ -467,7 +431,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/disclosure_text.py b/src/agent_comms/disclosure_text.py index f209ae8882..a8d759036f 100644 --- a/src/agent_comms/disclosure_text.py +++ b/src/agent_comms/disclosure_text.py @@ -4,11 +4,13 @@ from collections.abc import Collection, Mapping, Sequence from dataclasses import dataclass, is_dataclass -from functools import singledispatchmethod +from functools import cached_property, singledispatchmethod +from itertools import zip_longest +from textwrap import wrap from typing import ClassVar, Hashable from agent_comms.declared_family import FamilyMeta -from agent_comms.field_codec import DisclosureIdentity, FieldCodec, FieldPlan +from agent_comms.field_codec import DisclosureIdentity, FieldCodec, FieldPlan, ViewColumn @dataclass(frozen=True) @@ -66,6 +68,51 @@ def view(cls, value: object, view: str) -> tuple[str, ...]: for column in FieldCodec.view_columns(type(value), view)) +@dataclass(frozen=True) +class ViewTable[R]: + """One declared column set owns cells, content demand and optional reader-side fitting.""" + + record: type[R] + view: str + records: tuple[R, ...] + + @cached_property + def columns(self) -> tuple[ViewColumn, ...]: + return FieldCodec.view_columns(self.record, self.view) + + @cached_property + def cells(self) -> tuple[tuple[DisclosureText, ...], ...]: + return tuple(tuple(DisclosureText.of(column.current(record)) for column in self.columns) + for record in self.records) + + @cached_property + def texts(self) -> tuple[tuple[str, ...], ...]: + return tuple(tuple(cell.summary() for cell in row) for row in self.cells) + + def widths(self, available: int | None = None) -> tuple[int, ...]: + weights = 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)) + if available is None: + return weights + minimums = tuple(column.minimum_width(row[index] for row in self.texts) + for index, column in enumerate(self.columns)) + return ViewColumn.fit_widths(weights, available, minimums) + + def lines(self, width: int | None = None) -> tuple[str, ...]: + """A producer supplies no width. Only a reader may ask this view to fit its viewport.""" + available = None if width is None else width - len(" ") * (len(self.columns) - 1) + widths = self.widths(available) + lines = [] + for row in (tuple(column.label for column in self.columns), *self.texts): + wrapped = tuple(tuple(part for line in text.splitlines() or ("",) + for part in wrap(line, cell_width, replace_whitespace=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="")) + return tuple(lines) + + class RecordText(DisclosureText): def summary_fields(self) -> tuple[FieldPlan, ...]: return tuple(plan for plan in FieldCodec.field_plans(type(self.value)) if plan.records) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 0d3109f2d8..d1bb026a5f 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -528,7 +528,7 @@ def add_to( owner.add_argument(*flags, **options) -class ViewColumn: +class ViewColumn(ABC): """Stored and computed fields expose one read-only presentation contract.""" name: str @@ -538,8 +538,8 @@ class ViewColumn: def label(self) -> str: return declared_field_label(self.annotation) or self.name.replace("_", " ").title() - def current(self, record: object) -> object: - raise NotImplementedError + @abstractmethod + def current(self, record: object) -> object: ... def minimum_width(self, values: Iterable[str]) -> int: """Keep headers and individual words intact while the viewport can afford them.""" diff --git a/src/agent_comms/heartbeat_frame.py b/src/agent_comms/heartbeat_frame.py index b6f7e48719..22942b8d69 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 from agent_comms.thread_identity import ThreadIncarnation from agent_comms.turn_digests import EditWorkplaces from agent_comms.todos import ( ClosedHeadReading, HeadReviewRecord, ProgressRead, StoredClosedHead, StoredHeadReview, + Todo, TodoStore, ) if TYPE_CHECKING: @@ -33,6 +36,7 @@ class HeartbeatFrame(HeldOrders): 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, pulls: ProgressRead, @@ -61,4 +65,5 @@ def capture(cls, comms: Comms, commander: str, pulls: ProgressRead, closed, reviews, EditWorkplaces.capture(comms, snapshot, scan.live, plan.todos, pulls), + TodoStore.timers_up_in(scan.todos, time.time(), pulls.progress(scan.todos, comms.registry)), ) diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index 87f66cc606..884bd1424f 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -760,9 +760,7 @@ class ReviewTimerUp(HeldOrderDue, FamilyScan): @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, diff --git a/src/agent_comms/task_attachments/commander_pings.py b/src/agent_comms/task_attachments/commander_pings.py index 67eb76b965..fed58850cf 100644 --- a/src/agent_comms/task_attachments/commander_pings.py +++ b/src/agent_comms/task_attachments/commander_pings.py @@ -1,9 +1,9 @@ """Committed commander notifications own the heartbeat's retained observations. -A packet carries only lines that need the commander. New lines wake it; unchanged lines stay -visible in passive heartbeat notices; lines still undisposed past the answer window 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 @@ -30,14 +30,13 @@ from agent_comms.task_sources import TaskAttachment from agent_comms.text_digest import TextDigest from agent_comms.thread_identity import ThreadIncarnation -from agent_comms.ui_model.unit_state import AwaitingCommander, UnitState +from agent_comms.ui_model.unit_state import AwaitingCommander, HudGroup, UnitState from agent_comms_hooks.board import BoardEntry if TYPE_CHECKING: from collections.abc import Iterator from agent_comms.bus import BusReader - 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 @@ -71,6 +70,10 @@ 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.""" began = self.since.recorded_at("A line") @@ -117,6 +120,10 @@ class RulePing(Ping): 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 @@ -139,6 +146,14 @@ class UnitPing(Ping): questions: tuple[MessageReference, ...] observation: TextDigest + @property + 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: return self.unit @@ -158,6 +173,14 @@ 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, ...] + + @dataclass(frozen=True) class CommanderUpdate(TaskAttachment, declared_name=None): commander: ThreadIncarnation @@ -174,6 +197,9 @@ def projections(self, message: Message) -> Iterator[TypedTable]: def commander_update(self, commander: ThreadIncarnation) -> tuple[CommanderUpdate, ...]: return (self,) if self.commander == commander else () + def should_publish(self, frame_changed: bool) -> bool: + return True + def new_pings(self) -> tuple[Ping, ...]: return () @@ -189,15 +215,17 @@ def board(self, comms: Comms, commander: str) -> tuple[BoardEntry, ...]: gate reads: a verdict drops its line on the next read, not the next packet.""" actors = CommanderService.actors(comms.registry.snapshot(), commander) history = CommanderHistory.read(comms, actors) - 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, comms: Comms, commander: str, cut: float) -> tuple[Ping, ...]: """Lines older than ``cut`` that no recorded action on their subjects has disposed of.""" @@ -239,10 +267,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 observations wake the commander. Every beat also publishes unchanged alarms - through a passive notice; publication never counts as disposing of an alarm.""" + history: CommanderHistory, undisposed: tuple[Ping, ...] = (), + not_applied: tuple[str, ...] = ()) -> tuple[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()} @@ -254,12 +282,13 @@ 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 (CommanderNotice(commander, handled, not_applied=not_applied),) + notice = CommanderNotice(commander, handled, not_applied=not_applied) + opened = notice.open_pings(history) + packet = CommanderDecisions( + commander, handled, new, + tuple(ping for ping in undisposed if ping in handled), 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, @@ -293,12 +322,15 @@ def send(self, comms: Comms, now: float, hud: tuple[str, ...]) -> Message: @dataclass(frozen=True) class CommanderNotice(CommanderUpdate): - """The current HUD without new requests: publish it, never wake for it.""" + """Publish a changed quiet frame once, without waking for an identical copy.""" role = NoticeRole() - async def wake(self, bridge: ClaudeBridge, message: Message) -> type[Outcome]: - """Unchanged alarms remain visible; nothing new needs the session.""" + def should_publish(self, frame_changed: bool) -> bool: + return frame_changed + + async def wake(self, bridge: CliBridge, message: Message) -> type[Outcome]: + """The current display has no unresolved request that needs model input.""" return DeliveredOutcome @@ -310,8 +342,68 @@ 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 new_pings(self) -> tuple[Ping, ...]: return self.decisions + + +class AlarmGroup(HudGroup, affix="Alarms"): + """Alarm section membership and headings share the HUD family's declaration behavior.""" + + @classmethod + @abstractmethod + def members(cls, packets: tuple[CommanderUpdate, ...], + opened: tuple[Ping, ...]) -> tuple[Ping, ...]: ... + + @classmethod + def sections(cls, packets: tuple[CommanderUpdate, ...], + opened: tuple[Ping, ...]) -> tuple[AlarmSection, ...]: + return tuple(AlarmSection(group, members) + for group in cls.members_with(cls) + for members in (group.members(packets, opened),) + if members or group.show_empty) + + +class NewAlarms(AlarmGroup): + terminal_colour = 36 + + @classmethod + def members(cls, packets: tuple[CommanderUpdate, ...], + opened: tuple[Ping, ...]) -> tuple[Ping, ...]: + return tuple(ping for packet in packets for ping in packet.new_pings()) + + +class StillOpenAlarms(AlarmGroup): + terminal_colour = 33 + + @classmethod + def members(cls, packets: tuple[CommanderUpdate, ...], + opened: tuple[Ping, ...]) -> tuple[Ping, ...]: + new = frozenset(NewAlarms.members(packets, opened)) + return tuple(ping for ping in opened if ping not in new) + + +@dataclass(frozen=True) +class AlarmSummary: + kind: type[DeclaredFamily] + count: int + oldest_age: str + + +@dataclass(frozen=True) +class AlarmSection: + group: type[AlarmGroup] + pings: tuple[Ping, ...] + + def heading(self) -> str: + return self.group.count_heading(len(self.pings)) + + def summaries(self, now: float) -> tuple[AlarmSummary, ...]: + grouped: dict[type[DeclaredFamily], list[Ping]] = {} + for ping in self.pings: + grouped.setdefault(ping.alarm_kind, []).append(ping) + return tuple(AlarmSummary(kind, len(pings), + min(pings, key=lambda ping: ping.since.chronology()).since.age(now)) + for kind, pings in grouped.items()) diff --git a/src/agent_comms/ui_model/unit_state.py b/src/agent_comms/ui_model/unit_state.py index 13dc098b75..6c881df1f2 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 @@ -26,6 +26,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 +35,6 @@ MergedProgress, NoCommitProgress, OrderProgress, - ProgressRead, ProgressSource, Todo, ) @@ -86,12 +86,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,15 +157,15 @@ 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: if self.todos: for path in self.thread.home_checkouts: - self.pulls.branch_difference(path) + self.frame.pulls.branch_difference(path) 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() @@ -174,24 +179,47 @@ def merges(self) -> dict[str, OrderProgress]: for origin in self.sources} +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(DeclaredFamily, affix="Group"): """The declared order and headings shared by heartbeat and native table views.""" terminal_colour: ClassVar[int] - show_empty: ClassVar[bool] = True + + @classmethod + def table_lines(cls, tables: Iterable[tuple[str, ...]]) -> tuple[str, ...]: + return () + + @classmethod + def needs_hand(cls) -> bool: + return False @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 f"\x1b[{cls.terminal_colour}m{text}\x1b[0m" if colour else text -class UrgentGroup(HudGroup): +class UrgentGroup(ExpandedHudGroup, HudGroup): terminal_colour = 33 + @classmethod + def needs_hand(cls) -> bool: + return True + class InProgressGroup(HudGroup): terminal_colour = 32 @@ -203,7 +231,6 @@ class ReadyToTestGroup(HudGroup): class IdleGroup(HudGroup): terminal_colour = 90 - show_empty = False class NeedsCommanderAttention: @@ -260,13 +287,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 @@ -492,8 +518,18 @@ def matches(cls, source: UnitObservation) -> bool: ) +class ReviewDue(NeedsCommanderAttention, 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(NeedsCommanderAttention, ConditionalState, UnitState): - hud_group = ReadyToTestGroup question = "delivered and still open. why isn't it closed?" action = "install the merge or close with the working application evidence" diff --git a/tests/test_commander_hud_projection.py b/tests/test_commander_hud_projection.py index fa4c5fb656..652dd08d76 100644 --- a/tests/test_commander_hud_projection.py +++ b/tests/test_commander_hud_projection.py @@ -65,7 +65,7 @@ def test_shared_hud_groups_strip_full_alarms_and_plain_text() -> None: 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.units) == ( + assert tuple(unit.unit.name for section in groups for unit in section.records) == ( "blocked", "screen", "run-next", "idle", ) plain = hud.hud_text(1000, width=120) @@ -73,13 +73,15 @@ def test_shared_hud_groups_strip_full_alarms_and_plain_text() -> None: assert "LOAD 7 · REVIEW UP 1 · UNJUDGED 2 · FORKS 0" in plain[0] assert plain[0].endswith("heartbeat 10 s ago") assert "\x1b" not in "\n".join(plain) - headings = [section.group.heading() for section in groups] + 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, width=120, colour=True)) - alarms = hud.alarm_text(hud.packets, 1000) - assert alarms == ( - "NEW", f" {hud.packets[0].new_pings()[0].render(1000)}", "STILL OPEN", - f" {hud.packets[0].handled[0].render(1000)}", + section, = hud.alarms + summary, = section.summaries(1000) + assert summary.kind is ServicePassFailing and summary.count == 1 + assert hud.board == () + assert hud.alarm_detail(hud.alarms, 1000) == ( + section.heading(), hud.packets[0].new_pings()[0].render(1000), ) - assert tuple(unit.unit for unit in groups[0].units) == (hud.units[1].unit,) + assert tuple(unit.unit for unit in groups[0].records) == (hud.units[1].unit,) diff --git a/tests/test_commander_notifications.py b/tests/test_commander_notifications.py index 6a0dd2cc45..c411014ccd 100644 --- a/tests/test_commander_notifications.py +++ b/tests/test_commander_notifications.py @@ -15,7 +15,7 @@ from agent_comms.child_process import ProcessIdentity from agent_comms.commander_heartbeat import CommanderHeartbeat, CommanderObservation 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 @@ -31,11 +31,10 @@ 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.wake_policy import PassiveWake from agent_comms.bus_publication import AgentOrigin from agent_comms.channel_targets import ThreadTarget @@ -189,7 +188,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. @@ -236,6 +236,43 @@ def everything_to_lead(comms): return {message.reference for message in page.messages} +def test_identical_quiet_beats_do_not_append_bus_history(notification_bus): + comms = notification_bus + beat(comms) + first, = notifications(comms) + assert isinstance(first.task, CommanderNotice) + beat(comms) + beat(comms) + 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, "lead") + 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("following-rule", time.time(), "independent source") @@ -245,11 +282,10 @@ def test_an_independent_heartbeat_message_never_replaces_the_board(notification_ lead = comms.registry.require("lead").incarnation assert CommanderUpdate.latest(comms, lead) == (board.task,) assert CommanderUpdate.current_board(comms, "lead") == board.task.board(comms, "lead") - assert tuple(entry.text for entry in lead_hud(comms).board) == tuple( - ping.text for ping in board.task.handled) + assert lead_hud(comms).board == board.task.board(comms, "lead") assert raw.reference in everything_to_lead(comms) - # The next pass derives from the typed board and passively repeats the open alarm. + # The next pass clears NEW without promoting a subjectless observation to STILL OPEN. beat(comms, (failing,)) _, repeated = notifications(comms) assert isinstance(repeated.task, CommanderNotice) @@ -357,48 +393,15 @@ def test_frame_puts_hands_first_and_folds_finished_phases_per_plan(notification_ CommanderPhase(RecordPlanePhase, False, True, ()), CommanderPhase(ReviewQueuePhase, True, False, (worker,)), ), OverloadDegradation.from_orders((), 0, 0), (worker,), CodexUsage(UnreadableUsage(reason='not requested'))) - assert (str(hud.load), *(row.line() for row in hud.rows())) == ( - str(hud.load), - "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", - ) - - -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), - ) - pings = tuple(replace(RulePing.capture(ServicePassFailing(name, began, text)), - since=RecordedEventTime(began)) - for name, began, text in (("blocked", now - 120, "BLOCKED waiting-run"), - ("judge", now - 240, "JUDGE #1"))) - board = tuple(ping.board_entry() for ping in pings) - hud = CommanderHud( - RecordedThreadIncarnation("lead", 3.0), (awaiting, stuck), (), - OverloadDegradation.from_orders((), 0, 0), (), - CodexUsage(UnreadableUsage(reason="not requested")), - (CommanderNotice(RecordedThreadIncarnation("lead", 3.0), 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) + 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_a_registered_commander_fork_is_on_the_hud(notification_bus): @@ -433,27 +436,28 @@ 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_keeps_original_alarm_visible_without_a_new_wake(notification_bus): - """A fresh watcher retains the original alarm age and repeats it as a passive notice.""" +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("following-rule", time.time(), "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("LOAD ") assert f"UNJUDGED {hud.load.unjudged}" in first.body - assert all(card.unit.name in first.body for card in hud.units) + 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 assert isinstance(unchanged.task, CommanderNotice) assert aged(unchanged.task.handled) == aged(first.task.handled) - assert failing.line() in unchanged.body.split("STILL OPEN", 1)[1] + assert lead_hud(comms).board == () + assert CommanderFrame.current(comms, "lead").alarms == () def test_overdue_head_refusal_is_published_and_later_rule_still_runs(notification_bus): @@ -492,18 +496,19 @@ def test_rule_packets_list_every_new_line_then_repeat_it_without_a_new_wake(noti 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 sum(summary.count for section in lead_hud(comms).alarms + for summary in section.summaries(time.time())) == len(rules) assert first.body.startswith("LOAD ") beat(comms, rules) _, unchanged = notifications(comms) assert isinstance(unchanged.task, CommanderNotice) - assert all(rule.line() in unchanged.body.split("STILL OPEN", 1)[1] for rule in rules) + 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)) - _, _, changed = notifications(comms) - assert isinstance(changed.task, CommanderNotice) - assert aged(changed.task.handled) == aged(first.task.handled) - assert "new diagnostic detail" in changed.body + assert len(notifications(comms)) == 2 + 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): @@ -519,12 +524,12 @@ def test_resolution_is_passive_and_same_rule_can_recur(notification_bus): assert isinstance(quiet.task, CommanderNotice) assert quiet.body.startswith("LOAD ") assert all(isinstance(decision.wake_mode, PassiveWake) for decision in quiet.decisions) - # Even an empty board publishes the current HUD without waking its commander. + # An identical quiet frame updates no bus history. beat(comms, ServicePassFailing.violations(comms, "lead")) - assert len(notifications(comms)) == 3 + assert len(notifications(comms)) == 2 # Use the same source occurrence to prove it was retired, not merely hidden. beat(comms, rules) - _, _, _, repeated = notifications(comms) + _, _, repeated = notifications(comms) assert repeated.task.decisions == first.task.decisions @@ -551,7 +556,8 @@ def test_new_local_commit_on_an_unpushed_branch_is_not_a_resolution(notification beat(comms, (first,)) beat(comms, (moved,)) original, repeated = notifications(comms) - assert isinstance(repeated.task, CommanderNotice) + assert isinstance(repeated.task, CommanderDecisions) + assert repeated.task.decisions == () assert aged(repeated.task.handled) == aged(original.task.handled) @@ -637,7 +643,7 @@ def test_codex_usage_decodes_window_and_keeps_refusal_visible(): assert FieldCodec.decode(CodexUsage, FieldCodec.encode(refused)) == refused -def test_waiting_notification_keeps_every_open_alarm_and_its_original_evidence(notification_bus): +def test_waiting_notification_summarises_kinds_and_retains_original_evidence(notification_bus): comms = notification_bus from agent_comms.head_review import PullRequestHead from agent_comms.repository_name import RepositoryName @@ -654,8 +660,10 @@ def test_waiting_notification_keeps_every_open_alarm_and_its_original_evidence(n ) hud = replace(lead_hud(comms), packets=(packet,)) message = packet.send(comms, time.time(), hud.hud_text(time.time())) - assert sum(f"#{rule.request.pr} " in message.body for rule in rules) == len(rules) - assert all(rule.line() in message.body.split("STILL OPEN", 1)[1] for rule in rules) + assert len(hud.alarm_text(hud.alarms, time.time())) < len(rules) + assert all(rule.line() not in message.body for rule in rules) + details = "\n".join(hud.alarm_detail(hud.alarms, time.time())) + assert all(rule.line() in details for rule in rules) assert len(message.task.handled) == 12 assert {entry.text for entry in message.task.board(comms, "lead")} == {rule.line() for rule in rules} assert len(pickle.loads(pickle.dumps(message.task)).handled) == 12 diff --git a/tests/test_order_completion.py b/tests/test_order_completion.py index bf59c061fb..ad552ad2fc 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 agent_comms.heartbeat_frame import HeartbeatFrame 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(), + HeartbeatFrame.capture(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_standing_rules.py b/tests/test_standing_rules.py index 0e153ba4ff..717baacac0 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 135272b0c3..910df4096d 100644 --- a/tests/test_unit_state.py +++ b/tests/test_unit_state.py @@ -21,6 +21,7 @@ from agent_comms.order_runs import ApplicationOrderRun from agent_comms.read_basis import DMDisplayScope from agent_comms.recorded_time import RecordedEventTime +from agent_comms.standing_rules import ReviewTimerUp from agent_comms.repo import Checkout from agent_comms.session_file import RegistrySessionLocation from agent_comms.thread_execution import ExternalThreadExecution @@ -37,6 +38,7 @@ Detached, Idle, Moving, + ReviewDue, Spinning, StalledOnNothing, Stuck, @@ -151,6 +153,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: @@ -190,8 +197,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", HeartbeatFrame.capture(comms, "lead", ProgressRead())) + frame = HeartbeatFrame.capture(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 From f071d12c3ef4823ac966ab2d262fe6955f715c1c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 07:48:48 -0400 Subject: [PATCH 06/16] Derive HUD display facts, request keys and narrow layouts from their declarations --- src/agent_comms/commander/standing.py | 10 +- src/agent_comms/commander/vibe_check.py | 5 +- src/agent_comms/commander_heartbeat.py | 9 +- src/agent_comms/commander_hud.py | 97 +++++++-- src/agent_comms/disclosure_text.py | 195 ++++++++++++++---- src/agent_comms/field_codec.py | 94 +++++++-- src/agent_comms/recorded_time.py | 15 +- src/agent_comms/standing_rules.py | 16 +- .../task_attachments/commander_pings.py | 106 +++++++--- src/agent_comms/ui_model/unit_state.py | 51 +++-- tests/test_commander_hud_projection.py | 45 ++-- tests/test_commander_notifications.py | 64 ++++-- tests/test_head_review.py | 4 +- tests/test_unowned_checkout_work.py | 2 +- 14 files changed, 538 insertions(+), 175 deletions(-) diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index 58b0d17312..1ecb3df80b 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -166,10 +166,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}") @@ -333,9 +333,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" @@ -387,8 +386,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 0e69ee42d0..3657c97444 100644 --- a/src/agent_comms/commander/vibe_check.py +++ b/src/agent_comms/commander/vibe_check.py @@ -28,6 +28,7 @@ ) from agent_comms.messages import Message from agent_comms.read_basis import RepliesDisplayScope +from agent_comms.recorded_time import RecordedEventTime from agent_comms.registry_document import RegistrySnapshot from agent_comms.service_sender import ServiceSender from agent_comms.standing_rules import ( @@ -228,8 +229,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 b3ba2e1014..20edb4d381 100644 --- a/src/agent_comms/commander_heartbeat.py +++ b/src/agent_comms/commander_heartbeat.py @@ -80,7 +80,7 @@ def failed(self, opened_wire: Callable[[], AbstractContextManager[Comms]], actions = CommanderHistory.read(comms, self.actors(comms.registry.snapshot(), thread.name)) sections = AlarmGroup.sections((update,), update.open_pings(actions)) update.send(comms, now, ( - HudUnavailableGroup.heading(), failure.line(), + HudUnavailableGroup.heading(), *CommanderHud.alarm_text(sections, now), )) @@ -99,7 +99,7 @@ def pass_body(self, comms: Comms, observed: tuple[CommanderObservation, ...]) -> try: for item in observed: comms.goals.todos.record_merges(item.merged) - previous = CommanderUpdate.latest(comms, item.thread.incarnation) + previous = item.hud.packets known = frozenset(ping for packet in previous for ping in packet.handled) rejected = StandingRule.enforce_all( comms, item.thread.name, @@ -107,6 +107,8 @@ 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)), ) + # Observations may have waited on forge/provider reads. Acknowledgements + # committed during those reads belong to this publication, not the old capture. actions = CommanderHistory.read(comms, self.actors(comms.registry.snapshot(), item.thread.name)) for delivery in CommanderUpdate.derive( item.thread.incarnation, @@ -115,8 +117,7 @@ def pass_body(self, comms: Comms, observed: tuple[CommanderObservation, ...]) -> now=now, history=actions, undisposed=tuple( ping for packet in previous - for ping in packet.undisposed(comms, item.thread.name, - now - AwaitingCommander.seconds)), + for ping in packet.undisposed(actions, now - AwaitingCommander.seconds)), not_applied=tuple( rule.rejected_line(error) for rule, error in rejected.items()), ): diff --git a/src/agent_comms/commander_hud.py b/src/agent_comms/commander_hud.py index a520a66552..180f22a652 100644 --- a/src/agent_comms/commander_hud.py +++ b/src/agent_comms/commander_hud.py @@ -21,10 +21,12 @@ from agent_comms.commander_frame_file import CommanderFrameFile from agent_comms.commander_service import CommanderService from agent_comms.coordinator import Coordination -from agent_comms.disclosure_text import ViewTable +from agent_comms.disclosure_text import ( + AccentTone, ErrorTone, ViewFacts, ViewSectionFacts, ViewTable, +) from agent_comms.errors import ToolRefusedError from agent_comms.field_codec import ( - DisclosureIdentity, HudExclude, MalformedRecord, OrderId, WireTransient, projected, + DisclosureIdentity, HudExclude, MalformedRecord, OrderId, PublicationExclude, WireTransient, projected, ) from agent_comms.heartbeat_frame import HeartbeatFrame from agent_comms.heartbeat_subjects import CommanderHistory @@ -39,7 +41,7 @@ from agent_comms.turn_digests import LastTurns, OrderEditCountRead from agent_comms.ui_model.unit_state import ExpandedHudGroup, HudGroup, UnitClock, UnitState, UrgentGroup from agent_comms.task_attachments.commander_pings import ( - AlarmGroup, AlarmSection, AlarmSummary, CommanderUpdate, Ping, + AlarmGroup, AlarmSection, CommanderUpdate, Ping, ) from agent_comms_hooks.board import BoardEntry @@ -62,15 +64,15 @@ class CommanderOrder: class PlanPhasesGroup(HudGroup): - terminal_colour = 36 + tone = AccentTone class FinishedPlansGroup(ExpandedHudGroup, HudGroup): - terminal_colour = 90 + pass class HudUnavailableGroup(HudGroup): - terminal_colour = 31 + tone = ErrorTone class HudRecord(ABC): @@ -83,6 +85,10 @@ def hud_group(self) -> type[HudGroup]: ... def needs_hand(self) -> bool: return self.hud_group.needs_hand() + def detail_key(self, root: Path, commander: ThreadIncarnation) -> object: + """A plain record's original disclosure changes with that record.""" + return self + @dataclass(frozen=True) class CommanderUnit(HudRecord): @@ -116,10 +122,20 @@ def change(self) -> str: 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_key(self, root: Path, commander: ThreadIncarnation) -> object: + """The actual read request is its invalidation key, not the card's hidden clocks.""" + from agent_comms.ui_model.observation import ReadCommanderUnit + + return ReadCommanderUnit(str(root), commander, self.unit, self.order_ids) + 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) @@ -188,7 +204,7 @@ class CommanderUnitDetail: 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, ...] @@ -211,7 +227,7 @@ def hud_group(self) -> type[HudGroup]: class FinishedPhases(HudRecord): """One declaration-derived finished count per plan, through the same table projection.""" - plan: type[Plan] + plan: Annotated[type[Plan], DisclosureIdentity()] phases: Annotated[tuple[type[PlanPhase], ...], HudExclude()] hud_group: ClassVar[type[HudGroup]] = FinishedPlansGroup @@ -248,6 +264,26 @@ def needs_hand(self) -> bool: return self.reading.needs_hand() +@dataclass(frozen=True) +class CommanderStatus: + load: float + review_up: int + unjudged: int + forks: int + usage: str + heartbeat: Annotated[str, PublicationExclude()] + + +@dataclass(frozen=True) +class HudDisplayFacts: + commander: ThreadIncarnation + status: ViewFacts + groups: tuple[ViewSectionFacts, ...] + alarms: tuple[ViewSectionFacts, ...] + alarm_membership: tuple[AlarmSection, ...] + not_applied: tuple[str, ...] + + @dataclass(frozen=True) class CommanderHud(ToolResult): """One disposable view of native records, shared by comms_hud and the commander board. @@ -265,7 +301,7 @@ class CommanderHud(ToolResult): forks: tuple[ThreadIncarnation, ...] usage: CodexUsage packets: tuple[CommanderUpdate, ...] = () - open_pings: tuple[Ping, ...] = field(default=(), kw_only=True) + open_pings: tuple[Ping, ...] = field(kw_only=True) heartbeat: Annotated[EventTime, WireTransient()] = field( default_factory=UnrecordedEventTime, compare=False, kw_only=True, ) @@ -319,8 +355,8 @@ def of(cls, comms: Comms, commander: str, frame: HeartbeatFrame, forks = tuple(fork.incarnation for fork in CommanderService.forks(snapshot, commander) if snapshot.status(fork.name).receiving) packets = CommanderUpdate.latest(comms, owner) - actions = CommanderHistory.read(comms, CommanderService.actors(snapshot, commander)) - opened = tuple(ping for packet in packets for ping in packet.open_pings(actions)) + history = CommanderHistory.read(comms, CommanderService.actors(snapshot, commander)) + opened = tuple(ping for packet in packets for ping in packet.open_pings(history)) return cls(owner, tuple(units), phases, OverloadDegradation.from_orders(held, len(timers), sum(edits.values())), forks, CodexUsage.read(), packets, open_pings=opened, @@ -343,18 +379,35 @@ def groups(self) -> tuple[HudSection, ...]: return HudSection.partition((*self.units, *(phase for phase in self.phases if not phase.finished), *folded)) - def status_strip(self, now: float) -> str: - return ( - f"LOAD {self.load.total:.0f} · REVIEW UP {self.load.timers_up} · " - f"UNJUDGED {self.load.unjudged} · FORKS {len(self.forks)} · " - f"{self.usage.line()} · heartbeat {self.heartbeat.age(now)}" + def status_table(self, now: float) -> ViewTable[CommanderStatus]: + return ViewTable(CommanderStatus, "hud", (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, *, width: int | None = None) -> str: + return "\n".join(self.status_table(now).labelled_lines(width)) + + @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), ) @staticmethod def alarm_text(sections: tuple[AlarmSection, ...], now: float, *, - width: int | None = None) -> tuple[str, ...]: + width: int | None = None, colour: bool = False) -> tuple[str, ...]: return tuple(line for section in sections for line in ( - section.heading(), *ViewTable(AlarmSummary, "hud", section.summaries(now)).lines(width), + section.group.paint(section.heading(), colour), + *(line for table in section.tables(now) for line in table.labelled_lines(width)), )) @staticmethod @@ -367,13 +420,13 @@ def alarm_detail(sections: tuple[AlarmSection, ...], now: float) -> tuple[str, . def hud_text(self, now: float, *, colour: bool = False, width: int | None = None) -> tuple[str, ...]: """The producer passes no width; an actual reader may supply its own viewport.""" - result = [self.status_strip(now)] + result = list(self.status_strip(now, width=width).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(width) for table in section.tables)) - result.extend(("", *self.alarm_text(self.alarms, now, width=width), + result.extend(("", *self.alarm_text(self.alarms, now, width=width, colour=colour), *(line for packet in self.packets for line in packet.not_applied))) return tuple(result) @@ -410,7 +463,7 @@ def changed(self, hud: CommanderHud) -> bool: previous = self.read() except (MalformedRecord, ToolRefusedError): return True - return previous != hud + return previous.display_facts != hud.display_facts @classmethod def current(cls, comms: Comms, commander: str) -> CommanderHud: diff --git a/src/agent_comms/disclosure_text.py b/src/agent_comms/disclosure_text.py index a8d759036f..c48506a5fc 100644 --- a/src/agent_comms/disclosure_text.py +++ b/src/agent_comms/disclosure_text.py @@ -3,60 +3,52 @@ from __future__ import annotations from collections.abc import Collection, Mapping, Sequence -from dataclasses import dataclass, is_dataclass -from functools import cached_property, singledispatchmethod +from dataclasses import dataclass +from functools import cached_property from itertools import zip_longest from textwrap import wrap from typing import ClassVar, Hashable -from agent_comms.declared_family import FamilyMeta -from agent_comms.field_codec import DisclosureIdentity, FieldCodec, FieldPlan, ViewColumn +from agent_comms.declared_family import DeclaredFamily, FamilyMeta +from agent_comms.field_codec import FieldCodec, FieldPlan, ValueDescription, ViewColumn @dataclass(frozen=True) -class DisclosureText: +class DisclosureText(ValueDescription["DisclosureText"]): value: object - @singledispatchmethod @classmethod def of(cls, value: object) -> DisclosureText: - """Recover foreign shape once, before heartbeat and UI consumers borrow it.""" - if is_dataclass(value): - identity = tuple( - plan for plan in FieldCodec.field_plans(type(value)) - if DisclosureIdentity.read(plan.annotation, False) - ) - if identity: - return IdentifiedRecordText(value, identity) - return RecordText(value) + return FieldCodec.describe(value, cls) + + @classmethod + def scalar(cls, value: object) -> DisclosureText: return cls(value) - @of.register(type) @classmethod - def _class(cls, value: type) -> DisclosureText: + def record(cls, value: object) -> DisclosureText: + return RecordText(value) + + @classmethod + def identified_record(cls, value: object, identity: tuple[FieldPlan, ...]) -> DisclosureText: + return IdentifiedRecordText(value, identity) + + @classmethod + def class_value(cls, value: type) -> DisclosureText: return ClassText(value) - @of.register(FamilyMeta) @classmethod - def _family(cls, value: FamilyMeta) -> DisclosureText: + def family_value(cls, value: FamilyMeta) -> DisclosureText: return FamilyText(value) - @of.register(Mapping) @classmethod - def _mapping(cls, value: Mapping[Hashable, object]) -> DisclosureText: + def mapping(cls, value: Mapping[Hashable, object]) -> DisclosureText: return MappingText(value) - @of.register(Sequence) @classmethod - def _sequence(cls, value: Sequence[object]) -> DisclosureText: + def sequence(cls, value: Sequence[object]) -> DisclosureText: return SequenceText(value) - @of.register(str) - @of.register(bytes) - @classmethod - def _scalar(cls, value: str | bytes) -> DisclosureText: - return cls(value) - def summary(self, width: int | None = None) -> str: text = str(self.value)[:width] return text if width is None else text.partition("\n")[0] @@ -68,50 +60,177 @@ def view(cls, value: object, view: str) -> tuple[str, ...]: for column in FieldCodec.view_columns(type(value), view)) +@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 declared column set owns cells, content demand and optional reader-side fitting.""" + """One declaration owns semantic cells and both wide and stacked presentation.""" record: type[R] view: str records: tuple[R, ...] + show_header: ClassVar[bool] = True + row_separator: ClassVar[tuple[str, ...]] = () @cached_property - def columns(self) -> tuple[ViewColumn, ...]: + 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 cells(self) -> tuple[tuple[DisclosureText, ...], ...]: - return tuple(tuple(DisclosureText.of(column.current(record)) for column in self.columns) + return tuple(tuple(DisclosureText.of(column.current(record)) for column in self.declared_columns) for record in self.records) @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("publication")) + 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 widths(self, available: int | None = None) -> tuple[int, ...]: weights = 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)) if available is None: return weights - minimums = tuple(column.minimum_width(row[index] for row in self.texts) - for index, column in enumerate(self.columns)) - return ViewColumn.fit_widths(weights, available, minimums) + return ViewColumn.fit_widths(weights, available, self.minimums) + + def layout(self, available: int | None) -> ViewTable[R]: + """Geometry classification happens once here, never again in the native adapter.""" + if available is not None and 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, width: int | None = None) -> tuple[str, ...]: - """A producer supplies no width. Only a reader may ask this view to fit its viewport.""" available = None if width is None else width - len(" ") * (len(self.columns) - 1) + layout = self.layout(available) + available = None if width is None else width - len(" ") * (len(layout.columns) - 1) + return layout.render_lines(available) + + def render_lines(self, available: int | None) -> tuple[str, ...]: widths = self.widths(available) lines = [] - for row in (tuple(column.label for column in self.columns), *self.texts): + for row in (*self.header_rows(), *self.texts): wrapped = tuple(tuple(part for line in text.splitlines() or ("",) - for part in wrap(line, cell_width, replace_whitespace=False) 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, width: int | None = None) -> tuple[str, ...]: + """Compact strips use declared labels too, not another list of metric names.""" + records = tuple(" · ".join(f"{column.label}: {cell}" + for column, cell in zip(self.columns, row)) for row in self.texts) + if width is None: + return records + return tuple(line for record in records + for line in wrap(record, max(1, width), break_long_words=False, break_on_hyphens=False)) + + +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 | None = None) -> tuple[int, ...]: + return (max(1, available),) if available is not None else super().widths() + + def layout(self, available: int | None) -> 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(DisclosureText): def summary_fields(self) -> tuple[FieldPlan, ...]: diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index d1bb026a5f..3e03ffafad 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -11,11 +11,11 @@ import re import types from abc import ABC, abstractmethod -from collections.abc import Callable, Iterable, Iterator, Mapping +from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping, Sequence from dataclasses import MISSING, Field, dataclass, field, fields, is_dataclass from datetime import datetime from enum import Enum, IntFlag -from functools import cache, cached_property, lru_cache, partial, reduce +from functools import cache, cached_property, lru_cache, partial, reduce, singledispatchmethod from operator import or_ from pathlib import Path from typing import ( @@ -47,7 +47,7 @@ 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 @@ -541,6 +541,14 @@ def label(self) -> str: @abstractmethod def current(self, record: object) -> object: ... + @cached_property + def view_exclusions(self) -> tuple[ViewExclusion, ...]: + return ViewExclusion.on(self.annotation) + + def excluded_in(self, view: str) -> bool: + return any(directive.value for directive in self.view_exclusions + if type(directive).declared_name == view) + 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)) @@ -550,9 +558,10 @@ 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: - minimums = (1,) * len(weights) + 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 @@ -625,20 +634,12 @@ def omit_default(self) -> bool: 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) - @cached_property def value(self) -> ValueDecoder[object]: return compile_decoder(self.annotation) @@ -2247,6 +2248,38 @@ def minimum_digits(cls) -> int: return 7 +class ValueDescription[Product](ABC): + """A nominal presentation implements the canonical boundary's recovered Python shapes.""" + + @classmethod + @abstractmethod + def scalar(cls, value: object) -> Product: ... + + @classmethod + @abstractmethod + def record(cls, value: object) -> Product: ... + + @classmethod + @abstractmethod + def identified_record(cls, value: object, identity: tuple[FieldPlan, ...]) -> Product: ... + + @classmethod + @abstractmethod + def class_value(cls, value: type) -> Product: ... + + @classmethod + @abstractmethod + def family_value(cls, value: FamilyMeta) -> Product: ... + + @classmethod + @abstractmethod + def mapping(cls, value: Mapping[Hashable, object]) -> Product: ... + + @classmethod + @abstractmethod + def sequence(cls, value: Sequence[object]) -> Product: ... + + class Projected(ViewColumn, property): """A computed field declared by its owner for a named read-only view.""" @@ -2295,6 +2328,43 @@ class FieldCodec(MroDispatch, Sealed): the single record codec and declaration-derived schema. """ + @singledispatchmethod + @classmethod + def describe[T](cls, value: object, presentation: type[ValueDescription[T]]) -> T: + """Recover foreign presentation shape here, never in either renderer.""" + if is_dataclass(value): + identity = tuple(plan for plan in cls.field_plans(type(value)) + if DisclosureIdentity.read(plan.annotation, False)) + return (presentation.identified_record(value, identity) if identity + else presentation.record(value)) + return presentation.scalar(value) + + @describe.register(type) + @classmethod + def _describe_class[T](cls, value: type, presentation: type[ValueDescription[T]]) -> T: + return presentation.class_value(value) + + @describe.register(FamilyMeta) + @classmethod + def _describe_family[T](cls, value: FamilyMeta, presentation: type[ValueDescription[T]]) -> T: + return presentation.family_value(value) + + @describe.register(Mapping) + @classmethod + def _describe_mapping[T](cls, value: Mapping[Hashable, object], presentation: type[ValueDescription[T]]) -> T: + return presentation.mapping(value) + + @describe.register(Sequence) + @classmethod + def _describe_sequence[T](cls, value: Sequence[object], presentation: type[ValueDescription[T]]) -> T: + return presentation.sequence(value) + + @describe.register(str) + @describe.register(bytes) + @classmethod + def _describe_scalar[T](cls, value: str | bytes, presentation: type[ValueDescription[T]]) -> T: + return presentation.scalar(value) + @classmethod def decode_external[T]( cls, annotation: TypeForm[T], value: object, record: Mapping[str, object], diff --git a/src/agent_comms/recorded_time.py b/src/agent_comms/recorded_time.py index 21a632e1cb..f7c7404a9d 100644 --- a/src/agent_comms/recorded_time.py +++ b/src/agent_comms/recorded_time.py @@ -87,6 +87,9 @@ def before(self, timestamp: float) -> bool: def clock(self) -> str: return "-" + def stamp(self) -> str: + return "-" + def age(self, now: float) -> str: return "unrecorded" @@ -144,8 +147,18 @@ 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: - return f"{max(0, int(now - self.at))} s ago" + 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()} ({self.age(now)})",) diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index e73bbcfcf4..05a2113af1 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -54,6 +54,7 @@ from agent_comms.goal_management import Goals from agent_comms.heartbeat_frame import HeartbeatFrame from agent_comms.registry_document import RegistrySnapshot + from agent_comms.task_attachments.commander_pings import Ping from agent_comms.todos import ( OrderClaim, OwnerRequest, @@ -232,6 +233,10 @@ def clearing_commands(self) -> str: def attention(self) -> bool: return True + @classmethod + def alarm_pings(cls, ping: Ping) -> tuple[Ping, ...]: + 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.""" @@ -736,8 +741,11 @@ 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(cls, ping: Ping) -> tuple[Ping, ...]: + return () @classmethod def conditions(cls, comms: Comms, frame: HeartbeatFrame) -> Iterable[tuple[Todo, EventTime]]: @@ -978,7 +986,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)}") @@ -1030,7 +1038,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 fed58850cf..061ef4eca6 100644 --- a/src/agent_comms/task_attachments/commander_pings.py +++ b/src/agent_comms/task_attachments/commander_pings.py @@ -9,7 +9,7 @@ from abc import abstractmethod from dataclasses import dataclass, field, replace -from typing import TYPE_CHECKING, ClassVar, Self +from typing import TYPE_CHECKING, Annotated, ClassVar, Self from agent_comms.bus_publication import AgentOrigin from agent_comms.channel_targets import ThreadTarget @@ -18,7 +18,8 @@ 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, OrderId, PublicationExclude from agent_comms.message_reference import MessageReference from agent_comms.heartbeat_subjects import CommanderHistory, HeartbeatSubject from agent_comms.messages import ( @@ -30,7 +31,7 @@ from agent_comms.task_sources import TaskAttachment from agent_comms.text_digest import TextDigest from agent_comms.thread_identity import ThreadIncarnation -from agent_comms.ui_model.unit_state import AwaitingCommander, HudGroup, UnitState +from agent_comms.ui_model.unit_state import AwaitingCommander, UnitState from agent_comms_hooks.board import BoardEntry if TYPE_CHECKING: @@ -108,7 +109,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 alarm_pings(self) -> tuple[Ping, ...]: + return (self,) @dataclass(frozen=True) @@ -132,6 +136,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()) @@ -162,6 +169,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( @@ -207,7 +218,7 @@ def body(self, hud: tuple[str, ...]) -> str: """The existing HUD owns presentation; this packet owns publication and history.""" return "\n".join(( *hud, "", - "Expand in Toad: F2 or /commander · original orders, story, commits, activity and every alert", + "Full evidence: comms_hud board/open_pings, or Toad /commander original details", )) def board(self, comms: Comms, commander: str) -> tuple[BoardEntry, ...]: @@ -227,10 +238,8 @@ async def delivered_by(self, bridge: CliBridge, message: Message) -> type[Outcom async def wake(self, bridge: CliBridge, message: Message) -> type[Outcome]: """The packet's wake decision is the same on every harness.""" - def undisposed(self, comms: Comms, commander: str, cut: float) -> tuple[Ping, ...]: - """Lines older than ``cut`` that no recorded action on their subjects has disposed of.""" - actors = CommanderService.actors(comms.registry.snapshot(), commander) - history = CommanderHistory.read(comms, actors) + def undisposed(self, history: CommanderHistory, cut: float) -> tuple[Ping, ...]: + """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_publication(self, sender: str, registry: RegistrySnapshot, @@ -254,8 +263,9 @@ def latest(cls, comms: Comms, commander: ThreadIncarnation) -> tuple[CommanderUp @classmethod def owed(cls, comms: Comms, commander: str, cut: float) -> tuple[Ping, ...]: """The newest packet's lines still undisposed past ``cut``.""" + history = CommanderHistory.read(comms, CommanderService.actors(comms.registry.snapshot(), commander)) return tuple(ping for latest in cls.latest(comms, comms.registry.require(commander).incarnation) - for ping in latest.undisposed(comms, commander, cut)) + for ping in latest.undisposed(history, cut)) @classmethod def current_board(cls, comms: Comms, commander: str) -> tuple[BoardEntry, ...]: @@ -349,47 +359,79 @@ def new_pings(self) -> tuple[Ping, ...]: return self.decisions -class AlarmGroup(HudGroup, affix="Alarms"): - """Alarm section membership and headings share the HUD family's declaration behavior.""" +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, ...]) -> 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, ...]: return tuple(AlarmSection(group, members) for group in cls.members_with(cls) - for members in (group.members(packets, opened),) - if members or group.show_empty) + for members in (group.members(packets, opened),) if members) class NewAlarms(AlarmGroup): - terminal_colour = 36 + tone = AccentTone + + @classmethod + def members(cls, packets: tuple[CommanderUpdate, ...], + opened: tuple[Ping, ...]) -> tuple[Ping, ...]: + return tuple(line for packet in packets for ping in packet.new_pings() + for line in ping.alarm_pings()) + + +class CompactAlarms: + """Persistent 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 StillOpenAlarms(CompactAlarms, AlarmGroup): + tone = WarningTone @classmethod def members(cls, packets: tuple[CommanderUpdate, ...], opened: tuple[Ping, ...]) -> tuple[Ping, ...]: - return tuple(ping for packet in packets for ping in packet.new_pings()) + new = frozenset(NewAlarms.members(packets, opened)) + return tuple(line for ping in opened for line in ping.alarm_pings() if line not in new) -class StillOpenAlarms(AlarmGroup): - terminal_colour = 33 +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, ...]) -> tuple[Ping, ...]: new = frozenset(NewAlarms.members(packets, opened)) - return tuple(ping for ping in opened if ping not in 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) + + +@dataclass(frozen=True) +class AlarmLine: + kind: type[DeclaredFamily] + line: str + age: Annotated[str, PublicationExclude()] @dataclass(frozen=True) -class AlarmSummary: +class AlarmOverflow: kind: type[DeclaredFamily] - count: int - oldest_age: str + more: int + oldest_age: Annotated[str, PublicationExclude()] @dataclass(frozen=True) @@ -400,10 +442,22 @@ class AlarmSection: def heading(self) -> str: return self.group.count_heading(len(self.pings)) - def summaries(self, now: float) -> tuple[AlarmSummary, ...]: + 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) - return tuple(AlarmSummary(kind, len(pings), - min(pings, key=lambda ping: ping.since.chronology()).since.age(now)) - for kind, pings in grouped.items()) + 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, "hud", tuple(lines)), + ] + if overflow: + tables.append(ViewTable(AlarmOverflow, "hud", tuple(overflow))) + return tuple(tables) diff --git a/src/agent_comms/ui_model/unit_state.py b/src/agent_comms/ui_model/unit_state.py index 6c881df1f2..069b25c4c2 100644 --- a/src/agent_comms/ui_model/unit_state.py +++ b/src/agent_comms/ui_model/unit_state.py @@ -17,6 +17,7 @@ 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 ( MalformedRecord, ToolCallId, @@ -187,11 +188,9 @@ def table_lines(cls, tables: Iterable[tuple[str, ...]]) -> tuple[str, ...]: return tuple(line for table in tables for line in table) -class HudGroup(DeclaredFamily, affix="Group"): +class HudGroup(ViewHeading, affix="Group"): """The declared order and headings shared by heartbeat and native table views.""" - terminal_colour: ClassVar[int] - @classmethod def table_lines(cls, tables: Iterable[tuple[str, ...]]) -> tuple[str, ...]: return () @@ -200,44 +199,41 @@ def table_lines(cls, tables: Iterable[tuple[str, ...]]) -> tuple[str, ...]: def needs_hand(cls) -> bool: return False - @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 f"\x1b[{cls.terminal_colour}m{text}\x1b[0m" if colour else text - - class UrgentGroup(ExpandedHudGroup, HudGroup): - terminal_colour = 33 + tone = WarningTone @classmethod def needs_hand(cls) -> bool: return True -class InProgressGroup(HudGroup): - terminal_colour = 32 +class InProgressGroup(ExpandedHudGroup, HudGroup): + tone = SuccessTone -class ReadyToTestGroup(HudGroup): - terminal_colour = 36 +class ReadyToTestGroup(ExpandedHudGroup, HudGroup): + tone = AccentTone class IdleGroup(HudGroup): - terminal_colour = 90 + pass -class NeedsCommanderAttention: - """Urgent siblings share their ping behavior without claiming each other's state.""" +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 def attention(cls) -> bool: return True @@ -518,7 +514,7 @@ def matches(cls, source: UnitObservation) -> bool: ) -class ReviewDue(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?" @@ -529,7 +525,7 @@ def matches(cls, source: UnitObservation) -> bool: return bool(source.review_timers) -class DeliveredUnclosed(NeedsCommanderAttention, ConditionalState, UnitState): +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" @@ -543,12 +539,11 @@ 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.""" - hud_group = ReadyToTestGroup question = "installed. did its holder actually run it yet?" action = "its holder runs the application proof on the installed build; close it on its evidence" commander_disposes = False diff --git a/tests/test_commander_hud_projection.py b/tests/test_commander_hud_projection.py index 652dd08d76..b720dec989 100644 --- a/tests/test_commander_hud_projection.py +++ b/tests/test_commander_hud_projection.py @@ -3,7 +3,7 @@ from dataclasses import replace from agent_comms.commander.vibe_check import OverloadDegradation -from agent_comms.commander_hud import CommanderHud, CommanderOrder, CommanderUnit, CodexUsage, UnreadableUsage +from agent_comms.commander_hud import CommanderHud, CommanderOrder, CommanderUnit, CodexUsage, HudRecord, UnreadableUsage from agent_comms.disclosure_text import DisclosureText from agent_comms.field_codec import DisclosureIdentity, FieldCodec, OrderId from agent_comms.recorded_time import RecordedEventTime @@ -13,7 +13,7 @@ from agent_comms.todos import OpenPrProgress from agent_comms.ui_model.unit_state import ( AwaitingApplicationRun, Idle, IdleGroup, InProgressGroup, Moving, NoPendingClock, - ReadyToTestGroup, Stuck, UrgentGroup, + PendingClock, ReadyToTestGroup, Stuck, UrgentGroup, ) @@ -41,7 +41,7 @@ def example_hud() -> CommanderHud: ), (), OverloadDegradation(3, 1, 2), (), CodexUsage(UnreadableUsage(reason="not requested in source example")), (CommanderDecisions(commander, (old, new), (new,), (old,)),), - heartbeat=RecordedEventTime(990), + heartbeat=RecordedEventTime(990), open_pings=(), ) @@ -69,19 +69,40 @@ def test_shared_hud_groups_strip_full_alarms_and_plain_text() -> None: "blocked", "screen", "run-next", "idle", ) plain = hud.hud_text(1000, width=120) - assert plain[0] == hud.status_strip(1000) - assert "LOAD 7 · REVIEW UP 1 · UNJUDGED 2 · FORKS 0" in plain[0] - assert plain[0].endswith("heartbeat 10 s ago") + status = tuple(hud.status_strip(1000, width=120).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, width=120, colour=True)) - section, = hud.alarms - summary, = section.summaries(1000) - assert summary.kind is ServicePassFailing and summary.count == 1 + 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 == () - assert hud.alarm_detail(hud.alarms, 1000) == ( - section.heading(), hud.packets[0].new_pings()[0].render(1000), - ) + assert all(ping.render(1000) in hud.alarm_detail(hud.alarms, 1000) + for ping in hud.packets[0].handled) + for section in groups: + for table in section.tables: + narrow = "\n".join(table.lines(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_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 b901ac43a8..e26fba89da 100644 --- a/tests/test_commander_notifications.py +++ b/tests/test_commander_notifications.py @@ -33,7 +33,7 @@ from agent_comms.cli_bridges.codex_connection import CodexConnection from agent_comms.standing_rules import ReviewTimerUp, ServicePassFailing, StandingRule, UndatedStandingRule from agent_comms.ui_model.unit_state import ( - Spinning, UnitState, UrgentGroup, + PendingClock, Spinning, UnitState, UrgentGroup, ) from agent_comms.wake_policy import PassiveWake from agent_comms.bus_publication import AgentOrigin @@ -70,6 +70,12 @@ def lead_hud(comms, states=()): return CommanderHud.of(comms, "lead", HeartbeatFrame.capture(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=()): frame = CommanderObservation( comms.registry.require("lead"), (), rules, states, hud=lead_hud(comms, states)) @@ -236,13 +242,20 @@ def everything_to_lead(comms): return {message.reference for message in page.messages} -def test_identical_quiet_beats_do_not_append_bus_history(notification_bus): +def test_hidden_tool_time_does_not_append_quiet_bus_history(notification_bus): comms = notification_bus - beat(comms) + states = state_cards(comms, UnitState) + hud = lead_hud(comms, states) + card = replace(hud.units[0], clock=PendingClock(1, 2)) + hud = replace(hud, units=(card, *hud.units[1:])) + frame = CommanderObservation(comms.registry.require("lead"), (), (), states, hud) + CommanderHeartbeat().pass_body(comms, (frame,)) first, = notifications(comms) assert isinstance(first.task, CommanderNotice) - beat(comms) - beat(comms) + for instant in (3, 4): + current = CommanderFrame.current(comms, "lead") + later = replace(current, units=(replace(card, clock=PendingClock(1, instant)), *hud.units[1:])) + CommanderHeartbeat().pass_body(comms, (replace(frame, hud=later),)) assert tuple(message.reference for message in notifications(comms)) == (first.reference,) @@ -392,7 +405,8 @@ def test_frame_puts_hands_first_and_folds_finished_phases_per_plan(notification_ CommanderPhase(DesignReconciledPhase, False, False, (worker,)), CommanderPhase(RecordPlanePhase, False, True, ()), CommanderPhase(ReviewQueuePhase, True, False, (worker,)), - ), OverloadDegradation.from_orders((), 0, 0), (worker,), CodexUsage(UnreadableUsage(reason='not requested'))) + ), OverloadDegradation.from_orders((), 0, 0), (worker,), 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]) @@ -447,8 +461,7 @@ def test_recreated_watcher_retains_non_disposable_observation_without_nagging(no 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("LOAD ") - assert f"UNJUDGED {hud.load.unjudged}" in first.body + 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)) @@ -457,7 +470,11 @@ def test_recreated_watcher_retains_non_disposable_observation_without_nagging(no assert isinstance(unchanged.task, CommanderNotice) assert aged(unchanged.task.handled) == aged(first.task.handled) assert lead_hud(comms).board == () - assert CommanderFrame.current(comms, "lead").alarms == () + 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): @@ -498,16 +515,16 @@ def test_rule_packets_list_every_new_line_then_repeat_it_without_a_new_wake(noti assert isinstance(first.task, CommanderDecisions) assert set(first.task.decisions) == set(first.task.handled) == { RulePing.capture(rule) for rule in rules} - assert sum(summary.count for section in lead_hud(comms).alarms - for summary in section.summaries(time.time())) == len(rules) - assert first.body.startswith("LOAD ") + assert sum(len(section.pings) for section in lead_hud(comms).alarms) == len(rules) + assert all(rule.line() in first.body for rule in rules) + assert_status_fields(first, lead_hud(comms)) beat(comms, rules) _, 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)) == 2 + 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) @@ -524,7 +541,7 @@ 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 quiet.body.startswith("LOAD ") + assert_status_fields(quiet, lead_hud(comms)) assert all(isinstance(decision.wake_mode, PassiveWake) for decision in quiet.decisions) # An identical quiet frame updates no bus history. beat(comms, ServicePassFailing.violations(comms, "lead")) @@ -645,7 +662,7 @@ def test_codex_usage_decodes_window_and_keeps_refusal_visible(): assert FieldCodec.decode(CodexUsage, FieldCodec.encode(refused)) == refused -def test_waiting_notification_summarises_kinds_and_retains_original_evidence(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 @@ -660,15 +677,28 @@ def test_waiting_notification_summarises_kinds_and_retains_original_evidence(not packet = CommanderDecisions( original.task.commander, original.task.handled, (), original.task.handled, ) - hud = replace(lead_hud(comms), packets=(packet,)) + 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())) assert len(hud.alarm_text(hud.alarms, time.time())) < len(rules) - assert all(rule.line() not in message.body for rule in rules) + assert rules[0].line() in message.body + overflow = tuple(row for section in hud.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 + assert all(rule.line() in original.body for rule in rules) details = "\n".join(hud.alarm_detail(hud.alarms, time.time())) assert all(rule.line() in details for rule in rules) assert len(message.task.handled) == 12 assert {entry.text for entry in message.task.board(comms, "lead")} == {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_a_commander_that_stood_down_keeps_its_requests_but_gets_no_heartbeat(notification_bus): diff --git a/tests/test_head_review.py b/tests/test_head_review.py index 27b26cc1fa..881e583978 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -518,8 +518,8 @@ 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") def working_on(comms, source, tmp_path, name, branch): diff --git a/tests/test_unowned_checkout_work.py b/tests/test_unowned_checkout_work.py index dd16af14a5..681c4fbc0c 100644 --- a/tests/test_unowned_checkout_work.py +++ b/tests/test_unowned_checkout_work.py @@ -186,7 +186,7 @@ def struck_unowned_bus(checkout_bus: Comms) -> tuple[Comms, StrikeTodo]: def run_heartbeat_rules(c: Comms, rules: tuple[StandingRule, ...]) -> None: lead = c.registry.require('lead') hud = CommanderHud(lead.incarnation, (), (), OverloadDegradation.from_orders((), 0, 0), (), - CodexUsage(UnreadableUsage(reason='not requested'))) + CodexUsage(UnreadableUsage(reason='not requested')), open_pings=()) CommanderHeartbeat().pass_body(c, (CommanderObservation(lead, (), rules, (), hud),)) From f9c83aa03dd762392ef042a19406a856c8a6b659 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 08:48:05 -0400 Subject: [PATCH 07/16] Bound new alarms and separate disclosure products from shape factories --- src/agent_comms/commander_hud.py | 12 +--- src/agent_comms/disclosure_text.py | 62 ++++++++++--------- .../task_attachments/commander_pings.py | 22 +++---- tests/test_commander_notifications.py | 21 ++++--- 4 files changed, 56 insertions(+), 61 deletions(-) diff --git a/src/agent_comms/commander_hud.py b/src/agent_comms/commander_hud.py index 180f22a652..6ae028289f 100644 --- a/src/agent_comms/commander_hud.py +++ b/src/agent_comms/commander_hud.py @@ -85,10 +85,6 @@ def hud_group(self) -> type[HudGroup]: ... def needs_hand(self) -> bool: return self.hud_group.needs_hand() - def detail_key(self, root: Path, commander: ThreadIncarnation) -> object: - """A plain record's original disclosure changes with that record.""" - return self - @dataclass(frozen=True) class CommanderUnit(HudRecord): @@ -100,7 +96,7 @@ class CommanderUnit(HudRecord): unit: Annotated[ThreadIncarnation, DisclosureIdentity()] state: type[UnitState] - clock: Annotated[UnitClock, HudExclude()] + clock: Annotated[UnitClock, HudExclude()] = field(compare=False) orders: Annotated[tuple[CommanderOrder, ...], HudExclude()] strikes: Annotated[int, HudExclude()] ready_prs: Annotated[tuple[PullRequest, ...], HudExclude()] @@ -126,12 +122,6 @@ def next(self) -> str: def order_ids(self) -> tuple[OrderId, ...]: return tuple(dict.fromkeys(card.order for card in self.orders)) - def detail_key(self, root: Path, commander: ThreadIncarnation) -> object: - """The actual read request is its invalidation key, not the card's hidden clocks.""" - from agent_comms.ui_model.observation import ReadCommanderUnit - - return ReadCommanderUnit(str(root), commander, self.unit, self.order_ids) - def detail(self, comms: Comms, commander: ThreadIncarnation, orders: tuple[OrderId, ...]) -> CommanderUnitDetail: """Expand this published selection from its original record owners.""" diff --git a/src/agent_comms/disclosure_text.py b/src/agent_comms/disclosure_text.py index c48506a5fc..8067100813 100644 --- a/src/agent_comms/disclosure_text.py +++ b/src/agent_comms/disclosure_text.py @@ -14,40 +14,12 @@ @dataclass(frozen=True) -class DisclosureText(ValueDescription["DisclosureText"]): +class DisclosureText: value: object @classmethod def of(cls, value: object) -> DisclosureText: - return FieldCodec.describe(value, cls) - - @classmethod - def scalar(cls, value: object) -> DisclosureText: - return cls(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) - - @classmethod - def class_value(cls, value: type) -> DisclosureText: - return ClassText(value) - - @classmethod - def family_value(cls, value: FamilyMeta) -> DisclosureText: - return FamilyText(value) - - @classmethod - def mapping(cls, value: Mapping[Hashable, object]) -> DisclosureText: - return MappingText(value) - - @classmethod - def sequence(cls, value: Sequence[object]) -> DisclosureText: - return SequenceText(value) + return FieldCodec.describe(value, DisclosureShapes) def summary(self, width: int | None = None) -> str: text = str(self.value)[:width] @@ -290,3 +262,33 @@ class SequenceText(CollectionText): class MappingText(CollectionText): value: Mapping[Hashable, object] noun = "field" + + +class DisclosureShapes(ValueDescription[DisclosureText]): + @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) + + @classmethod + def class_value(cls, value: type) -> DisclosureText: + return ClassText(value) + + @classmethod + def family_value(cls, value: FamilyMeta) -> DisclosureText: + return FamilyText(value) + + @classmethod + def mapping(cls, value: Mapping[Hashable, object]) -> DisclosureText: + return MappingText(value) + + @classmethod + def sequence(cls, value: Sequence[object]) -> DisclosureText: + return SequenceText(value) diff --git a/src/agent_comms/task_attachments/commander_pings.py b/src/agent_comms/task_attachments/commander_pings.py index 061ef4eca6..175df662a2 100644 --- a/src/agent_comms/task_attachments/commander_pings.py +++ b/src/agent_comms/task_attachments/commander_pings.py @@ -379,18 +379,8 @@ def sections(cls, packets: tuple[CommanderUpdate, ...], for members in (group.members(packets, opened),) if members) -class NewAlarms(AlarmGroup): - tone = AccentTone - - @classmethod - def members(cls, packets: tuple[CommanderUpdate, ...], - opened: tuple[Ping, ...]) -> tuple[Ping, ...]: - return tuple(line for packet in packets for ping in packet.new_pings() - for line in ping.alarm_pings()) - - class CompactAlarms: - """Persistent sections keep actionable representatives and explicit per-kind overflow.""" + """Sections keep actionable representatives and explicit per-kind overflow.""" maximum_per_kind: ClassVar[int] = 1 @@ -399,6 +389,16 @@ 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, ...]) -> tuple[Ping, ...]: + return tuple(line for packet in packets for ping in packet.new_pings() + for line in ping.alarm_pings()) + + class StillOpenAlarms(CompactAlarms, AlarmGroup): tone = WarningTone diff --git a/tests/test_commander_notifications.py b/tests/test_commander_notifications.py index e26fba89da..529510fbe0 100644 --- a/tests/test_commander_notifications.py +++ b/tests/test_commander_notifications.py @@ -506,7 +506,7 @@ def enforce(self, _comms, _commander): ServicePassStore.at(comms.root).read().commander_completed) -def test_rule_packets_list_every_new_line_then_repeat_it_without_a_new_wake(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(f"watch-{index}", 1.0, "failed source read") for index in range(7)) @@ -516,7 +516,9 @@ def test_rule_packets_list_every_new_line_then_repeat_it_without_a_new_wake(noti assert set(first.task.decisions) == set(first.task.handled) == { RulePing.capture(rule) for rule in rules} assert sum(len(section.pings) for section in lead_hud(comms).alarms) == len(rules) - assert all(rule.line() in first.body for rule in 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) _, unchanged = notifications(comms) @@ -681,14 +683,15 @@ def test_waiting_notification_keeps_actionable_subjects_overflow_and_original_ev hud = replace(lead_hud(comms), packets=(packet,), open_pings=packet.handled) message = packet.send(comms, time.time(), hud.hud_text(time.time())) - assert len(hud.alarm_text(hud.alarms, time.time())) < len(rules) + 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 + details = "\n".join(frame.alarm_detail(frame.alarms, time.time())) + assert all(rule.line() in details for rule in rules) + assert rules[0].line() in original.body assert rules[0].line() in message.body - overflow = tuple(row for section in hud.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 - assert all(rule.line() in original.body for rule in rules) - details = "\n".join(hud.alarm_detail(hud.alarms, time.time())) - assert all(rule.line() in details for rule in rules) assert len(message.task.handled) == 12 assert {entry.text for entry in message.task.board(comms, "lead")} == {rule.line() for rule in rules} assert len(pickle.loads(pickle.dumps(message.task)).handled) == 12 From a51f7f2a88733829a59376f650a434046e8323b3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:28:46 -0400 Subject: [PATCH 08/16] Keep expanded alarm evidence free of duplicated headings --- src/agent_comms/commander_hud.py | 6 ++---- tests/test_commander_hud_projection.py | 5 +++-- tests/test_commander_notifications.py | 7 +++++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/agent_comms/commander_hud.py b/src/agent_comms/commander_hud.py index 6ae028289f..d5b3f7efc4 100644 --- a/src/agent_comms/commander_hud.py +++ b/src/agent_comms/commander_hud.py @@ -402,10 +402,8 @@ def alarm_text(sections: tuple[AlarmSection, ...], now: float, *, @staticmethod def alarm_detail(sections: tuple[AlarmSection, ...], now: float) -> tuple[str, ...]: - """The expandable board borrows the same sections but retains every original line.""" - return tuple(line for section in sections for line in ( - section.heading(), *(ping.render(now) for ping in section.pings), - )) + """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, width: int | None = None) -> tuple[str, ...]: diff --git a/tests/test_commander_hud_projection.py b/tests/test_commander_hud_projection.py index b720dec989..8c3f3f8c4d 100644 --- a/tests/test_commander_hud_projection.py +++ b/tests/test_commander_hud_projection.py @@ -80,8 +80,9 @@ def test_shared_hud_groups_strip_full_alarms_and_plain_text() -> None: assert all(ping.text in "\n".join(hud.alarm_text(hud.alarms, 1000)) for ping in hud.packets[0].handled) assert hud.board == () - assert all(ping.render(1000) in hud.alarm_detail(hud.alarms, 1000) - for ping in hud.packets[0].handled) + assert hud.alarm_detail(hud.alarms, 1000) == tuple( + ping.render(1000) for section in hud.alarms for ping in section.pings + ) for section in groups: for table in section.tables: narrow = "\n".join(table.lines(60)) diff --git a/tests/test_commander_notifications.py b/tests/test_commander_notifications.py index 529510fbe0..772cdfad76 100644 --- a/tests/test_commander_notifications.py +++ b/tests/test_commander_notifications.py @@ -688,8 +688,11 @@ def test_waiting_notification_keeps_actionable_subjects_overflow_and_original_ev 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 - details = "\n".join(frame.alarm_detail(frame.alarms, time.time())) - assert all(rule.line() in details for rule in rules) + now = time.time() + original_lines = frame.alarm_detail(frame.alarms, now) + assert original_lines == tuple(ping.render(now) for section in frame.alarms + for ping in section.pings) + 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 From 61ab2bacd22fb77211f1b9fe4dfe8818c5d60cb7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 14:38:17 -0400 Subject: [PATCH 09/16] Own typed disclosure shapes and separate natural from bounded layout --- src/agent_comms/commander_hud.py | 21 ++-- src/agent_comms/disclosure_text.py | 86 +++++++---------- src/agent_comms/field_codec.py | 129 +++++++++++++++++++------ tests/test_commander_hud_projection.py | 15 +-- tests/test_commander_notifications.py | 3 +- 5 files changed, 157 insertions(+), 97 deletions(-) diff --git a/src/agent_comms/commander_hud.py b/src/agent_comms/commander_hud.py index d5b3f7efc4..c1c0799cec 100644 --- a/src/agent_comms/commander_hud.py +++ b/src/agent_comms/commander_hud.py @@ -306,7 +306,7 @@ def of(cls, comms: Comms, commander: str, frame: HeartbeatFrame, states: tuple[UnitState, ...]) -> CommanderHud: """Cards follow ``states``, already in the UnitState family's declared order.""" snapshot = comms.registry.snapshot() - owner = snapshot.require(commander).incarnation + owner = snapshot.require(commander).incarnation.require_recorded() pulls, reading = frame.pulls, frame.plan todo_history = reading.todos held = frame.todos @@ -375,8 +375,8 @@ def status_table(self, now: float) -> ViewTable[CommanderStatus]: len(self.forks), self.usage.line(), self.heartbeat.age(now), ),)) - def status_strip(self, now: float, *, width: int | None = None) -> str: - return "\n".join(self.status_table(now).labelled_lines(width)) + def status_strip(self, now: float) -> str: + return "\n".join(self.status_table(now).labelled_lines()) @property def display_facts(self) -> HudDisplayFacts: @@ -394,10 +394,10 @@ def display_facts(self) -> HudDisplayFacts: @staticmethod def alarm_text(sections: tuple[AlarmSection, ...], now: float, *, - width: int | None = None, colour: bool = False) -> tuple[str, ...]: + 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(width)), + *(line for table in section.tables(now) for line in table.labelled_lines()), )) @staticmethod @@ -405,16 +405,15 @@ 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, - width: int | None = None) -> tuple[str, ...]: - """The producer passes no width; an actual reader may supply its own viewport.""" - result = list(self.status_strip(now, width=width).splitlines()) + 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(width) for table in section.tables)) - result.extend(("", *self.alarm_text(self.alarms, now, width=width, colour=colour), + 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) diff --git a/src/agent_comms/disclosure_text.py b/src/agent_comms/disclosure_text.py index 8067100813..686410f469 100644 --- a/src/agent_comms/disclosure_text.py +++ b/src/agent_comms/disclosure_text.py @@ -9,7 +9,7 @@ from textwrap import wrap from typing import ClassVar, Hashable -from agent_comms.declared_family import DeclaredFamily, FamilyMeta +from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import FieldCodec, FieldPlan, ValueDescription, ViewColumn @@ -19,11 +19,10 @@ class DisclosureText: @classmethod def of(cls, value: object) -> DisclosureText: - return FieldCodec.describe(value, DisclosureShapes) + return FieldCodec.shape(value).describe(DisclosureShapes) - def summary(self, width: int | None = None) -> str: - text = str(self.value)[:width] - return text if width is None else text.partition("\n")[0] + def summary(self) -> str: + return str(self.value) @classmethod def view(cls, value: object, view: str) -> tuple[str, ...]: @@ -86,32 +85,28 @@ 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 widths(self, available: int | None = None) -> tuple[int, ...]: - weights = tuple(max(len(column.label), max( + 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)) - if available is None: - return weights - return ViewColumn.fit_widths(weights, available, self.minimums) - def layout(self, available: int | None) -> ViewTable[R]: + 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 available is not None and sum(self.minimums) > available: + 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, width: int | None = None) -> tuple[str, ...]: - available = None if width is None else width - len(" ") * (len(self.columns) - 1) - layout = self.layout(available) - available = None if width is None else width - len(" ") * (len(layout.columns) - 1) - return layout.render_lines(available) + def lines(self) -> tuple[str, ...]: + return self.render_lines(self.natural_widths()) - def render_lines(self, available: int | None) -> tuple[str, ...]: - widths = self.widths(available) - lines = [] + 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, @@ -122,14 +117,10 @@ def render_lines(self, available: int | None) -> tuple[str, ...]: lines.extend(self.row_separator) return tuple(lines) - def labelled_lines(self, width: int | None = None) -> tuple[str, ...]: + def labelled_lines(self) -> tuple[str, ...]: """Compact strips use declared labels too, not another list of metric names.""" - records = tuple(" · ".join(f"{column.label}: {cell}" - for column, cell in zip(self.columns, row)) for row in self.texts) - if width is None: - return records - return tuple(line for record in records - for line in wrap(record, max(1, width), break_long_words=False, break_on_hyphens=False)) + 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]): @@ -148,10 +139,10 @@ def texts(self) -> tuple[tuple[str, ...], ...]: for column, cell in zip(self.declared_columns, row)),) for row in self.cells) - def widths(self, available: int | None = None) -> tuple[int, ...]: - return (max(1, available),) if available is not None else super().widths() + def widths(self, available: int) -> tuple[int, ...]: + return (max(1, available),) - def layout(self, available: int | None) -> ViewTable[R]: + def layout(self, available: int) -> ViewTable[R]: return self def header_rows(self) -> tuple[tuple[str, ...], ...]: @@ -205,19 +196,16 @@ def paint(cls, text: str, colour: bool) -> str: class RecordText(DisclosureText): - def summary_fields(self) -> tuple[FieldPlan, ...]: + def fields(self) -> tuple[FieldPlan, ...]: + """All disclosed fields; identity-only summaries never remove detail children.""" return tuple(plan for plan in FieldCodec.field_plans(type(self.value)) if plan.records) - def summary(self, width: int | None = None) -> str: - parts: list[str] = [] - length = 0 - for plan in self.summary_fields(): - part = DisclosureText.of(plan.current(self.value)).summary(width) - parts.append(part) - length += len(part) + len(" · ") - if width is not None and length >= width: - break - return " · ".join(parts)[:width] + def summary_fields(self) -> tuple[FieldPlan, ...]: + return self.fields() + + def summary(self) -> str: + return " · ".join(DisclosureText.of(plan.current(self.value)).summary() + for plan in self.summary_fields()) @dataclass(frozen=True) @@ -232,25 +220,25 @@ def summary_fields(self) -> tuple[FieldPlan, ...]: class ClassText(DisclosureText): value: type - def summary(self, width: int | None = None) -> str: - return self.value.__name__[:width] + def summary(self) -> str: + return self.value.__name__ @dataclass(frozen=True) class FamilyText(ClassText): - value: FamilyMeta + value: type[DeclaredFamily] - def summary(self, width: int | None = None) -> str: - return self.value.declared_name[:width] + def summary(self) -> str: + return self.value.declared_name class CollectionText(DisclosureText): value: Collection[object] noun: ClassVar[str] = "item" - def summary(self, width: int | None = None) -> str: + def summary(self) -> str: count = len(self.value) - return f"{count} {self.noun}{'s' if count != 1 else ''}"[:width] + return f"{count} {self.noun}{'s' if count != 1 else ''}" @dataclass(frozen=True) @@ -282,7 +270,7 @@ def class_value(cls, value: type) -> DisclosureText: return ClassText(value) @classmethod - def family_value(cls, value: FamilyMeta) -> DisclosureText: + def family_value(cls, value: type[DeclaredFamily]) -> DisclosureText: return FamilyText(value) @classmethod diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 3e03ffafad..85887bb445 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -5,6 +5,7 @@ import annotationlib import argparse import hashlib +import inspect import json import math import os @@ -531,8 +532,13 @@ def add_to( class ViewColumn(ABC): """Stored and computed fields expose one read-only presentation contract.""" - name: str - annotation: object + @property + @abstractmethod + def name(self) -> str: ... + + @property + @abstractmethod + def annotation(self) -> object: ... @property def label(self) -> str: @@ -579,7 +585,7 @@ 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]]] = [] @@ -596,6 +602,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) @@ -2269,7 +2279,7 @@ def class_value(cls, value: type) -> Product: ... @classmethod @abstractmethod - def family_value(cls, value: FamilyMeta) -> Product: ... + def family_value(cls, value: type[DeclaredFamily]) -> Product: ... @classmethod @abstractmethod @@ -2280,10 +2290,75 @@ def mapping(cls, value: Mapping[Hashable, object]) -> Product: ... def sequence(cls, value: Sequence[object]) -> Product: ... +@dataclass(frozen=True) +class ValueShape(ABC): + """A Python value recovered once by the canonical foreign-shape boundary.""" + + value: object + + @abstractmethod + def describe[T](self, presentation: type[ValueDescription[T]]) -> T: ... + + +class ScalarShape(ValueShape): + def describe[T](self, presentation: type[ValueDescription[T]]) -> T: + return presentation.scalar(self.value) + + +class RecordShape(ValueShape): + def describe[T](self, presentation: type[ValueDescription[T]]) -> T: + return presentation.record(self.value) + + +@dataclass(frozen=True) +class IdentifiedRecordShape(RecordShape): + identity: tuple[FieldPlan, ...] + + def describe[T](self, presentation: type[ValueDescription[T]]) -> T: + return presentation.identified_record(self.value, self.identity) + + +@dataclass(frozen=True) +class ClassShape(ValueShape): + value: type + + def describe[T](self, presentation: type[ValueDescription[T]]) -> T: + return presentation.class_value(self.value) + + +@dataclass(frozen=True) +class FamilyShape(ClassShape): + value: type[DeclaredFamily] + + def describe[T](self, presentation: type[ValueDescription[T]]) -> T: + return presentation.family_value(self.value) + + +@dataclass(frozen=True) +class MappingShape(ValueShape): + value: Mapping[Hashable, object] + + def describe[T](self, presentation: type[ValueDescription[T]]) -> T: + return presentation.mapping(self.value) + + +@dataclass(frozen=True) +class SequenceShape(ValueShape): + value: Sequence[object] + + def describe[T](self, presentation: type[ValueDescription[T]]) -> T: + return presentation.sequence(self.value) + + 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] + + @property + def name(self) -> str: + return self.__name__ def __init__(self, getter, *, view: str, name: str | None = None): super().__init__(getter) @@ -2292,12 +2367,12 @@ def __init__(self, getter, *, view: str, name: str | None = None): def __set_name__(self, owner: type, name: str) -> None: """Every class body declaring this field records it, a slots rebuild included.""" - self.name = name + super().__set_name__(owner, name) Projected._declared.setdefault(owner, {})[name] = self @cached_property def annotation(self) -> object: - return get_type_hints(self.fget, include_extras=True)["return"] + return inspect.signature(self.fget, eval_str=True).return_annotation def current(self, record: object) -> object: return self.__get__(record, type(record)) @@ -2330,40 +2405,39 @@ class FieldCodec(MroDispatch, Sealed): @singledispatchmethod @classmethod - def describe[T](cls, value: object, presentation: type[ValueDescription[T]]) -> T: + def shape(cls, value: object) -> ValueShape: """Recover foreign presentation shape here, never in either renderer.""" if is_dataclass(value): identity = tuple(plan for plan in cls.field_plans(type(value)) if DisclosureIdentity.read(plan.annotation, False)) - return (presentation.identified_record(value, identity) if identity - else presentation.record(value)) - return presentation.scalar(value) + return IdentifiedRecordShape(value, identity) if identity else RecordShape(value) + return ScalarShape(value) - @describe.register(type) + @shape.register(type) @classmethod - def _describe_class[T](cls, value: type, presentation: type[ValueDescription[T]]) -> T: - return presentation.class_value(value) + def _shape_class(cls, value: type) -> ValueShape: + return ClassShape(value) - @describe.register(FamilyMeta) + @shape.register(FamilyMeta) @classmethod - def _describe_family[T](cls, value: FamilyMeta, presentation: type[ValueDescription[T]]) -> T: - return presentation.family_value(value) + def _shape_family(cls, value: type[DeclaredFamily]) -> ValueShape: + return FamilyShape(value) - @describe.register(Mapping) + @shape.register(Mapping) @classmethod - def _describe_mapping[T](cls, value: Mapping[Hashable, object], presentation: type[ValueDescription[T]]) -> T: - return presentation.mapping(value) + def _shape_mapping(cls, value: Mapping[Hashable, object]) -> ValueShape: + return MappingShape(value) - @describe.register(Sequence) + @shape.register(Sequence) @classmethod - def _describe_sequence[T](cls, value: Sequence[object], presentation: type[ValueDescription[T]]) -> T: - return presentation.sequence(value) + def _shape_sequence(cls, value: Sequence[object]) -> ValueShape: + return SequenceShape(value) - @describe.register(str) - @describe.register(bytes) + @shape.register(str) + @shape.register(bytes) @classmethod - def _describe_scalar[T](cls, value: str | bytes, presentation: type[ValueDescription[T]]) -> T: - return presentation.scalar(value) + def _shape_scalar(cls, value: str | bytes) -> ValueShape: + return ScalarShape(value) @classmethod def decode_external[T]( @@ -2737,7 +2811,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) @@ -2778,7 +2852,6 @@ def _field_plan(field: Field[object], annotation: object) -> type[FieldPlan]: return selected.pop() @classmethod - @cache def view_columns(cls, declaration: type, view: str) -> tuple[ViewColumn, ...]: """One column source for native tables and plain-text views.""" return ( diff --git a/tests/test_commander_hud_projection.py b/tests/test_commander_hud_projection.py index 8c3f3f8c4d..48a120c05a 100644 --- a/tests/test_commander_hud_projection.py +++ b/tests/test_commander_hud_projection.py @@ -68,24 +68,25 @@ def test_shared_hud_groups_strip_full_alarms_and_plain_text() -> None: assert tuple(unit.unit.name for section in groups for unit in section.records) == ( "blocked", "screen", "run-next", "idle", ) - plain = hud.hud_text(1000, width=120) - status = tuple(hud.status_strip(1000, width=120).splitlines()) + 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, width=120, colour=True)) + 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 == () - assert hud.alarm_detail(hud.alarms, 1000) == tuple( - ping.render(1000) for section in hud.alarms for ping in section.pings - ) + 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: - narrow = "\n".join(table.lines(60)) + 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,) diff --git a/tests/test_commander_notifications.py b/tests/test_commander_notifications.py index 772cdfad76..ebe5cf03fe 100644 --- a/tests/test_commander_notifications.py +++ b/tests/test_commander_notifications.py @@ -690,8 +690,7 @@ def test_waiting_notification_keeps_actionable_subjects_overflow_and_original_ev assert sum(row.more for row in overflow) == len(rules) - 1 now = time.time() original_lines = frame.alarm_detail(frame.alarms, now) - assert original_lines == tuple(ping.render(now) for section in frame.alarms - for ping in section.pings) + 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 From 22ee35c7422ee9227c187566f3d3b153994a0243 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 17:58:01 -0400 Subject: [PATCH 10/16] Own quiet publication in committed packets and reuse captured action evidence --- src/agent_comms/commander_heartbeat.py | 31 +++-- src/agent_comms/commander_hud.py | 34 ++---- src/agent_comms/disclosure_text.py | 64 +++++----- src/agent_comms/field_codec.py | 108 +++------------- src/agent_comms/heartbeat_subjects.py | 61 ++++++---- .../task_attachments/commander_pings.py | 66 ++++++---- tests/commander_heartbeat_fixture.py | 17 +++ tests/test_commander_hud_projection.py | 10 +- tests/test_commander_notifications.py | 115 +++++++++++++++++- tests/test_fleet_replay.py | 4 +- tests/test_head_review.py | 4 +- tests/test_order_progress.py | 4 +- tests/test_standing_rules.py | 4 +- tests/test_unit_state.py | 4 +- 14 files changed, 291 insertions(+), 235 deletions(-) diff --git a/src/agent_comms/commander_heartbeat.py b/src/agent_comms/commander_heartbeat.py index a483d2cfb1..51041a2db8 100644 --- a/src/agent_comms/commander_heartbeat.py +++ b/src/agent_comms/commander_heartbeat.py @@ -5,12 +5,14 @@ import time from collections.abc import Callable, Mapping from contextlib import AbstractContextManager -from dataclasses import dataclass, replace +from dataclasses import dataclass from typing import TYPE_CHECKING -from agent_comms.commander_hud import CommanderFrame, CommanderHud, HudUnavailableGroup +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 AlarmGroup, 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 @@ -110,26 +112,23 @@ 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)), ) - hud = CommanderHud.of(comms, item.thread.name, item.frame, item.states) - published[item.thread.incarnation] = hud + usage = CodexUsage.read() # Include acknowledgements committed during forge/provider reads and enforcement. actions = CommanderHistory.read(comms, self.actors(comms.registry.snapshot(), item.thread.name)) - for delivery in CommanderUpdate.derive( + delivery = CommanderUpdate.derive( item.thread.incarnation, tuple(state for state in item.states if state not in rejected), tuple(rule for rule in item.rules if rule not in rejected), previous, now=now, history=actions, - undisposed=tuple( - ping for packet in previous - for ping in packet.undisposed(actions, now - AwaitingCommander.seconds)), not_applied=tuple( rule.rejected_line(error) for rule, error in rejected.items()), - ): - hud = replace(hud, packets=(delivery.packet,), open_pings=delivery.open_pings) - published[item.thread.incarnation] = hud - changed = CommanderFrame.at(comms.root, hud.commander).changed(hud) - if delivery.packet.should_publish(changed): - delivery.packet.send(comms, now, hud.hud_text(now)) + ) + hud = CommanderHud.of(comms, item.thread.name, item.frame, item.states, + delivery=delivery, usage=usage) + published[item.thread.incarnation] = hud + facts = PublishedFacts.of(hud.display_facts) + if delivery.packet.should_publish(facts, previous): + delivery.packet.send(comms, now, hud.hud_text(now), publication=facts) completed.append(item.thread.incarnation) finally: # A later commander's failure does not erase frames/passes already completed. diff --git a/src/agent_comms/commander_hud.py b/src/agent_comms/commander_hud.py index 43eadf78a6..bb8c3061a8 100644 --- a/src/agent_comms/commander_hud.py +++ b/src/agent_comms/commander_hud.py @@ -26,10 +26,9 @@ ) from agent_comms.errors import ToolRefusedError from agent_comms.field_codec import ( - DisclosureIdentity, HudExclude, MalformedRecord, OrderId, PublicationExclude, WireTransient, projected, + DisclosureIdentity, HudExclude, OrderId, PublicationExclude, WireTransient, projected, ) from agent_comms.heartbeat_frame import HeartbeatFrame -from agent_comms.heartbeat_subjects import CommanderHistory from agent_comms.locked_store import JsonDocument from agent_comms.plan_phases import Plan, PlanPhase from agent_comms.order_story import OrderStory @@ -41,7 +40,7 @@ from agent_comms.turn_digests import LastTurns, OrderEditCountRead from agent_comms.ui_model.unit_state import ExpandedHudGroup, HudGroup, UnitClock, UnitState, UrgentGroup from agent_comms.task_attachments.commander_pings import ( - AlarmGroup, AlarmSection, CommanderUpdate, Ping, + AlarmGroup, AlarmSection, CommanderDelivery, CommanderUpdate, Ping, ) from agent_comms_hooks.board import BoardEntry @@ -303,7 +302,8 @@ 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 = comms.registry.snapshot() owner = snapshot.require(commander).incarnation.require_recorded() @@ -344,12 +344,9 @@ 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) - packets = CommanderUpdate.latest(comms, owner) - history = CommanderHistory.read(comms, CommanderService.actors(snapshot, commander)) - opened = tuple(ping for packet in packets for ping in packet.open_pings(history)) return cls(owner, tuple(units), phases, OverloadDegradation.from_orders(held, len(timers), sum(edits.values())), - forks, CodexUsage.read(), packets, open_pings=opened, + forks, usage, (delivery.packet,), open_pings=delivery.open_pings, heartbeat=ServicePassStore.at(comms.root).read().heartbeat_completed_at(owner)) @property @@ -357,10 +354,11 @@ 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) - @property + @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) @@ -378,14 +376,14 @@ def status_table(self, now: float) -> ViewTable[CommanderStatus]: def status_strip(self, now: float) -> str: return "\n".join(self.status_table(now).labelled_lines()) - @property + @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()), + 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. @@ -408,7 +406,7 @@ def alarm_detail(sections: tuple[AlarmSection, ...], now: float) -> tuple[str, . 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(): + 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( @@ -429,8 +427,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. A previous frame is only a - quiet-publication baseline: an older shape is replaced, never treated as current evidence. + CommanderHud the heartbeat reads, and keeps no history. It is a current view, never + proof that a packet was successfully published. """ path: Path @@ -444,14 +442,6 @@ def at(cls, root: Path, commander: ThreadIncarnation) -> Self: def empty(self) -> CommanderHud: raise ToolRefusedError(f"Tristan frame missing: {self.path}") - def changed(self, hud: CommanderHud) -> bool: - """Only the disposable frame is compared; a prior schema cannot block its replacement.""" - try: - previous = self.read() - except (MalformedRecord, ToolRefusedError): - return True - return previous.display_facts != hud.display_facts - @classmethod def current(cls, comms: Comms, commander: str) -> CommanderHud: """Read the heartbeat's publication, never repeat its forge and history work.""" diff --git a/src/agent_comms/disclosure_text.py b/src/agent_comms/disclosure_text.py index 686410f469..192ceed55d 100644 --- a/src/agent_comms/disclosure_text.py +++ b/src/agent_comms/disclosure_text.py @@ -14,21 +14,43 @@ @dataclass(frozen=True) -class DisclosureText: +class DisclosureText(ValueDescription["DisclosureText"]): value: object @classmethod def of(cls, value: object) -> DisclosureText: - return FieldCodec.shape(value).describe(DisclosureShapes) + return FieldCodec.describe(value, DisclosureText) def summary(self) -> str: return str(self.value) @classmethod - def view(cls, value: object, view: str) -> tuple[str, ...]: - """Read the original values, not their flattened JSON representations.""" - return tuple(cls.of(column.current(value)).summary() - for column in FieldCodec.view_columns(type(value), view)) + 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) + + @classmethod + def class_value(cls, value: type) -> DisclosureText: + return ClassText(value) + + @classmethod + def family_value(cls, value: type[DeclaredFamily]) -> DisclosureText: + return FamilyText(value) + + @classmethod + def mapping(cls, value: Mapping[Hashable, object]) -> DisclosureText: + return MappingText(value) + + @classmethod + def sequence(cls, value: Sequence[object]) -> DisclosureText: + return SequenceText(value) @dataclass(frozen=True) @@ -250,33 +272,3 @@ class SequenceText(CollectionText): class MappingText(CollectionText): value: Mapping[Hashable, object] noun = "field" - - -class DisclosureShapes(ValueDescription[DisclosureText]): - @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) - - @classmethod - def class_value(cls, value: type) -> DisclosureText: - return ClassText(value) - - @classmethod - def family_value(cls, value: type[DeclaredFamily]) -> DisclosureText: - return FamilyText(value) - - @classmethod - def mapping(cls, value: Mapping[Hashable, object]) -> DisclosureText: - return MappingText(value) - - @classmethod - def sequence(cls, value: Sequence[object]) -> DisclosureText: - return SequenceText(value) diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 85887bb445..76094617f7 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -16,7 +16,7 @@ from dataclasses import MISSING, Field, dataclass, field, fields, is_dataclass from datetime import datetime from enum import Enum, IntFlag -from functools import cache, cached_property, lru_cache, partial, reduce, singledispatchmethod +from functools import cache, cached_property, lru_cache, partial, reduce from operator import or_ from pathlib import Path from typing import ( @@ -2290,66 +2290,6 @@ def mapping(cls, value: Mapping[Hashable, object]) -> Product: ... def sequence(cls, value: Sequence[object]) -> Product: ... -@dataclass(frozen=True) -class ValueShape(ABC): - """A Python value recovered once by the canonical foreign-shape boundary.""" - - value: object - - @abstractmethod - def describe[T](self, presentation: type[ValueDescription[T]]) -> T: ... - - -class ScalarShape(ValueShape): - def describe[T](self, presentation: type[ValueDescription[T]]) -> T: - return presentation.scalar(self.value) - - -class RecordShape(ValueShape): - def describe[T](self, presentation: type[ValueDescription[T]]) -> T: - return presentation.record(self.value) - - -@dataclass(frozen=True) -class IdentifiedRecordShape(RecordShape): - identity: tuple[FieldPlan, ...] - - def describe[T](self, presentation: type[ValueDescription[T]]) -> T: - return presentation.identified_record(self.value, self.identity) - - -@dataclass(frozen=True) -class ClassShape(ValueShape): - value: type - - def describe[T](self, presentation: type[ValueDescription[T]]) -> T: - return presentation.class_value(self.value) - - -@dataclass(frozen=True) -class FamilyShape(ClassShape): - value: type[DeclaredFamily] - - def describe[T](self, presentation: type[ValueDescription[T]]) -> T: - return presentation.family_value(self.value) - - -@dataclass(frozen=True) -class MappingShape(ValueShape): - value: Mapping[Hashable, object] - - def describe[T](self, presentation: type[ValueDescription[T]]) -> T: - return presentation.mapping(self.value) - - -@dataclass(frozen=True) -class SequenceShape(ValueShape): - value: Sequence[object] - - def describe[T](self, presentation: type[ValueDescription[T]]) -> T: - return presentation.sequence(self.value) - - class Projected(ViewColumn, property): """A computed field declared by its owner for a named read-only view.""" @@ -2403,41 +2343,25 @@ class FieldCodec(MroDispatch, Sealed): the single record codec and declaration-derived schema. """ - @singledispatchmethod @classmethod - def shape(cls, value: object) -> ValueShape: - """Recover foreign presentation shape here, never in either renderer.""" + def describe[T](cls, value: object, presentation: type[ValueDescription[T]]) -> T: + """Decode foreign Python shape once, retaining the capability's concrete product type.""" + if isinstance(value, type): + if issubclass(value, DeclaredFamily): + return presentation.family_value(value) + return presentation.class_value(value) + if isinstance(value, (str, bytes)): + return presentation.scalar(value) + if isinstance(value, Mapping): + return presentation.mapping(value) + if isinstance(value, Sequence): + return presentation.sequence(value) if is_dataclass(value): identity = tuple(plan for plan in cls.field_plans(type(value)) if DisclosureIdentity.read(plan.annotation, False)) - return IdentifiedRecordShape(value, identity) if identity else RecordShape(value) - return ScalarShape(value) - - @shape.register(type) - @classmethod - def _shape_class(cls, value: type) -> ValueShape: - return ClassShape(value) - - @shape.register(FamilyMeta) - @classmethod - def _shape_family(cls, value: type[DeclaredFamily]) -> ValueShape: - return FamilyShape(value) - - @shape.register(Mapping) - @classmethod - def _shape_mapping(cls, value: Mapping[Hashable, object]) -> ValueShape: - return MappingShape(value) - - @shape.register(Sequence) - @classmethod - def _shape_sequence(cls, value: Sequence[object]) -> ValueShape: - return SequenceShape(value) - - @shape.register(str) - @shape.register(bytes) - @classmethod - def _shape_scalar(cls, value: str | bytes) -> ValueShape: - return ScalarShape(value) + return (presentation.identified_record(value, identity) if identity + else presentation.record(value)) + return presentation.scalar(value) @classmethod def decode_external[T]( diff --git a/src/agent_comms/heartbeat_subjects.py b/src/agent_comms/heartbeat_subjects.py index 4c57b7379f..a0f5d82328 100644 --- a/src/agent_comms/heartbeat_subjects.py +++ b/src/agent_comms/heartbeat_subjects.py @@ -7,8 +7,7 @@ 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,14 +15,15 @@ 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.service_passes import ServicePasses, ServicePassStore from agent_comms.thread_identity import RecordedThreadIncarnation, ThreadIncarnation from agent_comms.todos import StoredHeadReview if TYPE_CHECKING: from agent_comms.comms import Comms from agent_comms.plan_phases import OrderPhase + from agent_comms.registry_document import RegistrySnapshot @dataclass(frozen=True) @@ -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,38 @@ def phases_checked(self) -> Mapping[type[OrderPhase], EventTime]: @dataclass(frozen=True) class HeartbeatSubject(DeclaredFamily, affix="Subject"): - @abstractmethod - def disposed(self, history: CommanderHistory, since: float) -> bool: - """Whether Core recorded an action on this subject at or after ``since``.""" + def action_at(self, history: CommanderHistory) -> EventTime: + """Subjects without a disposing action never invent one.""" + return UnrecordedEventTime() @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 action_at(self, history: CommanderHistory) -> EventTime: + return max((review.at for review in StoredHeadReview.head_reviews( + history.comms.goals.todos, self.request) if review.verdict.decides()), + 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.""" - def disposed(self, history: CommanderHistory, since: float) -> bool: - return False - - def acted_on(self, acted: frozenset[HeartbeatSubject]) -> bool: - return False + def action_at(self, history: CommanderHistory) -> EventTime: + return UnrecordedEventTime() @dataclass(frozen=True) @@ -94,8 +105,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 +114,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/task_attachments/commander_pings.py b/src/agent_comms/task_attachments/commander_pings.py index 175df662a2..ee57b5c95b 100644 --- a/src/agent_comms/task_attachments/commander_pings.py +++ b/src/agent_comms/task_attachments/commander_pings.py @@ -78,7 +78,7 @@ def alarm_kind(self) -> type[DeclaredFamily]: ... def acted(self, history: CommanderHistory) -> bool: """Whether a subject has a recorded action since this line began.""" began = self.since.recorded_at("A line") - return any(subject.disposed(history, began) for subject in self.subjects) + return any(history.action_at(subject).since(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 @@ -192,11 +192,30 @@ class CommanderDelivery: 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(TaskAttachment, 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] writes: ClassVar[tuple[type[ProducedRow], ...]] = (CommanderUpdatePosition,) @@ -208,7 +227,7 @@ def projections(self, message: Message) -> Iterator[TypedTable]: def commander_update(self, commander: ThreadIncarnation) -> tuple[CommanderUpdate, ...]: return (self,) if self.commander == commander else () - def should_publish(self, frame_changed: bool) -> bool: + def should_publish(self, facts: PublishedFacts, previous: tuple[CommanderUpdate, ...]) -> bool: return True def new_pings(self) -> tuple[Ping, ...]: @@ -277,8 +296,8 @@ 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, - history: CommanderHistory, undisposed: tuple[Ping, ...] = (), - not_applied: tuple[str, ...] = ()) -> tuple[CommanderDelivery, ...]: + 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} @@ -294,11 +313,14 @@ def derive(cls, commander: ThreadIncarnation, states: tuple[UnitState, ...], new = tuple(ping for ping in handled if known.get(ping.key) != ping) 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 ping in undisposed if ping in handled), not_applied=not_applied, + 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),) + return CommanderDelivery(packet, opened) @classmethod def report_failure(cls, commander: ThreadIncarnation, failure: ServicePassFailing, @@ -317,13 +339,14 @@ 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, hud: tuple[str, ...]) -> Message: + def send(self, comms: Comms, now: float, hud: tuple[str, ...], *, + publication: PublicationFacts = UnpublishedFacts()) -> Message: from agent_comms.commander_heartbeat import CommanderHeartbeat message = Message( sender=CommanderHeartbeat.sender, target=self.commander.name, body=self.body(hud), - type=InfoKind, role=self.role, task=self, + type=InfoKind, role=self.role, task=replace(self, publication=publication), ) return comms.bus.publisher.publish_ordinary( message, origin=AgentOrigin(), destination=ThreadTarget(self.commander.name), @@ -336,8 +359,8 @@ class CommanderNotice(CommanderUpdate): role = NoticeRole() - def should_publish(self, frame_changed: bool) -> bool: - return frame_changed + def should_publish(self, facts: PublishedFacts, previous: tuple[CommanderUpdate, ...]) -> bool: + return not any(packet.publication == facts for packet in previous) async def wake(self, bridge: CliBridge, message: Message) -> type[Outcome]: """The current display has no unresolved request that needs model input.""" @@ -365,7 +388,7 @@ class AlarmGroup(ViewHeading, affix="Alarms"): @classmethod @abstractmethod def members(cls, packets: tuple[CommanderUpdate, ...], - opened: tuple[Ping, ...]) -> tuple[Ping, ...]: ... + opened: tuple[Ping, ...], new: tuple[Ping, ...]) -> tuple[Ping, ...]: ... @classmethod def visible(cls, pings: tuple[Ping, ...]) -> tuple[Ping, ...]: @@ -374,9 +397,11 @@ def visible(cls, pings: tuple[Ping, ...]) -> tuple[Ping, ...]: @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),) if members) + for members in (group.members(packets, opened, new),) if members) class CompactAlarms: @@ -394,9 +419,8 @@ class NewAlarms(CompactAlarms, AlarmGroup): @classmethod def members(cls, packets: tuple[CommanderUpdate, ...], - opened: tuple[Ping, ...]) -> tuple[Ping, ...]: - return tuple(line for packet in packets for ping in packet.new_pings() - for line in ping.alarm_pings()) + opened: tuple[Ping, ...], new: tuple[Ping, ...]) -> tuple[Ping, ...]: + return new class StillOpenAlarms(CompactAlarms, AlarmGroup): @@ -404,9 +428,9 @@ class StillOpenAlarms(CompactAlarms, AlarmGroup): @classmethod def members(cls, packets: tuple[CommanderUpdate, ...], - opened: tuple[Ping, ...]) -> tuple[Ping, ...]: - new = frozenset(NewAlarms.members(packets, opened)) - return tuple(line for ping in opened for line in ping.alarm_pings() if line not in new) + 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): @@ -414,10 +438,10 @@ class WatchingAlarms(CompactAlarms, AlarmGroup): @classmethod def members(cls, packets: tuple[CommanderUpdate, ...], - opened: tuple[Ping, ...]) -> tuple[Ping, ...]: - new = frozenset(NewAlarms.members(packets, opened)) + 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) + if not ping.commander_disposes for line in ping.alarm_pings() if line not in new_members) @dataclass(frozen=True) diff --git a/tests/commander_heartbeat_fixture.py b/tests/commander_heartbeat_fixture.py index 95dbb373bb..d0b7688ac4 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(), commander)) + 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 index 48a120c05a..f98ece8dd2 100644 --- a/tests/test_commander_hud_projection.py +++ b/tests/test_commander_hud_projection.py @@ -4,7 +4,7 @@ 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 +from agent_comms.disclosure_text import ViewTable from agent_comms.field_codec import DisclosureIdentity, FieldCodec, OrderId from agent_comms.recorded_time import RecordedEventTime from agent_comms.standing_rules import ServicePassFailing @@ -50,18 +50,16 @@ def test_commander_hud_projection_has_one_progress_answer() -> None: assert tuple(FieldCodec.projections(CommanderUnit, "hud")) == ("work", "change", "next") columns = FieldCodec.view_columns(CommanderUnit, "hud") assert tuple(column.name for column in columns) == ("unit", "state", "work", "change", "next") - assert DisclosureText.view(unit, "hud") == ( - "screen", "moving", "P930 pending", "PR #3589 open at 8636a6f28", "let it continue", - ) 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")) - assert "\n" in DisclosureText.view(replace(unit, orders=(*unit.orders, second)), "hud")[2] + table = ViewTable(CommanderUnit, "hud", (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() + groups = hud.groups assert tuple(section.group for section in groups) == ( UrgentGroup, InProgressGroup, ReadyToTestGroup, IdleGroup, ) diff --git a/tests/test_commander_notifications.py b/tests/test_commander_notifications.py index 524ac7b288..b67b9fc698 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 @@ -16,7 +17,7 @@ from agent_comms.child_process import ProcessIdentity 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 ( CommanderFrame, CommanderHud, CodexUsage, FinishedPlansGroup, UnreadableUsage, ) @@ -83,7 +84,7 @@ def notifications(comms): 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): @@ -420,10 +421,10 @@ def test_frame_puts_hands_first_and_folds_finished_phases_per_plan(notification_ CommanderPhase(ReviewQueuePhase, True, False, (worker,)), ), OverloadDegradation.from_orders((), 0, 0), (worker,), CodexUsage(UnreadableUsage(reason='not requested')), open_pings=()) - urgent = next(section for section in hud.groups() if section.group is UrgentGroup) + 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() + folded, = next(section.records for section in hud.groups if section.group is FinishedPlansGroup) assert folded.phases == (RetainedStorePhase, OneOrderOwnerPhase) assert folded.done == 2 @@ -476,7 +477,7 @@ def test_recreated_watcher_retains_non_disposable_observation_without_nagging(no assert not any(state.line() in first.body for state in states) hud = lead_hud(comms, states) assert_status_fields(first, hud) - assert all(section.heading() in first.body for section in hud.groups()) + 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) @@ -720,6 +721,110 @@ def test_waiting_notification_keeps_actionable_subjects_overflow_and_original_ev 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, + ) + + +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): """The registry names the commander; an old open request does not make one.""" comms = notification_bus diff --git a/tests/test_fleet_replay.py b/tests/test_fleet_replay.py index aa0935fbb1..f5577768a3 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 4d2b5c730e..097f658d0c 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -342,10 +342,10 @@ 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) + assert CommanderHistory.read(comms, frozenset()).action_at(subject).since(since) invoke(comms, request, UnderReviewHeadVerdict()) assert owed() == [request] 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 717baacac0..7000394b6b 100644 --- a/tests/test_standing_rules.py +++ b/tests/test_standing_rules.py @@ -70,7 +70,7 @@ def test_blocked_goal_leads_the_board_and_clears_when_active(comms, tmp_path, st text="IDLE housekeeping", since=RecordedEventTime(now - 600)) previous = CommanderDecisions(thread.incarnation, (old,), (old,), ()) history = CommanderHistory.read(comms, frozenset({thread.name})) - delivery, = CommanderUpdate.derive(thread.incarnation, (), (rule,), (previous,), + delivery = CommanderUpdate.derive(thread.incarnation, (), (rule,), (previous,), now=now, history=history) update = delivery.packet assert update.handled[0].urgent @@ -80,7 +80,7 @@ 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,), + 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 1ba8a98f3e..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 @@ -386,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) From ada09494c2c6ffaa065ed7bd05ce01c1f5f71878 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:18:41 -0400 Subject: [PATCH 11/16] Declare disclosure membership and nominal views; share prepared source children --- src/agent_comms/activity.py | 2 +- src/agent_comms/child_process.py | 4 +- src/agent_comms/cli_options.py | 4 +- src/agent_comms/commander/vibe_check.py | 6 +- src/agent_comms/commander_hud.py | 28 +-- src/agent_comms/connector.py | 4 +- src/agent_comms/context_store.py | 4 +- src/agent_comms/coordination_snapshot.py | 4 +- .../coordination_tables/assignments.py | 10 +- .../coordination_tables/attempts.py | 8 +- .../coordination_tables/executions.py | 4 +- .../coordination_tables/responses.py | 6 +- src/agent_comms/diagnostics.py | 4 +- src/agent_comms/disclosure_text.py | 162 +++++++++++++++--- src/agent_comms/field_codec.py | 126 ++++++++------ src/agent_comms/history_views.py | 4 +- src/agent_comms/messages.py | 4 +- src/agent_comms/native_tools.py | 4 +- src/agent_comms/order_story.py | 6 +- src/agent_comms/presentation.py | 16 +- src/agent_comms/recorded_time.py | 4 +- src/agent_comms/todos.py | 11 +- src/agent_comms/tool_result.py | 4 +- src/agent_comms/tools.py | 4 +- src/agent_comms/transcript_events.py | 16 +- src/agent_comms/transcripts.py | 10 +- src/agent_comms/turn_digests.py | 4 +- src/agent_comms/ui_model/unit_state.py | 3 +- src/agent_comms/work_branches.py | 4 +- tests/guards/test_debt_ratchet.py | 4 +- tests/test_commander_hud_projection.py | 41 ++++- tests/test_coordination.py | 10 +- tests/test_coordination_store.py | 4 +- tests/test_field_codec.py | 24 +-- tests/test_json_shape_field_codec.py | 4 +- tools/debt_ratchet.py | 14 +- 36 files changed, 390 insertions(+), 181 deletions(-) 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 1fe0c35e84..2c87bddd4c 100644 --- a/src/agent_comms/child_process.py +++ b/src/agent_comms/child_process.py @@ -44,7 +44,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 .sealed import Sealed from .systemd_unit import AgentsSlice, SystemdUnit @@ -1380,7 +1380,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_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/vibe_check.py b/src/agent_comms/commander/vibe_check.py index 3657c97444..7fc9ed06b1 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 @@ -151,11 +153,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 diff --git a/src/agent_comms/commander_hud.py b/src/agent_comms/commander_hud.py index cad0160e09..74bbfa99d7 100644 --- a/src/agent_comms/commander_hud.py +++ b/src/agent_comms/commander_hud.py @@ -2,6 +2,8 @@ from __future__ import annotations +from agent_comms.field_codec import WireExclude + import asyncio import time from collections.abc import Iterable @@ -26,7 +28,7 @@ ) from agent_comms.errors import ToolRefusedError from agent_comms.field_codec import ( - DisclosureIdentity, HudExclude, OrderId, PublicationExclude, WireTransient, projected, + DisclosedRecord, DisclosureIdentity, HudExclude, OrderId, PublicationExclude, WireTransient, projected, ) from agent_comms.heartbeat_frame import HeartbeatFrame from agent_comms.locked_store import JsonDocument @@ -49,7 +51,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()] @@ -74,7 +76,7 @@ class HudUnavailableGroup(HudGroup): tone = ErrorTone -class HudRecord(ABC): +class HudRecord(DisclosedRecord, ABC): """Native rows expose their declared group, not another hand-joined renderer.""" @property @@ -105,15 +107,15 @@ class CommanderUnit(HudRecord): def hud_group(self) -> type[HudGroup]: return self.state.hud_group - @projected(view="hud") + @projected(view=HudExclude) def work(self) -> str: return "\n".join(f"{order.problem} {order.state}" for order in self.orders) - @projected(view="hud") + @projected(view=HudExclude) def change(self) -> str: return "\n".join(order.progress.line for order in self.orders) - @projected(view="hud") + @projected(view=HudExclude) def next(self) -> str: return self.state.action @@ -165,13 +167,13 @@ 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, "hud", tuple(rows)) + 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: +class CommanderOrderDetail(DisclosedRecord): """The selected card beside its original instructions, story and captured Git points.""" card: Annotated[CommanderOrder, DisclosureIdentity()] @@ -181,7 +183,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()] @@ -220,7 +222,7 @@ class FinishedPhases(HudRecord): phases: Annotated[tuple[type[PlanPhase], ...], HudExclude()] hud_group: ClassVar[type[HudGroup]] = FinishedPlansGroup - @projected(view="hud") + @projected(view=HudExclude) def done(self) -> int: return len(self.phases) @@ -254,7 +256,7 @@ def needs_hand(self) -> bool: @dataclass(frozen=True) -class CommanderStatus: +class CommanderStatus(DisclosedRecord): load: float review_up: int unjudged: int @@ -368,7 +370,7 @@ def groups(self) -> tuple[HudSection, ...]: *folded)) def status_table(self, now: float) -> ViewTable[CommanderStatus]: - return ViewTable(CommanderStatus, "hud", (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), ),)) @@ -415,7 +417,7 @@ def hud_text(self, now: float, *, colour: bool = False) -> tuple[str, ...]: *(line for packet in self.packets for line in packet.not_applied))) return tuple(result) - @projected(view="wire") + @projected(view=WireExclude) def frame(self) -> tuple[str, ...]: return self.hud_text(time.time()) diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index 86f02f4d49..9acfb6c565 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 @@ -274,7 +276,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 index 192ceed55d..c5e923bc45 100644 --- a/src/agent_comms/disclosure_text.py +++ b/src/agent_comms/disclosure_text.py @@ -2,15 +2,51 @@ from __future__ import annotations -from collections.abc import Collection, Mapping, Sequence +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 agent_comms.declared_family import DeclaredFamily -from agent_comms.field_codec import FieldCodec, FieldPlan, ValueDescription, ViewColumn +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) @@ -21,9 +57,22 @@ class DisclosureText(ValueDescription["DisclosureText"]): def of(cls, value: object) -> DisclosureText: return FieldCodec.describe(value, DisclosureText) + @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.of(column.current(self.value)) + + @handles(object, str, bytes) @classmethod def scalar(cls, value: object) -> DisclosureText: return DisclosureText(value) @@ -36,23 +85,42 @@ def record(cls, value: object) -> DisclosureText: 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: Sequence[object]) -> DisclosureText: + 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.""" @@ -73,7 +141,7 @@ class ViewTable[R]: """One declaration owns semantic cells and both wide and stacked presentation.""" record: type[R] - view: str + view: type[ViewExclusion] records: tuple[R, ...] show_header: ClassVar[bool] = True row_separator: ClassVar[tuple[str, ...]] = () @@ -86,21 +154,32 @@ def declared_columns(self) -> tuple[ViewColumn, ...]: def columns(self) -> tuple[ViewColumn, ...]: return self.declared_columns + @cached_property + def row_values(self) -> tuple[DisclosureText, ...]: + return tuple(DisclosureText.of(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(DisclosureText.of(column.current(record)) for column in self.declared_columns) - for record in self.records) + 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) + 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("publication")) + 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)) + tuple(tuple(row[index].summary for index in retained) for row in self.cells)) @cached_property def minimums(self) -> tuple[int, ...]: @@ -157,7 +236,7 @@ def columns(self) -> tuple[ViewColumn, ...]: @cached_property def texts(self) -> tuple[tuple[str, ...], ...]: - return tuple(("\n".join(f"{column.label}: {cell.summary()}" + return tuple(("\n".join(f"{column.label}: {cell.summary}" for column, cell in zip(self.declared_columns, row)),) for row in self.cells) @@ -217,31 +296,57 @@ def paint(cls, text: str, colour: bool) -> str: return cls.tone.paint(text, colour) -class RecordText(DisclosureText): +class RecordText(StructuredText): + @cached_property def fields(self) -> tuple[FieldPlan, ...]: - """All disclosed fields; identity-only summaries never remove detail children.""" + """Identity-only summaries never remove original detail children.""" return tuple(plan for plan in FieldCodec.field_plans(type(self.value)) if plan.records) - def summary_fields(self) -> tuple[FieldPlan, ...]: - return self.fields() + @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(DisclosureText.of(plan.current(self.value)).summary() - for plan in self.summary_fields()) + return " · ".join(child.value.summary for child in self.children) @dataclass(frozen=True) class IdentifiedRecordText(RecordText): identity: tuple[FieldPlan, ...] - def summary_fields(self) -> tuple[FieldPlan, ...]: - return self.identity + @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__ @@ -250,14 +355,16 @@ def summary(self) -> str: class FamilyText(ClassText): value: type[DeclaredFamily] + @cached_property def summary(self) -> str: return self.value.declared_name -class CollectionText(DisclosureText): +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 ''}" @@ -265,10 +372,23 @@ def summary(self) -> str: @dataclass(frozen=True) class SequenceText(CollectionText): - value: Sequence[object] + value: Collection[object] + + @cached_property + def children(self) -> tuple[DisclosureEntry, ...]: + entries = [] + for index, value in enumerate(self.value): + text = DisclosureText.of(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.of(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 76094617f7..d94499066f 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -12,7 +12,7 @@ import re import types from abc import ABC, abstractmethod -from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping, Sequence +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 @@ -234,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): @@ -324,13 +328,27 @@ class ActivityExclude(ViewExclusion): class SnapshotExclude(ViewExclusion): - pass + @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 + + class ContentExclude(ViewExclusion): pass @@ -547,13 +565,8 @@ def label(self) -> str: @abstractmethod def current(self, record: object) -> object: ... - @cached_property - def view_exclusions(self) -> tuple[ViewExclusion, ...]: - return ViewExclusion.on(self.annotation) - - def excluded_in(self, view: str) -> bool: - return any(directive.value for directive in self.view_exclusions - if type(directive).declared_name == view) + 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.""" @@ -640,15 +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) - - 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 key_in(self, view: type[ViewExclusion]) -> str: + return view.key_in(self.annotation, self.key) @cached_property def value(self) -> ValueDecoder[object]: @@ -1309,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 @@ -1371,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 @@ -1680,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) @@ -2258,8 +2264,35 @@ def minimum_digits(cls) -> int: return 7 -class ValueDescription[Product](ABC): - """A nominal presentation implements the canonical boundary's recovered Python shapes.""" +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](self, presentation: type[ValueDescription[T]]) -> T: + identity = type(self).disclosure_identity() + return (presentation.identified_record(self, identity) if identity + else presentation.record(self)) + + +class ValueDescription[Product](MroDispatch, ABC): + """Presentation dispatch is independent of FieldCodec's external decoder dispatch.""" + + @classmethod + def describe(cls, value: object) -> Product: + handler: Callable[[object], Product] = cls.handler_for(type(value)) + return handler(value) + + @handles(DisclosedRecord) + @classmethod + def disclosed(cls, value: DisclosedRecord) -> Product: + return value.disclose(cls) @classmethod @abstractmethod @@ -2287,7 +2320,7 @@ def mapping(cls, value: Mapping[Hashable, object]) -> Product: ... @classmethod @abstractmethod - def sequence(cls, value: Sequence[object]) -> Product: ... + def sequence(cls, value: Collection[object]) -> Product: ... class Projected(ViewColumn, property): @@ -2300,7 +2333,7 @@ class Projected(ViewColumn, property): def name(self) -> str: return self.__name__ - def __init__(self, getter, *, view: str, name: str | None = None): + def __init__(self, getter, *, view: type[ViewExclusion], name: str | None = None): super().__init__(getter) self.view = view self.wire_name = name or getter.__name__ @@ -2324,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) @@ -2345,23 +2378,8 @@ class FieldCodec(MroDispatch, Sealed): @classmethod def describe[T](cls, value: object, presentation: type[ValueDescription[T]]) -> T: - """Decode foreign Python shape once, retaining the capability's concrete product type.""" - if isinstance(value, type): - if issubclass(value, DeclaredFamily): - return presentation.family_value(value) - return presentation.class_value(value) - if isinstance(value, (str, bytes)): - return presentation.scalar(value) - if isinstance(value, Mapping): - return presentation.mapping(value) - if isinstance(value, Sequence): - return presentation.sequence(value) - if is_dataclass(value): - identity = tuple(plan for plan in cls.field_plans(type(value)) - if DisclosureIdentity.read(plan.annotation, False)) - return (presentation.identified_record(value, identity) if identity - else presentation.record(value)) - return presentation.scalar(value) + """The presentation owns its nominal input handlers, not the decoder's table.""" + return presentation.describe(value) @classmethod def decode_external[T]( @@ -2655,7 +2673,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. @@ -2666,7 +2684,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)): @@ -2675,7 +2693,7 @@ 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( @@ -2748,7 +2766,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) @@ -2776,7 +2794,7 @@ def _field_plan(field: Field[object], annotation: object) -> type[FieldPlan]: return selected.pop() @classmethod - def view_columns(cls, declaration: type, view: str) -> tuple[ViewColumn, ...]: + 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) @@ -2786,7 +2804,7 @@ def view_columns(cls, declaration: type, view: str) -> tuple[ViewColumn, ...]: @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({ @@ -3559,11 +3577,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/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 7425d003dd..1f6b88b1b0 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 @@ -989,7 +991,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 b55d695ebd..309ae99004 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 @@ -106,7 +108,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 3a21c7715a..b1ea1899ab 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 f7c7404a9d..631ee5cb9b 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.""" diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index a3e7130b1f..0fdff672dd 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, @@ -664,7 +665,7 @@ def existing(cls, repo: RepositoryName, number: int) -> Self: @dataclass(frozen=True) -class PullRequest(HeadReviewStatus, DeclaredFamily, affix="PullRequest"): +class PullRequest(HeadReviewStatus, DisclosedRecord, DeclaredFamily, affix="PullRequest"): number: int branch: str head: str @@ -1206,7 +1207,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 @@ -1219,14 +1220,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, ...] = () @@ -1676,7 +1677,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 157824b9ee..19a86556b7 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 de4b8f8185..85c6f92360 100644 --- a/src/agent_comms/transcript_events.py +++ b/src/agent_comms/transcript_events.py @@ -206,11 +206,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) @@ -287,7 +287,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 @@ -295,11 +295,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 @@ -585,7 +585,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()) @@ -593,7 +593,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] = {} @@ -602,7 +602,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 7f98da9363..0e6eece63b 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 46bbb2c55b..2600a8f40f 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 @@ -54,7 +56,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): diff --git a/src/agent_comms/ui_model/unit_state.py b/src/agent_comms/ui_model/unit_state.py index c2cceeb462..6b3e8d3189 100644 --- a/src/agent_comms/ui_model/unit_state.py +++ b/src/agent_comms/ui_model/unit_state.py @@ -19,6 +19,7 @@ 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, ) @@ -47,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 diff --git a/src/agent_comms/work_branches.py b/src/agent_comms/work_branches.py index a2e62d2f06..4d0e7f7692 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) diff --git a/tests/guards/test_debt_ratchet.py b/tests/guards/test_debt_ratchet.py index 738aecee97..1c35006df0 100644 --- a/tests/guards/test_debt_ratchet.py +++ b/tests/guards/test_debt_ratchet.py @@ -1,5 +1,7 @@ """Exercise the command against real Git commits, without mocking Git or ASTs.""" +from agent_comms.field_codec import ReportExclude + import ast import asyncio import json @@ -214,7 +216,7 @@ def compare(self, base: str, head: str, *measured: type[Measure]) -> tuple[int, The command line itself is proven by the tests that launch it. """ result = compare(self.path, base, head, self.root, 2, measured) - return int(result.failed), json.loads(json.dumps(FieldCodec.project(result, "report"))) + return int(result.failed), json.loads(json.dumps(FieldCodec.project(result, ReportExclude))) def test_compare_timeout_retires_pool_probe_and_defiant_grandchild(tmp_path: Path) -> None: diff --git a/tests/test_commander_hud_projection.py b/tests/test_commander_hud_projection.py index f98ece8dd2..271b158b3a 100644 --- a/tests/test_commander_hud_projection.py +++ b/tests/test_commander_hud_projection.py @@ -1,10 +1,14 @@ """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 ViewTable +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 @@ -47,13 +51,13 @@ def example_hud() -> CommanderHud: def test_commander_hud_projection_has_one_progress_answer() -> None: unit = example_hud().units[0] - assert tuple(FieldCodec.projections(CommanderUnit, "hud")) == ("work", "change", "next") - columns = FieldCodec.view_columns(CommanderUnit, "hud") + 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, "hud", (replace(unit, orders=(*unit.orders, second)),)) + table = ViewTable(CommanderUnit, HudExclude, (replace(unit, orders=(*unit.orders, second)),)) assert "\n" in table.texts[0][2] @@ -102,6 +106,35 @@ def test_display_facts_ignore_only_hidden_clocks_and_paint_time() -> None: 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.of(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.of({"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) diff --git a/tests/test_coordination.py b/tests/test_coordination.py index 3aa0e0e9a5..6773891ac4 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 @@ -325,7 +327,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) @@ -369,8 +371,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_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/tools/debt_ratchet.py b/tools/debt_ratchet.py index 301c03804a..edb8d7d178 100644 --- a/tools/debt_ratchet.py +++ b/tools/debt_ratchet.py @@ -3,6 +3,8 @@ from __future__ import annotations +from agent_comms.field_codec import ReportExclude + import argparse import ast import atexit @@ -2801,23 +2803,23 @@ def keyed[Count: MeasureCount, Value](counts: dict[Count, Value]) -> dict[str, V """The report's JSON keys: the one place a count's record becomes text.""" return {identity.text: value for identity, value in counts.items()} - @projected(view="report", name="base") + @projected(view=ReportExclude, name="base") def base_report(self) -> dict[str, int]: return self.keyed(self.base) - @projected(view="report", name="head") + @projected(view=ReportExclude, name="head") def head_report(self) -> dict[str, int]: return self.keyed(self.head) - @projected(view="report", name="introduced") + @projected(view=ReportExclude, name="introduced") def introduced_report(self) -> dict[str, list[int]]: return self.keyed(self.introduced) - @projected(view="report", name="found") + @projected(view=ReportExclude, name="found") def found_report(self) -> dict[str, list[Site]]: return self.keyed(self.found) - @projected(view="report", name="delta") + @projected(view=ReportExclude, name="delta") def delta_report(self) -> dict[str, int]: return self.keyed(self.delta) @@ -2900,7 +2902,7 @@ def main() -> int: args = parser.parse_args() result = compare(Path.cwd(), args.base, args.head, args.root, args.workers) # Flushed first: stdout is block-buffered into a pipe, and the growth lines must end the log. - print(json.dumps(FieldCodec.project(result, "report"), indent=2), flush=True) + print(json.dumps(FieldCodec.project(result, ReportExclude), indent=2), flush=True) # The failing step's last lines become check annotations (.github/run-step.sh merges # stderr), so what grew goes to stderr last; stdout stays the one JSON report. for key, grown in sorted(result.delta.items(), key=lambda item: item[0].text): From 92d62ced42394632cc989918b0ffce70230fe314 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 20:48:08 -0400 Subject: [PATCH 12/16] Read archived checkout status and retained exact-head supersession --- src/agent_comms/heartbeat_subjects.py | 26 ++-- src/agent_comms/recorded_time.py | 7 ++ .../task_attachments/commander_pings.py | 8 +- src/agent_comms/work_branches.py | 3 +- tests/test_head_review.py | 112 +++++++++++++++++- 5 files changed, 137 insertions(+), 19 deletions(-) diff --git a/src/agent_comms/heartbeat_subjects.py b/src/agent_comms/heartbeat_subjects.py index a0f5d82328..aedf2b2145 100644 --- a/src/agent_comms/heartbeat_subjects.py +++ b/src/agent_comms/heartbeat_subjects.py @@ -1,8 +1,8 @@ """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 @@ -18,7 +18,7 @@ from agent_comms.recorded_time import EventTime, RecordedEventTime, UnrecordedEventTime 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 @@ -69,6 +69,10 @@ 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: + """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): @@ -85,18 +89,24 @@ def action_at(self, history: CommanderHistory) -> EventTime: class HeadSubject(HeartbeatSubject): request: PullRequestHead + 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 review.verdict.decides()), + 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 action_at(self, history: CommanderHistory) -> EventTime: - return UnrecordedEventTime() + def clears(self, verdict: HeadVerdict) -> bool: + return verdict.supersedes() + + def disposed(self, history: CommanderHistory, since: float) -> bool: + return history.action_at(self).occurred() @dataclass(frozen=True) diff --git a/src/agent_comms/recorded_time.py b/src/agent_comms/recorded_time.py index 631ee5cb9b..aaa110907b 100644 --- a/src/agent_comms/recorded_time.py +++ b/src/agent_comms/recorded_time.py @@ -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 @@ -138,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 diff --git a/src/agent_comms/task_attachments/commander_pings.py b/src/agent_comms/task_attachments/commander_pings.py index ee57b5c95b..822e10e991 100644 --- a/src/agent_comms/task_attachments/commander_pings.py +++ b/src/agent_comms/task_attachments/commander_pings.py @@ -76,13 +76,13 @@ def key(self) -> object: ... 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(history.action_at(subject).since(began) for subject in self.subjects) + 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: diff --git a/src/agent_comms/work_branches.py b/src/agent_comms/work_branches.py index 4d0e7f7692..7b679941e5 100644 --- a/src/agent_comms/work_branches.py +++ b/src/agent_comms/work_branches.py @@ -643,7 +643,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/test_head_review.py b/tests/test_head_review.py index 097f658d0c..27860c57b3 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 @@ -345,7 +355,10 @@ def owed() -> list[PullRequestHead]: assert not CommanderHistory.read(comms, frozenset()).action_at(subject).since(since) invoke(comms, request, ReviewedHeadVerdict(ACCEPTED)) assert owed() == [] - assert CommanderHistory.read(comms, frozenset()).action_at(subject).since(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] @@ -524,11 +537,71 @@ def test_an_idle_draft_and_a_conflicting_ready_head_are_board_items(head_bus): 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( @@ -568,6 +641,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. From d308358076ea78ef7fcaea6b0ffce238d25f8563 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 01:31:28 -0400 Subject: [PATCH 13/16] Cover quiet publication for each shared-board recipient --- tests/test_commander_notifications.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/test_commander_notifications.py b/tests/test_commander_notifications.py index 5fcd6ba6ca..b9ecec0968 100644 --- a/tests/test_commander_notifications.py +++ b/tests/test_commander_notifications.py @@ -86,9 +86,9 @@ 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)) @@ -903,8 +903,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): From ba3b05bf87eef8eca0adff02060d54e310c99d81 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 01:36:28 -0400 Subject: [PATCH 14/16] Share failure-publication history across commander recipients --- src/agent_comms/commander_heartbeat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent_comms/commander_heartbeat.py b/src/agent_comms/commander_heartbeat.py index 2c2f0eefd5..d28bd3273c 100644 --- a/src/agent_comms/commander_heartbeat.py +++ b/src/agent_comms/commander_heartbeat.py @@ -73,13 +73,13 @@ 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, ): - actions = CommanderHistory.read(comms, self.actors(comms.registry.snapshot())) sections = AlarmGroup.sections((update,), update.open_pings(actions)) update.send(comms, now, ( HudUnavailableGroup.heading(), From 36d2959f3b678cda55385c60efe0093ccc0146c1 Mon Sep 17 00:00:00 2001 From: commander-screen Date: Fri, 30 Oct 2026 02:03:51 -0400 Subject: [PATCH 15/16] Repair captured commander frame consumers and import dependencies --- src/agent_comms/commander_hud.py | 2 +- src/agent_comms/heartbeat_subjects.py | 2 +- src/agent_comms/standing_rules.py | 7 +++---- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/agent_comms/commander_hud.py b/src/agent_comms/commander_hud.py index 4bebaf3658..9d3301c85f 100644 --- a/src/agent_comms/commander_hud.py +++ b/src/agent_comms/commander_hud.py @@ -9,7 +9,7 @@ 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, ClassVar, Self diff --git a/src/agent_comms/heartbeat_subjects.py b/src/agent_comms/heartbeat_subjects.py index aedf2b2145..a4e86a8a49 100644 --- a/src/agent_comms/heartbeat_subjects.py +++ b/src/agent_comms/heartbeat_subjects.py @@ -16,6 +16,7 @@ from agent_comms.field_codec import MessageId from agent_comms.head_review import PullRequestHead 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 HeadVerdict, StoredHeadReview @@ -23,7 +24,6 @@ if TYPE_CHECKING: from agent_comms.comms import Comms from agent_comms.plan_phases import OrderPhase - from agent_comms.registry_document import RegistrySnapshot @dataclass(frozen=True) diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index 7ba2e5efa4..53ea7b3600 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -57,7 +57,6 @@ from agent_comms.goal_management import Goals from agent_comms.heartbeat_frame import HeartbeatFrame from agent_comms.registry_document import RegistrySnapshot - from agent_comms.task_attachments.commander_pings import Ping from agent_comms.todos import ( OrderClaim, OwnerRequest, @@ -237,7 +236,7 @@ def attention(self) -> bool: return True @classmethod - def alarm_pings(cls, ping: Ping) -> tuple[Ping, ...]: + def alarm_pings[P](cls, ping: P) -> tuple[P, ...]: return (ping,) def subjects(self) -> tuple[HeartbeatSubject, ...]: @@ -636,7 +635,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)) ) @@ -851,7 +850,7 @@ class ReviewTimerUp(HeldOrderDue, FamilyScan): """The timer owns the request and wake; the unit's ReviewDue row owns its display.""" @classmethod - def alarm_pings(cls, ping: Ping) -> tuple[Ping, ...]: + def alarm_pings[P](cls, ping: P) -> tuple[P, ...]: return () @classmethod From fd6edbe71e01b42900ff0872e523df258a8db1d4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 02:53:39 -0400 Subject: [PATCH 16/16] Remove presentation forwarders and bind descriptions to their factory --- src/agent_comms/disclosure_text.py | 14 +++++-------- src/agent_comms/field_codec.py | 29 +++++++++++--------------- tests/test_commander_hud_projection.py | 4 ++-- uv.lock | 4 ++-- 4 files changed, 21 insertions(+), 30 deletions(-) diff --git a/src/agent_comms/disclosure_text.py b/src/agent_comms/disclosure_text.py index c5e923bc45..0b5938e4f0 100644 --- a/src/agent_comms/disclosure_text.py +++ b/src/agent_comms/disclosure_text.py @@ -50,13 +50,9 @@ class MappingKey(DisclosureKey): @dataclass(frozen=True) -class DisclosureText(ValueDescription["DisclosureText"]): +class DisclosureText(ValueDescription): value: object - @classmethod - def of(cls, value: object) -> DisclosureText: - return FieldCodec.describe(value, DisclosureText) - @cached_property def summary(self) -> str: return str(self.value) @@ -70,7 +66,7 @@ def identity_part(self) -> object: return self.value def column_value(self, column: ViewColumn) -> DisclosureText: - return DisclosureText.of(column.current(self.value)) + return DisclosureText.describe(column.current(self.value)) @handles(object, str, bytes) @classmethod @@ -156,7 +152,7 @@ def columns(self) -> tuple[ViewColumn, ...]: @cached_property def row_values(self) -> tuple[DisclosureText, ...]: - return tuple(DisclosureText.of(record) for record in self.records) + return tuple(DisclosureText.describe(record) for record in self.records) @cached_property def entities(self) -> Mapping[DisclosureKey, R]: @@ -378,7 +374,7 @@ class SequenceText(CollectionText): def children(self) -> tuple[DisclosureEntry, ...]: entries = [] for index, value in enumerate(self.value): - text = DisclosureText.of(value) + text = DisclosureText.describe(value) entries.append(DisclosureEntry(text.sequence_key, str(index + 1), text)) return tuple(entries) @@ -390,5 +386,5 @@ class MappingText(CollectionText): @cached_property def children(self) -> tuple[DisclosureEntry, ...]: - return tuple(DisclosureEntry(MappingKey(key), str(key), DisclosureText.of(value)) + 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 d94499066f..a6dae25a28 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -2275,52 +2275,52 @@ 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](self, presentation: type[ValueDescription[T]]) -> T: + 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[Product](MroDispatch, ABC): +class ValueDescription(MroDispatch, ABC): """Presentation dispatch is independent of FieldCodec's external decoder dispatch.""" @classmethod - def describe(cls, value: object) -> Product: - handler: Callable[[object], Product] = cls.handler_for(type(value)) + 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) -> Product: + def disclosed(cls, value: DisclosedRecord) -> Self: return value.disclose(cls) @classmethod @abstractmethod - def scalar(cls, value: object) -> Product: ... + def scalar(cls, value: object) -> Self: ... @classmethod @abstractmethod - def record(cls, value: object) -> Product: ... + def record(cls, value: object) -> Self: ... @classmethod @abstractmethod - def identified_record(cls, value: object, identity: tuple[FieldPlan, ...]) -> Product: ... + def identified_record(cls, value: object, identity: tuple[FieldPlan, ...]) -> Self: ... @classmethod @abstractmethod - def class_value(cls, value: type) -> Product: ... + def class_value(cls, value: type) -> Self: ... @classmethod @abstractmethod - def family_value(cls, value: type[DeclaredFamily]) -> Product: ... + def family_value(cls, value: type[DeclaredFamily]) -> Self: ... @classmethod @abstractmethod - def mapping(cls, value: Mapping[Hashable, object]) -> Product: ... + def mapping(cls, value: Mapping[Hashable, object]) -> Self: ... @classmethod @abstractmethod - def sequence(cls, value: Collection[object]) -> Product: ... + def sequence(cls, value: Collection[object]) -> Self: ... class Projected(ViewColumn, property): @@ -2376,11 +2376,6 @@ class FieldCodec(MroDispatch, Sealed): the single record codec and declaration-derived schema. """ - @classmethod - def describe[T](cls, value: object, presentation: type[ValueDescription[T]]) -> T: - """The presentation owns its nominal input handlers, not the decoder's table.""" - return presentation.describe(value) - @classmethod def decode_external[T]( cls, annotation: TypeForm[T], value: object, record: Mapping[str, object], diff --git a/tests/test_commander_hud_projection.py b/tests/test_commander_hud_projection.py index f9e9d9d65b..7183248ef8 100644 --- a/tests/test_commander_hud_projection.py +++ b/tests/test_commander_hud_projection.py @@ -108,7 +108,7 @@ def test_display_facts_ignore_only_hidden_clocks_and_paint_time() -> None: def test_disclosure_reuses_original_children_and_declared_identity() -> None: unit = example_hud().units[0] - text = DisclosureText.of(unit) + text = DisclosureText.describe(unit) assert isinstance(text, IdentifiedRecordText) assert text.summary == unit.unit.name assert text.children is text.children @@ -121,7 +121,7 @@ def test_disclosure_reuses_original_children_and_declared_identity() -> None: 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.of({"units": (unit,)}) + mapping = DisclosureText.describe({"units": (unit,)}) assert isinstance(mapping, MappingText) assert mapping.children is mapping.children sequence = mapping.children[0].value diff --git a/uv.lock b/uv.lock index a785cebd3d..44ffefd1c0 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.14.8, <3.15" [[package]] @@ -512,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" },