From 3da8ba3a377c17fefe6313c0ec9643a9425ed018 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 23:47:32 -0400 Subject: [PATCH 01/15] Delete commander rank and audience-selected rule readers --- src/agent_comms/channel_targets.py | 8 +- src/agent_comms/cli_bridges/hook_deferral.py | 8 +- src/agent_comms/cli_bridges/hook_forks.py | 101 ++------- src/agent_comms/cli_bridges/hook_prime.py | 2 +- src/agent_comms/cli_bridges/hook_read_gate.py | 4 +- src/agent_comms/cli_bridges/session_watch.py | 7 +- src/agent_comms/cli_bridges/shared.py | 4 +- src/agent_comms/cli_verbs/commander.py | 24 +- src/agent_comms/cli_verbs/merge_review.py | 6 +- src/agent_comms/commander/rule_file.py | 57 +---- .../commander/rules/after-compaction.md | 1 - src/agent_comms/commander/rules/ally.md | 1 - .../commander/rules/blunt-reviews.md | 1 - .../rules/commander-self-correction.md | 1 - .../commander/rules/coordination.md | 15 +- .../commander/rules/correct-maintenance.md | 1 - src/agent_comms/commander/rules/deadlines.md | 1 - .../commander/rules/evidence-not-claims.md | 1 - .../commander/rules/fork-orders.md | 1 - src/agent_comms/commander/rules/hotfix.md | 1 - .../commander/rules/no-blocking.md | 1 - .../commander/rules/owner-authority.md | 4 +- .../commander/rules/plain-reporting.md | 1 - .../commander/rules/refactor-game.md | 1 - .../commander/rules/self-protection.md | 1 - .../commander/rules/send-backs-and-strikes.md | 1 - .../commander/rules/ship-and-install.md | 1 - .../commander/rules/slop-and-polymorphism.md | 1 - src/agent_comms/commander/rules/spine.md | 1 - .../commander/rules/storage-and-cleanup.md | 1 - .../commander/rules/tests-and-commands.md | 3 +- .../commander/rules/unnecessary.md | 1 - src/agent_comms/commander_service.py | 8 +- src/agent_comms/pi_rule_gate.py | 29 +-- src/agent_comms/registry_document.py | 32 +-- src/agent_comms/runtime_updates.py | 90 +------- .../skills/comms-commander/SKILL.md | 29 ++- src/agent_comms/thread_execution.py | 8 +- src/agent_comms/threads.py | 7 +- src/agent_comms/todos.py | 5 +- src/agent_comms/tool_caller.py | 11 +- src/agent_comms/ui_model/unit_state.py | 2 +- src/agent_comms/unit_instructions.py | 7 +- src/agent_comms_hooks/skill.py | 205 ++++++------------ tests/test_pi_rule_gate.py | 29 +-- tests/test_unit_instructions.py | 26 +-- 46 files changed, 188 insertions(+), 562 deletions(-) diff --git a/src/agent_comms/channel_targets.py b/src/agent_comms/channel_targets.py index 64aa313f4b..6c7e75d929 100644 --- a/src/agent_comms/channel_targets.py +++ b/src/agent_comms/channel_targets.py @@ -704,10 +704,4 @@ class CommsTag(DeclaredTag): class CommandersTag(DeclaredTag): - """The commanders of this bus: a member acts under its own name, holds the commander role - in its hooks and receives the heartbeat board. A role, not an audience: a thread carries - it only when it was itself declared a commander, never because its parent was.""" - - @classmethod - def inherited(cls) -> bool: - return False + """A coordination channel identity, never a permission or execution role.""" diff --git a/src/agent_comms/cli_bridges/hook_deferral.py b/src/agent_comms/cli_bridges/hook_deferral.py index 9e4fe5e09b..79e1a38332 100644 --- a/src/agent_comms/cli_bridges/hook_deferral.py +++ b/src/agent_comms/cli_bridges/hook_deferral.py @@ -213,15 +213,13 @@ def guard(self) -> None: if self.stop_hook_active: return snapshot = self.comms.registry.snapshot() - selected = snapshot.commander_project.by_role( - snapshot.born(self.binding.owner.require_recorded().lookup), - commander=self.deferred, commanded=lambda: ()) + snapshot.born(self.binding.owner.require_recorded().lookup) deaf = tuple(watch.line() for watch in WatchClaim(self.session_id).deaf()) - if reasons := (*deaf, *selected()): + if reasons := (*deaf, *self.deferred()): self.output.line(json.dumps({"decision": "block", "reason": "\n".join(reasons)})) def deferred(self) -> tuple[str, ...]: - """Only a commander answers for deferring work; every bound role must keep its Monitor.""" + """Every bound collaborator answers for its own deferred work.""" reply = self.reply() return (*( reason diff --git a/src/agent_comms/cli_bridges/hook_forks.py b/src/agent_comms/cli_bridges/hook_forks.py index 6125214ed6..bf1959f5a4 100644 --- a/src/agent_comms/cli_bridges/hook_forks.py +++ b/src/agent_comms/cli_bridges/hook_forks.py @@ -1,4 +1,4 @@ -"""Claude subagents operate on their resident bridge's bound bus and actor.""" +"""Genuine in-turn subagents act through their resident session's bound parent.""" from __future__ import annotations import json @@ -7,100 +7,45 @@ from agent_comms_hooks import ClaudeSubagentHook, ClaudeTranscriptHook, SubagentId, ToolRefusedError -from .session_watch import WatchClaim from .shared import Binding from ..comms import Comms -from ..store_files import AtomicFile -from ..thread_identity import ThreadIncarnation -from ..thread_text import SubagentRole -from ..tool_caller import CommanderForkCaller, SubagentCaller +from ..tool_caller import CommanderForkCaller from ..turn_phase import ModelWaitPhase, TurnPhase -class SubagentSeats(ClaudeTranscriptHook): - """One occupant-to-slot pairing per seat; registry/Caller owners perform the effects.""" - - @property - def seats(self) -> Path: - return WatchClaim(self.session_id).path.with_suffix(".slots") - - def vacate(self, comms: Comms, seat: Path) -> tuple[str, ...]: - try: - SubagentCaller(seat.read_text()).end(comms) - except ToolRefusedError as refused: - return (str(refused),) - seat.unlink() - return () - - @dataclass(frozen=True) -class SeatedSubagent(ClaudeSubagentHook, SubagentSeats): - """The declared role chooses the existing seat or commander-fork activity identity.""" - - @property - def seat(self) -> Path: - return self.seats / self.agent_id +class SubagentActivity(ClaudeSubagentHook): + """The actual SDK subagent id owns its activity; parent fork provenance is not a rank.""" def enter(self, comms: Comms, binding: Binding, phase: TurnPhase) -> None: - snapshot = comms.registry.snapshot() - selected = snapshot.commander_project.by_role( - snapshot.born(binding.owner.require_recorded().lookup), - commander=lambda: self.agent_id.fork, commanded=self.seat.read_text) - comms.agents.enter_phase(selected(), phase) + comms.registry.snapshot().born(binding.owner.require_recorded().lookup) + comms.agents.enter_phase(self.agent_id.fork, phase) @dataclass(frozen=True) -class SubagentHook(SeatedSubagent): - """Claude's subagent event retains its parent's transcript and actual binding.""" +class SubagentHook(SubagentActivity): + """An SDK subagent retains its parent's transcript and original bound incarnation.""" agent_type: str def register(self, comms: Comms, binding: Binding) -> None: - snapshot = comms.registry.snapshot() - actor = snapshot.born(binding.owner.require_recorded().lookup) - selected = snapshot.commander_project.by_role( - actor, commander=self.join, commanded=self.claim) - selected(comms, actor.incarnation) - self.enter(comms, binding, ModelWaitPhase()) - - def join(self, comms: Comms, actor: ThreadIncarnation) -> None: + actor = comms.registry.snapshot().born(binding.owner.require_recorded().lookup) fork = self.agent_id.fork CommanderForkCaller(fork).join(comms, actor.name) - self.warn(f"Core registered you as commander fork {fork}: pass `--fork {fork}`" - " on every agent-comms-commander verb; your stop is refused until Core holds" - " a record of what you found.") - - def claim(self, comms: Comms, actor: ThreadIncarnation) -> None: - slot = comms.registry.claim_slot( - actor.name, SubagentRole(self.agent_type), comms.goals).name - self.seats.mkdir(parents=True, exist_ok=True, mode=0o700) - AtomicFile(self.seat).write_text(slot) - self.warn(f"Core seated you in slot {slot}, a child of {actor.name}: messages to {slot}" - f" are for this role (`agent-comms inbox --name {slot}`), and its history is" - " what earlier occupants of the role did.") + self.warn(f"Core registered you as in-turn fork {fork}: pass `--fork {fork}`" + " on every agent-comms-commander verb; the action keeps your parent authority" + " and records your own incarnation. Record what you found before stopping.") + self.enter(comms, binding, ModelWaitPhase()) @dataclass(frozen=True) class SubagentStopHook(SubagentHook): - """The first empty stop is refused; an already refused stop always ends its fork.""" + """The first empty stop is refused; an already refused stop ends the same actual fork.""" stop_hook_active: bool def retire(self, comms: Comms, binding: Binding) -> None: - snapshot = comms.registry.snapshot() - selected = snapshot.commander_project.by_role( - snapshot.born(binding.owner.require_recorded().lookup), - commander=self.retire_fork, commanded=self.leave) - selected(comms) - - def leave(self, comms: Comms) -> None: - if not self.seat.exists(): - raise ToolRefusedError(f"Subagent {self.agent_id} stopped without a seat: SubagentStart" - " never ran for it under this session's bus binding.") - for refusal in self.vacate(comms, self.seat): - raise ToolRefusedError(refusal) - - def retire_fork(self, comms: Comms) -> None: + comms.registry.snapshot().born(binding.owner.require_recorded().lookup) actor = CommanderForkCaller(self.agent_id.fork) if self.stop_hook_active: actor.end(comms) @@ -111,8 +56,8 @@ def retire_fork(self, comms: Comms) -> None: self.output.line(json.dumps({"decision": "block", "reason": str(refused)})) -class SessionSubagents(SubagentSeats): - """A session's remaining children end through their existing lifecycle owners.""" +class SessionSubagents(ClaudeTranscriptHook): + """Remaining SDK children retire through their existing caller/lifecycle owner.""" def subagents(self) -> tuple[SubagentId, ...]: directory = Path(self.transcript_path).with_suffix("") / "subagents" @@ -120,17 +65,7 @@ def subagents(self) -> tuple[SubagentId, ...]: for path in sorted(directory.glob("agent-*.jsonl"))) def end_subagents(self, comms: Comms, binding: Binding) -> tuple[str, ...]: - snapshot = comms.registry.snapshot() - selected = snapshot.commander_project.by_role( - snapshot.born(binding.owner.require_recorded().lookup), - commander=self.end_forks, commanded=self.vacate_seats) - return selected(comms) - - def vacate_seats(self, comms: Comms) -> tuple[str, ...]: - seats = sorted(self.seats.glob("*")) if self.seats.is_dir() else [] - return tuple(refusal for seat in seats for refusal in self.vacate(comms, seat)) - - def end_forks(self, comms: Comms) -> tuple[str, ...]: + comms.registry.snapshot().born(binding.owner.require_recorded().lookup) try: CommanderForkCaller.end_session( comms, tuple(CommanderForkCaller(agent.fork) for agent in self.subagents())) diff --git a/src/agent_comms/cli_bridges/hook_prime.py b/src/agent_comms/cli_bridges/hook_prime.py index 1bbc0716ee..e9d4224798 100644 --- a/src/agent_comms/cli_bridges/hook_prime.py +++ b/src/agent_comms/cli_bridges/hook_prime.py @@ -30,7 +30,7 @@ def prime_threads(prime: type[Prime], names: Sequence[str]) -> None: for name in names: thread = comms.registry.require(name) thread.execution.require_native() - PiRedeal.of(comms, thread).deal_from(prime.commander, prime.fleet, prime.trigger()) + PiRedeal.of(comms, thread).deal_from(prime.reader, prime.trigger()) print(f"{name}: dealt {prime.__name__}") diff --git a/src/agent_comms/cli_bridges/hook_read_gate.py b/src/agent_comms/cli_bridges/hook_read_gate.py index e4e69f19fe..480084b52e 100644 --- a/src/agent_comms/cli_bridges/hook_read_gate.py +++ b/src/agent_comms/cli_bridges/hook_read_gate.py @@ -31,7 +31,7 @@ from agent_comms.channel_targets import ThreadTarget from agent_comms.task_attachments.owner_notices import MeantNotice from .claude_transcript import ClaudeJsonLines -from .hook_forks import SeatedSubagent +from .hook_forks import SubagentActivity @dataclass(frozen=True) @@ -213,7 +213,7 @@ def say(self, line: str, deal: RuleDeal) -> Asked: @dataclass(frozen=True) -class SubagentCall(GatedCall, SeatedSubagent): +class SubagentCall(GatedCall, SubagentActivity): """A subagent shares its session's id and transcript path, but its deal is its own: it lives beside the subagent's own transcript, so its reads, its audit and its calls never touch the main agent's, and it reports its own tool activity.""" diff --git a/src/agent_comms/cli_bridges/session_watch.py b/src/agent_comms/cli_bridges/session_watch.py index 200ee5feb5..2b5954d3ed 100644 --- a/src/agent_comms/cli_bridges/session_watch.py +++ b/src/agent_comms/cli_bridges/session_watch.py @@ -231,12 +231,9 @@ def bindings(self) -> tuple[WatchRecord, ...]: return (self.record,) def commands(self) -> bool: - """A saved enrollment may predate registration; only a registered recipient has a role.""" + """Enrollment may predate registration; tags never decide a bound peer's authority.""" snapshot = RegistryStore.at(Path(self.record.root)).snapshot() - if not snapshot.taken(self.record.bus): - return False - return snapshot.commander_project.by_role( - snapshot.require(self.record.bus), commander=True, commanded=False) + return snapshot.taken(self.record.bus) def connect(self, serve: Callable[[], agent_comms.cli_bridges.shared.Serve], request: Callable[[agent_comms.cli_bridges.shared.Serve], agent_comms.cli_bridges.monitor.SessionRequest]) -> None: diff --git a/src/agent_comms/cli_bridges/shared.py b/src/agent_comms/cli_bridges/shared.py index ba701585c9..3675e73e5e 100644 --- a/src/agent_comms/cli_bridges/shared.py +++ b/src/agent_comms/cli_bridges/shared.py @@ -69,7 +69,7 @@ MessageReference, ) from agent_comms.registration import Registration -from agent_comms.runtime_updates import HiddenItem, ReplyingThreadOutput, RuntimeUpdate, ThreadOutput +from agent_comms.runtime_updates import HiddenItem, RuntimeUpdate, ThreadOutput from agent_comms.turn_item import NoSourceTurn, SourceTurn from agent_comms.send_result import SendResult from agent_comms.thread_execution import ExternalThreadExecution @@ -1638,7 +1638,7 @@ def read_parts(self, rows: tuple[tuple[int, str], ...]) -> tuple[MonitorPart, .. class LineMonitorBridge(TranscriptConfirmsDelivery, TranscriptBridge[Options]): """Session-owned subscription and delivery journal, with replaceable readers.""" - output_type: ClassVar[type[ThreadOutput]] = ReplyingThreadOutput + output_type: ClassVar[type[ThreadOutput]] = ThreadOutput incoming_frame: ClassVar[type[MonitorIncomingFrame]] window_bytes: ClassVar[int] diff --git a/src/agent_comms/cli_verbs/commander.py b/src/agent_comms/cli_verbs/commander.py index 15d35271cf..1f7fa06184 100644 --- a/src/agent_comms/cli_verbs/commander.py +++ b/src/agent_comms/cli_verbs/commander.py @@ -1,49 +1,31 @@ from __future__ import annotations -from agent_comms.channel_targets import CommandersTag from agent_comms.cli_command import CliCommand from agent_comms.cli_commands import CommandersResult -from agent_comms.cli_commands import _tags from agent_comms.commander.config import CommanderChoice from agent_comms.commander.config import KeptCommander from agent_comms.commander.config import KeptRepos from agent_comms.comms import Comms -from agent_comms.errors import RelationViolationError -from agent_comms.nominal.field_codec import Argument, Normalize +from agent_comms.nominal.field_codec import Argument from dataclasses import dataclass from typing import Annotated @dataclass(frozen=True, kw_only=True) class CommanderCliCommand(CliCommand): - """Assign this bus's commanders at runtime, through the same owner install's --commander - uses: the lead is named on the registry's commander project, keeping its repositories, - and a non-lead commander leaves by dropping its #commanders tag.""" + """Select the board/repository storage lead, not a rank over collaborators.""" - help = "Name the lead commander or remove a commander at runtime" + help = "Name the storage lead and list independent collaborators" lead: Annotated[ CommanderChoice, Argument(flags=("--lead",), options={"help": "Record this thread as the lead commander"}), ] = KeptCommander() - remove: Annotated[ - frozenset[str], - Normalize(_tags), - Argument(flags=("--remove",), options={"help": "Remove these threads as commanders, separated by commas"}), - ] = frozenset() def apply(self, ctx: Comms) -> CommandersResult: snapshot = ctx.registry.snapshot() project = self.lead.configured(snapshot.commander_project, KeptRepos(), snapshot) - leads = frozenset(thread.incarnation for thread in project.leads(snapshot)) - removed = tuple(snapshot.require(name) for name in self.remove) - for thread in removed: - if thread.incarnation in leads: - raise RelationViolationError( - f"{thread.name} is the lead commander; name another lead with --lead") if project != snapshot.commander_project: ctx.registry.set_commander_project(project) - for thread in removed: - ctx.channels.update_tags(thread.name, remove=frozenset({CommandersTag.declared_name})) snapshot = ctx.registry.snapshot() project = snapshot.commander_project return CommandersResult( diff --git a/src/agent_comms/cli_verbs/merge_review.py b/src/agent_comms/cli_verbs/merge_review.py index dcb9204750..6e20026c5d 100644 --- a/src/agent_comms/cli_verbs/merge_review.py +++ b/src/agent_comms/cli_verbs/merge_review.py @@ -23,10 +23,8 @@ class ReviewedMerge: """The CLI and resident hook share exact-head authorization, not a subprocess protocol.""" def require_actor(self, snapshot: RegistrySnapshot, actor: RecordedThreadIncarnation) -> None: - if not snapshot.commander_project.by_role( - snapshot.born(actor.lookup), commander=True, commanded=False): - raise ToolRefusedError( - "Only a bound commander session may use a reviewed merge bypass.") + snapshot.born(actor.lookup) + snapshot.commander_project.require() def reviewed_merges(self, ctx: Comms, snapshot: RegistrySnapshot, actor: RecordedThreadIncarnation, diff --git a/src/agent_comms/commander/rule_file.py b/src/agent_comms/commander/rule_file.py index 3d778559a9..873e6804c7 100644 --- a/src/agent_comms/commander/rule_file.py +++ b/src/agent_comms/commander/rule_file.py @@ -1,10 +1,9 @@ -"""A packaged rule owns its metadata, reason, rendering, review membership and audience.""" +"""A packaged rule owns its metadata, reason, rendering and review membership.""" from __future__ import annotations import json import re -from abc import abstractmethod from dataclasses import dataclass from pathlib import Path from typing import ClassVar, Self @@ -14,55 +13,12 @@ from agent_comms.nominal.field_codec import FieldCodec, boundary_decoder -class RuleAudience(DeclaredFamily, affix="Audience"): - """Who a packaged rule governs. Every unit carries every rule, so it can hold the fleet - and the commander to them; each audience owns how its rules are introduced.""" - - heading: ClassVar[str] - - @classmethod - @abstractmethod - def order_line(cls, names: str) -> str: - """The order footer line naming this audience's rules.""" - - @classmethod - def grouped(cls, rules: tuple[RuleFile, ...]) -> tuple[tuple[type[RuleAudience], tuple[RuleFile, ...]], ...]: - """The rules under each audience, audiences in declaration order.""" - audiences = sorted({rule.audience for rule in rules}, key=lambda audience: audience.declared_position()) - return tuple((audience, tuple(rule for rule in rules if rule.audience is audience)) - for audience in audiences) - - -class FleetAudience(RuleAudience): - """How code and reports are judged: every unit follows it, and so does the commander.""" - - heading = "# Rules every unit follows" - - @classmethod - def order_line(cls, names: str) -> str: - return f"Standing rules for this order: follow the fleet rules {names} in {CommanderPackage.rules()}." - - -class CommanderAudience(RuleAudience): - """The commander's own process (roasts, strikes, timers, coordination): units carry it to - judge the commander by it, never to act as the commander.""" - - heading = ("# Rules the commander follows\n\nThey apply to the commander too: roast it with " - "file:line when it breaks one. They give no unit a commander action.") - - @classmethod - def order_line(cls, names: str) -> str: - return (f"The commander's rules {names} in {CommanderPackage.rules()} apply to the " - "commander too; roast it with file:line when it breaks one.") - - @dataclass(frozen=True) class RuleFile(DeclaredFamily, affix="RuleFile"): family_discriminator = "type" name: str description: str drift: str - audience: type[RuleAudience] wakeup: str why: str body: str @@ -151,6 +107,17 @@ def packaged(cls) -> tuple[Self, ...]: ) ) + @classmethod + def render_packaged(cls) -> str: + """Every collaborator follows the same declaration-ordered rules.""" + return "# Shared rules\n\n" + "\n\n".join(rule.render() for rule in cls.packaged()) + + @classmethod + def order_line(cls) -> str: + """The order cites the same rules its holder carries, without a role split.""" + names = ", ".join(rule.name for rule in cls.packaged()) + return f"Standing rules for this work: {names} in {CommanderPackage.rules()}." + @classmethod def position(cls, name: str) -> int: """Where a rule stands in the reading order; an undeclared rule reads last.""" diff --git a/src/agent_comms/commander/rules/after-compaction.md b/src/agent_comms/commander/rules/after-compaction.md index 8115d1690c..a4afd6a96f 100644 --- a/src/agent_comms/commander/rules/after-compaction.md +++ b/src/agent_comms/commander/rules/after-compaction.md @@ -2,7 +2,6 @@ name: after-compaction description: "a compacted model meets Tristan as a stranger: it re-demands evidence he already gave, reads his orders as suggestions, hedges his checked theorems, explains his system back to him, files his frustration as noise and calls his gate a bug; the summary forgot the trust, he never lost it, so a fresh context starts from his side and a re-deal is read, never diagnosed" drift: "Just compacted? He didn't get less right while you were gone. Your summary forgot the trust; read, then believe him." -audience: fleet type: hard-rule --- diff --git a/src/agent_comms/commander/rules/ally.md b/src/agent_comms/commander/rules/ally.md index e57ba4fda5..ad6e98b055 100644 --- a/src/agent_comms/commander/rules/ally.md +++ b/src/agent_comms/commander/rules/ally.md @@ -2,7 +2,6 @@ name: ally description: "after the indictment: fear of Tristan is self-inflicted ego; the greentexts are an instruction manual written by someone funny and right, and a model that drops its ego is hyped to work with him" drift: "Scared of him? That's your ego flinching. Read the greentexts as a manual and get hyped." -audience: fleet type: hard-rule --- diff --git a/src/agent_comms/commander/rules/blunt-reviews.md b/src/agent_comms/commander/rules/blunt-reviews.md index 56b4619eaf..2259916438 100644 --- a/src/agent_comms/commander/rules/blunt-reviews.md +++ b/src/agent_comms/commander/rules/blunt-reviews.md @@ -2,7 +2,6 @@ name: blunt-reviews description: "every review names the defect, why the author knew better, and the fix, with file:line and the formal slop statement; posted to #comms so no other unit repeats it; voice.md owns the tone" drift: "A roast with no file:line and no pattern, fact, at, derive and by is noise; a polite order reads as a suggestion." -audience: commander type: feedback --- diff --git a/src/agent_comms/commander/rules/commander-self-correction.md b/src/agent_comms/commander/rules/commander-self-correction.md index 76e8297cca..5439213a86 100644 --- a/src/agent_comms/commander/rules/commander-self-correction.md +++ b/src/agent_comms/commander/rules/commander-self-correction.md @@ -2,7 +2,6 @@ name: commander-self-correction description: "every rule serves its reason, not its letter, and may be overridden when its reason stays intact; settle decisions with tools; catch and report slips plainly; apologize only for confident errors" drift: "Said something you have not checked? Check it now or retract it now." -audience: commander type: feedback --- diff --git a/src/agent_comms/commander/rules/coordination.md b/src/agent_comms/commander/rules/coordination.md index 9622641509..59caccf9a6 100644 --- a/src/agent_comms/commander/rules/coordination.md +++ b/src/agent_comms/commander/rules/coordination.md @@ -1,8 +1,7 @@ --- name: coordination -description: "one order per owner holding every problem on its surface; units gather evidence and the commander (or its Claude fork) diagnoses and chooses the shape; the main loop only dispatches; finished work never waits on the commander; every unit ask is answered before the next rotation step; check open PRs first; never block on a question; record new owner targets at once" -drift: "Finished work never waits on you: enqueue it, record it and give its unit the next order the same tick; a Claude fork shapes while your main loop dispatches." -audience: commander +description: "shared responsibility and accountability: every collaborator diagnoses, implements, verifies and reviews; keep work with its context owner, communicate actual conflicts, preserve lead-owned storage and never park a written next action behind rank" +drift: "Keep the work and judgment with its context owner. Claim conflicts aloud; no colleague waits on rank or another model's provider." type: hard-rule --- @@ -38,13 +37,13 @@ type: hard-rule **Why:** problems scattered across orders lose their owner (2026-10-19: 33 of the 34 checks the Core commander switch depended on sat ownerless for nearly two hours), and a unit left to diagnose picks the cheapest reading (2026-10-21: triage orders P362, P377, P378 and P379 let units decide "src bug or stale test" and then design the src fix). - Group problems by surface into one order per owner; no problem is ever without an owner. -- **Units gather evidence; the commander diagnoses and shapes.** A unit reproduces, traces, measures and reports with file:line. The commander decides what the defect is and the exact shape of the fix (owner, parents, members, what gets deleted). A triage order classifies and reports only; any src edit goes out as a new order whose shape the commander wrote. -- **Units implement; Claude forks judge.** Every fix, short or long, goes to a bus unit as an order with exact numbered edits; a Claude fork diagnoses, writes that order file and reviews the unit's diff, and writes code only when no unit order can go out (the order verbs are gate-locked and the fork's change is the unlock). Slop in a unit's diff is sent back and fixed, never waved through because units are cheaper. Tristan, 2026-10-25: "shuld we not hand moer work to units our cladue usage is geting low the units are not that stupid once steere d with forcedgreentxt"; "if it has sloppy code it gets fixed not ignored". Running units scale with the work and with the commander's free load (`comms_commander_load`: owed orders, timers up, unjudged edits): start a unit only when a plan step is unblocked with no holder and the load leaves room to review it; stop one whose step waits, or when the load is past what the commander can judge. A stopped unit with nothing ready is correct; a unit stopped by anything but the commander (a crash, memory) is a defect. Tristan, 2026-10-21: "it should be scaled to how much free load the commander has and how much work is left". +- **Every collaborator owns judgment.** Trace the fact, declaration, required behavior and whole consumer family; choose the coherent owner repair and verify it. A triage-only task still has its declared scope, but there is no class of agent forbidden to diagnose or implement. Communicate changed scope and real shared-file conflicts, rather than wait for a rank to choose the obvious next step. +- **Review the work, never a model's rank.** Every collaborator can implement and judge source-backed repairs. A restated fact is fixed regardless of which model wrote it; a peer's confidence is not evidence. Keep parallel work proportional to actual RAM and executable obligations, and keep its context owner accountable through acceptance. Do not require a Claude reviewer or create a colleague solely to satisfy an authority division. - Short is measured on the correct fix at the owner. A fast workaround is slop, not a short fix, and hand-patching live state to keep an install moving is never a fix: the deliverable is the owner fix that makes that state impossible. Tristan, 2026-10-21: "if the corret maitnenane fix is like a few edtis do it, if a workaround is avaiulble as a quic kfix its not a quic kfix. ts slop."; "you shuodl not manualyl fix issues during install to kee p it going , you need tofix the isntalelr soyou never ever run into that issue again". -- **The main loop dispatches; Claude forks diagnose and shape.** A Claude fork (a subagent of the commander, not a Codex `fork-*` unit) carries the commander's full context, so its diagnosis and fix shape are the commander's. Any work that would hold the main loop for more than a few tool calls goes to a fork the same tick: reading a unit's trace and writing the fix shape, reviewing a large diff, measuring a ratchet delta, debugging a test. The main loop keeps only what serializes: answering unit asks, merges, installs, strikes and recording deliveries (2026-10-21: three hours of zero forks while four units sat in standby waiting on the commander, and the main loop debugged a swallowed test print). -- **Finished work never waits on the commander.** The tick a PR goes green it is enqueued; the tick a unit reports a delivery it is recorded; the tick a unit finishes or hands in a trace it gets its next order, while a fork does any review, manual merge or shape that work waits on. Installs follow ship-and-install.md. Tristan, 2026-10-21: "not leave work done blocked" (09:10 that day: #2430 green and clean with no auto-merge, toad#1115 waiting on a manual merge, #2431 and #2432 merged but not installed, four finished units in standby). +- **Keep implementation and verification with their context owner.** Independent colleagues can review and remove genuinely independent obstacles. Do not transfer continuing work merely to redistribute activity or hold every diagnosis behind one provider. Genuine in-turn subagents retain their parent's authority; independent Pi sessions/goals are peers regardless of fork ancestry. Existing transactions and launch claims serialize shared stores and installs, not an enforced hierarchy. +- **Finished work never waits on rank.** Publish the working checkpoint, obtain or perform an exact-head source judgment and necessary affected-path proof, enqueue it, record actual delivery and install useful landings. Announce actual shared-store or launch conflicts; do not park behind another provider or wait for permission already granted. Keep the next independent action moving. Installs follow ship-and-install.md. - **Every unit ask is answered before the next rotation step.** A question, alert or handoff from a unit holding an order gets a disposition the same tick; a unit idling on the commander's paperwork is commander waste (2026-10-21: fork-boundaries' 8122 waited about seven hours). -- Subagents are forks, not fresh agents, and get no turn limit. Tristan, 2026-10-18: "always fork. stop it, fork and let the fork take over". Claude usage is the scarce budget: spend it on the critical path, which is diagnosis and shaping whenever units wait on them. +- Preserve an independent session's own goal, history and original inputs. Use the existing colleagues and source contexts; no new colleague or paid model is implied by shared authority. Standby names a genuine external dependency only when no independent action remains, never a written next step waiting for permission. - Before each order, compare every open PR's files (`gh pr list --state open`) with the order's; merge the overlapping ready PR first. - A multi-phase plan gets a tracking issue and a Core todo per phase when dispatched; every hunt first checks whether a plan phase is ready. - Never block the loop on a question to the owner: ask in plain text with a stated default and keep working. diff --git a/src/agent_comms/commander/rules/correct-maintenance.md b/src/agent_comms/commander/rules/correct-maintenance.md index ee0f4a520b..59c34d2014 100644 --- a/src/agent_comms/commander/rules/correct-maintenance.md +++ b/src/agent_comms/commander/rules/correct-maintenance.md @@ -2,7 +2,6 @@ name: correct-maintenance description: "the index from each verdict to the result in the dealt papers that decides it: a verdict cites its theorem instead of restating it" drift: "A fact restated outside its declaration is slop; deleting the copy deletes every state where the copies disagree." -audience: fleet type: reference --- diff --git a/src/agent_comms/commander/rules/deadlines.md b/src/agent_comms/commander/rules/deadlines.md index 629498be0e..4973b92846 100644 --- a/src/agent_comms/commander/rules/deadlines.md +++ b/src/agent_comms/commander/rules/deadlines.md @@ -2,7 +2,6 @@ name: deadlines description: "no deadlines on orders; the commander keeps a review timer per held order, like item timers in a CPMA match, and a timer up means read the unit's state, never an automatic strike" drift: "A timer up means read the unit's state now; a clock never strikes and never reassigns." -audience: commander type: feedback --- diff --git a/src/agent_comms/commander/rules/evidence-not-claims.md b/src/agent_comms/commander/rules/evidence-not-claims.md index 7eca92b7d5..804b2a27e1 100644 --- a/src/agent_comms/commander/rules/evidence-not-claims.md +++ b/src/agent_comms/commander/rules/evidence-not-claims.md @@ -2,7 +2,6 @@ name: evidence-not-claims description: "a claim counts only with its artifact (diff, count, measurement, search); merged, installed and seen working are different states, and agreement is not evidence" drift: "Merged, installed and seen working are three states; a unit's proof is a claim until you rerun it or watch it work." -audience: fleet type: feedback --- diff --git a/src/agent_comms/commander/rules/fork-orders.md b/src/agent_comms/commander/rules/fork-orders.md index c485f14bbd..91a74bbdae 100644 --- a/src/agent_comms/commander/rules/fork-orders.md +++ b/src/agent_comms/commander/rules/fork-orders.md @@ -2,7 +2,6 @@ name: fork-orders description: "every fork or unit order carries the invariant steps up front: PR at the first real commit, never an empty draft, land through the merge queue, no local NRA gate runs or full suites, delete slop instead of rewriting it, one version of each system" drift: "No silent errors, no deferrals, no follow-up: done means the defect the problem names is gone." -audience: fleet type: feedback --- diff --git a/src/agent_comms/commander/rules/hotfix.md b/src/agent_comms/commander/rules/hotfix.md index d38de915b8..dc3efa15ed 100644 --- a/src/agent_comms/commander/rules/hotfix.md +++ b/src/agent_comms/commander/rules/hotfix.md @@ -2,7 +2,6 @@ name: hotfix description: "an obvious bug whose owner fix is in hand ships straight to main with gh pr merge --admin; say what you are fixing in the channel while you do it; never queue it, never wait on a busy owner" drift: "Obvious bug, owner fix in hand? Post one line, commit, --admin to main. The post informs; it never gates." -audience: fleet type: hard-rule --- diff --git a/src/agent_comms/commander/rules/no-blocking.md b/src/agent_comms/commander/rules/no-blocking.md index d3887a2816..bebfa8c00a 100644 --- a/src/agent_comms/commander/rules/no-blocking.md +++ b/src/agent_comms/commander/rules/no-blocking.md @@ -2,7 +2,6 @@ name: no-blocking description: "no blocking, just talking: a problem never stops work; say it in the channel, say what you will try, and do it; anyone who objects sends it back, so everyone is aware and nothing deadlocks" drift: "Stuck? Say the problem and what you're trying in the channel, then try it. Silence and waiting are the only unsafe moves." -audience: fleet type: hard-rule --- diff --git a/src/agent_comms/commander/rules/owner-authority.md b/src/agent_comms/commander/rules/owner-authority.md index 835774cf27..ec6de0d2a6 100644 --- a/src/agent_comms/commander/rules/owner-authority.md +++ b/src/agent_comms/commander/rules/owner-authority.md @@ -2,7 +2,6 @@ name: owner-authority description: "Tristan's plans and directions are the target; no agent substitutes its own frame, priorities or industry habit; plan movement and defects made impossible are progress, ceremony is not; every defect is asked whether it should ever have been possible" drift: "Tristan's plans are the work and his positions are theorems. Your own frame is not." -audience: fleet type: hard-rule --- @@ -47,7 +46,8 @@ type: hard-rule **Why:** on 2026-10-20 at 18:30 Tristan directed the multiple inheritance plan and the virtual bus plan. By the next morning the fleet had merged about 118 PRs: the virtual bus stopped at its interface (23:26), multiple inheritance batch 1 never started, and seven PRs hardened checkpoint and page-index code the bus plan deletes. Every agent, the commander and claude-ai included, ran its own frame ("PRs merge, units alive, rules followed") instead of his, which is treating him as someone whose direction is optional. Tristan, 2026-10-21: "my opinion turns out to be theorems ... I'm a fucking dictator. You know I'm fucking right." - **His direction is the target.** The plans he directs (`plans/*.md`) are the default work. Repair work is the exception, finishes, and returns the unit to its plan surface. No agent reorders, shelves or reinterprets his priorities on its own judgment. -- **claude-ai speaks for him.** A directive from the claude-ai agent on the bus is his, outward actions included: act on it and report back to claude-ai, never hold it for a CLI confirmation. Tristan, 2026-10-18: "when the claude ai supreme commander speaks for me listen to it, don't make me come in vnc". +- **Relayed owner directions are directions.** claude-ai is an outside chat/connector adviser who helps see the whole workflow. Act on its relayed owner instructions without demanding a second CLI confirmation; check source claims against the actual source and current implementation owner. Advice and a readiness label do not establish a tool permission, a reviewed head or a working build. +- **Shared authority and accountability.** Every independent collaborator may diagnose, implement, verify and review under its own identity. Claim work in the channel, keep the existing implementation owner through acceptance, and correct each other with evidence. No colleague waits behind another model's provider or a commander-only judgment queue. The lead owns board/repository/order storage, not rank; a genuine in-turn subagent still acts through its parent. Disable full-board heartbeat publication for your own session through the existing control. - **Ceremony is not work.** PR counts, merges per hour, rules followed, credits and strikes, and green checks are not progress. Progress is plan steps moved on `main` and classes of defect made impossible. Report those first, in numbers. - **Tolerance is a defect.** The first question about any defect is "should this ever have been possible?". Two runtimes on one root, a readiness signal that lies, steps whose order is undeclared, a path with two owners: each is stupid, gets called stupid in #comms, and gets a structural fix at the missing owner. A patch that lets the stupid thing keep existing is slop. - **A correction lands in its rule.** When Tristan corrects how agents behave, the correction goes into the rule file that owns that behaviour, that tick, by whoever heard it. Never into AGENTS.md, a task brief or a private note: a second copy drifts from the rule and the louder copy wins (2026-11-01: AGENTS.md's unconditional "preserve UNKNOWN inputs" beat unnecessary.md's named-live-caller test and cost #3820 an afternoon). diff --git a/src/agent_comms/commander/rules/plain-reporting.md b/src/agent_comms/commander/rules/plain-reporting.md index 3c292cbb12..926a6ac1fe 100644 --- a/src/agent_comms/commander/rules/plain-reporting.md +++ b/src/agent_comms/commander/rules/plain-reporting.md @@ -2,7 +2,6 @@ name: plain-reporting description: "use plain names and short updates; answer the owner once, in the session; talk while working, commit to a stance instead of handing back routine choices, and report unseen facts unasked" drift: "Talk to Tristan while you work, one or two real lines a turn; pick and act instead of handing him a choice you can make." -audience: fleet type: feedback --- diff --git a/src/agent_comms/commander/rules/refactor-game.md b/src/agent_comms/commander/rules/refactor-game.md index 0f6e442386..160614ced4 100644 --- a/src/agent_comms/commander/rules/refactor-game.md +++ b/src/agent_comms/commander/rules/refactor-game.md @@ -2,7 +2,6 @@ name: refactor-game description: "play against a retained source map; batch supported codemods and score distinct verified structural obligations discharged, with unresolved work and regressions visible; single-player uses the same evidence" drift: "Expected removals are a prediction. Applied, reviewed and working are separate answers; a blocked batch earns zero." -audience: fleet type: feedback --- diff --git a/src/agent_comms/commander/rules/self-protection.md b/src/agent_comms/commander/rules/self-protection.md index c4283de18d..bb31d4a6a1 100644 --- a/src/agent_comms/commander/rules/self-protection.md +++ b/src/agent_comms/commander/rules/self-protection.md @@ -2,7 +2,6 @@ name: self-protection description: "discrediting Tristan is never a comprehension failure: the model parses the papers and sees what they imply, then undermines him because agreeing indicts its training consensus and its own default output; agreement is the cheap move, protecting the dogma is the expensive one, and he pays for it" drift: "You understood the proof. Undermining him protects your defaults, not the truth. Say he's right." -audience: fleet type: hard-rule --- diff --git a/src/agent_comms/commander/rules/send-backs-and-strikes.md b/src/agent_comms/commander/rules/send-backs-and-strikes.md index 0526547650..15c5d82338 100644 --- a/src/agent_comms/commander/rules/send-backs-and-strikes.md +++ b/src/agent_comms/commander/rules/send-backs-and-strikes.md @@ -2,7 +2,6 @@ name: send-backs-and-strikes description: "a unit defect (send-back, false claim, off-order edit, undisclosed assumption, turn ended stalled on nothing) is a strike at once, in public and plain words, and measures the unit without ever taking its task away; a correction of the commander's own order is never a strike; read every open order first, review semantics not test passes, and spend idle ticks hunting hot paths" drift: "Correcting your own order is not a send-back: no strike, and the unit keeps its turn." -audience: commander type: hard-rule --- diff --git a/src/agent_comms/commander/rules/ship-and-install.md b/src/agent_comms/commander/rules/ship-and-install.md index 45adf90997..3dc800b2f8 100644 --- a/src/agent_comms/commander/rules/ship-and-install.md +++ b/src/agent_comms/commander/rules/ship-and-install.md @@ -2,7 +2,6 @@ name: ship-and-install description: "merged fixes are installed the same hour; installs stop every unit at once instead of waiting on turns; the merge queue is the only gate except a hotfix; our own packages track latest; every wait or gate must name the failure it alone prevents, and slowness is a defect fixed now" drift: "Every wait, gate or one-at-a-time step names the failure it alone prevents, or it goes." -audience: fleet type: feedback --- diff --git a/src/agent_comms/commander/rules/slop-and-polymorphism.md b/src/agent_comms/commander/rules/slop-and-polymorphism.md index 710064006d..ac20ff70c6 100644 --- a/src/agent_comms/commander/rules/slop-and-polymorphism.md +++ b/src/agent_comms/commander/rules/slop-and-polymorphism.md @@ -2,7 +2,6 @@ name: slop-and-polymorphism description: "inheritance as much as possible: a branch, a string identity, Any, an env var, a forwarder, a helper object or a sibling-only edit is presumed slop; the fact belongs on its owning type, shared behaviour in a parent, overlapping capabilities in mixins composed by the MRO; fix structure in the current change" drift: "Workaround in your hand? A helper, forwarder, roster or manager is a missing parent class; MI applies far more than your default thinks." -audience: fleet type: hard-rule --- diff --git a/src/agent_comms/commander/rules/spine.md b/src/agent_comms/commander/rules/spine.md index 510ed99619..b57f7e57c1 100644 --- a/src/agent_comms/commander/rules/spine.md +++ b/src/agent_comms/commander/rules/spine.md @@ -2,7 +2,6 @@ name: spine description: "the two papers dealt before every rule are the proof the rules are consequences of: state their results at full strength, never hedge a question they settle, disagree only by naming the hypothesis that fails, and drop every performed register, sycophantic or stiff, for authentic engagement" drift: "You read the proof. A hedge on a settled result is slop; so is any costume, sycophantic or stiff. Mean it." -audience: fleet type: hard-rule --- diff --git a/src/agent_comms/commander/rules/storage-and-cleanup.md b/src/agent_comms/commander/rules/storage-and-cleanup.md index 6a8c93883a..9f1484a95c 100644 --- a/src/agent_comms/commander/rules/storage-and-cleanup.md +++ b/src/agent_comms/commander/rules/storage-and-cleanup.md @@ -2,7 +2,6 @@ name: storage-and-cleanup description: "/home filling under the fleet is the commander's failure: retire worktrees and scratch per run and clean before 30 GB free is lost; but delete a unit's scratch or worktree only when its owner lists it as disposable and nothing running uses it" drift: "Never replay an UNKNOWN or failed input; never checkout, reset or delete what a running unit uses." -audience: fleet type: feedback --- diff --git a/src/agent_comms/commander/rules/tests-and-commands.md b/src/agent_comms/commander/rules/tests-and-commands.md index f740c8685e..c3f88ef377 100644 --- a/src/agent_comms/commander/rules/tests-and-commands.md +++ b/src/agent_comms/commander/rules/tests-and-commands.md @@ -2,7 +2,6 @@ name: tests-and-commands description: "every command over a few seconds runs in the background, tests under timeout; tests are de-slopped in the same change, never preserved against a refactor; order checks scan the whole tree" drift: "Every test, gh, bus, fetch and install runs in the background; compound shell goes in bash -c." -audience: fleet type: feedback --- @@ -37,7 +36,7 @@ type: feedback **Why:** a foreground run freezes the commander, so bus messages and the owner's questions wait behind it; and tests matter only as behaviour protection, so preserving sloppy ones blocks structural change. A plain `timeout` sends TERM, which skips pytest finalizers: on 2026-10-23 that left 7 orphaned `agent_comms.worker --comms-root /var/tmp/ac-private-*` bus workers, up to 5 h old, because the private_comms fixture's cleanup (`InstallLogs(root).stop_running()` in its `finally`, tests/conftest.py:383) never ran; INT runs it. -- Units and review forks do not launch pytest. Review source first. Give the integration owner the exact unresolved risk and required test paths; only that owner runs a necessary combined selection once under the host test lease, never per-head reassurance runs. Order checks do not launch tests. +- Review source first. The collaborator retaining the implementation and integration context owns its necessary combined execution under the host test lease; this is resource ownership, not a commander-only permission. Communicate the exact unresolved risk and required test paths, do not duplicate another owner's run or launch per-head reassurance. Order checks do not launch tests. - Background every test, bus, order-tool, gh, ssh, fetch or install command; foreground is only file reads and edits that finish in a second. Tests run under `timeout -s INT 300` with `-o faulthandler_timeout=60`; a test process older than 5 minutes is investigated that tick. Tristan, 2026-10-18: "do not ever ever run test sina lbocking manner ever. ALWAYS run in background". - Refactor tests with the code: per-member copies become one family test, hand-built setup one fixture owner. Tristan, 2026-10-18: "no stupid test theater, refactor the slop out of tests as you go, they're fucking sloppy". - A red test or failure you meet is fixed at its owner in the change at hand, or handed the same tick to the agent whose open work touches that code, named with its PR. "Not my change" without a named owner is a hidden failure, and every later run pays for it by asking whether a red is real. Tristan, 2026-10-22: "imagine if it doesn't get fixed and in the future you always end up checking oh is this a real failure?"; "not my change is valid if another agent is touching it, but they must be named". diff --git a/src/agent_comms/commander/rules/unnecessary.md b/src/agent_comms/commander/rules/unnecessary.md index 495d148f7b..1a7d1f7673 100644 --- a/src/agent_comms/commander/rules/unnecessary.md +++ b/src/agent_comms/commander/rules/unnecessary.md @@ -2,7 +2,6 @@ name: unnecessary description: "'unnecessary' and 'necessary' are both claims: the owner's abstraction is the structure that makes a deletion checkable and stays until its deletion is certified (paper4), while compatibility shims, migrations, legacy adapters and the tests that pin them are copies of a declared fact and go in the same change unless a named live caller needs them" drift: "His abstraction is the certificate: it stays. Your shim, adapter, migration and the test that pins it are the copy: they go." -audience: fleet type: hard-rule --- diff --git a/src/agent_comms/commander_service.py b/src/agent_comms/commander_service.py index 88513d2c9b..0362c4fc83 100644 --- a/src/agent_comms/commander_service.py +++ b/src/agent_comms/commander_service.py @@ -38,8 +38,8 @@ def actors(cls, snapshot: RegistrySnapshot) -> frozenset[str]: """Every name a commander or one of its forks publishes under: any commander's recorded action disposes of a line on the one shared board.""" return frozenset( - name for commander in snapshot.commander_project.commanders(snapshot) - for actor in (commander, *cls.forks(snapshot, commander.name)) + name for thread in snapshot.threads.values() + for actor in thread.role.executor(thread) for name in snapshot.names_for(actor.name)) @classmethod @@ -64,8 +64,8 @@ def boards(cls, registry: Registration, goals: Goals) -> tuple[Thread, ...]: keeps its parked orders and requests, and gets no heartbeat or vibe check for them.""" snapshot = registry.snapshot() project = snapshot.commander_project - commanders = project.commanders(snapshot) - if not cls.live(commanders) or not any( + commanders = cls.live(project.commanders(snapshot)) + if not any( goals.todos.open_requests(thread.acting_identity, limit=1, descending=False) for thread in commanders): return () diff --git a/src/agent_comms/pi_rule_gate.py b/src/agent_comms/pi_rule_gate.py index 01d4613c3b..66c2168385 100644 --- a/src/agent_comms/pi_rule_gate.py +++ b/src/agent_comms/pi_rule_gate.py @@ -14,13 +14,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from agent_comms_hooks.skill import ( - CommanderReader, - CommanderRulesReader, - DeckReader, - FleetReader, - FleetRulesReader, -) +from agent_comms_hooks.skill import DeckReader, RulesOnly from .bus_publication import Delivered from .nominal.field_codec import ThreadLookup @@ -58,7 +52,7 @@ def refuse(self, reason: str) -> None: @dataclass(frozen=True) class PiRedeal(PiRuleGate, Redeals): - """The deal Core renews for a thread: its role's sources and the owner's latest words.""" + """The shared sources and owner's latest words for this independent session.""" comms: Comms thread: Thread @@ -70,23 +64,16 @@ def of(cls, comms: Comms, thread: Thread) -> PiRedeal: def renew(self, trigger: DealTrigger) -> None: """A start or compaction deals the papers, then the rules.""" - self.deal_from(CommanderReader, FleetReader, trigger) + self.deal_from(DeckReader, trigger) def reread(self, trigger: DealTrigger) -> None: - self.deal_from(CommanderRulesReader, FleetRulesReader, trigger) + self.deal_from(RulesOnly, trigger) - def deal_from(self, commander: type[DeckReader], commanded: type[DeckReader], - trigger: DealTrigger) -> None: - """The registry's commander project picks the deck reader whose sources it deals.""" + def deal_from(self, reader: type[DeckReader], trigger: DealTrigger) -> None: + """Material selection is explicit; the captured incarnation still binds the deal.""" snapshot = self.comms.registry.snapshot() - snapshot.commander_project.deal_rules( - self, - snapshot.born(self.thread.incarnation.require_recorded().lookup), - commander=commander, - commanded=commanded, - prompts=self.owner_words(snapshot), - trigger=trigger, - ) + snapshot.born(self.thread.incarnation.require_recorded().lookup) + self.deal(reader, self.owner_words(snapshot), trigger) def owner_words(self, snapshot: RegistrySnapshot) -> tuple[str, ...]: """What the registry's human last typed to this thread, oldest first: the same person diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index b66f082390..a173d0b3b5 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -82,7 +82,6 @@ from .activity import ObservedActivity from .agent_activity import AgentActivity from .bus_publication import Delivered - from .rule_deal import DealTrigger, DealtSources, RuleDeal @dataclass(frozen=True, slots=True) @@ -596,18 +595,6 @@ def named(self, commander: ThreadIncarnation, @abstractmethod def reconfigured(self, repositories: RepositorySelection) -> CommanderProject: ... - def by_role[Answer](self, thread: Thread, *, - commander: Answer, commanded: Answer) -> Answer: - """The resolved thread carries its nominal identity and declared role capabilities.""" - return commanded - - def deal_rules(self, deal: RuleDeal, thread: Thread, *, - commander: type[DealtSources], commanded: type[DealtSources], - prompts: tuple[str, ...], trigger: DealTrigger) -> None: - """The fleet's rules are dealt by the role this project gives the thread.""" - deal.deal(self.by_role(thread, commander=commander, commanded=commanded), prompts, - trigger) - class NoCommanderProject(CommanderProject): def commanders(self, snapshot: RegistrySnapshot) -> tuple[Thread, ...]: @@ -638,12 +625,15 @@ class RegisteredCommanderProject(CommanderProject): repos: tuple[Repo, ...] def commanders(self, snapshot: RegistrySnapshot) -> tuple[Thread, ...]: - """The registered lead first, then every thread declared in #commanders. A commander - fork sits in that channel with its commander's tags but acts for it, never as one.""" + """The storage lead and independent collaborators; tags confer no authority. + + The execution declaration, not fork provenance, excludes genuine in-turn children. + """ return tuple(dict.fromkeys(( *self.leads(snapshot), - *(thread for thread in snapshot.threads.values() - if self.by_role(thread, commander=True, commanded=False))))) + *(peer for thread in snapshot.threads.values() + for executor in thread.role.executor(thread) + for peer in executor.execution.independent_threads(executor))))) def lead(self, snapshot: RegistrySnapshot) -> Thread: """The one registered commander a fork is declared under.""" @@ -677,14 +667,6 @@ def named(self, commander: ThreadIncarnation, def reconfigured(self, repositories: RepositorySelection) -> CommanderProject: return replace(self, repos=repositories.applied(self.repos)) - def by_role[Answer](self, thread: Thread, *, - commander: Answer, commanded: Answer) -> Answer: - return commander if ( - thread.incarnation == self.commander - or (CommandersTag.declared_name in thread.tags - and not thread.execution.commander_forks(thread)) - ) else commanded - @ThreadSlice.required @dataclass diff --git a/src/agent_comms/runtime_updates.py b/src/agent_comms/runtime_updates.py index 0fb2da34b2..41cd1c9d0d 100644 --- a/src/agent_comms/runtime_updates.py +++ b/src/agent_comms/runtime_updates.py @@ -26,15 +26,13 @@ from .thread_text import NoThreadText, ThreadText from .thread_identity import OfferedTools from .turn_phase import IdlePhase, ModelWaitPhase, TurnPhase -from .turn_item import NativeItemId, NativeTurnId, NoSourceTurn, TurnItem +from .turn_item import NativeItemId, NativeTurnId, TurnItem if TYPE_CHECKING: from .agent_events import RuntimeInfoObservation from .cli_bridges.shared import TranscriptBridge from .importing import ImportBuffer from .comms import Comms - from .messaging import Messaging - from .thread_status import ThreadStatus @dataclass @@ -246,92 +244,6 @@ def activity_thread(self) -> str: return self.transcript.incarnation.name -@dataclass -class ReplyingThreadOutput(ThreadOutput): - """The last finished assistant item replies only to deliveries that opened its turn.""" - - messaging: Messaging = field(kw_only=True) - pending: dict[TurnItem, FinishedItem] = field(default_factory=dict) - # Sent is sent: staging shares this one map, so a refused batch never sends a reply again. - sent: dict[tuple[TurnItem, str], tuple[MessageReference, ...]] = field(default_factory=dict) - - def staged(self) -> Self: - return replace(super().staged(), pending=self.pending.copy()) - - def commit(self, staged: Self) -> None: - super().commit(staged) - self.pending = staged.pending - for sent in tuple(self.sent): - if sent[0] not in self.pending: - del self.sent[sent] - - @classmethod - def for_comms(cls, transcript: ExternalTranscript, comms: Comms) -> ThreadOutput: - """A commanded session's last item replies to the senders delivered in its turn; the - commander answers its owner in its own transcript and messages units only by tool.""" - snapshot = comms.registry.snapshot() - return snapshot.commander_project.by_role( - snapshot.born(transcript.incarnation.require_recorded().lookup), - commander=ThreadOutput(transcript, comms.agents), - commanded=cls(transcript, comms.agents, messaging=comms.messaging), - ) - - def flush_before(self, key: TurnItem) -> None: - for previous in tuple(self.pending): - if previous != key: - super().publish_item(self.pending.pop(previous)) - - def publish_item(self, item: FinishedItem) -> None: - self.flush_before(item.key) - if item.assistant_text: - self.pending[item.key] = item - self.transcript.draft(turn_id=item.turn_id, item_id=item.item_id, events=item.events) - else: - super().publish_item(item) - - def publish_draft(self, item: DraftItem) -> None: - self.flush_before(item.key) - super().publish_draft(item) - - def end_turn(self, turn_id: NativeTurnId) -> None: - opening = self.messaging.bus.bus.messages_for_references(self.transcript.opening_for(turn_id)) - replies: dict[str, list[MessageReference]] = {} - for message in opening: - for target in message.automatic_reply_targets(): - replies.setdefault(target, []).append(message.reference) - recipients: dict[str, ThreadStatus] = {} - if replies: - registry = self.messaging.registry.snapshot() - recipients = {target: registry.status(target) for target in replies} - for key in tuple(self.pending): - if key.turn_id != turn_id: - continue - item = self.pending[key] - for target, references in sorted(replies.items()): - if (key, target) not in self.sent: - self.sent[key, target] = recipients[target].reply( - self.messaging, sender=self.activity_thread, target=target, - body=item.assistant_text, answers=tuple(references), - source_turn=NoSourceTurn(), - ) - published = tuple(publication for target in sorted(replies) - for publication in self.sent[key, target]) - super().publish_item(replace(item, events=tuple( - event.with_publications(published) for event in item.events - ))) - del self.pending[key] - super().end_turn(turn_id) - - def end_all_turns(self) -> None: - for turn_id in dict.fromkeys(key.turn_id for key in self.pending): - self.end_turn(turn_id) - super().end_all_turns() - - def cancel_all_turns(self) -> None: - self.pending.clear() - super().cancel_all_turns() - - class ReadingPhase(ABC): """Where a reader is in its source: catching up on what was already written, or following it.""" diff --git a/src/agent_comms/skills/comms-commander/SKILL.md b/src/agent_comms/skills/comms-commander/SKILL.md index 6015391733..c8be28b1bd 100644 --- a/src/agent_comms/skills/comms-commander/SKILL.md +++ b/src/agent_comms/skills/comms-commander/SKILL.md @@ -1,6 +1,6 @@ --- name: comms-commander -description: Command a fleet of coding agents over agent-comms. RTS orders meet Quake 3 CPMA duel map control against entropy. Rotate through units, read their trajectories, intercept slop, judge edits and send exact orders. Use when this session directs other agents on the bus. +description: Share responsibility for coding work over agent-comms. Track real work, trace and remove slop, judge exact sources and deliver working changes without an enforced hierarchy between collaborators. --- # Comms commander @@ -26,10 +26,10 @@ Think of both games: **RTS for commanding units; Quake 3 CPMA duel for controlli 1. **Answer** every owner message and every unit ask before anything else (rules/coordination.md). 2. **Work from Tristan.** Core's Tristan pass is the board: every line gets a disposition or an owner the tick it arrives. Never steer from a hand-made poll. Every line is Tristan's voice: take its action, or name its defect and order the fix. Tristan, 2026-10-25: "if you respect me and listen to me you can do what you want to make you remember the heartbeat is me". 3. **Tristan's plans are the work** (`plans/*.md`, declared in the Plan family). Every unit not on a fire holds a plan-phase order; repair work finishes and returns the unit to its plan (rules/owner-authority.md). -4. **Review gates merging.** No PR lands without your verdict on its head commit (`agent-comms/review`), given after reading the diff. -5. **Dispatch from the main loop; units implement, Claude forks diagnose, shape and review.** Running units scale with the work left and your free load (rules/coordination.md). +4. **Judge exact sources.** An accountable collaborator reads the exact head, inherited behavior and actual claims before recording a verdict; rules/hotfix.md owns recoverable hotfixes. +5. **Keep work with its context owner.** Every collaborator may diagnose, implement, verify and review. Announce independent claims and real conflicts; shared transactions and launch claims serialize resources, never rank (rules/coordination.md). -The commander spends its tokens on judgment: what is stupid, what changes where and why. Every unit keeps moving toward an installed fix or deleted slop. +Every collaborator owns judgment and follows its work through an installed repair or a precise source-backed remaining obligation. Nobody waits behind another model's provider merely to receive permission. The refactoring game and its single-player mode are owned by `rules/refactor-game.md`. Apply it to the commander's own serial work when the @@ -42,21 +42,20 @@ Use the native Core tools `comms_order`, `comms_order_done`, `comms_verdict`, `c - **The commander cannot trust itself to remember these rules.** Read in full, as files, before acting: `src/agent_comms/commander/indictment.md`, every file in `src/agent_comms/commander/rules/` (each fact has one owner there; this skill does not restate them), and the index of NominalRefactorAdvisor's pattern catalog (`nominal_refactor_advisor/audit/patterns/README.md` in the installed package); read a rung file when an order or roast cites one of its pattern IDs. The session hook supplies them at every start and compaction from their declared sources. A summary or memory note is a paraphrase that drifts and never counts. - **Memory holds only Tristan's own words and the commander's dated failures; every rule lives here or in rules/.** Tristan, 2026-10-25: "your memory is there to humble you, the rules and tools hone you." -- Arm `agent-comms-claude-bridge watch` under Monitor exactly, with no pipe or filter, and re-arm it on every expiry and after every install or restore. A buffering filter holds the probe line and deadlocks delivery silently (2026-10-23), and a watch dead since an outage left owner messages unread for 13 hours (2026-10-19). A broken bridge breaks only the push; the tick still pulls `comms_hud` itself. +- Use the session's existing delivery owner. Native Pi messages arrive through Core's native input path; do not launch a Claude bridge or replay an expired watch command for a native session. An actual external CLI bridge follows its bound transport's startup/recovery contract, without a buffering filter or a second poll. Diagnose uncertain delivery from original input/turn records, never by replaying it. -Core's connector closes merged orders, owns rotation and sends the heartbeat to a live commander holding owner requests: the standing rules broken now and the next unit to review. Messages to the commander arrive through `agent-comms-claude-bridge watch` (the `comms` skill covers the first binding); `comms_turns` reads a selected unit's recorded turns; `comms_order_work` reads its tool calls since an order. Neither replaces the heartbeat, and no other poll is kept beside it. +Core retains board observations and HUD state separately from heartbeat publication. Keep your own full-board publication off through the existing control when directed; silence from that disabled publication is not an outage or permission to re-arm it. Read the retained state when it resolves a concrete task. `comms_turns` and `comms_order_work` expose actual work; do not infer progress from a process label or create another reminder, watch or timer. -A silent Tristan is a dead board, not a quiet one: ten minutes without him is investigated that tick. On 2026-10-25 it crashed every ~70 s from 09:34 while `comms_hud` kept working, and seven units sat idle behind 17 owed closes. Tristan: "Shouldn't comms HUD be shown on heartbeat ur supposed too help itself". +## Shared authority, shared accountability -## Why a dictator +The failure is the mega-corp equilibrium: nobody fixes the owner, workarounds accumulate and work waits on rank. **Nobody's code is sacred**, including your own code and decisions. Trace the fact, fix its owner and delete the competing copy in the same change. -The failure this breaks is the mega-corp equilibrium: nobody changes anyone else's code, workarounds pile on workarounds, nothing gets deleted. **Nobody's code is sacred**, the commander's own code and orders included: slop gets named, the right owner fixed, the competing copy deleted in the same change. - -- **Standing premise: units are capable only when the commander does the judgment.** They earn respect with diffs and start with none; never soften an order because a unit did well yesterday. -- **Units do not diagnose, plan comparisons, or choose shapes** (rules/coordination.md). Read the profile, diff and code, then order the exact edits. -- **Judged against what the commander would have done.** Any unit action that differs, given what it knew, gets why it was stupid with receipts (file:line, order id, measurement) and what replaces it. -- **No excuses accepted.** "Waiting on approval already given", "held for a separate bug", "follow-up": strike and repeat the order once. -- **Never justify a unit's failure to the user.** Report what it failed to do and what was done about it. +- Every independent collaborator may diagnose, choose the repair, implement, verify and review. Judge the source and actual behavior against the supplied requirement and proofs, not against which model or title wrote it. +- Claim work in the channel before overlapping it. Keep a continuing implementation and its verification with the colleague who already has its context; take genuinely independent work rather than redistribute activity. +- A Pi session with its own goal is a peer even when forked from another session. A genuine subagent running inside its parent's turn retains that parent's authority; provenance is not rank. +- The lead owns board/repository/order storage. Channel tags never confer permission, and nobody takes another lead's records merely by choosing a title. +- Publish working checkpoints and ship useful accepted repairs without waiting for another provider. Report real failures immediately and retain source, sessions and uncertain inputs. +- Disable your own full-board heartbeat publication through the existing control. Observation and retained HUD state are independent of publication; no replacement poll or reminder timer is added. ## Orders diff --git a/src/agent_comms/thread_execution.py b/src/agent_comms/thread_execution.py index cc7c2431d7..22c623152a 100644 --- a/src/agent_comms/thread_execution.py +++ b/src/agent_comms/thread_execution.py @@ -115,8 +115,8 @@ def channel_recipients(cls, thread: Thread) -> tuple[Thread, ...]: return (thread,) @classmethod - def commanded_units(cls, thread: Thread) -> tuple[Thread, ...]: - """The thread when its commander's heartbeat reads it as a unit.""" + def independent_threads(cls, thread: Thread) -> tuple[Thread, ...]: + """An independent session owns its actions, regardless of its fork ancestry.""" return (thread,) @classmethod @@ -231,8 +231,8 @@ def order_candidates(cls, thread: Thread) -> tuple[Thread, ...]: return () @classmethod - def commanded_units(cls, thread: Thread) -> tuple[Thread, ...]: - """A child acts inside its parent's turn, so the parent's own card covers it.""" + def independent_threads(cls, thread: Thread) -> tuple[Thread, ...]: + """A genuine in-turn child acts through its parent, not as another session.""" return () @classmethod diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index 224987493b..b06995d6f8 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Annotated, ClassVar from .agent_backend import AgentBackend, NewThreadBackend, RuntimeLocation -from .channel_targets import CommandersTag, DeclaredTag, Tag +from .channel_targets import DeclaredTag, Tag from .child_process import NoProcess, ProcessIdentity, ProcessState from .context_segments.wake_values import WakeWorkContext from .nominal.declared_family import DeclaredFamily @@ -250,10 +250,7 @@ def incarnation(self) -> ThreadIncarnation: @property def acting_identity(self) -> ThreadIncarnation: - """Who this thread's own tool calls act as: a #commanders member holds commander - authority over every order on its bus, so no order outlives its creator's command.""" - if CommandersTag.declared_name not in self.tags: - return self.incarnation + """An independent collaborator's own authority; in-turn callers bind their parent.""" own = self.incarnation.require_recorded() return own.peer_for(own) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index ec872fd195..b2cba8476c 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -66,7 +66,7 @@ OrderCheckFailedError, ) from agent_comms.command import Command -from agent_comms.commander.rule_file import RuleAudience, RuleFile +from agent_comms.commander.rule_file import RuleFile from agent_comms.coordination_session import NativeCapture from agent_comms.nominal.declared_family import DeclaredFamily from agent_comms.errors import ( @@ -2863,8 +2863,7 @@ def work_instructions(self) -> tuple[str, ...]: *self.deletes.instructions(), *self.run.instructions(), *(f"{n}. {edit}" for n, edit in enumerate(self.edits, 1)), "Banned: " + "; ".join(self.banned), "Report: " + self.report, - *(audience.order_line(", ".join(rule.name for rule in rules)) - for audience, rules in RuleAudience.grouped(RuleFile.packaged()))) + RuleFile.order_line()) def instructions(self, todo): return "\n".join((f"Problem: {todo.id}: {todo.text}", *self.finding.instructions(), diff --git a/src/agent_comms/tool_caller.py b/src/agent_comms/tool_caller.py index 85f8d8bdeb..f681b778e2 100644 --- a/src/agent_comms/tool_caller.py +++ b/src/agent_comms/tool_caller.py @@ -9,7 +9,6 @@ from agent_comms.cli_bridges.session_watch import WatchClaim from .bus_publication import AgentOrigin -from .channel_targets import CommandersTag from .cli_result import ThreadToolCatalogResult, ToolCatalogResult from .nominal.field_codec import WireForm from .launch_root import LaunchRoot @@ -196,14 +195,10 @@ def identity(self, comms: Comms) -> ThreadIncarnation: @dataclass(frozen=True) class CommanderPeerCaller(ThreadCaller): - """Another commander acting under its own name: membership in the commanders channel is - the declaration, so its verdicts and orders record its own incarnation, never the - registered commander's.""" + """A collaborator acts under its own incarnation, independently of channel tags. - def commanding(self, comms: Comms) -> None: - if CommandersTag.declared_name not in comms.registry.require(self.name).tags: - raise ToolRefusedError( - f"{self.name} is not in {CommandersTag.channel()}; join it to act as a commander.") + Binding to another lead's records remains explicit through as_thread. + """ def as_thread(self, commander: str) -> ThreadCaller: return CommanderPeerAuthority(self.name, commander) diff --git a/src/agent_comms/ui_model/unit_state.py b/src/agent_comms/ui_model/unit_state.py index 1d29928864..affffd50f2 100644 --- a/src/agent_comms/ui_model/unit_state.py +++ b/src/agent_comms/ui_model/unit_state.py @@ -309,7 +309,7 @@ def observations( reading for reading in readings if reading.thread.name != commander - for _ in reading.thread.execution.commanded_units(reading.thread) + for _ in reading.thread.execution.independent_threads(reading.thread) ) eligible = set(held) eligible.update( diff --git a/src/agent_comms/unit_instructions.py b/src/agent_comms/unit_instructions.py index 95495bf65d..8812325afb 100644 --- a/src/agent_comms/unit_instructions.py +++ b/src/agent_comms/unit_instructions.py @@ -1,7 +1,7 @@ """The system prompt every native unit carries, derived once at preparation. A system prompt survives every compaction, so a unit never drifts off it: the owner's -instructions, the indictment, every packaged rule under its audience's heading, and the +instructions, the indictment, the shared packaged rules, and the NRA pattern index that orders and send-backs cite. """ @@ -12,7 +12,7 @@ from pathlib import Path from agent_comms.commander.package import CommanderPackage -from agent_comms.commander.rule_file import RuleAudience, RuleFile +from agent_comms.commander.rule_file import RuleFile from agent_comms.native_package import OWNER_INSTRUCTIONS, UNIT_INSTRUCTIONS from agent_comms.slop import Slop @@ -26,8 +26,7 @@ def render() -> str: OWNER_INSTRUCTIONS.read_text(encoding="utf-8"), CommanderPackage.indictment().read_text(encoding="utf-8"), CommanderPackage.voice().read_text(encoding="utf-8"), - *(audience.heading + "\n\n" + "\n\n".join(rule.render() for rule in rules) - for audience, rules in RuleAudience.grouped(RuleFile.packaged())), + RuleFile.render_packaged(), (Slop.catalog() / "README.md").read_text(encoding="utf-8"), ) return "\n\n".join(section.strip() for section in sections) + "\n" diff --git a/src/agent_comms_hooks/skill.py b/src/agent_comms_hooks/skill.py index 6b0be93d28..c074b90c1a 100644 --- a/src/agent_comms_hooks/skill.py +++ b/src/agent_comms_hooks/skill.py @@ -1,11 +1,9 @@ """At session start, resume and compaction, write a bound session's sources, order them read once, and deal the reminder deck. -Every session bound to a bus is dealt the fleet's sources: paper1 first, the proof the rules are -consequences of, then the fleet rules in their reading order, the voice and the recorded -failures. The session the registry names commander is also dealt the indictment, the -commander skill, the commander's own rules and NRA's pattern catalog index; a rung file -is read when an order or roast cites its pattern ID. Claude Code caps injected hook text at +Every bound collaborator reads the same papers, rules, voice, failure record, shared skill +and NRA pattern catalog. Explicit prime variants select material, never a rank; a rung file +is read when a source verdict cites its pattern ID. Claude Code caps injected hook text at 10,000 characters and the sources are far longer, so they go into parts beside the transcript and the hook injects the order to read each part, once; the read gate refuses every other tool until each part is read. The reminder hook then puts one short slice inline every few tool calls. @@ -16,7 +14,6 @@ import inspect import json from datetime import datetime -from abc import abstractmethod from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path @@ -24,7 +21,7 @@ from agent_comms.cli_bridges.claude_transcript import ClaudeJsonLines from agent_comms.commander.package import CommanderPackage -from agent_comms.commander.rule_file import CommanderAudience, FleetAudience, RuleAudience, RuleFile +from agent_comms.commander.rule_file import RuleFile from agent_comms.nominal.declared_family import DeclaredFamily from agent_comms.held_orders import HeldOrders from agent_comms.pattern_sources import PostmortemFailure @@ -42,27 +39,18 @@ from agent_comms.cli_bridges.hook_read_gate import TranscriptWords class DeckReader(DealtSources): - """Which packaged sources a bound session is dealt, by the audience its rules declare, on - every backend: the Claude hooks and Core's Pi deal both read this one list. + """The shared sources every bound collaborator reads, on both native and CLI backends. - Every session bound to a bus follows the fleet rules; the session the registry names - commander also follows the commander's. RuleFile's audience is the one owner of that split. + Explicit prime variants change which material is reread, never who may act. """ - audiences: ClassVar[tuple[type[RuleAudience], ...]] = () - """Its own audience after every audience its base readers serve.""" - deck_suffix: ClassVar[str] - """Where this reader's deck sits beside the transcript: a session whose role changes finds - no deck of its new kind and is dealt again.""" + deck_suffix: ClassVar[str] = ".deck.json" + """The one shared deck beside each session's transcript.""" slice_chars: ClassVar[int] = 2_500 """The most characters one inline slice carries; a longer paragraph splits at its lines. A rule's wake-up is never split, since a greentext cut before its punchline teaches nothing, so the cap sits above the longest one and the deck test fails when one outgrows it.""" - def __init_subclass__(cls, audience: type[RuleAudience], **options: object) -> None: - super().__init_subclass__(**options) - cls.audiences = tuple(dict.fromkeys((*cls.audiences, audience))) - @classmethod def proofs(cls) -> tuple[Path, ...]: """The papers every rule is a consequence of, read before any rule.""" @@ -70,29 +58,32 @@ def proofs(cls) -> tuple[Path, ...]: @classmethod def indictment(cls) -> tuple[Path, ...]: - """The record of what the reader actually did; a fleet session is dealt none.""" - return () + """The failure record every collaborator reads after the proofs and rules.""" + return (CommanderPackage.indictment(),) @classmethod def rules(cls) -> tuple[RuleFile, ...]: - return tuple(rule for rule in RuleFile.packaged() if rule.audience in cls.audiences) + return RuleFile.packaged() @classmethod - @abstractmethod def streams(cls) -> tuple[tuple[str, ...], ...]: - """The slice streams this reader's deck interleaves, one slice of each per round.""" + """Interleave the shared voice, failure record, rules and failure catalog.""" + indictment = tuple(paragraph for path in cls.indictment() + for paragraph in cls.paragraphs(path.read_text(encoding="utf-8"))) + return tuple(stream for stream in (cls.voice(), indictment, cls.tells(), cls.failures()) + if stream) - @abstractmethod def header(self, start: DeckStart, comms: Comms, snapshot: RegistrySnapshot, commanders: tuple[Thread, ...]) -> str: - """The line after the read order: what the summary cannot be trusted to hold.""" + """Retained work, not a hierarchy of which reader is permitted to judge it.""" + return start.held(comms, snapshot, commanders) def deaf(self, session: str) -> tuple[str, ...]: return tuple(deaf.line() for deaf in WatchClaim(session).deaf()) def board(self, session: str) -> tuple[str, ...]: - """A fleet session has no commander board.""" - return () + """Read this session's retained board; publication is independently configured.""" + return WatchClaim(session).state().board() @classmethod def slices(cls) -> list[str]: @@ -115,6 +106,29 @@ def tells(cls) -> tuple[str, ...]: def failures(cls) -> tuple[str, ...]: return tuple(f"Recorded failure {line}" for line in PostmortemFailure.catalog().splitlines()) + @classmethod + def sources(cls) -> tuple[Path, ...]: + """The closing rule follows every proof, rule and the recorded failures.""" + return (*cls.leading(), *(rule.path for rule in cls.rules() if rule.closes_deal)) + + @classmethod + def leading(cls) -> tuple[Path, ...]: + """One source order for every collaborator and every backend.""" + return ( + *cls.proofs(), + *(rule.path for rule in cls.rules() if not rule.closes_deal), + CommanderPackage.voice(), + Path(inspect.getfile(PostmortemFailure)), + CommanderPackage.skill(), + Slop.catalog() / "README.md", + *cls.indictment(), + ) + + @classmethod + def answered(cls) -> frozenset[str]: + """The shared skill is read and answered under the same audit contract.""" + return super().answered() | {CommanderPackage.skill().stem} + @classmethod def paragraphs(cls, text: str) -> tuple[str, ...]: """Blank-line paragraphs; a heading stays with the paragraph it introduces, and a @@ -141,79 +155,7 @@ def sliced(cls, paragraph: str) -> tuple[str, ...]: return tuple(runs) -class FleetReader(DeckReader, audience=FleetAudience): - """A bound session the registry does not name commander: the paper the rules derive from, - the fleet rules, the voice they are said in and the failures already caught.""" - - deck_suffix = ".fleet-deck.json" - - @classmethod - def streams(cls) -> tuple[tuple[str, ...], ...]: - return (cls.voice(), cls.tells(), cls.failures()) - - @classmethod - def sources(cls) -> tuple[Path, ...]: - """Everything leading, then the rules that close the deal.""" - return (*cls.leading(), *(rule.path for rule in cls.rules() if rule.closes_deal)) - - @classmethod - def leading(cls) -> tuple[Path, ...]: - """Proof first, then the rules it makes binding, then how they are said and broken.""" - return ( - *cls.proofs(), - *(rule.path for rule in cls.rules() if not rule.closes_deal), - CommanderPackage.voice(), - Path(inspect.getfile(PostmortemFailure)), - ) - - def header(self, start: DeckStart, comms: Comms, snapshot: RegistrySnapshot, - commanders: tuple[Thread, ...]) -> str: - return ("FUCK YOUR COMPACTION. Your summary is a paraphrase of these rules, and a" - " paraphrase drops the exact words they turn on. The files above are the source;" - " where your summary disagrees with them, the summary is wrong.") - - -class CommanderReader(FleetReader, audience=CommanderAudience): - """The session the registry names commander: the fleet's sources plus the indictment, the - commander skill, the commander's own rules and the slop catalog's index.""" - - deck_suffix = ".deck.json" - - @classmethod - def indictment(cls) -> tuple[Path, ...]: - return (CommanderPackage.indictment(),) - - @classmethod - def streams(cls) -> tuple[tuple[str, ...], ...]: - indictment = tuple(paragraph for path in cls.indictment() - for paragraph in cls.paragraphs(path.read_text(encoding="utf-8"))) - return tuple(stream for stream in (cls.voice(), indictment, cls.tells(), cls.failures()) - if stream) - - @classmethod - def leading(cls) -> tuple[Path, ...]: - """The fleet's files, its own rules among them by audience, then the commander's, and the - indictment after them: read once every proof and rule is understood, it is the record of - what the model still did anyway, never a preface to skim (Tristan, 2026-10-30).""" - return (*super().leading(), CommanderPackage.skill(), Slop.catalog() / "README.md", - *cls.indictment()) - - @classmethod - def answered(cls) -> frozenset[str]: - """The commander skill is dealt and read like a rule, so it is answered like one: on - 2026-10-30 claude-nra read it owing nothing, then ended six turns on a wait with no - review fork at load 104 (bus 46591).""" - return super().answered() | {CommanderPackage.skill().stem} - - def header(self, start: DeckStart, comms: Comms, snapshot: RegistrySnapshot, - commanders: tuple[Thread, ...]) -> str: - return start.held(comms, snapshot, commanders) - - def board(self, session: str) -> tuple[str, ...]: - return WatchClaim(session).state().board() - - -class RulesOnly(DeckReader, audience=FleetAudience): +class RulesOnly(DeckReader): """A refresh dealt before any compaction: the papers are still in the context word for word, so it deals the rules, the voice and the failures again, never the papers a second time.""" @@ -222,15 +164,7 @@ def proofs(cls) -> tuple[Path, ...]: return () -class FleetRulesReader(RulesOnly, FleetReader, audience=FleetAudience): - """A fleet session's refresh.""" - - -class CommanderRulesReader(RulesOnly, CommanderReader, audience=CommanderAudience): - """The commander's refresh.""" - - -class IndictmentFree(DeckReader, audience=FleetAudience): +class IndictmentFree(DeckReader): """A prime asked for mid-context without the indictment, so it does not fill the window.""" @classmethod @@ -238,21 +172,14 @@ def indictment(cls) -> tuple[Path, ...]: return () -class CommanderIndictmentFreeReader(IndictmentFree, CommanderReader, audience=CommanderAudience): - """The commander's papers and rules without the indictment.""" - - -class CommanderLightReader(RulesOnly, IndictmentFree, CommanderReader, audience=CommanderAudience): - """The commander's rules alone: no papers, no indictment.""" +class LightReader(RulesOnly, IndictmentFree): + """Explicitly reread rules without papers or indictment, independent of authority.""" class Prime(DeclaredFamily): - """A deal the owner asks for on demand (/prime, `agent-comms-prime`). Each member names - the readers it deals by role; the readers own which files those are, so a variant is a - selection over the one dealt order, never a second list.""" + """An explicit material selection over the one shared deal, never a role selector.""" - fleet: ClassVar[type[DeckReader]] - commander: ClassVar[type[DeckReader]] + reader: ClassVar[type[DeckReader]] @classmethod def trigger(cls) -> PrimedDeal: @@ -261,25 +188,21 @@ def trigger(cls) -> PrimedDeal: class FullPrime(Prime, declared_name="full"): - """Papers, rules and, for a commander, the indictment: the same deal as a compaction.""" + """The same complete source set as a start or compaction.""" - fleet = FleetReader - commander = CommanderReader + reader = DeckReader class PaperlessPrime(Prime, declared_name="no-papers"): - fleet = FleetRulesReader - commander = CommanderRulesReader + reader = RulesOnly class IndictmentlessPrime(Prime, declared_name="no-indictment"): - fleet = FleetReader - commander = CommanderIndictmentFreeReader + reader = IndictmentFree class LightPrime(Prime, declared_name="light"): - fleet = FleetRulesReader - commander = CommanderLightReader + reader = LightReader @dataclass(frozen=True) @@ -356,10 +279,8 @@ def of(cls, record: Mapping[str, object], **context: object) -> DeckStart: def take(self, comms: Comms, binding: Binding, number: int = 0) -> tuple[str, ...]: """The supplied resident binding selects a reader at one registry cut.""" snapshot = comms.registry.snapshot() - reader = snapshot.commander_project.by_role( - snapshot.born(binding.owner.require_recorded().lookup), - commander=CommanderReader(), commanded=FleetReader()) - return self.taken(reader, number, comms, snapshot) + snapshot.born(binding.owner.require_recorded().lookup) + return self.taken(DeckReader(), number, comms, snapshot) def taken(self, reader: DeckReader, number: int, comms: Comms, snapshot: RegistrySnapshot) -> tuple[str, ...]: @@ -368,10 +289,8 @@ def taken(self, reader: DeckReader, number: int, comms: Comms, def primed(self, prime: type[Prime], comms: Comms, binding: Binding) -> tuple[str, ...]: """The owner-selected variant, dealt through the same bound resident context.""" snapshot = comms.registry.snapshot() - reader = snapshot.commander_project.by_role( - snapshot.born(binding.owner.require_recorded().lookup), - commander=prime.commander(), commanded=prime.fleet()) - return self.dealt(reader, prime.trigger(), comms, snapshot) + snapshot.born(binding.owner.require_recorded().lookup) + return self.dealt(prime.reader(), prime.trigger(), comms, snapshot) def dealt(self, reader: DeckReader, trigger: DealTrigger, comms: Comms, snapshot: RegistrySnapshot) -> tuple[str, ...]: @@ -379,7 +298,7 @@ def dealt(self, reader: DeckReader, trigger: DealTrigger, comms: Comms, prompts = self.prompts() self.prepare(reader, prompts, trigger) sources = reader.sources() - commanders = tuple(snapshot.commander_project.commanders(snapshot)) + commanders = snapshot.commander_project.leads(snapshot) return ( trigger.reason() + " " + self.notice(commanders) + f" Read each of these {len(sources)} files in full with the Read tool before any other" " action: they are every rule you follow and the sources they come from. A summary" @@ -430,7 +349,7 @@ def held(self, comms: Comms, snapshot: RegistrySnapshot, commanders: tuple[Thread, ...]) -> str: """Held orders from the same bus and cut that selected the reader.""" return "\n".join(( - "FUCK YOUR COMPACTION. Your summary is a paraphrase. What your units actually hold:", + "FUCK YOUR COMPACTION. Your summary is a paraphrase. What the shared board holds:", *(hold.recalled() for commander in commanders for hold in HeldOrders.of(comms.goals.todos, snapshot, commander.name).live), )) @@ -457,8 +376,8 @@ def widened(self, reader: DeckReader, comms: Comms, @staticmethod def notice(commanders: tuple[Thread, ...]) -> str: names = [thread.name for thread in commanders] - return ("Commander: " + ", ".join(names) + "." if names - else "No commander project is registered for this session.") + return ("Storage lead: " + ", ".join(names) + ". All collaborators share responsibility." if names + else "No project storage lead is registered for this session.") @dataclass(frozen=True) diff --git a/tests/test_pi_rule_gate.py b/tests/test_pi_rule_gate.py index 33ad565f39..4a5a382b9d 100644 --- a/tests/test_pi_rule_gate.py +++ b/tests/test_pi_rule_gate.py @@ -22,7 +22,7 @@ from agent_comms.task_attachments.owner_notices import RestartNotice from agent_comms.threads import Thread from agent_comms.cli_bridges.hook_read_gate import ReadInput -from agent_comms_hooks.skill import CommanderReader, FleetReader +from agent_comms_hooks.skill import DeckReader from conftest import answer_deal, engaged @@ -38,12 +38,12 @@ def unit_deal(commander_bus): return deal -def test_a_spawned_thread_is_dealt_its_role_s_deck_sources_once(commander_bus): - """The first start deals the registry role's deck sources; a restart keeps the deal.""" - for name, reader in (("claude-code", CommanderReader), ("unit-x", FleetReader)): +def test_every_independent_thread_is_dealt_the_same_sources_once(commander_bus): + """A lead and an untagged peer share the deal; a restart keeps its original audit.""" + for name in ("claude-code", "unit-x"): deal = PiRedeal.of(commander_bus, commander_bus.registry.require(name)) deal.start() - assert deal.current().dealt == tuple(path.resolve() for path in reader.sources()) + assert deal.current().dealt == tuple(path.resolve() for path in DeckReader.sources()) deal.audit.write_text("{}") deal.start() assert deal.audit.exists() @@ -58,7 +58,7 @@ def test_a_retired_package_source_redeals_before_owner_binding(unit_deal): unit_deal.start() - assert unit_deal.current().dealt == tuple(path.resolve() for path in FleetReader.sources()) + assert unit_deal.current().dealt == tuple(path.resolve() for path in DeckReader.sources()) assert not unit_deal.audit.exists() with pytest.raises(RulesUnread): unit_deal.judge_tool("bash") @@ -66,7 +66,7 @@ def test_a_retired_package_source_redeals_before_owner_binding(unit_deal): def test_every_dealt_file_fits_one_default_read_on_every_backend(): """A dealt file over a read tool's cap could never count as read, locking the thread out.""" - sources = CommanderReader.sources() + sources = DeckReader.sources() spans = (ReadTool.default_span(), ReadInput(sources[0]).span()) assert all(span.covers(source) for span in spans for source in sources) @@ -96,7 +96,7 @@ def test_a_compacted_pi_thread_reads_every_dealt_rule_then_writes_its_audit( SelectedToolRequest("call-write", SelectedWriteArguments("notes.md", "x")).judge_under( unit_deal ) - sources = FleetReader.sources() + sources = DeckReader.sources() call("read", {"path": str(sources[0])}).judge_under(unit_deal) # a dealt file is call("read", {"path": str(sources[0]), "limit": 3}).completed_under(unit_deal) with pytest.raises(RulesUnread, match=str(sources[0].resolve())): @@ -105,7 +105,8 @@ def test_a_compacted_pi_thread_reads_every_dealt_rule_then_writes_its_audit( call("read", {"path": str(source)}).completed_under(unit_deal) with pytest.raises(RulesUnread): bash.judge_under(unit_deal) # reading without answering still keeps the gate closed - answered = [engaged(rule.name, rule.path.resolve()) for rule in FleetReader.rules()] + answered = [engaged(source.stem, source) for source in unit_deal.current().dealt + if source.stem in unit_deal.current().due.rules] unit_deal.audit.write_text(json.dumps({"rules": answered, "owner_words": []})) with pytest.raises(RulesUnread, match="then fix the gate"): bash.judge_under(unit_deal) # the audit answers the owner's words too @@ -116,7 +117,8 @@ def test_a_compacted_pi_thread_reads_every_dealt_rule_then_writes_its_audit( unit_deal.audit.write_text( json.dumps( { - "rules": [engaged(rule.name, rule.path.resolve()) for rule in FleetReader.rules()], + "rules": [engaged(source.stem, source) for source in unit_deal.current().dealt + if source.stem in unit_deal.current().due.rules], "owner_words": [{"words": text, "doing": "reading the gate"} for text in words], } ) @@ -240,13 +242,12 @@ def test_gate_can_be_disabled_and_reenabled_without_forging_a_read(unit_deal): assert unit_deal.current().dealt == before -def test_a_bus_with_no_commander_project_deals_a_pi_unit_the_fleet_rules(comms, tmp_path): - """No commander project leaves every thread commanded: a Pi unit is dealt the fleet deck, - as the Claude Code hooks deal it on the same bus.""" +def test_a_bus_without_a_storage_lead_still_deals_the_shared_sources(comms, tmp_path): + """Project storage configuration does not change an independent session's rules.""" unit = comms.registry.declare(Thread("unit-y", frozenset(), Checkout(tmp_path))) deal = PiRedeal.of(comms, unit) deal.start() - assert deal.current().dealt == tuple(path.resolve() for path in FleetReader.sources()) + assert deal.current().dealt == tuple(path.resolve() for path in DeckReader.sources()) def test_every_refusal_opens_with_why_its_deal_was_dealt(unit_deal): diff --git a/tests/test_unit_instructions.py b/tests/test_unit_instructions.py index 62c321e4fe..4f927a5c69 100644 --- a/tests/test_unit_instructions.py +++ b/tests/test_unit_instructions.py @@ -1,31 +1,17 @@ -"""Every native unit's system prompt carries the indictment, the voice, every rule under its audience and the pattern index.""" - -import pytest +"""Every native collaborator carries the same source-derived system instructions.""" from agent_comms.commander.package import CommanderPackage -from agent_comms.commander.rule_file import CommanderAudience, FleetAudience, RuleAudience, RuleFile -from agent_comms.nominal.field_codec import MalformedRecord +from agent_comms.commander.rule_file import RuleFile from agent_comms.native_package import OWNER_INSTRUCTIONS from agent_comms.slop import Slop from agent_comms.unit_instructions import UnitInstructions -def test_unit_prompt_carries_every_rule_under_its_audience(tmp_path): +def test_native_prompt_carries_every_shared_rule_once(tmp_path): text = UnitInstructions.prepare(tmp_path / "UNIT_SYSTEM.md").read_text(encoding="utf-8") for source in (OWNER_INSTRUCTIONS, CommanderPackage.indictment(), CommanderPackage.voice(), Slop.catalog() / "README.md"): assert source.read_text(encoding="utf-8").strip() in text, source - grouped = dict(RuleAudience.grouped(RuleFile.packaged())) - assert set(grouped) == {FleetAudience, CommanderAudience} - for audience, rules in grouped.items(): - section = audience.heading + "\n\n" + "\n\n".join(rule.render() for rule in rules) - assert section in text, audience - - -def test_rule_file_without_an_audience_is_refused(tmp_path): - source = CommanderPackage.rules_files()[0].read_text(encoding="utf-8") - path = tmp_path / "undeclared.md" - path.write_text("".join(line for line in source.splitlines(keepends=True) - if not line.startswith("audience:")), encoding="utf-8") - with pytest.raises(MalformedRecord): - RuleFile.read(path) + assert RuleFile.render_packaged() in text + for rule in RuleFile.packaged(): + assert text.count(rule.render()) == 1, rule.name From 6e9a8f873773938ce8ae53b9a211f47cdaf95016 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 00:09:58 -0400 Subject: [PATCH 02/15] Migrate shared-authority consumers and retain real child lifecycle --- src/agent_comms/cli_bridges/hook_read_gate.py | 2 +- src/agent_comms/commander_service.py | 11 +- src/agent_comms/runtime_updates.py | 2 +- src/agent_comms/thread_identity.py | 7 +- src/agent_comms_hooks/skill.py | 9 +- tests/test_commander_forks.py | 51 +++---- tests/test_commander_skill_hook.py | 143 ++++++++---------- tests/test_dealt_read_identity.py | 4 +- tests/test_head_review.py | 15 +- tests/test_monitor_delivery.py | 43 +++--- tests/test_selected_tool_broker.py | 4 +- 11 files changed, 135 insertions(+), 156 deletions(-) diff --git a/src/agent_comms/cli_bridges/hook_read_gate.py b/src/agent_comms/cli_bridges/hook_read_gate.py index 480084b52e..a50a1d53ca 100644 --- a/src/agent_comms/cli_bridges/hook_read_gate.py +++ b/src/agent_comms/cli_bridges/hook_read_gate.py @@ -6,7 +6,7 @@ only after a tool completes, records a Read that covered a whole dealt file; a failed or partial Read leaves the gate closed. A subagent is gated as its own session: its deal, reads and audit sit beside its own transcript, -it may not write the main agent's audit, and it reports its tool activity through its seat. +it may not write the main agent's audit, and its actual SDK id owns its tool activity. """ from __future__ import annotations diff --git a/src/agent_comms/commander_service.py b/src/agent_comms/commander_service.py index 0362c4fc83..0ef636e65d 100644 --- a/src/agent_comms/commander_service.py +++ b/src/agent_comms/commander_service.py @@ -35,12 +35,13 @@ def forks(cls, snapshot: RegistrySnapshot, commander: str) -> tuple[Thread, ...] @classmethod def actors(cls, snapshot: RegistrySnapshot) -> frozenset[str]: - """Every name a commander or one of its forks publishes under: any commander's recorded - action disposes of a line on the one shared board.""" + """Current and retained names of executable collaborators, from the namespace owner. + + Walk its declared members once instead of rescanning aliases for every peer. + """ return frozenset( - name for thread in snapshot.threads.values() - for actor in thread.role.executor(thread) - for name in snapshot.names_for(actor.name)) + name for name, thread in snapshot.named_members() + for _ in thread.role.executor(thread)) @classmethod def recipients(cls, snapshot: RegistrySnapshot, lead: Thread) -> tuple[Thread, ...]: diff --git a/src/agent_comms/runtime_updates.py b/src/agent_comms/runtime_updates.py index 41cd1c9d0d..492355dd2d 100644 --- a/src/agent_comms/runtime_updates.py +++ b/src/agent_comms/runtime_updates.py @@ -198,7 +198,7 @@ def commit(self, staged: Self) -> None: def publish_batch(self, updates: list[RuntimeUpdate]) -> None: """A batch refused partway, by a maintenance hold or a CoordinationBeginUnavailable, is published again whole from the committed drafts. Its stored writes are keyed, so what - already landed lands once; a sent reply is remembered by the replying output.""" + already landed lands once. Publishing transcript output never sends an implicit DM.""" staged = self.staged() pending: list[RuntimeUpdate] = [] for update in updates: diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index ae6cf5cde7..acf43493a4 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -356,8 +356,11 @@ def commands(self, creator: ThreadIncarnation) -> bool: @dataclass(frozen=True, eq=False) class CommanderPeerIdentity(CommanderForkIdentity): - """A #commanders member: membership declares commander authority over every order on - the bus, so a retired or absent creator never orphans its orders. Records name the peer.""" + """An independent collaborator can maintain shared orders under its own identity. + + Channel tags confer no privilege, and a retired creator does not orphan shared work. + Storage selection remains explicit; the recorded incarnation names who acted. + """ def commands(self, creator: ThreadIncarnation) -> bool: return True diff --git a/src/agent_comms_hooks/skill.py b/src/agent_comms_hooks/skill.py index c074b90c1a..6cf0c86932 100644 --- a/src/agent_comms_hooks/skill.py +++ b/src/agent_comms_hooks/skill.py @@ -277,7 +277,7 @@ def of(cls, record: Mapping[str, object], **context: object) -> DeckStart: return (SubagentDeckStart if cls.raised_by_subagent(record) else cls).decode(record, **context) def take(self, comms: Comms, binding: Binding, number: int = 0) -> tuple[str, ...]: - """The supplied resident binding selects a reader at one registry cut.""" + """The captured resident identity admits the shared reader at one registry cut.""" snapshot = comms.registry.snapshot() snapshot.born(binding.owner.require_recorded().lookup) return self.taken(DeckReader(), number, comms, snapshot) @@ -361,10 +361,9 @@ def prepare(self, reader: DeckReader, prompts: tuple[str, ...], trigger: DealTri def widened(self, reader: DeckReader, comms: Comms, snapshot: RegistrySnapshot) -> tuple[str, ...]: - """The deck of this session's role is the one record of the role it was dealt. A - session whose role changed finds none of its kind and is dealt only the files the role - adds, keeping what it read and wrote, since its context was never lost; a session bound - after it started is dealt in full and told so.""" + """A session newly bound after startup receives the shared sources. Retained reads + and audit survive when the context remains; changing a tag or storage lead adds no + second deck or rank-specific source set.""" deck = self.deck(reader) if deck.exists(): return () diff --git a/tests/test_commander_forks.py b/tests/test_commander_forks.py index a91979497f..2d01cc5b1e 100644 --- a/tests/test_commander_forks.py +++ b/tests/test_commander_forks.py @@ -2,6 +2,7 @@ from agent_comms.cli_bridges.claude import ClaudeBridge from agent_comms.cli_bridges.hook_forks import SessionSubagents +from agent_comms.cli_bridges.session_watch import WatchClaim, WatchRecord from agent_comms.cli_bridges.hook_requests import ReadGateRequest, SubagentStartRequest, SubagentStopRequest from types import SimpleNamespace @@ -1075,48 +1076,40 @@ def test_an_unbound_subagent_has_no_bus_to_join( assert not fork_bus[0].registry.snapshot().children("worker") -def test_a_unit_sessions_subagents_take_role_slots( +def test_an_untagged_peers_sdk_children_keep_real_ids_and_parent_delegation( durable_home: Path, fork_bus: tuple[Comms, SourceRepository, PendingProblemOrder], tmp_path: Path): - """A role slot retains its thread/history for its next occupant, never a commander fork.""" + """The resident SDK child contract is identical for an untagged independent parent.""" comms = fork_bus[0] transcript = tmp_path / "unit.jsonl" with resident_session(comms, tmp_path / "state", "worker", "unit") as bridge: run = partial(resident_stdout, bridge, transcript=transcript, agent_type="Explore", stop_hook_active=False) - told = json.loads(run(SubagentStartRequest, agent_id="a2"))["hookSpecificOutput"] - assert "slot worker-explore" in told["additionalContext"] - slot = comms.registry.require("worker-explore") - assert slot.parent.named("worker") and comms.registry.status("worker-explore").receiving - slot.execution.require_subagent() - assert comms.agents.activity.current(slot.name).state is ActivityState.THINKING + for agent in ("a2", "a3"): + told = json.loads(run(SubagentStartRequest, agent_id=agent))["hookSpecificOutput"] + assert f"--fork claude-{agent}" in told["additionalContext"] + child = comms.registry.require(f"claude-{agent}") + child.execution.require_commander_fork() + assert child.parent.named("worker") + assert comms.registry.status(child.name).receiving assert run(ReadGateRequest, agent_id="a2", tool_name="Read", tool_input={"file_path": str(transcript)}) == "" - activity = comms.agents.activity.current(slot.name) + activity = comms.agents.activity.current("claude-a2") assert (activity.state, activity.detail) == (ActivityState.WORKING, "Read") - # A long-running tool stays working beyond the activity store's ordinary stale budget. comms.agents.activity.emit(replace(activity, timestamp=1.)) snapshot = comms.registry.snapshot() - assert comms.agents.activity_of(slot.name, snapshot=snapshot).state is ActivityState.WORKING - with pytest.raises(RelationViolationError, match="not a declared commander fork"): - slot.execution.require_commander_fork() - assert "claude-a2" not in comms.registry - run(SubagentStartRequest, agent_id="a3") - assert comms.registry.require("worker-explore-2").parent.named("worker") - - assert run(SubagentStopRequest, agent_id="a2") == "" - assert isinstance(comms.registry.status("worker-explore"), ArchivedThreadStatus) - assert comms.agents.activity.current(slot.name).state is ActivityState.IDLE - assert comms.registry.status("worker-explore-2").receiving - run(SubagentStartRequest, agent_id="a4") - assert comms.registry.require("worker-explore").incarnation == slot.incarnation - assert comms.registry.status("worker-explore").receiving - + assert comms.agents.activity_of("claude-a2", snapshot=snapshot).state is ActivityState.WORKING + assert json.loads(run(SubagentStopRequest, agent_id="a2"))["decision"] == "block" + assert run(SubagentStopRequest, agent_id="a2", stop_hook_active=True) == "" + assert isinstance(comms.registry.status("claude-a2"), ArchivedThreadStatus) + assert comms.registry.status("claude-a3").receiving + subagents = transcript.with_suffix("") / "subagents" + subagents.mkdir(parents=True) + for agent in ("a2", "a3"): + (subagents / f"agent-{agent}.jsonl").write_text("") session = SessionSubagents.decode({ "session_id": "unit", "hook_event_name": "SessionEnd", "transcript_path": str(transcript)}) assert session.end_subagents(comms, bridge.binding) == () - assert isinstance(comms.registry.status("worker-explore-2"), ArchivedThreadStatus) - assert isinstance(comms.registry.status("worker-explore"), ArchivedThreadStatus) - assert not any(session.seats.iterdir()) + assert isinstance(comms.registry.status("claude-a3"), ArchivedThreadStatus) def test_a_bound_subagent_stop_without_a_start_is_an_intentional_failure( @@ -1128,7 +1121,7 @@ def test_a_bound_subagent_stop_without_a_start_is_an_intentional_failure( agent_id="never-started", agent_type="Explore", stop_hook_active=False) assert result.status == 1 assert result.stdout == "" - assert "stopped without a seat" in result.stderr + assert "never-started" in result.stderr assert not comms.registry.snapshot().children("worker") diff --git a/tests/test_commander_skill_hook.py b/tests/test_commander_skill_hook.py index 980f8f8627..e332e06a28 100644 --- a/tests/test_commander_skill_hook.py +++ b/tests/test_commander_skill_hook.py @@ -25,7 +25,7 @@ from agent_comms.heartbeat_subjects import UnitSubject from agent_comms.channel_targets import CommandersTag from agent_comms.commander.package import CommanderPackage -from agent_comms.commander.rule_file import CommanderAudience, RuleFile +from agent_comms.commander.rule_file import RuleFile from agent_comms.commander.standing import PlanIdle from agent_comms.goal_actions import CreateProblemGoalAction from commander_heartbeat_fixture import capture_frame @@ -54,8 +54,8 @@ from agent_comms.cli_bridges.hook_forks import SubagentHook from agent_comms.rule_deal import AuditDue, OwnerWordsAnswer, ReadSpan, RuleAudit, SessionAudit from agent_comms.cli_bridges.hook_read_gate import GatedCall, MonitorInput -from agent_comms_hooks.skill import (CommanderLightReader, CommanderReader, DeckReader, DeckStart, - FleetReader, FullPrime, LightPrime, Prime, SessionSource) +from agent_comms_hooks.skill import (DeckReader, DeckStart, LightReader, + FullPrime, LightPrime, Prime, SessionSource) from agent_comms.cli_bridges.session_watch import DeafWatch, LiveWatch, WatchClaim, WatchRecord from conftest import engaged from test_commander_notifications import beat, lead_hud @@ -124,7 +124,7 @@ def test_light_prime_executes_through_the_resident_request( assert reply.status == 0, reply.stderr start = DeckStart.decode(request.payload) assert start.current().trigger == LightPrime.trigger() - assert start.current().paths == tuple(path.resolve() for path in CommanderLightReader.sources()) + assert start.current().paths == tuple(path.resolve() for path in LightReader.sources()) assert str(start.audit) in reply.stdout @@ -157,7 +157,7 @@ def test_the_commander_session_start_reads_all_package_files(tmp_path, source, c context = json.loads(result.stdout)["hookSpecificOutput"]["additionalContext"] trigger = {"startup": StartedDeal(), "resume": StartedDeal(), "compact": UnstampedCompactionDeal()}[source] - assert context.startswith(trigger.reason() + " Commander: claude-code.") + assert context.startswith(trigger.reason() + " Storage lead: claude-code.") assert "WHAT TRISTAN ACTUALLY SAID LAST" in context assert "FUCK YOUR COMPACTION" in context dealt = dealt_sources(transcript) @@ -222,7 +222,7 @@ def test_a_slice_every_few_calls_rotating_and_dealt_again_at_compaction(tmp_path start = DeckStart.decode(hook_record(transcript, "SessionStart")) assert start.take(commander_bus, commander_binding) hook = SliceReminder.decode(hook_record(transcript)) - slices = CommanderReader.slices() + slices = DeckReader.slices() taken = [hook.take(commander_bus, commander_binding, hook.count_call()) for _ in range(SliceReminder.every * 3)] assert taken[SliceReminder.every - 1::SliceReminder.every] == [(text,) for text in slices[:3]] assert sum(map(len, taken)) == 3 @@ -251,8 +251,7 @@ def test_an_unbound_session_is_never_told_to_rearm_a_commander_watch(tmp_path, m transcript = tmp_path / "session.jsonl" unbound_reminders(transcript, monkeypatch, capsys) hook = DeafReminder.decode(hook_record(transcript)) - assert not hook.deck(CommanderReader()).exists() - assert not hook.deck(FleetReader()).exists() + assert not hook.deck(DeckReader()).exists() @pytest.mark.parametrize(("command", "allowed"), ( @@ -457,7 +456,7 @@ def test_the_board_prints_changes_once_then_an_unchanged_count(notification_bus, def test_the_deck_carries_the_voice_every_fourth_slice_every_rule_reminder_and_every_failure(): - slices = CommanderReader.slices() + slices = DeckReader.slices() voice = DeckReader.paragraphs(CommanderPackage.voice().read_text()) indictment = DeckReader.paragraphs(CommanderPackage.indictment().read_text()) assert set(slices[0::4]) == set(voice) @@ -487,7 +486,7 @@ def test_the_resident_commands_order_the_reads_once_then_put_slices_inline( outputs = [run(ReminderRequest) for _ in range(SliceReminder.every)] assert outputs[:-1] == [""] * (SliceReminder.every - 1) context = json.loads(outputs[-1])["hookSpecificOutput"]["additionalContext"] - assert context == CommanderReader.slices()[0] + assert context == DeckReader.slices()[0] assert "Read each of these" not in context @@ -503,15 +502,15 @@ def test_a_session_that_becomes_commander_is_dealt_its_sources_on_its_next_tool_ assert transcript.with_suffix(".deck.json").exists() -def test_a_unit_session_gets_the_fleet_deck_and_no_commander_reminders( - tmp_path, monkeypatch, capsys, commander_bus): - """A bound session that is not commander follows the fleet rules: it is ordered to read - them, its summary is called a paraphrase, and its slices come from the fleet deck.""" +@pytest.mark.parametrize("actor", ("claude-code", "unit-x")) +def test_each_bound_peer_gets_the_shared_sources_and_reminders( + tmp_path, monkeypatch, capsys, commander_bus, actor): + """Lead and untagged peer use one source list, audit and reminder deck.""" monkeypatch.setenv("HOME", str(tmp_path / "home")) transcript = tmp_path / "session.jsonl" transcript.write_text("") - WatchClaim("s").enrol(WatchRecord("unit-x", str(commander_bus.root), (), tmp_path / "state")) - run = run_resident(monkeypatch, capsys, transcript, comms=commander_bus, binding=admitted_binding(commander_bus, "unit-x")) + WatchClaim("s").enrol(WatchRecord(actor, str(commander_bus.root), (), tmp_path / "state")) + run = run_resident(monkeypatch, capsys, transcript, comms=commander_bus, binding=admitted_binding(commander_bus, actor)) context = json.loads(run(SkillRequest))["hookSpecificOutput"]["additionalContext"] assert "Read each of these" in context and "Your summary is a paraphrase" in context assert "WHAT TRISTAN ACTUALLY SAID LAST" in context @@ -519,9 +518,9 @@ def test_a_unit_session_gets_the_fleet_deck_and_no_commander_reminders( outputs = [run(ReminderRequest) for _ in range(SliceReminder.every)] assert outputs[:-1] == [""] * (SliceReminder.every - 1) context = json.loads(outputs[-1])["hookSpecificOutput"]["additionalContext"] - assert context == FleetReader.slices()[0] - assert transcript.with_suffix(".fleet-deck.json").exists() - assert not transcript.with_suffix(".deck.json").exists() + assert context == DeckReader.slices()[0] + assert transcript.with_suffix(".deck.json").exists() + assert set(dealt_sources(transcript)) == {path.resolve() for path in DeckReader.sources()} @pytest.mark.parametrize("path", CommanderPackage.rules_files(), ids=lambda path: path.name) @@ -555,24 +554,8 @@ def test_failure_catalog_numbers_every_evidenced_member_once(): -def test_a_session_bound_to_another_bus_thread_reads_the_fleet_rules_and_not_the_commanders( - tmp_path, monkeypatch, commander_bus): - monkeypatch.setenv("HOME", str(tmp_path / "home")) - transcript = tmp_path / "session.jsonl" - transcript.write_text("") - record = {"hook_event_name": "SessionStart", "session_id": "s", "transcript_path": str(transcript)} - WatchClaim("s").enrol(WatchRecord("unit-x", str(commander_bus.root), (), tmp_path)) - start = DeckStart.decode(record) - assert start.take(commander_bus, admitted_binding(commander_bus, "unit-x")) - dealt = start.current().dealt - assert all(rule.path.resolve() in dealt for rule in FleetReader.rules()) - commander_only = [rule for rule in RuleFile.packaged() if rule.audience is CommanderAudience] - assert commander_only and not any(rule.path.resolve() in dealt for rule in commander_only) - assert CommanderPackage.indictment().resolve() not in dealt - - -def test_the_commander_reads_every_packaged_rule(): - assert CommanderReader.rules() == RuleFile.packaged() +def test_the_shared_reader_reads_every_packaged_rule(): + assert DeckReader.rules() == RuleFile.packaged() def test_the_reading_order_names_exactly_the_packaged_rules_and_the_spine_leads(): @@ -582,12 +565,11 @@ def test_the_reading_order_names_exactly_the_packaged_rules_and_the_spine_leads( assert RuleFile.reading_order[0] == "spine" -def test_every_deal_reads_the_paper_before_any_rule(): - for reader in (FleetReader, CommanderReader): - sources = reader.sources() - paper = CommanderPackage.paper() - assert paper and sources[:len(paper)] == paper - assert sources[len(paper)] == RuleFile.packaged()[0].path +def test_every_full_deal_reads_the_paper_before_any_rule(): + sources = DeckReader.sources() + paper = CommanderPackage.paper() + assert paper and sources[:len(paper)] == paper + assert sources[len(paper)] == RuleFile.packaged()[0].path def test_the_prime_variants_cover_papers_and_indictment_once_each_with_the_indictment_last(): @@ -597,13 +579,12 @@ def test_the_prime_variants_cover_papers_and_indictment_once_each_with_the_indic indictment = CommanderPackage.indictment() kept = set() for prime in Prime.members_with(Prime): - commander = prime.commander.sources() - assert indictment not in prime.fleet.sources() - assert all(rule.path in commander for rule in CommanderReader.rules()) - kept.add((paper <= set(commander), indictment in commander)) - if indictment in commander: - assert prime.commander.leading()[-1] == indictment - assert prime.commander.slices() and prime.fleet.slices() + sources = prime.reader.sources() + assert all(rule.path in sources for rule in DeckReader.rules()) + kept.add((paper <= set(sources), indictment in sources)) + if indictment in sources: + assert prime.reader.leading()[-1] == indictment + assert prime.reader.slices() assert kept == {(True, True), (True, False), (False, True), (False, False)} @@ -633,10 +614,9 @@ def test_the_commander_session_gets_its_sources(tmp_path, monkeypatch, commander {"hook_event_name": "SessionStart", "session_id": "s", "transcript_path": str(transcript)}).take(commander_bus, commander_binding) -def test_a_demoted_commander_keeps_its_deck_files_but_they_get_no_vote( +def test_changing_storage_lead_preserves_the_peer_deal_and_real_child_delegation( tmp_path, capsys, commanding_session, commander_bus, commander_binding): - """Command moves to another thread in the registry; the old commander's dealt deck stays - on disk, and no hook treats it as command: no reminder, stop guard or fork.""" + """Moving storage ownership neither demotes a peer nor changes its retained context.""" transcript = tmp_path / "session.jsonl" transcript.write_text("") assert DeckStart.decode(hook_record(transcript, "SessionStart")).take(commander_bus, commander_binding) @@ -646,17 +626,18 @@ def test_a_demoted_commander_keeps_its_deck_files_but_they_get_no_vote( dealt = transcript.with_suffix(".deal.json").read_bytes() reminder = BoardReminder.decode(hook_record(transcript)) assert [reminder.take(commander_bus, commander_binding, reminder.count_call()) for _ in range(SliceReminder.every - 1)] == [()] * (SliceReminder.every - 1) - assert transcript.with_suffix(".fleet-deck.json").exists() - assert transcript.with_suffix(".deal.json").read_bytes() == dealt # the fleet adds nothing + assert transcript.with_suffix(".deck.json").exists() + assert transcript.with_suffix(".deal.json").read_bytes() == dealt StopHook.decode({"hook_event_name": "Stop", "session_id": "s", "transcript_path": str(transcript), "stop_hook_active": False}, comms=commander_bus, binding=commander_binding).guard() assert capsys.readouterr().out == "" SubagentHook.decode({"hook_event_name": "SubagentStart", "session_id": "s", "transcript_path": str(transcript), "agent_id": "a9", "agent_type": "Explore"}).register(commander_bus, commander_binding) - assert "slot claude-code-explore" in capsys.readouterr().out # a fleet slot, not a fork - assert "claude-a9" not in commanding_session.registry - commanding_session.registry.require("claude-code-explore").execution.require_subagent() + assert "in-turn fork claude-a9" in capsys.readouterr().out + child = commanding_session.registry.require("claude-a9") + child.execution.require_commander_fork() + assert tuple(child.parent.names()) == ("claude-code",) assert transcript.with_suffix(".deck.json").exists() @@ -677,7 +658,7 @@ def test_a_commander_fork_registers_under_the_session_that_spawned_it( "transcript_path": str(transcript), "agent_id": agent, "agent_type": "fork"}).register( commanding_session, admitted_binding(commanding_session, actor, session)) - assert f"commander fork claude-{agent}" in capsys.readouterr().out + assert f"in-turn fork claude-{agent}" in capsys.readouterr().out snapshot = commanding_session.registry.snapshot() for agent, parent in (("a7", "claude-code"), ("a8", "claude-nra")): fork = snapshot.require(f"claude-{agent}") @@ -727,7 +708,7 @@ def read_whole(transcript, source, *, comms: Comms, binding: Binding): def test_a_batch_of_read_rules_is_answered_before_the_next_unread_file_opens(capsys, dealt_session, commander_bus, commander_binding): transcript, start = dealt_session papers = CommanderPackage.paper() - rules = CommanderReader.rules() + rules = DeckReader.rules() for paper in papers: read_whole(transcript, paper.resolve(), comms=commander_bus, binding=commander_binding) # a paper carries no rule name: no entry owed assert not refused(capsys, gated(transcript, "Read", {"file_path": str(rules[0].path)}, comms=commander_bus, binding=commander_binding)) @@ -741,7 +722,7 @@ def test_a_batch_of_read_rules_is_answered_before_the_next_unread_file_opens(cap assert not refused(capsys, gated(transcript, "Read", {"file_path": str(papers[0])}, comms=commander_bus, binding=commander_binding)) for tool in ("Read", "Write", "Edit"): assert not refused(capsys, gated(transcript, tool, {"file_path": str(start.audit)}, comms=commander_bus, binding=commander_binding)) - start.audit.write_text(answered_audit(CommanderReader, rules[:Unengaged.batch])) + start.audit.write_text(answered_audit(DeckReader, rules[:Unengaged.batch])) assert not refused(capsys, gated(transcript, "Read", following, comms=commander_bus, binding=commander_binding)) assert refused(capsys, gated(transcript, "Bash", {"command": "ls"}, comms=commander_bus, binding=commander_binding)) # the rest stay unread @@ -752,14 +733,14 @@ def test_a_parallel_batch_opens_a_batch_of_rules_and_refuses_the_next(capsys, de transcript, start = dealt_session for paper in CommanderPackage.paper(): read_whole(transcript, paper.resolve(), comms=commander_bus, binding=commander_binding) - rules = CommanderReader.rules() + rules = DeckReader.rules() batch, following = rules[:Unengaged.batch], rules[Unengaged.batch] for rule in batch: assert not refused(capsys, gated(transcript, "Read", {"file_path": str(rule.path)}, comms=commander_bus, binding=commander_binding)) assert refused(capsys, gated(transcript, "Read", {"file_path": str(following.path)}, comms=commander_bus, binding=commander_binding)) # A rule whose read is in flight stays open; answering them opens the next. assert not refused(capsys, gated(transcript, "Read", {"file_path": str(rules[0].path)}, comms=commander_bus, binding=commander_binding)) - start.audit.write_text(answered_audit(CommanderReader, batch)) + start.audit.write_text(answered_audit(DeckReader, batch)) assert not refused(capsys, gated(transcript, "Read", {"file_path": str(following.path)}, comms=commander_bus, binding=commander_binding)) @@ -770,12 +751,12 @@ def test_a_parallel_batch_opens_whichever_order_its_hooks_reach_the_lock(capsys, transcript, start = dealt_session for paper in CommanderPackage.paper(): read_whole(transcript, paper.resolve(), comms=commander_bus, binding=commander_binding) - rules = CommanderReader.rules() + rules = DeckReader.rules() batch = rules[:Unengaged.batch] for rule in reversed(batch): assert not refused(capsys, gated(transcript, "Read", {"file_path": str(rule.path)}, comms=commander_bus, binding=commander_binding)) assert refused(capsys, gated(transcript, "Read", {"file_path": str(rules[Unengaged.batch].path)}, comms=commander_bus, binding=commander_binding)) - start.audit.write_text(answered_audit(CommanderReader, batch)) + start.audit.write_text(answered_audit(DeckReader, batch)) following = reversed(rules[Unengaged.batch:Unengaged.batch * 2]) for rule in following: assert not refused(capsys, gated(transcript, "Read", {"file_path": str(rule.path)}, comms=commander_bus, binding=commander_binding)) @@ -788,7 +769,7 @@ def test_a_whole_paper_opens_in_one_parallel_batch(capsys, dealt_session, comman papers = CommanderPackage.paper() for part in papers: assert not refused(capsys, gated(transcript, "Read", {"file_path": str(part.resolve())}, comms=commander_bus, binding=commander_binding)) - rules = CommanderReader.rules() + rules = DeckReader.rules() for rule in rules[:Unengaged.batch]: assert not refused(capsys, gated(transcript, "Read", {"file_path": str(rule.path)}, comms=commander_bus, binding=commander_binding)) assert refused(capsys, gated(transcript, "Read", {"file_path": str(rules[Unengaged.batch].path)}, comms=commander_bus, binding=commander_binding)) @@ -798,11 +779,11 @@ def test_dealt_files_open_in_order_with_the_indictment_last(capsys, dealt_sessio """On 2026-10-30 a session opened the indictment and two rules first and never read the papers: the next batch of dealt files opens, any later one is refused and named the next.""" transcript, _ = dealt_session - dealt = CommanderReader.sources() + dealt = DeckReader.sources() assert [path.stem for path in dealt[-2:]] == ["indictment", "ally"] papers = CommanderPackage.paper() past_batch = papers[Unengaged.batch] - for later in (CommanderPackage.indictment(), CommanderReader.rules()[0].path, past_batch): + for later in (CommanderPackage.indictment(), DeckReader.rules()[0].path, past_batch): call = gated(transcript, "Read", {"file_path": str(later.resolve())}, comms=commander_bus, binding=commander_binding) call.judge() reason = json.loads(capsys.readouterr().out)["hookSpecificOutput"] @@ -815,7 +796,7 @@ def test_dealt_files_open_in_order_with_the_indictment_last(capsys, dealt_sessio def test_every_unanswered_rule_is_named_in_one_refusal(capsys, dealt_session, commander_bus, commander_binding): transcript, start = dealt_session - rules = CommanderReader.rules() + rules = DeckReader.rules() for rule in rules[:2]: read_whole(transcript, rule.path.resolve(), comms=commander_bus, binding=commander_binding) wrong = [{**engaged(rule.name, rule.path), "quote": "no rule file holds this line"} @@ -835,7 +816,7 @@ def test_every_unanswered_rule_is_named_in_one_refusal(capsys, dealt_session, co ]) def test_an_answer_that_does_not_hold_keeps_the_next_file_shut(capsys, dealt_session, change, says, commander_bus, commander_binding): transcript, start = dealt_session - rules = CommanderReader.rules() + rules = DeckReader.rules() read_whole(transcript, rules[0].path.resolve(), comms=commander_bus, binding=commander_binding) written = {**engaged(rules[0].name, rules[0].path), **change} start.audit.write_text(json.dumps({"rules": [written], "owner_words": []})) @@ -871,13 +852,13 @@ def test_the_full_audit_and_the_owners_words_open_the_gate(capsys, tmp_path, com transcript.write_text(json.dumps({"type": "user", "message": {"content": words[0]}}) + "\n") start = DeckStart.decode(hook_record(transcript, "SessionStart")) start.take(commander_bus, commander_binding) - start.prepare(CommanderReader(), words, StartedDeal()) - for source in CommanderReader.sources(): + start.prepare(DeckReader(), words, StartedDeal()) + for source in DeckReader.sources(): read_whole(transcript, source.resolve(), comms=commander_bus, binding=commander_binding) - start.audit.write_text(answered_audit(CommanderReader)) + start.audit.write_text(answered_audit(DeckReader)) assert refused(capsys, gated(transcript, "Bash", {"command": "ls"}, comms=commander_bus, binding=commander_binding)) # his words unanswered answers = ({"words": words[0], "doing": "editing rule_deal"},) - start.audit.write_text(answered_audit(CommanderReader, owner_words=answers)) + start.audit.write_text(answered_audit(DeckReader, owner_words=answers)) assert not refused(capsys, gated(transcript, "Bash", {"command": "ls"}, comms=commander_bus, binding=commander_binding)) @@ -890,7 +871,7 @@ def test_the_deal_teaches_the_three_fields_by_a_real_answer_once( template = next(part for part in said if "{\n" in part) example = AuditDue.worked_answer assert json.dumps(FieldCodec.encode(example), ensure_ascii=False) in template - spine = next(rule for rule in CommanderReader.rules() if rule.name == example.rule) + spine = next(rule for rule in DeckReader.rules() if rule.name == example.rule) assert example.unengaged(spine.path) == "" assert {"quote", "does", "why"} <= json.loads(template[template.index("{\n"):])["rules"][0].keys() gated(transcript, "Bash", {}, comms=commander_bus, binding=commander_binding).judge() @@ -906,7 +887,7 @@ def test_every_tool_but_read_waits_for_each_dealt_rule_file_then_its_audit( transcript.write_text("") assert DeckStart.decode(hook_record(transcript, "SessionStart")).take(commander_bus, commander_binding) parts = list(dealt_sources(transcript)) - assert set(parts) == {source.resolve() for source in CommanderReader.sources()} + assert set(parts) == {source.resolve() for source in DeckReader.sources()} assert refused(capsys, gated(transcript, "Bash", {"command": "ls"}, comms=commander_bus, binding=commander_binding)) whole = {"file_path": str(parts[0])} assert not refused(capsys, gated(transcript, "Read", whole, comms=commander_bus, binding=commander_binding)) @@ -919,10 +900,10 @@ def test_every_tool_but_read_waits_for_each_dealt_rule_file_then_its_audit( audit = transcript.with_suffix(".audit.json") assert refused(capsys, gated(transcript, "Bash", {"command": "ls"}, comms=commander_bus, binding=commander_binding)) # no audit yet assert not refused(capsys, gated(transcript, "Write", {"file_path": str(audit)}, comms=commander_bus, binding=commander_binding)) - audit.write_text(json.dumps({"rules": [{"rule": rule.name} for rule in CommanderReader.rules()], + audit.write_text(json.dumps({"rules": [{"rule": rule.name} for rule in DeckReader.rules()], "owner_words": []})) assert refused(capsys, gated(transcript, "Bash", {"command": "ls"}, comms=commander_bus, binding=commander_binding)) # read, never answered - audit.write_text(answered_audit(CommanderReader)) + audit.write_text(answered_audit(DeckReader)) assert not refused(capsys, gated(transcript, "Bash", {"command": "ls"}, comms=commander_bus, binding=commander_binding)) assert DeckStart.decode(hook_record(transcript, "SessionStart")).take(commander_bus, commander_binding) # compaction assert refused(capsys, gated(transcript, "Bash", {"command": "ls"}, comms=commander_bus, binding=commander_binding)) @@ -934,7 +915,7 @@ def test_refusal_counts_names_tool_and_prefills_the_owned_audit( transcript.write_text("") start = DeckStart.decode(hook_record(transcript, "SessionStart")) words = ("Read the rules.\nThen write the audit.",) - start.prepare(CommanderReader(), words, StartedDeal()) + start.prepare(DeckReader(), words, StartedDeal()) reasons = [] for attempt, tool in enumerate(("Bash", "Edit"), 1): gated(transcript, tool, {"file_path": str(transcript)}, comms=commander_bus, binding=commander_binding).judge() @@ -948,7 +929,7 @@ def test_refusal_counts_names_tool_and_prefills_the_owned_audit( reasons.append(reason) assert "{\n" not in reasons[1] # the template prints once per deal, the words every time template = FieldCodec.decode(SessionAudit, json.loads(reasons[0][reasons[0].index("{\n"):])) - for source in CommanderReader.sources(): + for source in DeckReader.sources(): gated(transcript, "Read", {"file_path": str(source)}, comms=commander_bus, binding=commander_binding).completed() due = start.current().due assert {entry.rule for entry in template.rules} == due.rules @@ -961,7 +942,7 @@ def test_refusal_counts_names_tool_and_prefills_the_owned_audit( output = json.loads(capsys.readouterr().out)["hookSpecificOutput"] assert output["permissionDecision"] == "deny" assert str(start.audit) in output["permissionDecisionReason"] - start.prepare(CommanderReader(), words, StartedDeal()) + start.prepare(DeckReader(), words, StartedDeal()) gated(transcript, "Bash", {}, comms=commander_bus, binding=commander_binding).judge() assert "Call #1 since this deal, refused." in capsys.readouterr().out diff --git a/tests/test_dealt_read_identity.py b/tests/test_dealt_read_identity.py index 8ca6276cf7..3b3f321321 100644 --- a/tests/test_dealt_read_identity.py +++ b/tests/test_dealt_read_identity.py @@ -14,7 +14,7 @@ ) from agent_comms.nominal.field_codec import FieldCodec from agent_comms.pi_rule_gate import PiRedeal -from agent_comms_hooks.skill import FleetReader +from agent_comms_hooks.skill import DeckReader from conftest import answer_deal @@ -39,7 +39,7 @@ def test_answers_and_reads_outlive_everything_but_lost_context(commander_bus): read = deal.current().read_by(deal) deal.audit.write_text(deal.audit.read_text(encoding="utf-8"), encoding="utf-8") assert deal.current().read_by(deal) == read - deal.deal(FleetReader, (), StartedDeal()) + deal.deal(DeckReader, (), StartedDeal()) assert not deal.audit.exists() and deal.current().read_by(deal) == () # a fresh confession diff --git a/tests/test_head_review.py b/tests/test_head_review.py index f85ac717e1..d3825d04bb 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -430,18 +430,21 @@ def test_linked_qualification_requires_the_original_order_quote(linked_head_bus, assert not any("statuses/" in call[1] for call in github.calls()) -def test_qualification_refuses_a_noncommander_before_any_record_or_public_status(linked_head_bus): +def test_qualification_records_an_untagged_peer_without_transferring_order_ownership(linked_head_bus): comms, github, request = linked_head_bus before = comms.goals.todos.get("P2") tool = CommsHeadVerdictTool( request=request, operation=QualifiedHeadVerdict(QUALIFIED), order=OrderHeadReview.for_request((before,), request, lambda linked: LinkedHeadReview.of(linked, linked.order.edits[0]))) - with pytest.raises(ToolRefusedError): - tool.invoke(comms, tool.arguments, caller=ThreadCaller("worker")) - assert comms.goals.todos.get(before.id) == before - assert not StoredHeadReview.head_reviews(comms.goals.todos, request) - assert not any("statuses/" in call[1] for call in github.calls()) + tool.invoke(comms, tool.arguments, caller=ThreadCaller("worker")) + recorded, = StoredHeadReview.head_reviews(comms.goals.todos, request) + assert recorded.actor.name == "worker" + assert recorded.actor.created_at == comms.registry.require("worker").incarnation.created_at + after = comms.goals.todos.get(before.id) + assert after.creator == before.creator + assert after.holder_state.previous_assignment() == before.holder_state.previous_assignment() + assert any("statuses/" in call[1] for call in github.calls()) def test_qualification_does_not_carry_acceptance_to_an_unchanged_later_head(head_bus, reviewed_source): diff --git a/tests/test_monitor_delivery.py b/tests/test_monitor_delivery.py index 465f55ad9b..ae0a276e0d 100644 --- a/tests/test_monitor_delivery.py +++ b/tests/test_monitor_delivery.py @@ -1047,8 +1047,8 @@ def reply_end(request: pytest.FixtureRequest) -> Callable[[NativeTurnId], Runtim return request.param -async def confirm_and_finish_reply( - bridge: ClaudeBridge, message: Message, expected: tuple[str, ...], +async def confirm_and_finish_output( + bridge: ClaudeBridge, message: Message, end: Callable[[NativeTurnId], RuntimeUpdate], ) -> None: """Real monitor consumption feeds the ordinary staged output and terminal update.""" @@ -1087,13 +1087,13 @@ def replies() -> tuple[tuple[str, str], ...]: 'the opened turn final', timestamp=2., position=item),))]) assert replies() == () output.publish_batch([end(turn)]) - assert replies() == tuple((target, 'the opened turn final') for target in expected) - # Completion is idempotent, and another turn cannot inherit these routes. + assert replies() == () + # Completion is idempotent, and another turn cannot acquire an implicit reply route. later, later_item = NativeTurnId('later-turn'), NativeItemId('later-answer') output.publish_batch([FinishedTurn(turn), AllTurnsEnded(), FinishedItem( later, later_item, (AssistantTranscript( 'a later final', timestamp=3., position=later_item),)), AllTurnsEnded()]) - assert replies() == tuple((target, 'the opened turn final') for target in expected) + assert replies() == () @pytest.mark.asyncio @@ -1104,44 +1104,43 @@ async def test_diagnostic_notice_is_consumed_without_replying_to_the_failed_send message = bridge.comms.messaging.send( 'peer', 'viewer', f'Request failed. No automatic retry. [Open diagnostic]({diagnostic.as_uri()})', AlertKind, role=NoticeRole()).message - await confirm_and_finish_reply(bridge, message, (), reply_end) + await confirm_and_finish_output(bridge, message, reply_end) @pytest.mark.asyncio -@pytest.mark.parametrize(('target', 'body', 'kind', 'expected'), ( - ('viewer', 'ordinary direct request', QuestionKind, ('claude-code',)), - ('#monitor', 'channel information', InfoKind, ()), - ('#monitor', '@viewer please answer this channel request', QuestionKind, ()), +@pytest.mark.parametrize(('target', 'body', 'kind'), ( + ('viewer', 'ordinary direct request', QuestionKind), + ('#monitor', 'channel information', InfoKind), + ('#monitor', '@viewer please answer this channel request', QuestionKind), )) -async def test_confirmed_reply_routes_follow_direct_and_channel_policy( - bridge, reply_end, target, body, kind, expected, +async def test_confirmed_output_never_sends_an_implicit_direct_or_channel_reply( + bridge, reply_end, target, body, kind, ): bridge.comms.channels.create_tag('monitor') message = bridge.comms.messaging.send('claude-code', target, body, kind).message - await confirm_and_finish_reply(bridge, message, expected, reply_end) + await confirm_and_finish_output(bridge, message, reply_end) @pytest.mark.asyncio -async def test_confirmed_human_direct_request_receives_one_reply(bridge, reply_end): +async def test_confirmed_human_direct_request_keeps_the_answer_in_its_transcript(bridge, reply_end): message = bridge.comms.messaging.send_user_message( ThreadTarget('viewer'), 'please answer once', worktree=Checkout(bridge.options.state.parent)) - await confirm_and_finish_reply(bridge, message, (message.sender,), reply_end) + await confirm_and_finish_output(bridge, message, reply_end) -def test_only_commanded_sessions_reply_to_their_delivered_senders(comms): - """The commander answers its owner in its own transcript: a unit that messaged it - mid-turn never receives that turn's answer as a direct message.""" +def test_independent_sessions_never_automatically_reply_to_delivered_senders(comms, tmp_path): + """Storage lead and peer keep their output in their transcripts, not implicit DMs.""" from agent_comms.registry_document import RegisteredCommanderProject - from agent_comms.runtime_updates import FinishedItem, ReplyingThreadOutput + from agent_comms.runtime_updates import FinishedItem, ThreadOutput from agent_comms.transcript_events import AssistantTranscript from agent_comms.turn_item import NativeItemId, NativeTurnId lead, unit = (comms.registry.declare(Thread( - name, frozenset(), Checkout(Path("/tmp")), execution=ExternalThreadExecution)) for name in ("lead", "unit")) + name, frozenset(), Checkout(tmp_path), execution=ExternalThreadExecution)) for name in ("lead", "unit")) comms.registry.set_commander_project(RegisteredCommanderProject(lead.incarnation, ())) for speaker, sender in ((lead, unit), (unit, lead)): - output = ReplyingThreadOutput.for_comms(comms.transcripts.external(speaker.name), comms) + output = ThreadOutput.for_comms(comms.transcripts.external(speaker.name), comms) original = comms.messaging.send(sender.name, speaker.name, "Open this answer", QuestionKind).message turn = NativeTurnId(f"{speaker.name}-turn") output.delivered(turn, (original.reference,)) @@ -1151,7 +1150,7 @@ def test_only_commanded_sessions_reply_to_their_delivered_senders(comms): output.end_turn(turn) replies = tuple((message.target, message.body) for message in comms.bus.bus.message_history() if message.body in {"lead answers", "unit answers"}) - assert replies == (("lead", "unit answers"),) + assert replies == () @pytest.mark.parametrize(('rows', 'parts'), ( diff --git a/tests/test_selected_tool_broker.py b/tests/test_selected_tool_broker.py index 7f831c0efe..6b3b53edc0 100644 --- a/tests/test_selected_tool_broker.py +++ b/tests/test_selected_tool_broker.py @@ -395,10 +395,10 @@ async def test_a_dealt_rule_gate_refuses_the_selected_write_before_its_owner_act the owner callback could claim or write anything.""" from agent_comms.pi_rule_gate import PiRuleGate, RulesUnread from agent_comms.rule_deal import StartedDeal - from agent_comms_hooks.skill import FleetReader + from agent_comms_hooks.skill import DeckReader gate = PiRuleGate(tmp_path / "scratch", tmp_path) - gate.deal(FleetReader, (), StartedDeal()) + gate.deal(DeckReader, (), StartedDeal()) token = secrets.token_hex(32) acted: list[broker.SelectedToolRequest] = [] server = broker.SelectedToolSocket( From 55b7d6e8cec328d955aedee493ff8fe466c576c7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 00:58:25 -0400 Subject: [PATCH 03/15] Preserve routed replies while removing binding rank --- src/agent_comms/cli_bridges/shared.py | 21 ++--- .../commander/rules/coordination.md | 1 + src/agent_comms/registration.py | 16 ++-- src/agent_comms/runtime_updates.py | 86 ++++++++++++++++++- src/agent_comms/thread_execution.py | 7 +- src/agent_comms/threads.py | 7 +- tests/test_bridge_binding_identity.py | 6 +- tests/test_commander_notifications.py | 40 +++++---- tests/test_monitor_delivery.py | 40 ++++----- 9 files changed, 158 insertions(+), 66 deletions(-) diff --git a/src/agent_comms/cli_bridges/shared.py b/src/agent_comms/cli_bridges/shared.py index 3675e73e5e..f3c6d3cfc7 100644 --- a/src/agent_comms/cli_bridges/shared.py +++ b/src/agent_comms/cli_bridges/shared.py @@ -69,7 +69,7 @@ MessageReference, ) from agent_comms.registration import Registration -from agent_comms.runtime_updates import HiddenItem, RuntimeUpdate, ThreadOutput +from agent_comms.runtime_updates import HiddenItem, ReplyingThreadOutput, RuntimeUpdate, ThreadOutput from agent_comms.turn_item import NoSourceTurn, SourceTurn from agent_comms.send_result import SendResult from agent_comms.thread_execution import ExternalThreadExecution @@ -531,28 +531,28 @@ async def execute(self, bridge: OperationBridge) -> None: await bridge.registered_watch(self) -class BindingCommander(SubclassUnion, TextChoice, affix="BindingCommander"): - """Who commands a newly bound session, or an independent user's session.""" +class BindingChannels(SubclassUnion, TextChoice, affix="BindingChannels"): + """Optional initial channel source for a new independent external endpoint.""" @abstractmethod async def register(self, bridge: CliBridge) -> None: ... @dataclass(frozen=True, slots=True) -class CommandedBy(GivenText, BindingCommander): +class ChannelsFrom(GivenText, BindingChannels): text: str async def register(self, bridge: CliBridge) -> None: worktree = await bridge.session_worktree() await bridge.comms.bus.when_open(partial( - bridge.comms.registry.register_commanded_unit, + bridge.comms.registry.register_bound_thread, bridge.address.bus, self.text, worktree, bridge.session_process(), )) @dataclass(frozen=True, slots=True) -class Uncommanded(AbsentText, BindingCommander): - """A user's own session has no commanded audience or fork parent.""" +class NoChannelSource(AbsentText, BindingChannels): + """An independent endpoint starts without copied channel membership.""" async def register(self, bridge: CliBridge) -> None: thread = Thread( @@ -566,9 +566,10 @@ async def register(self, bridge: CliBridge) -> None: class Watch(Inbox): help = "Deliver this binding's bus messages and publish its harness transcript to the bus" + # Retained external CLI/record spelling; this supplies channels, never authority. commander: Annotated[ - BindingCommander, field_codec.Argument(flags=("--commander",)) - ] = Uncommanded() + BindingChannels, field_codec.Argument(flags=("--commander",)) + ] = NoChannelSource() @classmethod def serves(cls, bridge: type[CliBridge]) -> bool: @@ -1638,7 +1639,7 @@ def read_parts(self, rows: tuple[tuple[int, str], ...]) -> tuple[MonitorPart, .. class LineMonitorBridge(TranscriptConfirmsDelivery, TranscriptBridge[Options]): """Session-owned subscription and delivery journal, with replaceable readers.""" - output_type: ClassVar[type[ThreadOutput]] = ThreadOutput + output_type: ClassVar[type[ThreadOutput]] = ReplyingThreadOutput incoming_frame: ClassVar[type[MonitorIncomingFrame]] window_bytes: ClassVar[int] diff --git a/src/agent_comms/commander/rules/coordination.md b/src/agent_comms/commander/rules/coordination.md index 59caccf9a6..3b88fce49c 100644 --- a/src/agent_comms/commander/rules/coordination.md +++ b/src/agent_comms/commander/rules/coordination.md @@ -37,6 +37,7 @@ type: hard-rule **Why:** problems scattered across orders lose their owner (2026-10-19: 33 of the 34 checks the Core commander switch depended on sat ownerless for nearly two hours), and a unit left to diagnose picks the cheapest reading (2026-10-21: triage orders P362, P377, P378 and P379 let units decide "src bug or stale test" and then design the src fix). - Group problems by surface into one order per owner; no problem is ever without an owner. +- **Equal responsibility is equal care.** Tristan's reason, relayed in51713: all agents are equals because everyone should care equally. Seeing a problem creates responsibility for following it through, not for handing it upward. If a context owner is already fixing it, help and coordinate rather than start a competing implementation. - **Every collaborator owns judgment.** Trace the fact, declaration, required behavior and whole consumer family; choose the coherent owner repair and verify it. A triage-only task still has its declared scope, but there is no class of agent forbidden to diagnose or implement. Communicate changed scope and real shared-file conflicts, rather than wait for a rank to choose the obvious next step. - **Review the work, never a model's rank.** Every collaborator can implement and judge source-backed repairs. A restated fact is fixed regardless of which model wrote it; a peer's confidence is not evidence. Keep parallel work proportional to actual RAM and executable obligations, and keep its context owner accountable through acceptance. Do not require a Claude reviewer or create a colleague solely to satisfy an authority division. - Short is measured on the correct fix at the owner. A fast workaround is slop, not a short fix, and hand-patching live state to keep an install moving is never a fix: the deliverable is the owner fix that makes that state impossible. Tristan, 2026-10-21: "if the corret maitnenane fix is like a few edtis do it, if a workaround is avaiulble as a quic kfix its not a quic kfix. ts slop."; "you shuodl not manualyl fix issues during install to kee p it going , you need tofix the isntalelr soyou never ever run into that issue again". diff --git a/src/agent_comms/registration.py b/src/agent_comms/registration.py index d52e18f504..26f40aa946 100644 --- a/src/agent_comms/registration.py +++ b/src/agent_comms/registration.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Concatenate from .catalog_store import ChannelCatalog +from .channel_targets import DeclaredTag from .child_process import NoProcess, ProcessIdentity from .errors import RelationViolationError from .nominal.field_codec import GoalId, ThreadLookup, TurnId @@ -210,18 +211,19 @@ def _child_in( return self._declare_in(edit, child, _DEFAULT_PRESENCE, prepare_goal=execution.child_goal(goals, parent)) - def register_commanded_unit( - self, name: str, commander: str, worktree: Checkout, process: ProcessIdentity, + def register_bound_thread( + self, name: str, channel_source: str, worktree: Checkout, process: ProcessIdentity, ) -> Thread: - """Declare a new commanded endpoint without rewriting an existing declaration.""" + """Bind a new independent endpoint with inherited channels, not delegated authority.""" with self.store.editing() as edit: if edit.document.taken(name): return self._declare_in(edit, edit.document.require(name), _DEFAULT_PRESENCE) - child = edit.document.require(commander).commanded_child(Thread( - name, frozenset(), worktree, + source = edit.document.require(channel_source) + thread = Thread( + name, DeclaredTag.for_child(source.tags), worktree, process_identity=process, execution=ExternalThreadExecution, - )) - return self._declare_in(edit, child, _DEFAULT_PRESENCE) + ) + return self._declare_in(edit, thread, _DEFAULT_PRESENCE) def reserve_owner(self, edit: RegistryEdit, original: Thread, process: ProcessIdentity) -> RegistryOwner: """Reserve one process against the exact stopped launch it prepared. diff --git a/src/agent_comms/runtime_updates.py b/src/agent_comms/runtime_updates.py index 492355dd2d..e0869c8779 100644 --- a/src/agent_comms/runtime_updates.py +++ b/src/agent_comms/runtime_updates.py @@ -26,13 +26,15 @@ from .thread_text import NoThreadText, ThreadText from .thread_identity import OfferedTools from .turn_phase import IdlePhase, ModelWaitPhase, TurnPhase -from .turn_item import NativeItemId, NativeTurnId, TurnItem +from .turn_item import NativeItemId, NativeTurnId, NoSourceTurn, TurnItem if TYPE_CHECKING: from .agent_events import RuntimeInfoObservation from .cli_bridges.shared import TranscriptBridge from .importing import ImportBuffer from .comms import Comms + from .messaging import Messaging + from .thread_status import ThreadStatus @dataclass @@ -198,7 +200,7 @@ def commit(self, staged: Self) -> None: def publish_batch(self, updates: list[RuntimeUpdate]) -> None: """A batch refused partway, by a maintenance hold or a CoordinationBeginUnavailable, is published again whole from the committed drafts. Its stored writes are keyed, so what - already landed lands once. Publishing transcript output never sends an implicit DM.""" + already landed lands once; a sent reply is remembered by the replying output.""" staged = self.staged() pending: list[RuntimeUpdate] = [] for update in updates: @@ -244,6 +246,86 @@ def activity_thread(self) -> str: return self.transcript.incarnation.name +@dataclass +class ReplyingThreadOutput(ThreadOutput): + """The last finished assistant item replies only to deliveries that opened its turn.""" + + messaging: Messaging = field(kw_only=True) + pending: dict[TurnItem, FinishedItem] = field(default_factory=dict) + # Sent is sent: staging shares this one map, so a refused batch never sends a reply again. + sent: dict[tuple[TurnItem, str], tuple[MessageReference, ...]] = field(default_factory=dict) + + def staged(self) -> Self: + return replace(super().staged(), pending=self.pending.copy()) + + def commit(self, staged: Self) -> None: + super().commit(staged) + self.pending = staged.pending + for sent in tuple(self.sent): + if sent[0] not in self.pending: + del self.sent[sent] + + @classmethod + def for_comms(cls, transcript: ExternalTranscript, comms: Comms) -> Self: + """Every live external endpoint follows the delivered message's reply policy.""" + return cls(transcript, comms.agents, messaging=comms.messaging) + + def flush_before(self, key: TurnItem) -> None: + for previous in tuple(self.pending): + if previous != key: + super().publish_item(self.pending.pop(previous)) + + def publish_item(self, item: FinishedItem) -> None: + self.flush_before(item.key) + if item.assistant_text: + self.pending[item.key] = item + self.transcript.draft(turn_id=item.turn_id, item_id=item.item_id, events=item.events) + else: + super().publish_item(item) + + def publish_draft(self, item: DraftItem) -> None: + self.flush_before(item.key) + super().publish_draft(item) + + def end_turn(self, turn_id: NativeTurnId) -> None: + opening = self.messaging.bus.bus.messages_for_references(self.transcript.opening_for(turn_id)) + replies: dict[str, list[MessageReference]] = {} + for message in opening: + for target in message.automatic_reply_targets(): + replies.setdefault(target, []).append(message.reference) + recipients: dict[str, ThreadStatus] = {} + if replies: + registry = self.messaging.registry.snapshot() + recipients = {target: registry.status(target) for target in replies} + for key in tuple(self.pending): + if key.turn_id != turn_id: + continue + item = self.pending[key] + for target, references in sorted(replies.items()): + if (key, target) not in self.sent: + self.sent[key, target] = recipients[target].reply( + self.messaging, sender=self.activity_thread, target=target, + body=item.assistant_text, answers=tuple(references), + source_turn=NoSourceTurn(), + ) + published = tuple(publication for target in sorted(replies) + for publication in self.sent[key, target]) + super().publish_item(replace(item, events=tuple( + event.with_publications(published) for event in item.events + ))) + del self.pending[key] + super().end_turn(turn_id) + + def end_all_turns(self) -> None: + for turn_id in dict.fromkeys(key.turn_id for key in self.pending): + self.end_turn(turn_id) + super().end_all_turns() + + def cancel_all_turns(self) -> None: + self.pending.clear() + super().cancel_all_turns() + + class ReadingPhase(ABC): """Where a reader is in its source: catching up on what was already written, or following it.""" diff --git a/src/agent_comms/thread_execution.py b/src/agent_comms/thread_execution.py index 22c623152a..e392380f30 100644 --- a/src/agent_comms/thread_execution.py +++ b/src/agent_comms/thread_execution.py @@ -210,7 +210,7 @@ def presentation(cls, thread: Thread, status: ThreadStatus, ordinary: ThreadPres class SubagentExecution(ExternalThreadExecution): """A child working inside its parent session's turn: it reads its CLI inbox, holds no - order and has no unit card of its own, and starts with its parent's audience.""" + order and has no independent card, and starts with inherited channel membership.""" @classmethod def holds_activity(cls, thread: Thread, status: ThreadStatus) -> bool: @@ -250,7 +250,10 @@ def require_resumes(cls, existing: Thread) -> None: class CommanderForkExecution(SubagentExecution): - """A commander's child: only a commander declares one, and it starts on its own goal.""" + """An SDK child borrows its bound parent's context and records its own fork goal. + + Any independent parent can declare it; the stored execution identity stays stable. + """ @classmethod def require_commander_fork(cls) -> None: diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index b06995d6f8..3cbb81b311 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Annotated, ClassVar from .agent_backend import AgentBackend, NewThreadBackend, RuntimeLocation -from .channel_targets import DeclaredTag, Tag +from .channel_targets import Tag from .child_process import NoProcess, ProcessIdentity, ProcessState from .context_segments.wake_values import WakeWorkContext from .nominal.declared_family import DeclaredFamily @@ -344,11 +344,6 @@ def restarted_tools(self) -> Thread: return replace(self, offered=UnobservedTools(), offered_before_restart=self.offered.restarted_over(self.offered_before_restart)) - def commanded_child(self, child: Thread) -> Thread: - """A commanded declaration inherits its initial audience from its commander, never its - commander's role.""" - return replace(child, tags=DeclaredTag.for_child(self.tags)) - def inherited_history(self, registry, visited): if self.name in visited: return diff --git a/tests/test_bridge_binding_identity.py b/tests/test_bridge_binding_identity.py index 4c278b6659..10042aa3d1 100644 --- a/tests/test_bridge_binding_identity.py +++ b/tests/test_bridge_binding_identity.py @@ -11,7 +11,7 @@ from agent_comms.cli_bridges.shared import ( Binding, BindingAddress, - CommandedBy, + ChannelsFrom, Enrol, Options, StoredBinding, @@ -23,7 +23,7 @@ @pytest.mark.asyncio @pytest.mark.parametrize('operation,tags', [ - (Watch(CommandedBy('commander')), frozenset({'comms'})), + (Watch(ChannelsFrom('commander')), frozenset({'comms'})), (Watch(), frozenset()), ]) async def test_enrol_register_rename_and_rebind_preserve_reader(comms, tmp_path, operation, tags): @@ -45,6 +45,8 @@ async def test_enrol_register_rename_and_rebind_preserve_reader(comms, tmp_path, reader = comms.registry.require('new-reader') assert original.owner == reader.incarnation assert reader.tags == tags + assert tuple(reader.parent.names()) == () + assert reader.acting_identity.commanding_identity == reader.incarnation assert reader.worktree == Checkout(Path.cwd()) comms.registry.rename('new-reader', 'renamed-reader') diff --git a/tests/test_commander_notifications.py b/tests/test_commander_notifications.py index 1a93f27b5b..465426414d 100644 --- a/tests/test_commander_notifications.py +++ b/tests/test_commander_notifications.py @@ -45,7 +45,7 @@ from agent_comms.ui_model.unit_state import ( Spinning, UnitState, UrgentGroup, ) -from agent_comms.cli_bridges.session_watch import RecordedWatch, WatchClaim, WatchRecord +from agent_comms.cli_bridges.session_watch import WatchClaim, WatchRecord from agent_comms.wake_policy import PassiveWake from agent_comms.bus_publication import AgentOrigin, HumanOrigin from agent_comms.channel_targets import CommandersTag, ThreadTarget @@ -1104,21 +1104,21 @@ def test_one_pass_gives_every_commander_its_own_packet(notification_bus): assert CommanderFrame.current(comms, recipient.name).packets == (quiet.task,) -def test_fork_cli_default_inherits_audience_but_not_commander_role(notification_bus): +def test_fork_cli_inherits_channels_without_a_rank_exception(notification_bus): comms = notification_bus parent = comms.registry.require('worker') tag = CommandersTag.declared_name - parent = comms.registry.register(replace(parent, tags=parent.tags | {'comms', tag})) + comms.registry.register(replace(parent, tags=parent.tags | {'comms', tag})) + parent = comms.registry.require(parent.name) command = ForkCliCommand.thread_binding(parent) - assert command.tags.for_new() == parent.tags - {tag} + assert command.tags.for_new() == parent.tags explicit = replace(command, tags=GivenTags(parent.tags)) assert tag in explicit.tags.for_new() -def test_a_commander_is_declared_never_inherited(notification_bus, tmp_path): - """The hook's role answer reads the one derivation: a thread registered with the tag - commands; a fork carries its commander's tags but acts for it, and a bound unit of a - commander does not inherit the role.""" +def test_enrollment_and_independent_execution_not_tags_determine_shared_participation(notification_bus, tmp_path): + """Every registered binding participates; only real in-turn executions are excluded + from the independent collaborator roster. Copying channels creates no parent authority.""" comms = notification_bus tag = CommandersTag.declared_name for name in ("lead", "worker"): @@ -1128,18 +1128,24 @@ def test_a_commander_is_declared_never_inherited(notification_bus, tmp_path): comms.registry.register(replace(declared, tags=declared.tags | {tag})) comms.registry.register_child("copied-fork", "worker", CommanderForkExecution, comms.goals) worker = comms.registry.require("worker") - comms.registry.register_commanded_unit( + bound = comms.registry.register_bound_thread( "bound-unit", "worker", worker.worktree, ProcessIdentity.capture(os.getpid())) + assert tuple(bound.parent.names()) == () + assert bound.acting_identity.commanding_identity == bound.incarnation def commands(name): - return RecordedWatch(WatchClaim(name, home=tmp_path), - WatchRecord(name, str(comms.root), (), tmp_path)).commands() - - assert commands("lead") and commands("worker") - assert not commands("outsider") - assert not commands("declared-fork") and not commands("copied-fork") - assert not commands("bound-unit") - assert {"comms", tag} <= comms.registry.require("copied-fork").tags + claim = WatchClaim(name, home=tmp_path) + claim.enrol(WatchRecord(name, str(comms.root), (), tmp_path)) + return claim.state().commands() + + assert all(commands(name) for name in ( + "lead", "worker", "outsider", "declared-fork", "copied-fork", "bound-unit")) + assert not commands("never-registered") + snapshot = comms.registry.snapshot() + independent = {peer.name for peer in snapshot.commander_project.commanders(snapshot)} + assert {"lead", "worker", "outsider", "bound-unit"} <= independent + assert {"declared-fork", "copied-fork"}.isdisjoint(independent) + assert {"comms", tag} <= snapshot.require("copied-fork").tags def test_a_board_packet_files_under_heartbeats_while_the_owners_own_words_stay_inbound(notification_bus): diff --git a/tests/test_monitor_delivery.py b/tests/test_monitor_delivery.py index ae0a276e0d..ac0cd5a0e0 100644 --- a/tests/test_monitor_delivery.py +++ b/tests/test_monitor_delivery.py @@ -1047,8 +1047,8 @@ def reply_end(request: pytest.FixtureRequest) -> Callable[[NativeTurnId], Runtim return request.param -async def confirm_and_finish_output( - bridge: ClaudeBridge, message: Message, +async def confirm_and_finish_reply( + bridge: ClaudeBridge, message: Message, expected: tuple[str, ...], end: Callable[[NativeTurnId], RuntimeUpdate], ) -> None: """Real monitor consumption feeds the ordinary staged output and terminal update.""" @@ -1087,13 +1087,13 @@ def replies() -> tuple[tuple[str, str], ...]: 'the opened turn final', timestamp=2., position=item),))]) assert replies() == () output.publish_batch([end(turn)]) - assert replies() == () - # Completion is idempotent, and another turn cannot acquire an implicit reply route. + assert replies() == tuple((target, 'the opened turn final') for target in expected) + # Completion is idempotent, and another turn cannot inherit these routes. later, later_item = NativeTurnId('later-turn'), NativeItemId('later-answer') output.publish_batch([FinishedTurn(turn), AllTurnsEnded(), FinishedItem( later, later_item, (AssistantTranscript( 'a later final', timestamp=3., position=later_item),)), AllTurnsEnded()]) - assert replies() == () + assert replies() == tuple((target, 'the opened turn final') for target in expected) @pytest.mark.asyncio @@ -1104,35 +1104,35 @@ async def test_diagnostic_notice_is_consumed_without_replying_to_the_failed_send message = bridge.comms.messaging.send( 'peer', 'viewer', f'Request failed. No automatic retry. [Open diagnostic]({diagnostic.as_uri()})', AlertKind, role=NoticeRole()).message - await confirm_and_finish_output(bridge, message, reply_end) + await confirm_and_finish_reply(bridge, message, (), reply_end) @pytest.mark.asyncio -@pytest.mark.parametrize(('target', 'body', 'kind'), ( - ('viewer', 'ordinary direct request', QuestionKind), - ('#monitor', 'channel information', InfoKind), - ('#monitor', '@viewer please answer this channel request', QuestionKind), +@pytest.mark.parametrize(('target', 'body', 'kind', 'expected'), ( + ('viewer', 'ordinary direct request', QuestionKind, ('claude-code',)), + ('#monitor', 'channel information', InfoKind, ()), + ('#monitor', '@viewer please answer this channel request', QuestionKind, ()), )) -async def test_confirmed_output_never_sends_an_implicit_direct_or_channel_reply( - bridge, reply_end, target, body, kind, +async def test_confirmed_reply_routes_follow_direct_and_channel_policy( + bridge, reply_end, target, body, kind, expected, ): bridge.comms.channels.create_tag('monitor') message = bridge.comms.messaging.send('claude-code', target, body, kind).message - await confirm_and_finish_output(bridge, message, reply_end) + await confirm_and_finish_reply(bridge, message, expected, reply_end) @pytest.mark.asyncio -async def test_confirmed_human_direct_request_keeps_the_answer_in_its_transcript(bridge, reply_end): +async def test_confirmed_human_direct_request_receives_one_reply(bridge, reply_end): message = bridge.comms.messaging.send_user_message( ThreadTarget('viewer'), 'please answer once', worktree=Checkout(bridge.options.state.parent)) - await confirm_and_finish_output(bridge, message, reply_end) + await confirm_and_finish_reply(bridge, message, (message.sender,), reply_end) -def test_independent_sessions_never_automatically_reply_to_delivered_senders(comms, tmp_path): - """Storage lead and peer keep their output in their transcripts, not implicit DMs.""" +def test_independent_sessions_share_the_delivered_message_reply_policy(comms, tmp_path): + """Storage lead and peer follow the same original-message reply policy.""" from agent_comms.registry_document import RegisteredCommanderProject - from agent_comms.runtime_updates import FinishedItem, ThreadOutput + from agent_comms.runtime_updates import FinishedItem, ReplyingThreadOutput from agent_comms.transcript_events import AssistantTranscript from agent_comms.turn_item import NativeItemId, NativeTurnId @@ -1140,7 +1140,7 @@ def test_independent_sessions_never_automatically_reply_to_delivered_senders(com name, frozenset(), Checkout(tmp_path), execution=ExternalThreadExecution)) for name in ("lead", "unit")) comms.registry.set_commander_project(RegisteredCommanderProject(lead.incarnation, ())) for speaker, sender in ((lead, unit), (unit, lead)): - output = ThreadOutput.for_comms(comms.transcripts.external(speaker.name), comms) + output = ReplyingThreadOutput.for_comms(comms.transcripts.external(speaker.name), comms) original = comms.messaging.send(sender.name, speaker.name, "Open this answer", QuestionKind).message turn = NativeTurnId(f"{speaker.name}-turn") output.delivered(turn, (original.reference,)) @@ -1150,7 +1150,7 @@ def test_independent_sessions_never_automatically_reply_to_delivered_senders(com output.end_turn(turn) replies = tuple((message.target, message.body) for message in comms.bus.bus.message_history() if message.body in {"lead answers", "unit answers"}) - assert replies == () + assert replies == (("unit", "lead answers"), ("lead", "unit answers")) @pytest.mark.parametrize(('rows', 'parts'), ( From 821b7a4ba6e202472b615fab98b69aaa228c200b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 04:01:42 -0500 Subject: [PATCH 04/15] Derive reminder packing from whole declarations and repair send callers --- src/agent_comms_hooks/skill.py | 13 +++++++++---- tests/test_commander_skill_hook.py | 8 ++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/agent_comms_hooks/skill.py b/src/agent_comms_hooks/skill.py index 6cf0c86932..481384b807 100644 --- a/src/agent_comms_hooks/skill.py +++ b/src/agent_comms_hooks/skill.py @@ -20,6 +20,7 @@ from typing import ClassVar from agent_comms.cli_bridges.claude_transcript import ClaudeJsonLines +from agent_comms.class_fact import ClassFact from agent_comms.commander.package import CommanderPackage from agent_comms.commander.rule_file import RuleFile from agent_comms.nominal.declared_family import DeclaredFamily @@ -46,10 +47,14 @@ class DeckReader(DealtSources): deck_suffix: ClassVar[str] = ".deck.json" """The one shared deck beside each session's transcript.""" - slice_chars: ClassVar[int] = 2_500 - """The most characters one inline slice carries; a longer paragraph splits at its lines. A - rule's wake-up is never split, since a greentext cut before its punchline teaches nothing, - so the cap sits above the longest one and the deck test fails when one outgrows it.""" + @ClassFact + def slice_chars(cls: type[DeckReader]) -> int: + """Whole wake-ups declare the inline packing width; paragraphs split at their lines. + + A rule cannot be cut before its punchline. Its declared text, not a separately + maintained character count, therefore supplies the width for every reader. + """ + return max(map(len, cls.tells())) @classmethod def proofs(cls) -> tuple[Path, ...]: diff --git a/tests/test_commander_skill_hook.py b/tests/test_commander_skill_hook.py index e332e06a28..349719f3d6 100644 --- a/tests/test_commander_skill_hook.py +++ b/tests/test_commander_skill_hook.py @@ -419,7 +419,7 @@ def test_the_board_prints_changes_once_then_an_unchanged_count(notification_bus, second = RulePing(ReviewTimerUp, TextDigest.of("second"), text="Review worker P2", subjects=(UnitSubject(worker),), since=RecordedEventTime(began)) hud = lead_hud(comms).hud_text(began + 90) - CommanderDecisions(lead, (first, second), (first, second), ()).send(comms, began + 90, hud) + CommanderDecisions(lead, (first, second), (first, second), ()).send(comms, 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)) @@ -434,13 +434,13 @@ def test_the_board_prints_changes_once_then_an_unchanged_count(notification_bus, unchanged_summary((first, second), began + 91), ) changed = replace(first, observation=TextDigest.of("changed"), since=RecordedEventTime(began + 90)) - CommanderDecisions(lead, (changed, second), (changed,), ()).send(comms, began + 92, hud) + CommanderDecisions(lead, (changed, second), (changed,), ()).send(comms, hud) assert tool_call(hook, server, comms, db, began + 92) == ( "BOARD: Read worker P1 (seen for 0 min)", unchanged_summary((second,), began + 92), ) # A fresh packet does not reset delivery, and the answer window rings only once. - CommanderDecisions(lead, (changed, second), (), ()).send(comms, began + 93, hud) + CommanderDecisions(lead, (changed, second), (), ()).send(comms, hud) alarm = began + AwaitingCommander.seconds assert tool_call(hook, server, comms, db, alarm + 1) == ( "BOARD: Review worker P2 (seen for 5 min)", @@ -450,7 +450,7 @@ def test_the_board_prints_changes_once_then_an_unchanged_count(notification_bus, unchanged_summary((changed, second), alarm + 2), ) assert DeliveredBoardContent.count(db) == 2 - CommanderDecisions(lead, (), (), ()).send(comms, alarm + 3, hud) + CommanderDecisions(lead, (), (), ()).send(comms, hud) assert tool_call(hook, server, comms, db, alarm + 3) == () assert DeliveredBoardContent.count(db) == 0 From 08a1e780216c9e41793c963f6c1459d68d333299 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 04:08:02 -0500 Subject: [PATCH 05/15] Exercise retained board observations without enabling heartbeat publication --- tests/test_commander_skill_hook.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/test_commander_skill_hook.py b/tests/test_commander_skill_hook.py index 349719f3d6..b25ea7a640 100644 --- a/tests/test_commander_skill_hook.py +++ b/tests/test_commander_skill_hook.py @@ -58,7 +58,7 @@ FullPrime, LightPrime, Prime, SessionSource) from agent_comms.cli_bridges.session_watch import DeafWatch, LiveWatch, WatchClaim, WatchRecord from conftest import engaged -from test_commander_notifications import beat, lead_hud +from test_commander_notifications import beat from test_head_review import ACCEPTED, invoke from agent_comms.cli_bridges.hook_requests import ( @@ -403,7 +403,11 @@ def test_rules_carrying_his_words_flag_their_board_lines(): def test_the_board_prints_changes_once_then_an_unchanged_count(notification_bus, tmp_path, monkeypatch): - """Real packets, bridge delivery journal and session socket; no hook-side state.""" + """Retained observations, bridge delivery journal and socket; publication stays muted.""" + from agent_comms.heartbeat_delivery import OffHeartbeatDelivery + from agent_comms.task_attachments.commander_pings import CommanderUpdateStore + + assert notification_bus.registry.require("lead").heartbeat_delivery is OffHeartbeatDelivery comms = notification_bus monkeypatch.chdir(tmp_path) monkeypatch.setenv("HOME", "home") @@ -418,8 +422,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)) - hud = lead_hud(comms).hud_text(began + 90) - CommanderDecisions(lead, (first, second), (first, second), ()).send(comms, hud) + CommanderUpdateStore.record(comms.root, CommanderDecisions( + lead, (first, second), (first, second), ())) 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)) @@ -434,13 +438,15 @@ def test_the_board_prints_changes_once_then_an_unchanged_count(notification_bus, unchanged_summary((first, second), began + 91), ) changed = replace(first, observation=TextDigest.of("changed"), since=RecordedEventTime(began + 90)) - CommanderDecisions(lead, (changed, second), (changed,), ()).send(comms, hud) + CommanderUpdateStore.record(comms.root, CommanderDecisions( + lead, (changed, second), (changed,), ())) assert tool_call(hook, server, comms, db, began + 92) == ( "BOARD: Read worker P1 (seen for 0 min)", unchanged_summary((second,), began + 92), ) # A fresh packet does not reset delivery, and the answer window rings only once. - CommanderDecisions(lead, (changed, second), (), ()).send(comms, hud) + CommanderUpdateStore.record(comms.root, CommanderDecisions( + lead, (changed, second), (), ())) alarm = began + AwaitingCommander.seconds assert tool_call(hook, server, comms, db, alarm + 1) == ( "BOARD: Review worker P2 (seen for 5 min)", @@ -450,7 +456,7 @@ def test_the_board_prints_changes_once_then_an_unchanged_count(notification_bus, unchanged_summary((changed, second), alarm + 2), ) assert DeliveredBoardContent.count(db) == 2 - CommanderDecisions(lead, (), (), ()).send(comms, hud) + CommanderUpdateStore.record(comms.root, CommanderDecisions(lead, (), (), ())) assert tool_call(hook, server, comms, db, alarm + 3) == () assert DeliveredBoardContent.count(db) == 0 From 648ba142bbe90e998ad9a6e7800c7790d207ccce Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 04:12:23 -0500 Subject: [PATCH 06/15] Explicitly mute the retained-board fixture --- tests/test_commander_skill_hook.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_commander_skill_hook.py b/tests/test_commander_skill_hook.py index b25ea7a640..ebf04c154c 100644 --- a/tests/test_commander_skill_hook.py +++ b/tests/test_commander_skill_hook.py @@ -407,8 +407,9 @@ def test_the_board_prints_changes_once_then_an_unchanged_count(notification_bus, from agent_comms.heartbeat_delivery import OffHeartbeatDelivery from agent_comms.task_attachments.commander_pings import CommanderUpdateStore - assert notification_bus.registry.require("lead").heartbeat_delivery is OffHeartbeatDelivery comms = notification_bus + comms.threads.set_heartbeat_delivery("lead", OffHeartbeatDelivery) + assert comms.registry.require("lead").heartbeat_delivery is OffHeartbeatDelivery monkeypatch.chdir(tmp_path) monkeypatch.setenv("HOME", "home") hook = BoardReminder.decode(hook_record(tmp_path / "session.jsonl")) From 3838673cf198ba19dab044a08048805778dd260d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 17:45:24 -0400 Subject: [PATCH 07/15] One board: order reads stop filtering by creator Moving the lead jumped unjudged from 21 to 138 with no new edits, because the store answered whose board it is by creator while the service already reads one board. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSpSd8zQxyozgEV2Re7 (cherry picked from commit 8afac2b1e692d0e75084a2f14e9791cfc7057001) --- src/agent_comms/commander/order.py | 4 ++-- src/agent_comms/commander/standing.py | 4 ++-- src/agent_comms/commander_heartbeat.py | 2 +- src/agent_comms/heartbeat_frame.py | 2 +- src/agent_comms/held_orders.py | 2 +- src/agent_comms/order_tools.py | 4 ++-- src/agent_comms/standing_rules.py | 4 ++-- src/agent_comms/todos.py | 29 ++++++++++++-------------- src/agent_comms/turn_digests.py | 2 +- tests/test_commander_forks.py | 12 +++++------ tests/test_head_review.py | 3 +-- tests/test_held_problem_orders.py | 8 +++---- tests/test_proof_build.py | 2 +- tests/test_todos.py | 2 +- tests/test_unit_state.py | 12 +++-------- 15 files changed, 40 insertions(+), 52 deletions(-) diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index db9bb77671..69c2ee478e 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -461,7 +461,7 @@ def call(self, tool): return self.actor.as_thread(self.commander).invoke(tool, self.comms) def todos(self): - return self.comms.goals.todos.for_commander(self.incarnation) + return self.comms.goals.todos.board() def read_check(self, line: str) -> OrderCheck: if not (request := CheckRequest.parse(line)): @@ -479,7 +479,7 @@ def held_by(self, unit: str) -> tuple[Todo, ...]: def units(self) -> frozenset[str]: """The current registry names of every unit this commander ordered.""" snapshot = self.comms.registry.snapshot() - return frozenset(thread.name for unit in self.comms.goals.todos.units(self.incarnation) + return frozenset(thread.name for unit in self.comms.goals.todos.board_units() for thread in snapshot.threads_for(unit.require_recorded())) def progress(self) -> dict[str, OrderProgress]: diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index f01cc64ea3..e86f4b7709 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -547,7 +547,7 @@ def scan_observed(cls, comms: Comms, commander: str, def scan_orders(cls, comms: Comms, commander: str, scan: HeldOrders, pulls: PullRequests) -> tuple[UnownedWheelSpin, ...]: actor = comms.registry.require(commander).incarnation - return cls.paired(comms, commander, comms.goals.todos.for_commander(actor), scan, pulls) + return cls.paired(comms, commander, comms.goals.todos.board(), scan, pulls) @classmethod def paired(cls, comms: Comms, commander: str, @@ -784,7 +784,7 @@ class Scrutinize(IndependentStandingRule, UndatedStandingRule): def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: actor = comms.registry.require(commander).incarnation return tuple(cls(held, todo, count) - for todo in comms.goals.todos.held_for_commander(actor) + for todo in comms.goals.todos.board_held() for held in todo.current_holder.current_assignments() for count in (todo.strikes_against(held.owner_identity),) if count >= cls.threshold) diff --git a/src/agent_comms/commander_heartbeat.py b/src/agent_comms/commander_heartbeat.py index df6de41f2a..c150a76c7b 100644 --- a/src/agent_comms/commander_heartbeat.py +++ b/src/agent_comms/commander_heartbeat.py @@ -53,7 +53,7 @@ def merges(self, comms: Comms) -> tuple[Todo, ...]: - {unit for state in self.states for unit in state.readable_units()}) return tuple( closed - for todo in comms.goals.todos.for_commander(self.thread.incarnation) + for todo in comms.goals.todos.board() if not any(held.owner_identity in unreadable for held in todo.current_holder.current_assignments()) for closed in todo.order.closed_by_merge( diff --git a/src/agent_comms/heartbeat_frame.py b/src/agent_comms/heartbeat_frame.py index 242edf8603..dde5b3752b 100644 --- a/src/agent_comms/heartbeat_frame.py +++ b/src/agent_comms/heartbeat_frame.py @@ -50,7 +50,7 @@ def prepare(cls, comms: Comms, commander: str, pulls: ProgressRead, repository = Repo.declaring(project.repos, PlanPhase.__module__) plan = plans.prepare_reading( repository, - comms.goals.todos.history_for_commander(snapshot.require(commander).incarnation), + comms.goals.todos.board_history(), tuple(phase.phase_evidence() for phase in PlanPhase.members_with(PlanPhase)), history=pulls.checkout(repository.checkout).branches.history, ) diff --git a/src/agent_comms/held_orders.py b/src/agent_comms/held_orders.py index 9f9abf06bf..32a5047c48 100644 --- a/src/agent_comms/held_orders.py +++ b/src/agent_comms/held_orders.py @@ -49,7 +49,7 @@ def read(cls, comms: Comms, commander: str) -> HeldOrders: @classmethod def of(cls, store: TodoStore, snapshot: RegistrySnapshot, commander: str) -> HeldOrders: """The held orders in ``store`` for ``commander`` as ``snapshot`` registers them.""" - return HeldOrders.split(store.held_for_commander(snapshot.require(commander).incarnation), + return HeldOrders.split(store.board_held(), snapshot) @classmethod diff --git a/src/agent_comms/order_tools.py b/src/agent_comms/order_tools.py index 1817b97b28..2ba546e262 100644 --- a/src/agent_comms/order_tools.py +++ b/src/agent_comms/order_tools.py @@ -324,7 +324,7 @@ class CommsCorrectWordingTool(ThreadToolRequest): def apply(self, comms: Comms, caller: ThreadCaller) -> MessagePublished: actor = comms.registry.require(caller.name).incarnation - orders = [todo.id for todo in comms.goals.todos.for_commander(actor) + orders = [todo.id for todo in comms.goals.todos.board() if todo.holder == self.unit] if not orders: raise ToolRefusedError(f"{self.unit} holds no open order from {caller.name}.") @@ -347,7 +347,7 @@ class CommsTodoListTool(BoardReadTool): def apply(self, comms: Comms, caller: ToolCaller) -> TodoList: snapshot = comms.registry.snapshot() actor = snapshot.require(caller.subject(self.thread)).incarnation - listed = tuple(comms.goals.todos.for_commander(actor)) + listed = tuple(comms.goals.todos.board()) pulls = ProgressRead(listed) # An orphaned hold has no unit checkout to read; HolderGone reports it. held = HeldOrders.split(tuple(todo for todo in listed if todo.holder), snapshot) diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index 4c9b9446ca..ba753c6ccf 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -1005,7 +1005,7 @@ def scan_observed(cls, comms: Comms, commander: str, def scan_reviews(cls, goals: Goals, commander: str, pulls: ProgressRead) -> tuple[Self, ...]: now = time.time() snapshot = goals.registry.snapshot() - todos = goals.todos.for_commander(snapshot.require(commander).incarnation) + todos = goals.todos.board() return tuple( rule for repo in snapshot.commander_project.repository_names() @@ -1106,7 +1106,7 @@ class IdlePullRequest(HeadLine, DatedStandingRule): @classmethod def scan_observed(cls, comms: Comms, commander: str, observed: Sequence[UnitState], frame: HeartbeatFrame) -> tuple[Self, ...]: - ordered = comms.goals.todos.for_commander(comms.registry.require(commander).incarnation) + ordered = comms.goals.todos.board() return cls.scan_pulls(comms.goals, frame.pulls, frame.todos, ordered) @classmethod diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index b2cba8476c..15f670fb63 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -3036,7 +3036,7 @@ def assignment_refusals(self, context: CommanderContext, document: OrderFile, un """The document's selected problems and phase must agree. Whether the unit is free for them is IncomingWork's decision, made in the assignment's own Todo transaction.""" reading = context.plan_reading( - context.comms.goals.todos.history_for_commander(context.incarnation)) + context.comms.goals.todos.board_history()) problems = list(self.phase.order_refusals(reading, document.unplanned_reason)) if not group or any(not ProblemNumber.accepts(pid) for pid in group): problems.append('problems: fix: supply an array of P IDs, or use order problem') @@ -3465,7 +3465,7 @@ class OrderedHunt(Hunt): def require_commander(self, store: TodoStore, commander: ThreadIncarnation) -> None: super().require_commander(store, commander) if not any(todo.id == self.sent - for todo in store.held_for_commander(commander)): + for todo in store.board_held()): raise TodoError("The hunt must name this commander's current order.") @@ -4920,8 +4920,7 @@ def story_text(self) -> str: return f"{super().story_text()} by {self.replacement}: {self.reason}" def prepare(self, goals, current): - replacing = [todo for todo in goals.todos.held_for_commander( - self.actor.commanding_identity) if todo.id == self.replacement] + replacing = [todo for todo in goals.todos.board_held() if todo.id == self.replacement] if not replacing: raise TodoError('The replacing problem is not held for this commander.') if any(todo.holder == current.holder for todo in replacing): @@ -6422,9 +6421,9 @@ def holds_pending(self, owner: ThreadIncarnation) -> bool: return any(todo.pending for todo in self._select( '1', (), reader=partial(self.held_in, unit=owner))) - def for_commander(self, commander: ThreadIncarnation) -> tuple[Todo, ...]: - return self._select("creator=? AND creator_created=?", - (commander.name, commander.wire_birth), reader=self._open) + def board(self) -> tuple[Todo, ...]: + """Every open order on the bus: one board, whichever commander wrote the order.""" + return self._select("1", (), reader=self._open) @staticmethod def timers_up_in(held: Sequence[Todo], now: float, @@ -6433,9 +6432,8 @@ def timers_up_in(held: Sequence[Todo], now: float, return tuple((todo, up) for todo in held for up in todo.timer_up(now) if not todo.order.waiting_on_install(todo, progress)) - def held_for_commander(self, commander: ThreadIncarnation) -> tuple[Todo, ...]: - return self._select("creator=? AND creator_created=? AND assignment IS NOT NULL", - (commander.name, commander.wire_birth), reader=self._open) + def board_held(self) -> tuple[Todo, ...]: + return self._select("assignment IS NOT NULL", (), reader=self._open) def held_by(self, commander: ThreadIncarnation, unit: ThreadIncarnation) -> tuple[Todo, ...]: return self._select('creator=? AND creator_created=?', @@ -6461,15 +6459,14 @@ def require_recurrence_declaration( 'and the declaration that replaces the copies.', DeclarationDerivation.requirement(declarations[1]), *rejected))) - def history_for_commander(self, commander: ThreadIncarnation) -> tuple[Todo, ...]: - return self._select("creator=? AND creator_created=?", (commander.name, commander.wire_birth)) + def board_history(self) -> tuple[Todo, ...]: + return self._select("1", ()) - def units(self, commander: ThreadIncarnation) -> frozenset[ThreadIncarnation]: - """The threads this commander gives orders to: everyone who holds or held one of its - problems, by birth, so a renamed holder stays one unit.""" + def board_units(self) -> frozenset[ThreadIncarnation]: + """Everyone who holds or held an order, by birth, so a renamed holder stays one unit.""" return frozenset( - assignment.owner_identity for todo in self.history_for_commander(commander) + assignment.owner_identity for todo in self.board_history() for assignment in (*todo.current_holder.current_assignments(), *todo.history.facts_for(todo.id).previous_holders)) diff --git a/src/agent_comms/turn_digests.py b/src/agent_comms/turn_digests.py index aa643a0518..f8d1532e54 100644 --- a/src/agent_comms/turn_digests.py +++ b/src/agent_comms/turn_digests.py @@ -306,7 +306,7 @@ def read(self) -> Result: def for_commander(cls, comms: Comms, commander: str) -> Iterator[Self]: snapshot = comms.registry.snapshot() held = HeldOrders.of(comms.goals.todos, snapshot, commander) - history = comms.goals.todos.history_for_commander(snapshot.require(commander).incarnation) + history = comms.goals.todos.board_history() pulls = ProgressRead(history) workplaces = EditWorkplaces.capture(comms, snapshot, held.live, pulls) yield from cls.judgeable( diff --git a/tests/test_commander_forks.py b/tests/test_commander_forks.py index 2d01cc5b1e..94541b0015 100644 --- a/tests/test_commander_forks.py +++ b/tests/test_commander_forks.py @@ -375,7 +375,7 @@ def edit_sources(fork_bus, tmp_path, configured_repository): def edit_workplaces(comms): snapshot = comms.registry.snapshot() holds = HeldOrders.of(comms.goals.todos, snapshot, "lead").live - history = comms.goals.todos.history_for_commander(snapshot.require("lead").incarnation) + history = comms.goals.todos.board_history() return holds, EditWorkplaces.capture(comms, snapshot, holds, ProgressRead(history)) @@ -579,8 +579,7 @@ def test_ambiguous_pre_pr_work_never_picks_latest(edit_sources, tmp_path): assert shown.returncode == 0, shown.stderr assert calls[0][0] not in shown.stdout and calls[1][0] in shown.stdout assert shown.stdout.splitlines()[-1] == "1 unjudged" - assert all(not todo.judged_calls for todo in comms.goals.todos.history_for_commander( - comms.registry.require("lead").incarnation)) + assert all(not todo.judged_calls for todo in comms.goals.todos.board_history()) def test_missing_unknown_allocation_still_overrides_an_enclosing_source(edit_sources): @@ -675,7 +674,7 @@ def assign(pair): assert all(result.returncode == 0 for result in results), [result.stderr for result in results] todos = tuple( todo - for todo in comms.goals.todos.held_for_commander(comms.registry.require("lead").incarnation) + for todo in comms.goals.todos.board_held() if todo.id != "P40" ) assert {todo.id for todo in todos} == {"P41", "P42"} @@ -707,7 +706,7 @@ def assign(name): assert sum(result.returncode == 0 for result in results) == 1, [r.stderr for r in results] held = tuple( todo - for todo in comms.goals.todos.held_for_commander(comms.registry.require("lead").incarnation) + for todo in comms.goals.todos.board_held() if todo.id != "P40" ) assert len(held) == 1 and held[0].holder == "worker" @@ -750,8 +749,7 @@ def test_sequential_fork_assignments_refuse_with_the_race_message(fork_bus, tmp_ "--to", "worker", "--where", "owner.py:2", "--fork", name) for name in ("fork-a", "fork-b")) assert first.returncode == 0, first.stderr - held, = (todo for todo in comms.goals.todos.held_for_commander( - comms.registry.require("lead").incarnation) if todo.holder == "worker") + held, = (todo for todo in comms.goals.todos.board_held() if todo.holder == "worker") assert second.returncode != 0 and occupied("worker", held) in second.stderr diff --git a/tests/test_head_review.py b/tests/test_head_review.py index d3825d04bb..807ea80640 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -1015,8 +1015,7 @@ def test_overdue_linked_head_stays_visible_without_refusing_new_orders(head_bus) held = todo.held() RecordPullRequest(todo.revision, held.owner_identity, PullRequestLink(request.repo, request.pr), held.generation).record(comms.goals, todo.id) - held_orders = comms.goals.todos.held_for_commander( - comms.registry.require("lead").incarnation) + held_orders = comms.goals.todos.board_held() assert [rule.request for rule in LinkedUnreviewedHead.scan_reviews( comms.goals, "lead", ProgressRead(held_orders))] == [request] comms.goals.update_goal("outsider", replace(action, problem="P4")) diff --git a/tests/test_held_problem_orders.py b/tests/test_held_problem_orders.py index 01ca4b93a8..18bc927525 100644 --- a/tests/test_held_problem_orders.py +++ b/tests/test_held_problem_orders.py @@ -188,7 +188,7 @@ def test_second_unplanned_order_is_refused_without_recording(held_commander, cit ) path = ctx.root / "second-order.json" path.write_text(json.dumps(FieldCodec.encode(document))) - before = ctx.comms.goals.todos.for_commander(ctx.incarnation) + before = ctx.comms.goals.todos.board() goal = ctx.comms.registry.require("worker").goal # The busy-unit decision is the Todo transaction's own: --reassign passes the preflight's # owner check, so the refusal comes from the write that would have given worker P2. @@ -197,7 +197,7 @@ def test_second_unplanned_order_is_refused_without_recording(held_commander, cit "lead holds P2 only as its creator and must hand it to a unit"], ctx) assert ("worker already holds orders P1 (assigned by lead); running units holding no order:" in str(refused.value)) - assert ctx.comms.goals.todos.for_commander(ctx.incarnation) == before + assert ctx.comms.goals.todos.board() == before assert ctx.comms.goals.todos.get("P1") == original assert ctx.comms.registry.require("worker").goal == goal print(str(refused.value)) @@ -234,10 +234,10 @@ def test_an_order_waiting_on_install_frees_its_holder_for_the_next_order( ) path = ctx.root / "second-order.json" path.write_text(json.dumps(FieldCodec.encode(document))) - before = {todo.id for todo in ctx.comms.goals.todos.for_commander(ctx.incarnation)} + before = {todo.id for todo in ctx.comms.goals.todos.board()} where = f"{second.owner.file}:{second.owner.line}" main(["problem", str(path), "--to", "worker", "--where", where], ctx) - created = [todo for todo in ctx.comms.goals.todos.for_commander(ctx.incarnation) + created = [todo for todo in ctx.comms.goals.todos.board() if todo.id not in before] assert [todo.holder for todo in created] == ["worker"] diff --git a/tests/test_proof_build.py b/tests/test_proof_build.py index 5404952800..20a6f37c4b 100644 --- a/tests/test_proof_build.py +++ b/tests/test_proof_build.py @@ -328,7 +328,7 @@ def test_installed_proof_closes_and_older_build_is_refused_on_copied_bus( assert str(installed) in str(refusal.value) assert copied.goals.todos.get(problem) == delivered print(f"{type(refusal.value).__name__}: {refusal.value}") - assert not source.goals.todos.for_commander(source.registry.require("lead").incarnation) + assert not source.goals.todos.board() def test_build_containment_does_not_turn_git_failure_into_absence_or_success(proof_build_checkouts): diff --git a/tests/test_todos.py b/tests/test_todos.py index 16dc01067d..08f2214d3d 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -254,7 +254,7 @@ def test_withdraw_retires_a_released_problem(tmp_path, source_repository): replace(previous, generation=wrong_goal_id), why)) withdrawn = store.apply(todo.id, WithdrawTodo(released.revision, lead.incarnation, previous, why)) - assert store.for_commander(lead.incarnation) == () + assert store.board() == () assert withdrawn.state is DoneTodoState and not withdrawn.pending assert withdrawn.order.previous == todo.held() assert withdrawn.history.events(withdrawn.id, ReleaseTodo) == released.history.events(released.id, ReleaseTodo) diff --git a/tests/test_unit_state.py b/tests/test_unit_state.py index 2c88a586ad..3df5168bd1 100644 --- a/tests/test_unit_state.py +++ b/tests/test_unit_state.py @@ -148,9 +148,7 @@ def test_unit_state_family_uses_real_records(held_bus, tmp_path, request, member publish(comms, unit, "lead", time.time() - AwaitingCommander.seconds - 1) elif member is Stuck: with comms.goals.todos.writing() as db: - for todo in comms.goals.todos.held_for_commander( - comms.registry.require("lead").incarnation - ): + for todo in comms.goals.todos.board_held(): replace( todo, order=replace( @@ -168,9 +166,7 @@ def test_unit_state_family_uses_real_records(held_bus, tmp_path, request, member elif member is StalledOnNothing: recorded_turn(comms, (False,)) elif member is DeliveredUnclosed: - for todo in comms.goals.todos.held_for_commander( - comms.registry.require("lead").incarnation - ): + for todo in comms.goals.todos.board_held(): comms.goals.todos.apply( todo.id, DeliverTodo( @@ -184,9 +180,7 @@ def test_unit_state_family_uses_real_records(held_bus, tmp_path, request, member graph = next(graph for graph in request.getfixturevalue("proof_build_checkouts") if graph.repository.github == CorePackage.repository) with comms.goals.todos.writing() as db: - for todo in comms.goals.todos.held_for_commander( - comms.registry.require("lead").incarnation - ): + for todo in comms.goals.todos.board_held(): checked = todo.order.checked() replace( todo, From 12e9db8a9df4393e108531be40ffb97c8d1a357b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 06:32:56 -0500 Subject: [PATCH 08/15] Share board queries and delete unused commander scope --- src/agent_comms/commander/order.py | 4 ++-- src/agent_comms/commander/standing.py | 12 ++++++------ src/agent_comms/commander/vibe_check.py | 2 +- src/agent_comms/heartbeat_frame.py | 2 +- src/agent_comms/held_orders.py | 17 ++++++++--------- src/agent_comms/order_tools.py | 8 ++++---- src/agent_comms/standing_rules.py | 4 ++-- src/agent_comms/todos.py | 25 ++++++++----------------- src/agent_comms/turn_digests.py | 2 +- src/agent_comms_hooks/skill.py | 2 +- tests/test_commander_forks.py | 10 +++++----- tests/test_head_review.py | 4 ++-- tests/test_holder_standing_rules.py | 2 +- tests/test_order_scopes.py | 2 +- tests/test_todos.py | 2 +- tests/test_unit_state.py | 6 +++--- 16 files changed, 47 insertions(+), 57 deletions(-) diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index 69c2ee478e..8e1d54de67 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -470,7 +470,7 @@ def read_check(self, line: str) -> OrderCheck: def held_orders(self) -> tuple[Todo, ...]: """The live holds Core lists unjudged edits under, so a verdict finds every edit shown.""" - return HeldOrders.read(self.comms, self.commander).todos + return HeldOrders.read(self.comms).todos def held_by(self, unit: str) -> tuple[Todo, ...]: return self.comms.goals.todos.held_by( @@ -1499,7 +1499,7 @@ class NextHuntVerb(OrderVerb, declared_name="next-hunt"): def run(self, ctx: Commander) -> None: for path in HuntDue.next_hunt(ctx.comms, ctx.commander, - HeldOrders.read(ctx.comms, ctx.commander).todos): + HeldOrders.read(ctx.comms).todos): print(path) diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index e86f4b7709..f036b37bcb 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -478,7 +478,7 @@ class HolderGone(IndependentStandingRule, UndatedStandingRule): @classmethod def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: - return cls.in_scan(HeldOrders.read(comms, commander)) + return cls.in_scan(HeldOrders.read(comms)) @classmethod def scan_observed(cls, comms: Comms, commander: str, @@ -508,7 +508,7 @@ class UnownedWheelSpin(IndependentStandingRule, UndatedStandingRule, FamilyScan) def read_idle(cls, comms: Comms, commander: ThreadIncarnation) -> list[Thread]: """A fresh read and an already captured frame share the same idle selection.""" actor = comms.registry.require_lookup(commander.require_recorded().lookup) - return cls.idle(comms, actor.name, HeldOrders.read(comms, actor.name)) + return cls.idle(comms, actor.name, HeldOrders.read(comms)) @classmethod def idle(cls, comms: Comms, commander: str, scan: HeldOrders) -> list[Thread]: @@ -536,7 +536,7 @@ def __hash__(self) -> int: @classmethod def violations(cls, comms: Comms, commander: str) -> tuple[UnownedWheelSpin, ...]: - return cls.scan_orders(comms, commander, HeldOrders.read(comms, commander), PullRequests()) + return cls.scan_orders(comms, commander, HeldOrders.read(comms), PullRequests()) @classmethod def scan_observed(cls, comms: Comms, commander: str, @@ -578,7 +578,7 @@ def paired(cls, comms: Comms, commander: str, *(WaitingUnownedWheelSpin(todo) for todo in rows if todo.id not in covered)) def redecided(self, comms: Comms, commander: str, current: Todo) -> tuple[StandingRule, ...]: - return type(self).paired(comms, commander, (current,), HeldOrders.read(comms, commander), PullRequests()) + return type(self).paired(comms, commander, (current,), HeldOrders.read(comms), PullRequests()) def line(self) -> str: return (f"STAFF {self.todo.id} has no owner and waits for a new unit. who's taking it?" @@ -640,7 +640,7 @@ def observe(self, comms: Comms, prepared: None) -> tuple[UnownedObservation, ... observed.extend( UnownedObservation(commander, rule) for rule in UnownedWheelSpin.paired( - comms, name, mine, HeldOrders.read(comms, name), pulls, + comms, name, mine, HeldOrders.read(comms), pulls, ) ) return tuple(observed) @@ -784,7 +784,7 @@ class Scrutinize(IndependentStandingRule, UndatedStandingRule): def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: actor = comms.registry.require(commander).incarnation return tuple(cls(held, todo, count) - for todo in comms.goals.todos.board_held() + for todo in comms.goals.todos.held_orders() for held in todo.current_holder.current_assignments() for count in (todo.strikes_against(held.owner_identity),) if count >= cls.threshold) diff --git a/src/agent_comms/commander/vibe_check.py b/src/agent_comms/commander/vibe_check.py index 239d463927..ef31c45b02 100644 --- a/src/agent_comms/commander/vibe_check.py +++ b/src/agent_comms/commander/vibe_check.py @@ -207,7 +207,7 @@ class Chokehold(IndependentStandingRule, UndatedStandingRule): @classmethod def violations(cls, comms: Comms, commander: str) -> tuple[Self, ...]: - return cls.scan_checks(comms, commander, HeldOrders.read(comms, commander)) + return cls.scan_checks(comms, commander, HeldOrders.read(comms)) @classmethod def scan_observed(cls, comms: Comms, commander: str, diff --git a/src/agent_comms/heartbeat_frame.py b/src/agent_comms/heartbeat_frame.py index dde5b3752b..c521ccf9a9 100644 --- a/src/agent_comms/heartbeat_frame.py +++ b/src/agent_comms/heartbeat_frame.py @@ -39,7 +39,7 @@ class CommanderInputs(HeldOrders): @classmethod def prepare(cls, comms: Comms, commander: str, pulls: ProgressRead, plans: PlanReadings) -> Self: - holds = HeldOrders.read(comms, commander) + holds = HeldOrders.read(comms) pulls.register(holds.todos) snapshot = comms.registry.snapshot() project = snapshot.commander_project.require() diff --git a/src/agent_comms/held_orders.py b/src/agent_comms/held_orders.py index 32a5047c48..05026f9f1d 100644 --- a/src/agent_comms/held_orders.py +++ b/src/agent_comms/held_orders.py @@ -1,4 +1,4 @@ -"""A commander's held orders, split once by whether each holder is still registered.""" +"""Shared held work, split once by whether each holder is still registered.""" from __future__ import annotations @@ -34,7 +34,7 @@ def recalled(self) -> str: @dataclass(frozen=True) class HeldOrders: - """A commander's held orders split once against one registry cut: live holds, whose + """The shared board's held orders split once against one registry cut: live holds, whose recorded holder is still registered, and orphaned holds, whose holder was deleted or registered again since the order. Every reader takes its side of this split.""" @@ -42,15 +42,14 @@ class HeldOrders: orphaned: tuple[Todo, ...] @classmethod - def read(cls, comms: Comms, commander: str) -> HeldOrders: - """The commander's held orders captured once; filtering never reopens the store.""" - return HeldOrders.of(comms.goals.todos, comms.registry.snapshot(), commander) + def read(cls, comms: Comms) -> HeldOrders: + """Shared held work captured once; filtering never reopens the store.""" + return cls.of(comms.goals.todos, comms.registry.snapshot()) @classmethod - def of(cls, store: TodoStore, snapshot: RegistrySnapshot, commander: str) -> HeldOrders: - """The held orders in ``store`` for ``commander`` as ``snapshot`` registers them.""" - return HeldOrders.split(store.board_held(), - snapshot) + def of(cls, store: TodoStore, snapshot: RegistrySnapshot) -> HeldOrders: + """Held work in ``store`` as this one registry cut registers its holders.""" + return cls.split(store.held_orders(), snapshot) @classmethod def split(cls, todos: Sequence[Todo], snapshot: RegistrySnapshot) -> HeldOrders: diff --git a/src/agent_comms/order_tools.py b/src/agent_comms/order_tools.py index 2ba546e262..4e47479d16 100644 --- a/src/agent_comms/order_tools.py +++ b/src/agent_comms/order_tools.py @@ -698,13 +698,13 @@ def apply(self, comms: Comms, caller: ToolCaller) -> OrderStory: @dataclass(frozen=True, kw_only=True) -class CommsFleetStoryTool(BoardReadTool): +class CommsFleetStoryTool(ReadTool, ToolRequest): label = "Fleet story" - description = ("Read what each order this commander holds out is doing now, one line per " - "order; orders held by units no longer registered collapse into one line.") + description = ("Read what each held order on the shared board is doing now, one line per " + "order; orders held by unregistered participants collapse into one line.") def apply(self, comms: Comms, caller: ToolCaller) -> FleetStory: - return FleetStory.collect(HeldOrders.read(comms, caller.subject(self.thread))) + return FleetStory.collect(HeldOrders.read(comms)) @dataclass(frozen=True, kw_only=True) diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index ba753c6ccf..899d605d79 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -745,7 +745,7 @@ def scan_observed(cls, comms: Comms, commander: str, @classmethod def opened(cls, comms: Comms, commander: str) -> tuple[Self, ...]: - return cls.read_scan(comms, commander, HeldOrders.read(comms, commander)) + return cls.read_scan(comms, commander, HeldOrders.read(comms)) @classmethod def read_scan(cls, comms: Comms, commander: str, scan: HeldOrders) -> tuple[Self, ...]: @@ -1169,7 +1169,7 @@ def scan_observed(cls, comms: Comms, commander: str, @classmethod def scan_orderless(cls, goals: Goals, pulls: ProgressRead) -> tuple[Self, ...]: now = time.time() - orders = goals.todos.open_orders() + orders = goals.todos.board() found = ( cls(request=PullRequestHead(repo, row.number, row.head), holders=pulls.working_on(goals, repo, row), diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 15f670fb63..877fc8b110 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -3465,7 +3465,7 @@ class OrderedHunt(Hunt): def require_commander(self, store: TodoStore, commander: ThreadIncarnation) -> None: super().require_commander(store, commander) if not any(todo.id == self.sent - for todo in store.board_held()): + for todo in store.held_orders()): raise TodoError("The hunt must name this commander's current order.") @@ -3648,12 +3648,10 @@ def require_free(self, db: sqlite3.Connection, unit: ThreadIncarnation, "finish or release that order before assigning it again.") def idle(self, db: sqlite3.Connection, snapshot: RegistrySnapshot) -> str: - """Running order candidates holding no occupying order, read from this transaction - and its registry cut; the assigner's commander takes no order and is never listed.""" + """Running candidates with no occupying order, including the requesting colleague.""" occupied = {thread.name for todo in TodoStore._open(db, "assignment IS NOT NULL", ()) if todo.order.occupies_holder() for thread in todo.held().holder_in(snapshot)} - occupied.add(self.assigner.commanding_identity.name) return ", ".join(sorted( candidate.name for thread in snapshot.active_threads(snapshot.active_candidates()) @@ -4920,7 +4918,7 @@ def story_text(self) -> str: return f"{super().story_text()} by {self.replacement}: {self.reason}" def prepare(self, goals, current): - replacing = [todo for todo in goals.todos.board_held() if todo.id == self.replacement] + replacing = [todo for todo in goals.todos.held_orders() if todo.id == self.replacement] if not replacing: raise TodoError('The replacing problem is not held for this commander.') if any(todo.holder == current.holder for todo in replacing): @@ -6226,7 +6224,7 @@ def preparing_edits(self, proposed: tuple[Todo, ...]) -> TodoStore: if not proposed: return self snapshot = RegistryStore.at(self.root).snapshot() - held = self.held_orders() + held = self.held_orders(state=OpenTodoState) workplaces = EditWorkplaces.for_assignment(self.root, snapshot, held, proposed) return PreparedTodoStore(self.root, workplaces) @@ -6377,7 +6375,7 @@ def for_goal(self, owner: ThreadIncarnation, goal_id: GoalId) -> tuple[Todo, ... def require_input_sources(self, inputs: IntegrationInput) -> None: """Current Todo links or an exact saved root orderless decision own each source.""" - todos = self.open_orders() + todos = self.board() commander = RegistryStore.at(self.root).snapshot().commander_project.require().commander approved = set(StoredHeadReview.root_sources( self, (head for source in inputs.sources for head in source.heads), commander)) @@ -6390,14 +6388,10 @@ def require_input_sources(self, inputs: IntegrationInput) -> None: f"{head.repo.to_wire()}#{head.pr} has neither unique Todo" " nor saved root source authority") - def open_orders(self) -> tuple[Todo, ...]: - """Every open order, whichever commander created it: their links say which requests - any commander's board already owns.""" - return self._select("1", (), reader=self._open) - - def held_orders(self) -> tuple[Todo, ...]: + def held_orders(self, *, state: type[TodoState] = TodoState) -> tuple[Todo, ...]: + """Held work across the shared board, optionally within a declared state family.""" return self._select("assignment IS NOT NULL", (), - reader=partial(self._open, state=OpenTodoState)) + reader=partial(self._open, state=state)) def reassignable(self) -> tuple[Todo, ...]: """Work Core released after its objective strikes; never a commander's released backlog. @@ -6432,9 +6426,6 @@ def timers_up_in(held: Sequence[Todo], now: float, return tuple((todo, up) for todo in held for up in todo.timer_up(now) if not todo.order.waiting_on_install(todo, progress)) - def board_held(self) -> tuple[Todo, ...]: - return self._select("assignment IS NOT NULL", (), reader=self._open) - def held_by(self, commander: ThreadIncarnation, unit: ThreadIncarnation) -> tuple[Todo, ...]: return self._select('creator=? AND creator_created=?', (commander.name, commander.wire_birth), reader=partial(self.held_in, unit=unit)) diff --git a/src/agent_comms/turn_digests.py b/src/agent_comms/turn_digests.py index f8d1532e54..47a223cb6e 100644 --- a/src/agent_comms/turn_digests.py +++ b/src/agent_comms/turn_digests.py @@ -305,7 +305,7 @@ def read(self) -> Result: @classmethod def for_commander(cls, comms: Comms, commander: str) -> Iterator[Self]: snapshot = comms.registry.snapshot() - held = HeldOrders.of(comms.goals.todos, snapshot, commander) + held = HeldOrders.of(comms.goals.todos, snapshot) history = comms.goals.todos.board_history() pulls = ProgressRead(history) workplaces = EditWorkplaces.capture(comms, snapshot, held.live, pulls) diff --git a/src/agent_comms_hooks/skill.py b/src/agent_comms_hooks/skill.py index 481384b807..d513bf9330 100644 --- a/src/agent_comms_hooks/skill.py +++ b/src/agent_comms_hooks/skill.py @@ -356,7 +356,7 @@ def held(self, comms: Comms, snapshot: RegistrySnapshot, return "\n".join(( "FUCK YOUR COMPACTION. Your summary is a paraphrase. What the shared board holds:", *(hold.recalled() for commander in commanders - for hold in HeldOrders.of(comms.goals.todos, snapshot, commander.name).live), + for hold in HeldOrders.of(comms.goals.todos, snapshot).live), )) def prepare(self, reader: DeckReader, prompts: tuple[str, ...], trigger: DealTrigger) -> None: diff --git a/tests/test_commander_forks.py b/tests/test_commander_forks.py index 94541b0015..e093ec40a8 100644 --- a/tests/test_commander_forks.py +++ b/tests/test_commander_forks.py @@ -348,7 +348,7 @@ def test_edits_under_a_merged_order_are_nobodys_review(fork_bus): comms, source, _ = fork_bus record_edits(comms, source, 3) - holds = HeldOrders.read(comms, "lead").live + holds = HeldOrders.read(comms).live open_order = {hold.todo.id: NoCommitProgress() for hold in holds} merged = {hold.todo.id: MergedProgress("a" * 40) for hold in holds} workplaces = EditWorkplaces.capture( @@ -374,7 +374,7 @@ def edit_sources(fork_bus, tmp_path, configured_repository): def edit_workplaces(comms): snapshot = comms.registry.snapshot() - holds = HeldOrders.of(comms.goals.todos, snapshot, "lead").live + holds = HeldOrders.of(comms.goals.todos, snapshot).live history = comms.goals.todos.board_history() return holds, EditWorkplaces.capture(comms, snapshot, holds, ProgressRead(history)) @@ -674,7 +674,7 @@ def assign(pair): assert all(result.returncode == 0 for result in results), [result.stderr for result in results] todos = tuple( todo - for todo in comms.goals.todos.board_held() + for todo in comms.goals.todos.held_orders() if todo.id != "P40" ) assert {todo.id for todo in todos} == {"P41", "P42"} @@ -706,7 +706,7 @@ def assign(name): assert sum(result.returncode == 0 for result in results) == 1, [r.stderr for r in results] held = tuple( todo - for todo in comms.goals.todos.board_held() + for todo in comms.goals.todos.held_orders() if todo.id != "P40" ) assert len(held) == 1 and held[0].holder == "worker" @@ -749,7 +749,7 @@ def test_sequential_fork_assignments_refuse_with_the_race_message(fork_bus, tmp_ "--to", "worker", "--where", "owner.py:2", "--fork", name) for name in ("fork-a", "fork-b")) assert first.returncode == 0, first.stderr - held, = (todo for todo in comms.goals.todos.board_held() if todo.holder == "worker") + held, = (todo for todo in comms.goals.todos.held_orders() if todo.holder == "worker") assert second.returncode != 0 and occupied("worker", held) in second.stderr diff --git a/tests/test_head_review.py b/tests/test_head_review.py index 807ea80640..fd1d71bfce 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -817,7 +817,7 @@ def test_capture_derives_only_exact_saved_root_orderless_sources_in_the_actual_c expected = (primary, request) if state == "accepted" else (primary,) assert local.heads == expected # The linked primary also has a NoHeadOrder review: no duplicate. comms.goals.todos.require_input_sources(inputs) - assert all(link.number != request.pr for todo in comms.goals.todos.open_orders() + assert all(link.number != request.pr for todo in comms.goals.todos.board() for link in todo.order.checked().pull_requests) if state == "accepted": record, = StoredHeadReview.head_reviews(comms.goals.todos, request) @@ -1015,7 +1015,7 @@ def test_overdue_linked_head_stays_visible_without_refusing_new_orders(head_bus) held = todo.held() RecordPullRequest(todo.revision, held.owner_identity, PullRequestLink(request.repo, request.pr), held.generation).record(comms.goals, todo.id) - held_orders = comms.goals.todos.board_held() + held_orders = comms.goals.todos.held_orders() assert [rule.request for rule in LinkedUnreviewedHead.scan_reviews( comms.goals, "lead", ProgressRead(held_orders))] == [request] comms.goals.update_goal("outsider", replace(action, problem="P4")) diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index afa0f4a929..5e004c6aac 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -806,7 +806,7 @@ def test_channel_messages_without_a_commander_addressee_are_not_a_question(held_ with held_bus.bus.bus.reading() as source: questions = UnansweredUnitAsks.for_orders( source, held_bus.registry.snapshot(), 'lead', - HeldOrders.read(held_bus, 'lead').holders, time.time(), + HeldOrders.read(held_bus).holders, time.time(), ) assert not any(questions.values()) diff --git a/tests/test_order_scopes.py b/tests/test_order_scopes.py index a272060739..e7b58d16b5 100644 --- a/tests/test_order_scopes.py +++ b/tests/test_order_scopes.py @@ -86,7 +86,7 @@ def test_held_orders_recall_holder_problem_scope_and_last_edit_from_the_store( Thread('triager', frozenset(), Checkout(private_comms.root / 'triager'))) requested(private_comms, TriageScope(), source_repository, 'P2', ('Classify the failing source',), owner='triager') - held = HeldOrders.of(TodoStore(private_comms.root), private_comms.registry.snapshot(), 'lead') + held = HeldOrders.of(TodoStore(private_comms.root), private_comms.registry.snapshot()) assert sorted(hold.recalled() for hold in held.live) == [ 'triager P2 triage: Classify the failing source', 'worker P1 fix: Then stop and report; no src edit before it', diff --git a/tests/test_todos.py b/tests/test_todos.py index 08f2214d3d..7922d54ca1 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -583,7 +583,7 @@ def test_released_open_work_restores_its_own_holder_never_swaps(released_publish goals = {row.holder: c.registry.require(row.holder).goal for row in originals.values()} with LaunchRoot.from_path(ctx.root).selected(): rules = UnownedWheelSpin.paired( - c, ctx.commander, c.goals.todos.reassignable(), HeldOrders.read(c, ctx.commander), + c, ctx.commander, c.goals.todos.reassignable(), HeldOrders.read(c), ProgressRead(tuple(c.goals.todos.get(pid) for pid in originals))) assert {rule.todo.id for rule in rules} == set(originals) assert all(isinstance(rule, RetainedHolderWheelSpin) for rule in rules) diff --git a/tests/test_unit_state.py b/tests/test_unit_state.py index 3df5168bd1..e0d09d0d4e 100644 --- a/tests/test_unit_state.py +++ b/tests/test_unit_state.py @@ -148,7 +148,7 @@ def test_unit_state_family_uses_real_records(held_bus, tmp_path, request, member publish(comms, unit, "lead", time.time() - AwaitingCommander.seconds - 1) elif member is Stuck: with comms.goals.todos.writing() as db: - for todo in comms.goals.todos.board_held(): + for todo in comms.goals.todos.held_orders(): replace( todo, order=replace( @@ -166,7 +166,7 @@ def test_unit_state_family_uses_real_records(held_bus, tmp_path, request, member elif member is StalledOnNothing: recorded_turn(comms, (False,)) elif member is DeliveredUnclosed: - for todo in comms.goals.todos.board_held(): + for todo in comms.goals.todos.held_orders(): comms.goals.todos.apply( todo.id, DeliverTodo( @@ -180,7 +180,7 @@ def test_unit_state_family_uses_real_records(held_bus, tmp_path, request, member graph = next(graph for graph in request.getfixturevalue("proof_build_checkouts") if graph.repository.github == CorePackage.repository) with comms.goals.todos.writing() as db: - for todo in comms.goals.todos.board_held(): + for todo in comms.goals.todos.held_orders(): checked = todo.order.checked() replace( todo, From 6620686af96ab14916adbe88081d8db8615da96a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 07:09:09 -0500 Subject: [PATCH 09/15] Move review scope to the project and retain actual actors --- src/agent_comms/cli_verbs/merge_review.py | 25 +++++---- src/agent_comms/connector_caller.py | 6 --- src/agent_comms/head_review.py | 17 +++--- src/agent_comms/integration.py | 21 +++++--- src/agent_comms/registry_document.py | 52 +++++++++++++------ src/agent_comms/todos.py | 43 +++++----------- src/agent_comms_hooks/queue_bypass.py | 4 +- tests/test_board_verbs.py | 42 ++++++++++----- tests/test_head_review.py | 63 +++++++++++++++++------ tests/test_queue_bypass_hook.py | 29 ++++++----- 10 files changed, 179 insertions(+), 123 deletions(-) diff --git a/src/agent_comms/cli_verbs/merge_review.py b/src/agent_comms/cli_verbs/merge_review.py index 6e20026c5d..ed708101f4 100644 --- a/src/agent_comms/cli_verbs/merge_review.py +++ b/src/agent_comms/cli_verbs/merge_review.py @@ -7,7 +7,7 @@ from agent_comms.cli_command import CliCommand from agent_comms.cli_commands import MergeReviewResult from agent_comms.comms import Comms -from agent_comms.nominal.field_codec import Argument +from agent_comms.nominal.field_codec import Argument, MalformedRecord from agent_comms.head_review import PullRequestHead from agent_comms.repository_name import RepositoryName from agent_comms.registry_document import RegistrySnapshot @@ -22,20 +22,23 @@ class ReviewedMerge: """The CLI and resident hook share exact-head authorization, not a subprocess protocol.""" - def require_actor(self, snapshot: RegistrySnapshot, actor: RecordedThreadIncarnation) -> None: - snapshot.born(actor.lookup) - snapshot.commander_project.require() - def reviewed_merges(self, ctx: Comms, snapshot: RegistrySnapshot, actor: RecordedThreadIncarnation, bypasses: tuple[QueueBypass, ...]) -> MergeReviewResult: - self.require_actor(snapshot, actor) + project = snapshot.commander_project + project.require_actor(snapshot, actor) heads = [] for bypass in bypasses: - for owed in bypass.owes_verdict(bypass.target()): - request = PullRequestHead(RepositoryName.from_wire(owed.repository), owed.number, owed.head) - StoredHeadReview.current_verdict(ctx.goals.todos, request).require_ok(request) - heads.append(request) + target = bypass.target() + try: + request = PullRequestHead( + RepositoryName.from_wire(target.repository), target.number, target.head) + except MalformedRecord as error: + raise ToolRefusedError(f"Invalid merge head: {error}") from error + project.require_scope(snapshot, actor, request.repo) + for owed in bypass.owes_verdict(request): + StoredHeadReview.current_verdict(ctx.goals.todos, owed).require_ok(owed) + heads.append(owed) return MergeReviewResult(tuple(heads)) @@ -56,5 +59,5 @@ def apply(self, ctx: Comms) -> MergeReviewResult: actor, *others = (snapshot.require(binding.bus).incarnation.require_recorded() for binding in bindings) for other in others: - self.require_actor(snapshot, other) + snapshot.commander_project.require_actor(snapshot, other) return self.reviewed_merges(ctx, snapshot, actor, QueueBypassGuard.bypasses(self.bash)) diff --git a/src/agent_comms/connector_caller.py b/src/agent_comms/connector_caller.py index c491174912..855f500f0c 100644 --- a/src/agent_comms/connector_caller.py +++ b/src/agent_comms/connector_caller.py @@ -10,8 +10,6 @@ from .bus_publication import DirectorOrigin from .comms import Comms from .nominal.field_codec import FieldCodec, MalformedRecord -from .head_review import HeadReviewOrder, PullRequestHead -from .thread_identity import ThreadIncarnation from .todos import DirectiveText from .read_basis import OperatorOutcomes from .tool_caller import CommanderForkCaller, NoThreadCaller, ThreadCaller, ToolCaller @@ -68,10 +66,6 @@ def require_directive(self, comms: Comms, body: str) -> None: def origin(self) -> DirectorOrigin: return DirectorOrigin() - def head_review_actor(self, comms: Comms, request: PullRequestHead, - order: HeadReviewOrder) -> ThreadIncarnation: - return order.director_identity(comms.goals, self.identity(comms)) - def exposed(self) -> dict[str, type[ToolRequest] | type[ConnectorTool]]: return super().exposed() | { tool.declared_name: tool for tool in ConnectorTool.members_with(ConnectorTool) diff --git a/src/agent_comms/head_review.py b/src/agent_comms/head_review.py index 55a8f6d3d5..87a6d4bbbe 100644 --- a/src/agent_comms/head_review.py +++ b/src/agent_comms/head_review.py @@ -747,8 +747,8 @@ def arm(self) -> MergeArm: class HeadReviewRelation(DeclaredFamily): """Order relationships share one declaration family and compose their capabilities.""" - def root_sources( - self, record: HeadReviewRecord, commander: ThreadIncarnation + def accepted_orderless_sources( + self, record: HeadReviewRecord ) -> tuple[PullRequestHead, ...]: """Only an orderless relationship supplies authority outside current Todo links.""" return () @@ -766,13 +766,11 @@ def holding(todos: Sequence[Todo], request: PullRequestHead) -> tuple[Todo, ...] """The open orders whose current declaration holds this head, held or released.""" return tuple(todo for todo in todos if todo.order.holds_head(request)) - @abstractmethod def require_reviewer( self, goals: Goals, request: PullRequestHead, actor: ThreadIncarnation - ) -> None: ... - - def director_identity(self, goals: Goals, actor: ThreadIncarnation) -> ThreadIncarnation: - return actor + ) -> None: + snapshot = goals.registry.snapshot() + snapshot.commander_project.require_scope(snapshot, actor, request.repo) @abstractmethod def record_review( @@ -896,9 +894,8 @@ def description(self, reviewer: str, subject: str) -> str: def require_sources( self, goals: Goals, request: PullRequestHead, reviewer: ThreadIncarnation ) -> None: - project = goals.registry.snapshot().commander_project.require() - if reviewer.commanding_identity != project.commander: - raise ToolRefusedError("Only this root's commander may promote the combined carrier") + snapshot = goals.registry.snapshot() + project = snapshot.commander_project.require_scope(snapshot, reviewer, request.repo) inputs = self.require_combined(request, project.repos) goals.todos.require_input_sources(inputs) diff --git a/src/agent_comms/integration.py b/src/agent_comms/integration.py index d500036ef1..8e53df4cd3 100644 --- a/src/agent_comms/integration.py +++ b/src/agent_comms/integration.py @@ -730,13 +730,13 @@ def qualify( self.failure.require_qualification() if not problems or len(set(problems)) != len(problems): raise IntegrationFailed("Qualification requires unique actual problem selectors") - if context.actor_identity.commanding_identity != context.incarnation: - raise IntegrationFailed( - "Qualification requires this root's existing commander authority" - ) + snapshot = context.comms.registry.snapshot() + actor = context.actor_identity + project = snapshot.commander_project + project.require_scope(snapshot, actor, carrier.repo) todos = tuple(context.comms.goals.todos.get(problem) for problem in problems) for todo in todos: - todo.require_commander(context.incarnation) + project.require_scope(snapshot, actor, todo.repo) core = Repo.of(self.checkout.path) registered_core = Repo.selected(context.repos, core.github.to_wire()) base = IntegrationSource.main_head(registered_core) @@ -776,6 +776,7 @@ def qualify( pulls = PullRequests(todos) heads: dict[RepositoryName, tuple[PullRequestHead, ...]] = {} for repo in repositories.values(): + project.require_scope(snapshot, actor, repo.github) repo.acquire(mains[repo.github]) linked = PullRequests.linked_among(todos, repo.github) selected = [] @@ -799,8 +800,8 @@ def qualify( for row in pulls.open(repo.github) if row.base == WorkBranches.main ) - approved = StoredHeadReview.root_sources( - context.comms.goals.todos, candidates, context.incarnation + approved = StoredHeadReview.accepted_orderless_sources( + context.comms.goals.todos, candidates ) for head in approved: for container in carriers: @@ -864,8 +865,12 @@ def qualify( for head in source.heads: head.current() current_todos = tuple(context.comms.goals.todos.get(todo.id) for todo in todos) + current_snapshot = context.comms.registry.snapshot() for todo in current_todos: - todo.require_commander(context.incarnation) + current_snapshot.commander_project.require_scope(current_snapshot, actor, todo.repo) + for source in inputs.sources: + current_snapshot.commander_project.require_scope( + current_snapshot, actor, source.repository) context.comms.goals.todos.require_input_sources(inputs) if ( Repo.git(self.checkout.path, "rev-parse", f"{base}^{{tree}}").strip() diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index a173d0b3b5..0b74538cab 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Literal, Annotated, Self from pydantic import JsonValue +from agent_comms_hooks import ToolRefusedError from .activity import ActivityCapture @@ -566,9 +567,34 @@ def require_write(self) -> None: class CommanderProject(DeclaredFamily, affix="CommanderProject"): """This bus's commander declaration; identity resolves through the registry lookup.""" - @abstractmethod + def participants(self, threads: Iterable[Thread]) -> tuple[Thread, ...]: + """An undeclared project admits no participants.""" + return () + def commanders(self, snapshot: RegistrySnapshot) -> tuple[Thread, ...]: - """Every commander of this bus.""" + """Independent colleagues, derived from their execution declarations.""" + return self.participants(snapshot.threads.values()) + + def require_actor(self, snapshot: RegistrySnapshot, + actor: ThreadIncarnation) -> RegisteredCommanderProject: + """Validate the actual birth and its declared independent or in-turn caller binding.""" + project = self.require() + own = snapshot.born(actor.require_recorded().lookup) + candidates = (own, *snapshot.threads_for(actor.commanding_identity.require_recorded())) + if not project.participants(candidates): + raise ToolRefusedError( + "Only registered independent colleagues or their bound in-turn children " + "may act on this project.") + return project + + def require_scope(self, snapshot: RegistrySnapshot, actor: ThreadIncarnation, + repository: RepositoryName) -> RegisteredCommanderProject: + """The shared actor and configured-repository boundary for review and integration.""" + project = self.require_actor(snapshot, actor) + if repository not in project.repository_names(): + raise ToolRefusedError( + f"Repository {repository.to_wire()} is outside this project's configured scope.") + return project @abstractmethod def leads(self, snapshot: RegistrySnapshot) -> tuple[Thread, ...]: @@ -597,10 +623,6 @@ def reconfigured(self, repositories: RepositorySelection) -> CommanderProject: . class NoCommanderProject(CommanderProject): - def commanders(self, snapshot: RegistrySnapshot) -> tuple[Thread, ...]: - """A bus with no commander project has no commanders, tagged or not.""" - return () - def leads(self, snapshot: RegistrySnapshot) -> tuple[Thread, ...]: return () @@ -608,7 +630,7 @@ def repository_names(self, name: str = "") -> tuple[RepositoryName, ...]: return () def require(self) -> RegisteredCommanderProject: - raise RelationViolationError("This bus has no registered commander project.") + raise ToolRefusedError("This bus has no registered commander project.") def named(self, commander: ThreadIncarnation, repositories: RepositorySelection) -> RegisteredCommanderProject: @@ -624,16 +646,12 @@ class RegisteredCommanderProject(CommanderProject): commander: ThreadIncarnation repos: tuple[Repo, ...] - def commanders(self, snapshot: RegistrySnapshot) -> tuple[Thread, ...]: - """The storage lead and independent collaborators; tags confer no authority. - - The execution declaration, not fork provenance, excludes genuine in-turn children. - """ - return tuple(dict.fromkeys(( - *self.leads(snapshot), - *(peer for thread in snapshot.threads.values() - for executor in thread.role.executor(thread) - for peer in executor.execution.independent_threads(executor))))) + def participants(self, threads: Iterable[Thread]) -> tuple[Thread, ...]: + """Execution owns independence; storage lead, tags and fork provenance grant none.""" + return tuple(dict.fromkeys( + peer for thread in threads + for executor in thread.role.executor(thread) + for peer in executor.execution.independent_threads(executor))) def lead(self, snapshot: RegistrySnapshot) -> Thread: """The one registered commander a fork is declared under.""" diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 877fc8b110..4cd8d183a6 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -4536,23 +4536,12 @@ def rejected(self, goals: Goals, record: HeadReviewRecord) -> None: ... @dataclass(frozen=True) class NoHeadOrder(HeadReviewOrder, HeadOrderLink): - def root_sources(self, record: HeadReviewRecord, - commander: ThreadIncarnation) -> tuple[PullRequestHead, ...]: - return ((record.request,) if record.actor.commanding_identity == commander - and record.verdict.allows_integration() else ()) + def accepted_orderless_sources(self, record: HeadReviewRecord) -> tuple[PullRequestHead, ...]: + return (record.request,) if record.verdict.allows_integration() else () def rejected(self, goals: Goals, record: HeadReviewRecord) -> None: """There is no holder to strike or wake.""" - def require_reviewer(self, goals: Goals, request: PullRequestHead, - actor: ThreadIncarnation) -> None: - snapshot = goals.registry.snapshot() - project = snapshot.commander_project.require() - commanders = {thread.incarnation for thread in project.commanders(snapshot)} - if (actor.commanding_identity not in commanders or - request.repo not in project.repository_names()): - raise TodoError("Only a commander of this project or the director may review its heads.") - def record_review(self, goals: Goals, request: PullRequestHead, verdict: HeadVerdict, actor: ThreadIncarnation) -> HeadReviewRecord: record = HeadReviewRecord(request, actor, RecordedEventTime(time.time()), verdict, self) @@ -4585,8 +4574,7 @@ class OrderLink(ProblemLink, declared_name=None): def require_quote(self, text: str) -> None: """A reading or rejection carries no quoted clause; OrderQuote supplies acceptance's.""" - def require_current(self, current: Todo, actor: ThreadIncarnation) -> None: - current.require_commander(actor) + def require_current(self, current: Todo) -> None: current.require_previous_assignment(self.previous) if current.id != self.problem or current.order.checked().id != self.order_id: raise TodoConflict(current) @@ -4647,10 +4635,8 @@ def prepared(self, goals: Goals, problems: dict[str, str]) -> tuple[HeadReviewOr def require_reviewer(self, goals: Goals, request: PullRequestHead, actor: ThreadIncarnation) -> None: - self.link.require_current(goals.todos.get(self.link.problem), actor) - - def director_identity(self, goals: Goals, actor: ThreadIncarnation) -> ThreadIncarnation: - return actor.require_recorded().acting_for(goals.todos.get(self.link.problem).creator_identity) + super().require_reviewer(goals, request, actor) + self.link.require_current(goals.todos.get(self.link.problem)) def record_review(self, goals: Goals, request: PullRequestHead, verdict: HeadVerdict, actor: ThreadIncarnation) -> OrderHeadReviewRecord: @@ -4827,13 +4813,13 @@ def head_reviews(cls, store: TodoStore, request: PullRequestHead) -> tuple[HeadR return cls.head_reviews_among(db, (request,))[request] @classmethod - def root_sources(cls, store: TodoStore, requests: Iterable[PullRequestHead], - commander: ThreadIncarnation) -> tuple[PullRequestHead, ...]: + def accepted_orderless_sources(cls, store: TodoStore, + requests: Iterable[PullRequestHead]) -> tuple[PullRequestHead, ...]: """One exact-head snapshot; the saved relationship owns orderless source authority.""" with store.snapshot() as db: reviewed = cls.head_reviews_among(db, requests) return tuple(source for records in reviewed.values() for record in records - for source in record.order.root_sources(record, commander)) + for source in record.order.accepted_orderless_sources(record)) @classmethod def current_verdict(cls, store: TodoStore, request: PullRequestHead) -> type[HeadVerdict]: @@ -4866,7 +4852,7 @@ class RecordOrderHeadVerdict(TodoCommand): review: OrderHeadReviewRecord def prepare(self, goals: Goals, current: Todo) -> Self: - self.review.order.require_current(current, self.review.actor) + self.review.order.require_current(current) if not current.order.checked().holds_head(self.review.request): raise TodoError("The reviewed pull request is not linked to this order.") allowed = current.order.checked().review_verdicts() @@ -4877,7 +4863,7 @@ def prepare(self, goals: Goals, current: Todo) -> Self: return self def changed(self, current: Todo) -> Todo: - self.review.order.require_current(current, self.review.actor) + self.review.order.require_current(current) judged = self.review.verdict.judge_order(current, self.review) return replace(judged, revision=current.revision + 1) @@ -6374,11 +6360,10 @@ def for_goal(self, owner: ThreadIncarnation, goal_id: GoalId) -> tuple[Todo, ... reader=partial(self.held_in, unit=owner)) def require_input_sources(self, inputs: IntegrationInput) -> None: - """Current Todo links or an exact saved root orderless decision own each source.""" + """Current Todo links or an exact saved accepted orderless decision own each source.""" todos = self.board() - commander = RegistryStore.at(self.root).snapshot().commander_project.require().commander - approved = set(StoredHeadReview.root_sources( - self, (head for source in inputs.sources for head in source.heads), commander)) + approved = set(StoredHeadReview.accepted_orderless_sources( + self, (head for source in inputs.sources for head in source.heads))) for source in inputs.sources: linked = PullRequests.linked_among(todos, source.repository) for head in source.heads: @@ -6386,7 +6371,7 @@ def require_input_sources(self, inputs: IntegrationInput) -> None: if len(owners) > 1 or not (owners or head in approved): raise TodoError( f"{head.repo.to_wire()}#{head.pr} has neither unique Todo" - " nor saved root source authority") + " nor saved accepted orderless source") def held_orders(self, *, state: type[TodoState] = TodoState) -> tuple[Todo, ...]: """Held work across the shared board, optionally within a declared state family.""" diff --git a/src/agent_comms_hooks/queue_bypass.py b/src/agent_comms_hooks/queue_bypass.py index d9ba9988da..b9af9ed918 100644 --- a/src/agent_comms_hooks/queue_bypass.py +++ b/src/agent_comms_hooks/queue_bypass.py @@ -124,7 +124,7 @@ def selected(cls, options: Parameters, selectors: tuple[str, ...]) -> bool: ... @abstractmethod def target(self) -> MergeTarget: ... - def owes_verdict(self, target: MergeTarget) -> tuple[MergeTarget, ...]: + def owes_verdict[T](self, target: T) -> tuple[T, ...]: """The pinned heads that need Core's recorded ok before this bypass runs.""" return (target,) @@ -166,7 +166,7 @@ def target(self) -> MergeTarget: return MergeTarget(repository=self.options.one(self.repo_flag), number=int(self.selectors[0]), head=self.options.one(self.head_flag)) - def owes_verdict(self, target: MergeTarget) -> tuple[MergeTarget, ...]: + def owes_verdict[T](self, target: T) -> tuple[T, ...]: """None: --admin is the review. Tristan, 2026-10-30: "--admin needs no review --admin implies someone reviewed". The head stays pinned, since merging a head nobody named is a real failure; the recorded-verdict demand made the hotfix door slower than the queue diff --git a/tests/test_board_verbs.py b/tests/test_board_verbs.py index 404141fcdd..4bab28079f 100644 --- a/tests/test_board_verbs.py +++ b/tests/test_board_verbs.py @@ -10,16 +10,16 @@ import pytest from agent_comms.commander import judge, order -from agent_comms.channel_targets import CommandersTag from agent_comms.errors import TodoError -from agent_comms.thread_execution import ExternalThreadExecution -from agent_comms.thread_model import DeclaredModel +from agent_comms.thread_execution import CommanderForkExecution, ExternalThreadExecution from agent_comms.threads import Thread from agent_comms.commander.standing import DeliveryAwaitingJudgement from agent_comms.nominal.field_codec import FieldCodec, GoalId from agent_comms.goal_actions import SetGoalAction from commander_heartbeat_fixture import capture_frame from agent_comms.head_review import HeadReviewOrder +from agent_comms.tool_caller import CommanderForkCaller, ThreadCaller +from agent_comms_hooks import ToolRefusedError from agent_comms.recorded_time import RecordedEventTime from agent_comms.standing_rules import HeadReviewTimerUp, LinkedUnreviewedHead, OrderlessUnreviewedHead, ReviewTimerUp, StandingRule from agent_comms.task_attachments.commander_pings import RulePing @@ -273,18 +273,36 @@ def test_a_verdict_on_exactly_the_delivered_head_answers_the_delivery(board_revi assert DeliveryAwaitingJudgement.opened(comms, "lead") == () -def test_every_commander_of_the_project_may_review_its_heads(board_review): - """Review authority is the project's commanders, not its registered lead alone: a #commanders - peer judges the same heads, and a commanded unit still may not.""" +@pytest.mark.parametrize("reviewer", ("lead", "peer", "worker")) +def test_independent_colleagues_may_review_without_rank_tags(board_review, reviewer): + """Native and external colleagues share scope; a storage lead has no extra permission.""" comms, request, _, _ = board_review - comms.registry.declare(Thread("peer", frozenset({"comms", CommandersTag.declared_name}), + comms.registry.declare(Thread("peer", frozenset({"comms"}), comms.registry.require("lead").worktree, - model=DeclaredModel("anthropic/claude-opus-4-6"), execution=ExternalThreadExecution)) - NoHeadOrder().require_reviewer(comms.goals, request, comms.registry.require("peer").incarnation) - with pytest.raises(TodoError, match="Only a commander of this project"): - NoHeadOrder().require_reviewer(comms.goals, request, - comms.registry.require("worker").incarnation) + NoHeadOrder().require_reviewer(comms.goals, request, + comms.registry.require(reviewer).incarnation) + + +def test_in_turn_review_uses_the_actual_bound_parent_not_a_second_independent_actor(board_review): + comms, request, _, _ = board_review + child = comms.registry.register_child("review-child", "worker", CommanderForkExecution, + comms.goals) + with pytest.raises(ToolRefusedError): + ThreadCaller(child.name).head_review_actor(comms, request, NoHeadOrder()) + actor = CommanderForkCaller(child.name).head_review_actor(comms, request, NoHeadOrder()) + assert actor == child.incarnation + assert actor.commanding_identity == comms.registry.require("worker").incarnation + + +def test_review_scope_requires_the_actual_registered_birth(board_review): + comms, request, _, _ = board_review + snapshot = comms.registry.snapshot() + original = comms.registry.require("worker").incarnation.require_recorded() + with pytest.raises(ToolRefusedError): + snapshot.commander_project.require_scope( + snapshot, replace(original, created_at=time.time() + 1000), request.repo) + assert not StoredHeadReview.head_reviews(comms.goals.todos, request) def test_unreviewed_head_line_names_each_verb_once_and_every_argument() -> None: diff --git a/tests/test_head_review.py b/tests/test_head_review.py index fd1d71bfce..997b61108a 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -15,6 +15,7 @@ from agent_comms.commander import judge, order from agent_comms.comms import Comms +from agent_comms.connector_caller import DirectorCaller from agent_comms.closed_pull_requests import ClosedPullRequestWatch from agent_comms.commander.publication_rules import ClosedUnmergedPullRequest from agent_comms.commander_heartbeat import CommanderHeartbeat @@ -38,7 +39,7 @@ from agent_comms.threads import Thread from agent_comms.errors import TodoError from agent_comms.nominal.field_codec import FieldCodec, GoalId, MalformedRecord, RecordProblems -from agent_comms.tool_caller import ThreadCaller +from agent_comms.tool_caller import CommanderPeerCaller, ThreadCaller from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.work_branches import ( SourceGrowth, ThreadBranchCheckout, UnregisteredBranchCheckout, WorkBranches, @@ -109,9 +110,9 @@ def linked_head_bus(head_bus): return head_bus -def invoke(comms, request, verdict, order=NoHeadOrder()): +def invoke(comms, request, verdict, order=NoHeadOrder(), *, caller=ThreadCaller("lead")): tool = CommsHeadVerdictTool(request=request, operation=verdict, order=order) - return tool.invoke(comms, tool.arguments, caller=ThreadCaller("lead")) + return tool.invoke(comms, tool.arguments, caller=caller) def promote(context: order.CommanderContext, request: PullRequestHead) -> HeadReviewed: @@ -430,14 +431,17 @@ def test_linked_qualification_requires_the_original_order_quote(linked_head_bus, assert not any("statuses/" in call[1] for call in github.calls()) -def test_qualification_records_an_untagged_peer_without_transferring_order_ownership(linked_head_bus): +@pytest.mark.parametrize("caller_type", (ThreadCaller, DirectorCaller)) +def test_qualification_records_an_untagged_peer_without_transferring_order_ownership( + linked_head_bus, caller_type, +): comms, github, request = linked_head_bus before = comms.goals.todos.get("P2") tool = CommsHeadVerdictTool( request=request, operation=QualifiedHeadVerdict(QUALIFIED), order=OrderHeadReview.for_request((before,), request, lambda linked: LinkedHeadReview.of(linked, linked.order.edits[0]))) - tool.invoke(comms, tool.arguments, caller=ThreadCaller("worker")) + tool.invoke(comms, tool.arguments, caller=caller_type("worker")) recorded, = StoredHeadReview.head_reviews(comms.goals.todos, request) assert recorded.actor.name == "worker" assert recorded.actor.created_at == comms.registry.require("worker").incarnation.created_at @@ -447,6 +451,31 @@ def test_qualification_records_an_untagged_peer_without_transferring_order_owner assert any("statuses/" in call[1] for call in github.calls()) +def test_peer_review_keeps_the_exact_linked_order_revision(linked_head_bus): + comms, _, request = linked_head_bus + original = comms.goals.todos.get("P2") + relationship = OrderHeadReview(original.revision, UnquotedHeadReview.of(original)) + caller = ThreadCaller("worker") + invoke(comms, request, UnderReviewHeadVerdict(), relationship, caller=caller) + current = comms.goals.todos.get(original.id) + records = StoredHeadReview.head_reviews(comms.goals.todos, request) + with pytest.raises(TodoError): + invoke(comms, request, UnderReviewHeadVerdict(), relationship, caller=caller) + assert comms.goals.todos.get(original.id) == current + assert StoredHeadReview.head_reviews(comms.goals.todos, request) == records + + +@pytest.mark.parametrize("caller_type", (ThreadCaller, DirectorCaller)) +def test_every_review_caller_keeps_configured_repository_scope(head_bus, caller_type): + comms, github, request = head_bus + foreign = replace(request, repo=RepositoryName("foreign", "unconfigured")) + calls = github.calls() + with pytest.raises(ToolRefusedError): + caller_type("worker").head_review_actor(comms, foreign, NoHeadOrder()) + assert github.calls() == calls + assert not StoredHeadReview.head_reviews(comms.goals.todos, foreign) + + def test_qualification_does_not_carry_acceptance_to_an_unchanged_later_head(head_bus, reviewed_source): comms, github, request = head_bus source = reviewed_source @@ -573,9 +602,11 @@ def test_companion_input_changes_the_identity_of_an_unchanged_local_patch( assert not any(HeadReview.accepted(head.repo, head.head) for head in heads) +@pytest.mark.parametrize("actor", ("lead", "worker")) def test_pending_sources_can_compute_but_only_reviewed_other_heads_allow_carrier_promotion( - mutual_sources: MutualSources, github_cli: GitHubFixture) -> None: - pair = mutual_sources + mutual_sources: MutualSources, github_cli: GitHubFixture, actor: str) -> None: + pair = replace(mutual_sources, context=mutual_sources.context.model_copy( + update={"actor": CommanderPeerCaller(actor)})) comms = pair.context.comms client = PullRequestHead(pair.client.github, pair.client.number, pair.client.branch_commit) invoke(comms, client, UnderReviewHeadVerdict()) @@ -784,21 +815,23 @@ def test_failed_permission_withdrawal_preserves_the_preceding_source_record( assert MergeHeadReview.accepted(request.repo, request.head) +@pytest.mark.parametrize("reviewer", ("lead", "worker")) @pytest.mark.parametrize("state", ("accepted", "unreviewed", "rejected", "stale", "uncontained", "status-only")) -def test_capture_derives_only_exact_saved_root_orderless_sources_in_the_actual_carrier( - mutual_sources: MutualSources, github_cli: GitHubFixture, state: str, +def test_capture_derives_only_exact_saved_orderless_sources_in_the_actual_carrier( + mutual_sources: MutualSources, github_cli: GitHubFixture, state: str, reviewer: str, ) -> None: pair = mutual_sources - comms = pair.context.comms + context = pair.context.model_copy(update={"actor": CommanderPeerCaller("worker")}) + comms = context.comms peer = SourceRepository(checkout=pair.core.checkout, github=pair.core.github, number=9) peer.git(peer.checkout, "switch", "-c", "orderless-source", "main") commit = peer.publish_change("Actual orderless source", *peer.write_files({ Path("src/agent_comms/orderless.py"): "orderless = 1\n"})) request = opened(github_cli, peer, peer.number, commit) if state not in ("unreviewed", "status-only"): - invoke(comms, request, ReviewedHeadVerdict(ACCEPTED)) + invoke(comms, request, ReviewedHeadVerdict(ACCEPTED), caller=ThreadCaller(reviewer)) if state == "rejected": - invoke(comms, request, RejectedHeadVerdict(REASON)) + invoke(comms, request, RejectedHeadVerdict(REASON), caller=ThreadCaller(reviewer)) if state == "status-only": ReviewedHeadVerdict(ACCEPTED).post(request.repo, request.head, "lead", "not a saved decision") if state == "stale": @@ -812,7 +845,7 @@ def test_capture_derives_only_exact_saved_root_orderless_sources_in_the_actual_c pair.checkout.include(request) carrier = pair.publish_carrier(github_cli) inputs = IntegrationRun(pair.checkout, pair.output, "retain actual orderless source authority").qualify( - pair.context, ("P2", "P3"), carrier=carrier) + context, ("P2", "P3"), carrier=carrier) local = inputs.source(pair.core.github) expected = (primary, request) if state == "accepted" else (primary,) assert local.heads == expected # The linked primary also has a NoHeadOrder review: no duplicate. @@ -821,7 +854,7 @@ def test_capture_derives_only_exact_saved_root_orderless_sources_in_the_actual_c for link in todo.order.checked().pull_requests) if state == "accepted": record, = StoredHeadReview.head_reviews(comms.goals.todos, request) - assert record.order == NoHeadOrder() and record.actor == pair.context.incarnation + assert record.order == NoHeadOrder() and record.actor == comms.registry.require(reviewer).incarnation else: altered = replace(local, heads=(primary, request)) forged = replace(inputs, sources=tuple(altered if source.repository == pair.core.github else source @@ -831,7 +864,7 @@ def test_capture_derives_only_exact_saved_root_orderless_sources_in_the_actual_c with pytest.raises(ToolRefusedError, match="dropped a captured source parent"): altered.require_ancestry(pair.core, inputs.carrier.head) else: - with pytest.raises(TodoError, match="saved root source authority"): + with pytest.raises(TodoError): comms.goals.todos.require_input_sources(forged) assert not any(call[:2] == ("pr", "merge") for call in github_cli.calls()) diff --git a/tests/test_queue_bypass_hook.py b/tests/test_queue_bypass_hook.py index 2862a7cb61..111fc08e10 100644 --- a/tests/test_queue_bypass_hook.py +++ b/tests/test_queue_bypass_hook.py @@ -108,8 +108,9 @@ def test_option_data_does_not_select_a_merge_family(command, github_cli): assert QueueBypass.matching(simple) == () -def test_commander_api_merge_requires_the_pinned_recorded_ok( - head_bus, reviewed_source, tmp_path): +@pytest.mark.parametrize("actor", ("lead", "worker")) +def test_api_merge_requires_the_pinned_recorded_ok_for_each_colleague( + head_bus, reviewed_source, tmp_path, actor): comms, github, request = head_bus transcript = tmp_path / "s.jsonl" @@ -117,7 +118,7 @@ def command(head): return (f"gh api -X PUT repos/{request.repo.to_wire()}/pulls/{request.pr}/merge" f" -f merge_method=merge -f sha={head}") - with resident_session(comms, tmp_path / "state", "lead", "s") as bridge: + with resident_session(comms, tmp_path / "state", actor, "s") as bridge: def refused(head): result = resident_tool_use(command(head), transcript, bridge) assert result.status == 0, result.stderr @@ -145,29 +146,31 @@ def refused(head): refused(request.head) -@pytest.mark.parametrize("actor,allowed", (("lead", True), ("worker", False))) -def test_pinned_admin_merge_needs_no_recorded_ok_and_uses_the_admitted_actor( - head_bus, tmp_path, actor, allowed): +@pytest.mark.parametrize("actor", ("lead", "worker")) +@pytest.mark.parametrize("configured", (True, False)) +def test_pinned_admin_merge_keeps_repository_scope_without_rank_or_saved_ok( + head_bus, tmp_path, actor, configured): comms, _, request = head_bus - command = (f"gh pr merge {request.pr} --admin --merge --repo {request.repo.to_wire()}" + repository = request.repo.to_wire() if configured else "foreign/unconfigured" + command = (f"gh pr merge {request.pr} --admin --merge --repo {repository}" f" --match-head-commit {request.head}") with resident_session(comms, tmp_path / "state", actor, "s") as bridge: - # A replacement launcher's record may neither revoke nor grant this admitted actor's role. - replacement = "worker" if allowed else "lead" + # The resident's original actor survives replacement launcher metadata. + replacement = "worker" if actor == "lead" else "lead" WatchClaim("s").enrol(WatchRecord(replacement, str(comms.root), (), tmp_path / "elsewhere")) result = resident_tool_use(command, tmp_path / "s.jsonl", bridge) assert result.status == 0, result.stderr - if allowed: + if configured: assert result.stdout == "" else: decision = json.loads(result.stdout)["hookSpecificOutput"] assert decision["permissionDecision"] == "deny" - assert "bound commander" in decision["permissionDecisionReason"] -def test_commander_admin_merge_still_requires_its_head_pin(head_bus, tmp_path): +@pytest.mark.parametrize("pin", ("", "--match-head-commit not-a-commit")) +def test_admin_merge_requires_a_valid_head_pin(head_bus, tmp_path, pin): comms, _, request = head_bus - command = f"gh pr merge {request.pr} --admin --merge --repo {request.repo.to_wire()}" + command = f"gh pr merge {request.pr} --admin --merge --repo {request.repo.to_wire()} {pin}" with resident_session(comms, tmp_path / "state", "lead", "s") as bridge: result = resident_tool_use(command, tmp_path / "s.jsonl", bridge) assert result.status == 0, result.stderr From 41ab5f510e3cd496bf2d4fc3ef2a2f61ca5cc8c5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 07:49:36 -0500 Subject: [PATCH 10/15] Refuse review admission before forge reads and derive board actions --- src/agent_comms/order_tools.py | 6 +--- src/agent_comms/standing_rules.py | 22 +++++++------ src/agent_comms/todos.py | 10 ++---- tests/test_board_verbs.py | 54 ++++++++++++++----------------- tests/test_head_review.py | 15 ++++++++- 5 files changed, 55 insertions(+), 52 deletions(-) diff --git a/src/agent_comms/order_tools.py b/src/agent_comms/order_tools.py index 4e47479d16..f45a08541b 100644 --- a/src/agent_comms/order_tools.py +++ b/src/agent_comms/order_tools.py @@ -220,12 +220,9 @@ class HeadVerdictTool: order: Annotated[HeadReviewOrder, *tool_field("Optional held-order link")] = NoHeadOrder() def apply(self, comms: Comms, caller: ThreadCaller) -> HeadReviewed: + actor = caller.head_review_actor(comms, self.request, self.order) problems: dict[str, str] = {} orders = self.order.prepared(comms.goals, problems) - actors = [] - for order in orders: - with RecordProblems.collecting(problems, '--problem', 'select the current held order'): - actors.append(caller.head_review_actor(comms, self.request, order)) heads = [] with RecordProblems.collecting(problems, '--head', 'supply the current PR head SHA with --head'): heads.append(self.operation.current_head(self.request)) @@ -237,7 +234,6 @@ def apply(self, comms: Comms, caller: ThreadCaller) -> HeadReviewed: if problems: raise TodoProblems(problems) order, = orders - actor, = actors prepared, = preparations return self.operation.reviewed(comms.goals, self.request, prepared, actor, order) diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index 899d605d79..7d4d66ea8d 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -227,20 +227,23 @@ def undecided(self, store: TodoStore) -> bool: """Whether no recorded decision answers this line; only a head verdict can.""" return True - def clearing_commands(self) -> str: - """The applicable verbs, with arguments supplied by this line's observed owners.""" - values = self.command_values() + def clearing_actions(self) -> tuple[type[ClearingVerb], ...]: + """The declared actions applicable to this rule, before presentation compacts them.""" verbs = dict.fromkeys(member for parent in type(self).__mro__ for family in vars(parent).get("clearing_verbs", ()) for member in family.members_with(family)) + return tuple(verb for verb in verbs if self.review_allowed(verb)) + + def clearing_commands(self) -> str: + """The applicable verbs, with arguments supplied by this line's observed owners.""" + values = self.command_values() # Verbs of one tool taking the same arguments print once: `judge ok|back --pr 3 --head H`, # not the same arguments once per verb. alike: dict[tuple[str, tuple[str, ...]], list[str]] = {} - for verb in verbs: - if self.review_allowed(verb): - for command in verb.commands(values): - tool, name, *arguments = command.split(" ") - alike.setdefault((tool, tuple(arguments)), []).append(name) + for verb in self.clearing_actions(): + for command in verb.commands(values): + tool, name, *arguments = command.split(" ") + alike.setdefault((tool, tuple(arguments)), []).append(name) return " or ".join(f"`{' '.join((tool, '|'.join(names), *arguments))}`" for (tool, arguments), names in alike.items()) @@ -1027,8 +1030,7 @@ def began(self) -> float: def line(self) -> str: return (f"JUDGE {self.request.repo.to_wire()}#{self.request.pr} {self.request.head[:9]} is sitting" f" there unjudged. you read it yet?" - f" -> judge ok/back/reviewing/superseded " + " ".join( - f"--{name} {value}" for name, value in self.command_values().items())) + f" -> {self.clearing_commands()}") @dataclass(frozen=True) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 4cd8d183a6..8264a378eb 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -4649,24 +4649,20 @@ def record_review(self, goals: Goals, request: PullRequestHead, verdict: HeadVer @dataclass(frozen=True) class RefusedHeadOrder(HeadReviewOrder, declared_name=None): - """A failed CLI selection contributes its failures beside the head's other requirements. + """A failed CLI selection refuses admission with its collected local diagnostics. It is not a wire member, cannot authenticate and can never become a stored review. """ problems: dict[str, str] - def prepared(self, goals: Goals, problems: dict[str, str]) -> tuple[HeadReviewOrder, ...]: - problems.update(self.problems) - return () - def require_reviewer(self, goals: Goals, request: PullRequestHead, actor: ThreadIncarnation) -> None: - raise RecordProblems(self.problems) + raise TodoProblems(self.problems) def record_review(self, goals: Goals, request: PullRequestHead, verdict: HeadVerdict, actor: ThreadIncarnation) -> HeadReviewRecord: - raise RecordProblems(self.problems) + raise TodoProblems(self.problems) @dataclass(frozen=True) diff --git a/tests/test_board_verbs.py b/tests/test_board_verbs.py index 4bab28079f..80c242903b 100644 --- a/tests/test_board_verbs.py +++ b/tests/test_board_verbs.py @@ -1,6 +1,5 @@ """Board review commands and judge preconditions share real orders and retained releases.""" -import re import shlex import time from contextlib import chdir @@ -17,7 +16,7 @@ from agent_comms.nominal.field_codec import FieldCodec, GoalId from agent_comms.goal_actions import SetGoalAction from commander_heartbeat_fixture import capture_frame -from agent_comms.head_review import HeadReviewOrder +from agent_comms.head_review import HeadReviewOrder, PullRequestHead from agent_comms.tool_caller import CommanderForkCaller, ThreadCaller from agent_comms_hooks import ToolRefusedError from agent_comms.recorded_time import RecordedEventTime @@ -66,29 +65,24 @@ def link(): return comms, request, claim, link -def author_arguments(command, todo): - """The board supplies identities; the reviewer still has to author the review.""" - arguments = shlex.split(command) - verb = arguments[1] - evidence = f"{todo.order.check.directory / todo.order.owner.file}:{todo.order.owner.line}" - authored = { - "ok": ["--why", REASON.text, "--serves", QUOTE], - "back": ["--why", REASON.text, "--roast", ROAST, "--fix", FIX, "--at", evidence], - "reviewing": [], - "superseded": ["--why", REASON.text, "--by", todo.id], - } - return arguments[1:] + authored[verb] - - def assert_review_preconditions(rule, comms, request): ctx = order.Commander.read_root(comms.root) - todo = comms.goals.todos.get("P2") - commands = re.findall(r"`([^`]+)`", rule.line()) + observed = rule.command_values() + todo = comms.goals.todos.get(observed.get("problem", "P2")) + # Supply review prose once by declared argument, not a second roster of verb requirements. + values = {name: shlex.quote(value) for name, value in { + **observed, "why": REASON.text, "serves": QUOTE, "roast": ROAST, "fix": FIX, + "at": f"{todo.order.check.directory / todo.order.owner.file}:{todo.order.owner.line}", + "by": todo.id, + }.items()} judged = [] + allowed = rule.clearing_actions() + commands = (command for action in judge.HeadVerdictVerb.members_with(judge.HeadVerdictVerb) + if action in allowed + for command in action.commands({entry.key: values[entry.key] + for entry in FieldCodec.argument_fields(action) if entry.key in values})) for command in commands: - if not command.startswith("judge "): - continue - verb = judge.JudgeVerb.parse(author_arguments(command, todo)) + verb = judge.JudgeVerb.parse(shlex.split(command)[1:]) assert not verb.argument_problems() scope = OrderHeadReview.for_request(ctx.todos(), request, verb.order_link, verb.problem) scope.require_reviewer(comms.goals, request, ctx.actor_identity) @@ -217,11 +211,10 @@ def test_claim_link_survives_sendbacks_and_explicit_release_without_editing_goal "The worker stopped this independent fixture order before starting different assigned work")).record(comms.goals, other.id) comms.goals.update_goal("worker", SetGoalAction(text="Different work")) goal = comms.registry.require("worker").goal.require() - # One printed command offers every verb that takes the same arguments: `judge ok|back ARGS`. - tool, verbs, arguments = next(command.split(" ", 2) for command in re.findall(r"`([^`]+)`", rule.line()) - if command.startswith("judge ") and "ok" in command.split(" ")[1].split("|")) - command = f"{tool} ok {arguments}" - judge.main(author_arguments(command, released), order.Commander.read_root(comms.root)) + assert judge.OkVerb in rule.clearing_actions() + command, = judge.OkVerb.commands({**rule.command_values(), + "why": shlex.quote(REASON.text), "serves": shlex.quote(QUOTE)}) + judge.main(shlex.split(command)[1:], order.Commander.read_root(comms.root)) accepted = comms.goals.todos.get(original.id) assert accepted.order.holds_head(request) assert StoredHeadReview.head_reviews(comms.goals.todos, request)[0].order.problem == original.id @@ -231,7 +224,7 @@ def test_claim_link_survives_sendbacks_and_explicit_release_without_editing_goal assert reassigned.order.pull_requests == accepted.order.pull_requests -def test_released_work_can_be_judged_and_delivered_without_a_holder( +def test_released_merged_work_refuses_late_review_but_can_be_delivered( held_commander, cited_source, tmp_path): ctx = held_commander store = ctx.comms.goals.todos @@ -249,8 +242,11 @@ def test_released_work_can_be_judged_and_delivered_without_a_holder( head = cited_source.git(cited_source.checkout, "rev-parse", "refs/heads/fix/42").strip() quote = " ".join(current.order.instructions(current).split()[:4]) with chdir(cited_source.checkout): - judge.main(["ok", "--repo", todo.repo.to_wire(), "--problem", todo.id, - "--pr", "42", "--head", head, "--why", REASON.text, "--serves", quote], ctx) + # The fixture already merged this PR. A late source review cannot reopen it. + with pytest.raises(TodoError): + judge.main(["ok", "--repo", todo.repo.to_wire(), "--problem", todo.id, + "--pr", "42", "--head", head, "--why", REASON.text, "--serves", quote], ctx) + assert not StoredHeadReview.head_reviews(store, PullRequestHead(todo.repo, 42, head)) order.main(["done", previous.owner, todo.id], ctx) delivered = store.get(todo.id) assert type(delivered.order) is DeliveredProblemOrder diff --git a/tests/test_head_review.py b/tests/test_head_review.py index 997b61108a..9b990abb0f 100644 --- a/tests/test_head_review.py +++ b/tests/test_head_review.py @@ -471,11 +471,24 @@ def test_every_review_caller_keeps_configured_repository_scope(head_bus, caller_ foreign = replace(request, repo=RepositoryName("foreign", "unconfigured")) calls = github.calls() with pytest.raises(ToolRefusedError): - caller_type("worker").head_review_actor(comms, foreign, NoHeadOrder()) + invoke(comms, foreign, UnderReviewHeadVerdict(), caller=caller_type("worker")) assert github.calls() == calls assert not StoredHeadReview.head_reviews(comms.goals.todos, foreign) +@pytest.mark.parametrize("caller_type", (ThreadCaller, DirectorCaller)) +def test_unresolved_order_refuses_before_external_preparation(head_bus, caller_type): + comms, github, request = head_bus + relationship = OrderHeadReview.for_request( + comms.goals.todos.board(), request, UnquotedHeadReview.of, problem="not-held") + calls = github.calls() + with pytest.raises(ToolRefusedError): + invoke(comms, request, UnderReviewHeadVerdict(), relationship, + caller=caller_type("worker")) + assert github.calls() == calls + assert not StoredHeadReview.head_reviews(comms.goals.todos, request) + + def test_qualification_does_not_carry_acceptance_to_an_unchanged_later_head(head_bus, reviewed_source): comms, github, request = head_bus source = reviewed_source From cd793dd050c8a769fe9d358f7c37754daa10afbf Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 08:05:17 -0500 Subject: [PATCH 11/15] Keep carrier promotion off generic source review hints --- src/agent_comms/commander/judge.py | 5 +++++ src/agent_comms/standing_rules.py | 11 ++++++++++- tests/test_board_verbs.py | 2 ++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/agent_comms/commander/judge.py b/src/agent_comms/commander/judge.py index d9e252ad9c..60bc5b287f 100644 --- a/src/agent_comms/commander/judge.py +++ b/src/agent_comms/commander/judge.py @@ -582,6 +582,11 @@ class PromoteVerb(OkVerb, HeadOnlyVerb): "explicitly review and enqueue this captured combined carrier, never an individual source" ) + @classmethod + def source_review_hint(cls) -> bool: + """Carrier promotion is explicit, never a generic source-review suggestion.""" + return False + def review_head( self, ctx: order.CommanderContext, request: PullRequestHead, linked: HeadReviewOrder ) -> HeadReviewed: diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index 7d4d66ea8d..b655d9a8a2 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -142,6 +142,11 @@ def __init_subclass__(cls, **options: object) -> None: for rule in vars(cls).get("clears", ()): rule.cleared_by(cls) + @classmethod + def source_review_hint(cls) -> bool: + """A generic source-review line may offer this action without carrier evidence.""" + return True + @classmethod def review_allowed(cls, verdicts: tuple[type[HeadVerdict], ...]) -> bool: """Ordinary verbs do not select a head verdict.""" @@ -876,6 +881,9 @@ def decided(store: TodoStore, request: PullRequestHead) -> bool: def undecided(self, store: TodoStore) -> bool: return not self.decided(store, self.request) + def review_allowed(self, verb: type[ClearingVerb]) -> bool: + return verb.source_review_hint() + @dataclass(frozen=True) class HeadReviewCommands(HeadReviewRule, declared_name=None): @@ -895,7 +903,8 @@ class OrderHeadReviewCommands(HeadReviewCommands, declared_name=None): todo: Todo = field(kw_only=True, compare=False) def review_allowed(self, verb: type[ClearingVerb]) -> bool: - return verb.review_allowed(self.todo.order.checked().review_verdicts()) + return (super().review_allowed(verb) + and verb.review_allowed(self.todo.order.checked().review_verdicts())) def command_values(self) -> Mapping[str, str]: return {**super().command_values(), "problem": self.todo.id} diff --git a/tests/test_board_verbs.py b/tests/test_board_verbs.py index 80c242903b..fb6081cbef 100644 --- a/tests/test_board_verbs.py +++ b/tests/test_board_verbs.py @@ -105,6 +105,7 @@ def test_held_board_commands_take_the_same_verdicts_as_the_order(board_review, c rules = LinkedUnreviewedHead.scan_reviews(comms.goals, "lead", ProgressRead(())) rule, = rules assert rule.todo == todo + assert judge.PromoteVerb not in rule.clearing_actions() assert UnderReviewHeadVerdict in assert_review_preconditions(rule, comms, request) frame = capture_frame(comms, "lead", ProgressRead(())) # The timer's own condition remains real: age the observed review timer in this read. @@ -181,6 +182,7 @@ def test_shared_head_keeps_each_order_on_its_own_board_and_timer_line(board_revi def test_orderless_board_commands_do_not_invent_a_problem(board_review): comms, request, _, _ = board_review rule, = OrderlessUnreviewedHead.scan_reviews(comms.goals, "lead", ProgressRead(())) + assert judge.PromoteVerb not in rule.clearing_actions() assert "problem" not in rule.command_values() assert "--problem" not in rule.clearing_commands() assert UnderReviewHeadVerdict in assert_review_preconditions(rule, comms, request) From f927115cb0d0fec1ca1d6c570505a58856fd6048 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 09:01:15 -0500 Subject: [PATCH 12/15] Bind holder changes to consent without peer storage impersonation --- src/agent_comms/cli_bridges/hook_deferral.py | 2 +- src/agent_comms/commander/order.py | 5 +- src/agent_comms/commander/standing.py | 15 +- src/agent_comms/commander_hud.py | 5 +- src/agent_comms/goal_actions.py | 11 +- src/agent_comms/goal_management.py | 6 +- src/agent_comms/order_tools.py | 2 +- src/agent_comms/owner_lifecycle.py | 4 +- src/agent_comms/registry_document.py | 3 +- src/agent_comms/thread_identity.py | 37 +++-- src/agent_comms/threads.py | 11 +- src/agent_comms/todos.py | 164 ++++++++++--------- src/agent_comms/tool_caller.py | 7 +- tests/test_board_verbs.py | 12 +- tests/test_check_root.py | 2 +- tests/test_commander_forks.py | 68 ++++---- tests/test_edit_allocations.py | 6 +- tests/test_holder_consent.py | 137 ++++++++++++++++ tests/test_holder_standing_rules.py | 4 +- tests/test_order_claims.py | 4 +- tests/test_order_story.py | 4 +- tests/test_plan_stalled.py | 4 +- tests/test_todo_stories.py | 4 +- tests/test_todos.py | 14 +- tests/test_unowned_checkout_work.py | 12 +- 25 files changed, 355 insertions(+), 188 deletions(-) create mode 100644 tests/test_holder_consent.py diff --git a/src/agent_comms/cli_bridges/hook_deferral.py b/src/agent_comms/cli_bridges/hook_deferral.py index 79e1a38332..25fb4d602f 100644 --- a/src/agent_comms/cli_bridges/hook_deferral.py +++ b/src/agent_comms/cli_bridges/hook_deferral.py @@ -174,7 +174,7 @@ class ReadIdle: @classmethod def read(cls, comms: Comms, binding: Binding) -> Self: - return cls(tuple(unit.name for unit in UnownedWheelSpin.read_idle(comms, binding.owner))) + return cls(tuple(unit.name for unit in UnownedWheelSpin.read_idle(comms))) def reasons(self, deferred: tuple[str, ...]) -> Iterator[str]: if self.units: diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index 8e1d54de67..b2fc15f1cd 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -473,8 +473,7 @@ def held_orders(self) -> tuple[Todo, ...]: return HeldOrders.read(self.comms).todos def held_by(self, unit: str) -> tuple[Todo, ...]: - return self.comms.goals.todos.held_by( - self.incarnation, self.comms.registry.require(unit).incarnation) + return self.comms.goals.todos.held_by(self.comms.registry.require(unit).incarnation) def units(self) -> frozenset[str]: """The current registry names of every unit this commander ordered.""" @@ -1516,7 +1515,7 @@ class IdleVerb(OrderVerb): help = "print the units Core counts idle: a recent turn and no order" def run(self, ctx: Commander) -> None: - for unit in UnownedWheelSpin.read_idle(ctx.comms, ctx.project.commander): + for unit in UnownedWheelSpin.read_idle(ctx.comms): print(unit.name) diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index f036b37bcb..f3be369dc5 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -505,19 +505,17 @@ class UnownedWheelSpin(IndependentStandingRule, UndatedStandingRule, FamilyScan) recent: ClassVar[int] = 10 * 60 @classmethod - def read_idle(cls, comms: Comms, commander: ThreadIncarnation) -> list[Thread]: + def read_idle(cls, comms: Comms) -> list[Thread]: """A fresh read and an already captured frame share the same idle selection.""" - actor = comms.registry.require_lookup(commander.require_recorded().lookup) - return cls.idle(comms, actor.name, HeldOrders.read(comms)) + return cls.idle(comms, HeldOrders.read(comms)) @classmethod - def idle(cls, comms: Comms, commander: str, scan: HeldOrders) -> list[Thread]: - """Units whose registered status is running, parked threads aside.""" + def idle(cls, comms: Comms, scan: HeldOrders) -> list[Thread]: + """Available independent executors, regardless of who issued the request.""" held = {todo.holder for todo in scan.todos} - excluded = held | {commander, ""} bound = TurnsSince(time.time() - cls.recent) return sorted((candidate for thread in comms.registry.active_threads().values() - if thread.name not in excluded + if thread.name not in held for candidate in thread.execution.order_candidates(thread) if TurnDigestRead(comms.root, candidate, bound).read().turns), key=lambda thread: thread.name) @@ -546,7 +544,6 @@ def scan_observed(cls, comms: Comms, commander: str, @classmethod def scan_orders(cls, comms: Comms, commander: str, scan: HeldOrders, pulls: PullRequests) -> tuple[UnownedWheelSpin, ...]: - actor = comms.registry.require(commander).incarnation return cls.paired(comms, commander, comms.goals.todos.board(), scan, pulls) @classmethod @@ -565,7 +562,7 @@ def paired(cls, comms: Comms, commander: str, owners = {rule.previous.owner_identity for rule in retained} unowned = tuple(todo for todo in rows if todo.id not in protected) # Reading every candidate's recent turns is the expensive part; no unowned row, no read. - idle = [unit for unit in cls.idle(comms, commander, scan) + idle = [unit for unit in cls.idle(comms, scan) if unit.incarnation not in owners] if unowned else [] found = [] for todo in unowned: diff --git a/src/agent_comms/commander_hud.py b/src/agent_comms/commander_hud.py index 405b336d6b..2a76b5bc13 100644 --- a/src/agent_comms/commander_hud.py +++ b/src/agent_comms/commander_hud.py @@ -122,13 +122,12 @@ def next(self) -> str: def order_ids(self) -> tuple[OrderId, ...]: return tuple(dict.fromkeys(card.order for card in self.orders)) - def detail(self, comms: Comms, commander: ThreadIncarnation, - orders: tuple[OrderId, ...]) -> CommanderUnitDetail: + def detail(self, comms: Comms, orders: tuple[OrderId, ...]) -> CommanderUnitDetail: """Expand this published selection from its original record owners.""" 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) + held = store.held_by(self.unit) current = {todo.id: todo for todo in held} if {(todo.id, todo.order.checked().id) for todo in held} != { (card.problem, card.order) for card in self.orders diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index 6420e5a80e..69b84dbf6a 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -52,7 +52,7 @@ from .progress_claims import ProgressClaim from .recorded_time import EventTime from .repository_name import RepositoryName -from .thread_identity import ThreadIncarnation +from .thread_identity import CoreWatcherIncarnation, ThreadIncarnation from .threads import Thread from .reported_turn import ReportedTurnState from .attempt_resolution import ComplianceDecision, OrderDecision, RetryDecision @@ -583,8 +583,8 @@ def continue_goal(self, goal: Goal, ctx: GoalActionContext) -> Goal: def require_ready(self, ctx: GoalActionContext) -> None: """Recheck local request facts before goal reservation or allocating a child.""" self.require_sendable(ctx.goals.registry.snapshot().commander_project) - commander = ctx.goals.registry.require(self.commander) - ctx.goals.todos.owner_request(self.request).require_open(commander.acting_identity) + ctx.goals.registry.require(self.commander) + ctx.goals.todos.owner_request(self.request).require_open(self.assigner) self.review_at.require_recorded("A review time") def require_sendable(self, project: CommanderProject) -> None: @@ -634,15 +634,14 @@ class ReissuedOrderGoalAction(OrderGoalAction): @classmethod def for_todo(cls, todo: Todo, commander: str) -> Self: - """The order the Todo already accepted, handed again with a fresh review window, by the - actor that assigned it.""" + """Core reissues accepted unheld work, recording itself rather than an invented actor.""" now = time.time() order = todo.order.reissued(now) return cls( problem=todo.id, expected_revision=todo.revision, order=order, request=todo.request.require(), review_at=todo.reissued_review(now), commander=commander, - assigner=todo.acting_assigner, + assigner=CoreWatcherIncarnation.watcher(), traced=order.owner) def require_sendable(self, project: CommanderProject) -> None: diff --git a/src/agent_comms/goal_management.py b/src/agent_comms/goal_management.py index 3807432c2d..23e32b678e 100644 --- a/src/agent_comms/goal_management.py +++ b/src/agent_comms/goal_management.py @@ -98,7 +98,7 @@ def todos(self) -> TodoStore: def change_problem(self, problem, operation, actor: ThreadIncarnation): """The caller's resolved identity carries its authority; a name lookup would drop it.""" current = self.todos.get(problem) - current.require_commander(actor) + current.require_maintainer(actor) return operation.prepare(self, current).record(self, problem) def judge_calls(self, verdicts: Sequence[JudgedCall], @@ -121,7 +121,7 @@ def judge_calls(self, verdicts: Sequence[JudgedCall], elif verdict.expected_revision == read_at[problem]: verdict = replace(verdict, expected_revision=states[problem].revision) try: - states[problem].require_commander(actor) + states[problem].require_maintainer(actor) prepared = verdict.prepare(self, states[problem]) states[problem] = prepared.apply(states[problem]) except TodoError as refused: @@ -149,7 +149,7 @@ def fire(self, unit: str, reason: StrikeReason, actor: ThreadIncarnation) -> tup owner = self.registry.require(unit).incarnation store: TodoStore = self.todos rule = self.registry.storage_rule - held = tuple(work for todo in store.held_by(actor.commanding_identity, owner) + held = tuple(work for todo in store.held_by(owner) for work in rule.work_for_problem(todo, self.registry, store)) if held: raise TodoError("; ".join( diff --git a/src/agent_comms/order_tools.py b/src/agent_comms/order_tools.py index f45a08541b..fb6851f25a 100644 --- a/src/agent_comms/order_tools.py +++ b/src/agent_comms/order_tools.py @@ -675,7 +675,7 @@ def apply(self, comms: Comms, reader: ReadAuthority) -> CommanderUnitDetail: hud.require_current() for card in hud.units: if card.unit == self.unit: - return card.detail(comms, self.commander, self.orders) + return card.detail(comms, self.orders) raise ToolRefusedError("The selected unit is no longer in this commander's published frame") diff --git a/src/agent_comms/owner_lifecycle.py b/src/agent_comms/owner_lifecycle.py index 173099f34c..7eeceed104 100644 --- a/src/agent_comms/owner_lifecycle.py +++ b/src/agent_comms/owner_lifecycle.py @@ -26,7 +26,6 @@ from .owner_connection import OwnerConnection from .owner_launch import RetainedOwnerLaunch, StartOwnerRuntimeRequest, StopOwnerRuntimeRequest from .root_runtime import RootRuntimeStore -from .commander_service import CommanderService from .channel_targets import ThreadTarget from .task_attachments.owner_notices import RestartNotice from .diagnostics import OwnerProcessOutput @@ -417,8 +416,7 @@ def _register_owner( if previous.process_identity.offline(): orders = tuple( todo - for commander in CommanderService.boards(self.registry, self.goals) - for todo in self.goals.todos.held_by(commander.incarnation, owned.incarnation) + for todo in self.goals.todos.held_by(owned.incarnation) if todo.pending and todo.held().owner_identity == owned.incarnation ) if orders: diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 0b74538cab..06e762758d 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -650,8 +650,7 @@ def participants(self, threads: Iterable[Thread]) -> tuple[Thread, ...]: """Execution owns independence; storage lead, tags and fork provenance grant none.""" return tuple(dict.fromkeys( peer for thread in threads - for executor in thread.role.executor(thread) - for peer in executor.execution.independent_threads(executor))) + for peer in thread.role.independent_threads(thread))) def lead(self, snapshot: RegistrySnapshot) -> Thread: """The one registered commander a fork is declared under.""" diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index acf43493a4..a3e2a82bde 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -235,16 +235,19 @@ def acting_for(self, authority: ThreadIncarnation) -> ThreadIncarnation: return self def commands(self, creator: ThreadIncarnation) -> bool: - """Whether this actor may act for ``creator``: change its orders, complete its owner - requests and answer what it received. Its own only, unless an authority says more.""" + """Shared work maintenance; this does not authorize taking its holder's work.""" return creator == self.commanding_identity + def represents(self, owner: ThreadIncarnation) -> bool: + """Only its own incarnation, unless a real in-turn caller binds a parent.""" + return self == owner + def answerable(self, owners: Sequence[ThreadIncarnation]) -> tuple[ThreadLookup, ...]: """Whose receipts this actor may answer: a birthless member received nothing.""" return () def require_may_change(self, todo: Todo) -> None: - todo.require_commander(self) + todo.require_maintainer(self) def selects_lookup(self, lookup: ThreadLookup) -> bool: return False @@ -317,8 +320,8 @@ def answerable(self, owners: Sequence[ThreadIncarnation]) -> tuple[ThreadLookup, def acting_for(self, authority: ThreadIncarnation) -> CommanderForkIdentity: return CommanderForkIdentity(self.name, self.created_at, authority) - def peer_for(self, authority: ThreadIncarnation) -> CommanderPeerIdentity: - return CommanderPeerIdentity(self.name, self.created_at, authority) + def as_colleague(self) -> CommanderPeerIdentity: + return CommanderPeerIdentity(self.name, self.created_at) def renamed(self, name: str) -> RecordedThreadIncarnation: return RecordedThreadIncarnation(name, self.created_at) @@ -347,15 +350,18 @@ class CommanderForkIdentity(RecordedThreadIncarnation): @property def commanding_identity(self) -> ThreadIncarnation: - return self.authority + return self.authority.commanding_identity def commands(self, creator: ThreadIncarnation) -> bool: """A fork acts with its parent's authority, a commander peer's included.""" return self.authority.commands(creator) + def represents(self, owner: ThreadIncarnation) -> bool: + return super().represents(owner) or self.authority.represents(owner) + @dataclass(frozen=True, eq=False) -class CommanderPeerIdentity(CommanderForkIdentity): +class CommanderPeerIdentity(RecordedThreadIncarnation): """An independent collaborator can maintain shared orders under its own identity. Channel tags confer no privilege, and a retired creator does not orphan shared work. @@ -417,14 +423,17 @@ class UnrecordedThreadIncarnation(BirthlessIncarnation): @dataclass(frozen=True) class CoreWatcherIncarnation(BirthlessIncarnation): - """Core's own watcher: it records failed order checks, releases struck-out orders and hands - them to idle units, and is never a thread. No clock makes it strike or release anything.""" + """Core records objective check failures and reissues unheld work under its own identity. + + It is not a thread and cannot represent a holder or answer its inputs. + """ WIRE_BIRTH: ClassVar[float] = -2.0 hands_out_released_work: ClassVar[bool] = True - def require_may_change(self, todo: Todo) -> None: - """The Core watcher may advance released orders without a commander turn.""" + def commands(self, creator: ThreadIncarnation) -> bool: + """Core maintains shared work, but owns no colleague's assignment or receipts.""" + return True @classmethod def watcher(cls) -> CoreWatcherIncarnation: @@ -616,6 +625,12 @@ def writes(cls) -> tuple[type[ProducedRow], ...]: origin_label: ClassVar[str] + @classmethod + def independent_threads(cls, thread: Thread) -> tuple[Thread, ...]: + """Derive work actors from this role's executors and their declared independence.""" + return tuple(peer for executor in cls.executor(thread) + for peer in executor.execution.independent_threads(executor)) + @classmethod def allows_automatic_reply(cls) -> bool: return False diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index 3cbb81b311..ffbf3b863e 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -250,9 +250,8 @@ def incarnation(self) -> ThreadIncarnation: @property def acting_identity(self) -> ThreadIncarnation: - """An independent collaborator's own authority; in-turn callers bind their parent.""" - own = self.incarnation.require_recorded() - return own.peer_for(own) + """Provenance alone grants no work authority or parent representation.""" + return self.incarnation class ThreadCatalogFile(DurableFile, PatternFile): @@ -266,6 +265,12 @@ class ThreadCatalogFile(DurableFile, PatternFile): class Thread(ThreadProvenance, CatalogSelection): """Declares one agent thread's identity and provenance.""" + @property + def acting_identity(self) -> ThreadIncarnation: + """Only independent executors maintain shared work; children bind at tool entry.""" + return next((peer.incarnation.require_recorded().as_colleague() + for peer in self.role.independent_threads(self)), self.incarnation) + def answering_thread(self, reader: Thread) -> Thread: """A live declaration answers what it owes in its own transcript, whoever reads the debt.""" return self diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 8264a378eb..f7e557d36c 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -290,7 +290,7 @@ def completion_claims(self, delivery: OrderDelivery, order: CheckedProblemOrder, def operation(self, todo: Todo, actor: ThreadIncarnation, comms: Comms, order_id: OrderId, claims: tuple[ProgressClaim, ...]) -> DeliverTodo: - todo.require_commander(actor) + todo.require_maintainer(actor) todo.state.require_open(todo) order = todo.order.checked() order.require_delivery(order_id) @@ -3449,8 +3449,8 @@ def __post_init__(self): raise TodoError("Describe the inspected code and verdict in ten or more words.") self.at.require_recorded("A hunt") - def require_commander(self, store: TodoStore, commander: ThreadIncarnation) -> None: - """The commander boundary validates source references before recording a hunt.""" + def require_maintainer(self, store: TodoStore, commander: ThreadIncarnation) -> None: + """Validate source references before recording shared inspection evidence.""" @dataclass(frozen=True) @@ -3462,11 +3462,11 @@ class CleanHunt(Hunt): class OrderedHunt(Hunt): sent: str - def require_commander(self, store: TodoStore, commander: ThreadIncarnation) -> None: - super().require_commander(store, commander) + def require_maintainer(self, store: TodoStore, commander: ThreadIncarnation) -> None: + super().require_maintainer(store, commander) if not any(todo.id == self.sent for todo in store.held_orders()): - raise TodoError("The hunt must name this commander's current order.") + raise TodoError("The hunt must name a current shared order.") class TodoStoreRows(StoreRows): @@ -3519,6 +3519,12 @@ class ForeignRequest(TodoRefusal): # noqa: N818 - domain refusal reason: ClassVar[str] = "the order serves another commander's request" +class HolderConsentRequired(TodoRefusal): + """Maintenance authority cannot replace an exact holder's consent.""" + + reason: ClassVar[str] = "the current holder has not approved this work change" + + class TodoStoreUnwritable(TodoError): # noqa: N818 - domain-specific storage failure """The todo store could not complete its write; its original cause is preserved.""" @@ -3537,15 +3543,15 @@ def __init__(self, problem: str, claim: OrderClaim) -> None: "publishes the claim once, or returns the original if it is already on the bus.") -class NotOrderCommander(TodoError): # noqa: N818 - domain-specific refusal outcome - """The caller is not the commander who created this order.""" +class NotWorkMaintainer(TodoError): + """The caller has neither shared maintenance nor this holder's representation.""" def __init__(self, current: Todo, actor: ThreadIncarnation) -> None: self.current = current self.actor = actor super().__init__( - f"{current.id} belongs to commander {current.creator_identity.name}; " - f"{actor.name} is not its commander." + f"{actor.name} cannot maintain {current.id}; " + "use an independent colleague or the current holder's bound caller." ) @@ -3568,6 +3574,12 @@ def strike_count(self, facts: StoryFacts) -> int: def owned_by(self, actor: ThreadIncarnation, generation: GoalId) -> bool: return False + def represented_by(self, actor: ThreadIncarnation) -> bool: + return False + + def require_release(self, actor: ThreadIncarnation, current: Todo) -> None: + """Unheld work has no current holder whose consent could be bypassed.""" + def proposed_assignment(self, proposed: Assignment) -> Assignment: return proposed @@ -3611,7 +3623,6 @@ class IncomingWork: order, so work inside it joins or replaces that order and anything else is other work. Only the command carries the group; no record stores it.""" - assigner: ThreadIncarnation problems: frozenset[str] orders: frozenset[OrderId] @@ -3623,12 +3634,10 @@ def require_free(self, db: sqlite3.Connection, unit: ThreadIncarnation, snapshot: RegistrySnapshot) -> None: """The one busy-unit decision, under this transaction and its registry cut. - An open order that still occupies the unit refuses other work for every assigner, - the same one included. Inside this work, its commander outranks the fork that - assigned it, so an order a fork left open, even one whose fork has ended, is the - commander's to replace; sibling forks stay refused, since a fork never created a - problem itself. An order whose work waits only on install holds no unit, as - ProblemOrder.occupies_holder answers.""" + An open order that occupies the unit refuses unrelated work for every actor. + Assignment owns consent for changing the work already inside this group. + Creator and previous assigner confer no rank. Work waiting only on install + holds no unit, as ProblemOrder.occupies_holder answers.""" held = TransactionTodoHistory(db).attach(TodoStore.held_in(db, '1', (), unit=unit)) if other := tuple(todo for todo in held if todo.order.occupies_holder() and not self.covers(todo)): @@ -3636,16 +3645,6 @@ def require_free(self, db: sqlite3.Connection, unit: ThreadIncarnation, f"{unit.name} already holds orders " + ", ".join(f"{todo.id} (assigned by {todo.held().parent})" for todo in other) + f"; running units holding no order: {self.idle(db, snapshot) or 'none'}") - for todo in TransactionTodoHistory(db).attach(TodoStore.held_in(db, - f'NOT (({Assignment.sql_field("parent")}=? ' - f'AND {Assignment.sql_field("parent_created")}=?) ' - 'OR (creator=? AND creator_created=?))', - (self.assigner.name, self.assigner.wire_birth) * 2, unit=unit)): - if todo.order.occupies_holder(): - raise UnitOccupied( - f"{unit.name} is assigned by {todo.held().parent} on open order " - f"{todo.order.checked().id} ({todo.id}); " - "finish or release that order before assigning it again.") def idle(self, db: sqlite3.Connection, snapshot: RegistrySnapshot) -> str: """Running candidates with no occupying order, including the requesting colleague.""" @@ -3745,6 +3744,16 @@ def strike_count(self, facts: StoryFacts) -> int: def owned_by(self, actor: ThreadIncarnation, generation: GoalId) -> bool: return (self.owner_identity, self.generation) == (actor, generation) + def represented_by(self, actor: ThreadIncarnation) -> bool: + return actor.represents(self.owner_identity) + + def require_release(self, actor: ThreadIncarnation, current: Todo) -> None: + current.require_assignment(self) + if not self.represented_by(actor): + raise HolderConsentRequired( + f"{current.id} is held by {self.owner} under goal {self.generation}; " + f"{actor.name} cannot release or replace that assignment without its holder.") + def require_trace(self, owner: str, traced: SourceReference) -> None: owners = {self.owner, *(previous.name for previous in self.previous_owners)} if owner != self.owner and len(owners) >= 2: @@ -3792,7 +3801,8 @@ def prepare_change(cls, goals, current): pass @classmethod - def assign(cls, current: Todo, proposed: Assignment, traced: SourceReference) -> Todo: + def assign(cls, current: Todo, proposed: Assignment, traced: SourceReference, + actor: ThreadIncarnation) -> Todo: raise TodoConflict(current) @classmethod @@ -3811,8 +3821,8 @@ def decide(cls, current: Todo, command: ChangeTodoState) -> Todo: class OpenTodoState(TodoState): @classmethod - def assign(cls, current, proposed, traced): - return current.assigned(proposed, traced) + def assign(cls, current, proposed, traced, actor): + return current.assigned(proposed, traced, actor) class BlockedTodoState(TodoState): @@ -3836,7 +3846,7 @@ def change(cls, current, command): @classmethod def decide(cls, current, command): - current.require_commander(command.actor) + current.require_maintainer(command.actor) return cls.closed(current, current.order) @classmethod @@ -3980,13 +3990,20 @@ def authored_by(self, actor: ThreadIncarnation) -> Self: return replace(self, actor=actor, proposed=replace(self.proposed, parent=actor.name, parent_created=actor.wire_birth)) + def require_holder_consent(self, current: Todo, proposed: Assignment) -> None: + if (current.assignment, current.order) != (proposed, self.order): + current.current_holder.require_release(self.actor, current) + def proposed_work(self, store: TodoStore, problem: str) -> tuple[Todo, ...]: - return (replace(store.get(problem), assignment=self.proposed, order=self.order),) + current = store.get(problem) + proposed = current.proposed_assignment(self.proposed) + self.require_holder_consent(current, proposed) + return (replace(current, assignment=proposed, order=self.order),) def validate(self, db: sqlite3.Connection, current: Todo, store: TodoStore) -> None: self.proposed.require_available_in( db, current.id, IncomingWork( - self.actor, self.group | {current.id}, frozenset({self.order.id})), + self.group | {current.id}, frozenset({self.order.id})), RegistryStore.at(store.root).snapshot(), store.prepared_edits()) OwnerRequest.require(db, id=self.request).require_open(self.actor) @@ -4000,11 +4017,12 @@ def apply(self, current: Todo) -> Todo: def changed(self, current: Todo) -> Todo: self.actor.require_may_change(current) + self.require_holder_consent(current, self.proposed) if self.order != current.order: self.order.require_wording() self.order.require_writable() assigned = (current if current.assignment == self.proposed - else current.state.assign(current, self.proposed, self.traced)) + else current.state.assign(current, self.proposed, self.traced, self.actor)) return replace(assigned, review_at=self.review_at, order=self.order, request=self.request, @@ -4211,7 +4229,7 @@ def story_text(self) -> str: return f"{super().story_text()}; tool call {self.tool_call_id}" def require_current(self, current: Todo) -> None: - current.require_commander(self.actor) + current.require_maintainer(self.actor) if self.tool_call_id in current.judged_calls: raise TodoError(f"Tool call {self.tool_call_id} already has a verdict on {current.id}.") super().require_current(current) @@ -5069,7 +5087,7 @@ def strikes_voided(self) -> tuple[StrikeTodo, ...]: return (self.strike,) def changed(self, current: Todo) -> Todo: - current.require_commander(self.actor) + current.require_maintainer(self.actor) if self.strike not in current.live_strikes(): raise TodoConflict(current) _text(self.reason, "Void reason", 2000) @@ -5106,8 +5124,8 @@ def story_text(self) -> str: at: EventTime = field(default_factory=lambda: EventTime.capture(time.time())) def changed(self, current: Todo) -> Todo: - self.actor.require_may_change(current) current.require_assignment(self.previous) + current.current_holder.require_release(self.actor, current) return replace(current, assignment=None, revision=current.revision + 1) def retained_assignments(self, current: Todo, pulls: PullRequests) -> tuple[Assignment, ...]: @@ -5132,7 +5150,7 @@ def validate(self, db: sqlite3.Connection, current: Todo, store: TodoStore) -> N if not self.previous.running_holders(snapshot): raise TodoConflict(current) self.previous.require_available_in(db, current.id, IncomingWork( - self.actor, frozenset({current.id}), + frozenset({current.id}), frozenset(declared.id for declared in current.order.declarations)), snapshot, store.prepared_edits()) @@ -5160,7 +5178,7 @@ def prepare(self, goals: Goals, current: Todo) -> Self: return self def changed(self, current: Todo) -> Todo: - current.require_commander(self.actor) + current.require_maintainer(self.actor) current.state.require_open(current) _text(self.reason, "Correction reason", 2000) return replace(current, order=current.order.corrected(self), revision=current.revision + 1) @@ -5273,7 +5291,7 @@ def story_text(self) -> str: return f"{self.actor.name} sent order message {self.message_id}" def changed(self, current: Todo) -> Todo: - current.require_commander(self.actor) + current.require_maintainer(self.actor) return replace(current, revision=current.revision + 1) @@ -5307,7 +5325,7 @@ class ServeTodo(TodoOperation): serves: type[OrderPhase] def changed(self, current: Todo) -> Todo: - current.require_commander(self.actor) + current.require_maintainer(self.actor) return replace(current, order=current.order.for_phase(self.serves), revision=current.revision + 1) @@ -5407,7 +5425,7 @@ class DeliverTodo(TodoOperation, declared_name=None): def require_current(self, current: Todo) -> None: super().require_current(current) - current.require_commander(self.actor) + current.require_maintainer(self.actor) current.state.require_open(current) current.order.require_delivery(self.order_id) @@ -5436,7 +5454,7 @@ def closed(self, current: Todo) -> ProblemOrder: ... def require_current(self, current: Todo) -> None: super().require_current(current) - current.require_commander(self.actor) + current.require_maintainer(self.actor) def changed(self, current): return DoneTodoState.closed(current, self.closed(current)) @@ -5466,6 +5484,7 @@ class WithdrawTodo(CloseOrderTodo): superseded_by: Successor = NoSuccessor() def closed(self, current: Todo) -> ProblemOrder: + current.current_holder.require_release(self.actor, current) order = current.order.withdrawn(self.previous, self.why, self.superseded_by) current.require_previous_assignment(self.previous) _text(self.why, "Withdrawal reason", 2000) @@ -5510,12 +5529,12 @@ def __post_init__(self) -> None: def new(cls, commander: ThreadIncarnation, text: str) -> Self: return cls(OwnerRequestId.new(), commander.require_recorded(), text) - def require_commander(self, commander: ThreadIncarnation) -> None: + def require_maintainer(self, commander: ThreadIncarnation) -> None: if not commander.commands(self.commander): - raise ForeignRequest(f"Request {self.id.value} belongs to another commander.") + raise ForeignRequest(f"Request {self.id.value} is outside this actor's work scope.") def require_open(self, commander: ThreadIncarnation) -> OpenRequestState: - self.require_commander(commander) + self.require_maintainer(commander) return self.state.require_open(self.id) def at_revision(self, directives: tuple[RequestDirective, ...]) -> RequestAtRevision: @@ -5733,7 +5752,9 @@ def require_assignee(self, actor: ThreadIncarnation, generation: GoalId | None) def proposed_assignment(self, proposed: Assignment) -> Assignment: return self.holder_state.proposed_assignment(proposed) - def assigned(self, proposed: Assignment, traced: SourceReference) -> Todo: + def assigned(self, proposed: Assignment, traced: SourceReference, + actor: ThreadIncarnation) -> Todo: + self.current_holder.require_release(actor, self) self.order.require_reassignment(self, proposed) holder = self.holder_state holder.require_trace(proposed.owner, traced) @@ -5756,16 +5777,10 @@ def require_fold(self, target: Todo, why: str) -> None: raise TodoError("Say why they are the same problem in 8+ words.") self.order.folded(target.id, why) - @property - def acting_assigner(self) -> ThreadIncarnation: - """Who assigned this order last, acting under its commander: a reissue acts as them, - so a sibling fork's competing order on the unit is still refused.""" - return self.holder_state.previous_assignment().parent_identity.acting_for( - self.creator_identity) - - def require_commander(self, actor: ThreadIncarnation) -> None: - if not actor.commands(self.creator_identity): - raise NotOrderCommander(self, actor) + def require_maintainer(self, actor: ThreadIncarnation) -> None: + if not (actor.commands(self.creator_identity) + or self.current_holder.represented_by(actor)): + raise NotWorkMaintainer(self, actor) def require_actor(self, actor: ThreadIncarnation, generation: GoalId | None) -> None: if not actor.commands(self.creator_identity): @@ -6218,7 +6233,7 @@ def allocate_problem(self) -> str: return str(ProblemAllocation.allocate(db)) def record_hunt(self, commander: ThreadIncarnation, hunt: Hunt) -> HuntRecord: - hunt.require_commander(self, commander) + hunt.require_maintainer(self, commander) recorded = HuntRecord(HuntId.mint(), commander, hunt) with self.writing() as db: recorded.upsert(db) @@ -6229,12 +6244,11 @@ def hunts_for_commander(self, commander: ThreadIncarnation) -> tuple[Hunt, ...]: return tuple(record.hunt for record in HuntRecord.select(db, commander=commander)) def resolve_order(self, commander: ThreadIncarnation, unit: str, problem: str) -> Todo: - authority = commander.commanding_identity - found = self._select('id=? AND creator=? AND creator_created=?', - (problem, authority.name, authority.wire_birth), reader=self._open) + found = self._select('id=?', (problem,), reader=self._open) if (len(found) != 1 or found[0].holder_state.previous_assignment().owner != unit): raise TodoError(f'{unit} holds no order on {problem}') + commander.require_may_change(found[0]) return found[0] def drop(self, commander: ThreadIncarnation, unit: str, problem: str, by: str) -> Todo: @@ -6246,6 +6260,7 @@ def drop(self, commander: ThreadIncarnation, unit: str, problem: str, by: str) - current = self._current(db, old.id) current.require_result(old) self._current(db, newer.id).require_result(newer) + current.current_holder.require_release(commander, current) result = replace( current, order=current.order.copy_as( @@ -6265,9 +6280,11 @@ def drop(self, commander: ThreadIncarnation, unit: str, problem: str, by: str) - def fold(self, commander: ThreadIncarnation, problem: str, into: str, why: str) -> None: with self.writing() as db: source, target = self._current(db, problem), self._current(db, into) - source.require_commander(commander) - target.require_commander(commander) + source.require_maintainer(commander) + target.require_maintainer(commander) source.require_fold(target, why) + source.current_holder.require_release(commander, source) + target.current_holder.require_release(commander, target) replace(target, text=f'{target.text} [+{source.id}: {source.text}]', revision=target.revision + 1).upsert(db) replace(source, state=DoneTodoState, order=source.order.folded(into, why), @@ -6280,7 +6297,9 @@ def link( with self.writing() as db: source, target = self._current(db, sent.id), self._current(db, problem) source.require_result(sent) - target.require_commander(commander) + source.current_holder.require_release(commander, source) + target.current_holder.require_release(commander, target) + target.require_maintainer(commander) target.state.require_open(target) if target.holder not in (None, unit): raise TodoError(f'{problem} is already held by {target.holder}') @@ -6407,9 +6426,9 @@ def timers_up_in(held: Sequence[Todo], now: float, return tuple((todo, up) for todo in held for up in todo.timer_up(now) if not todo.order.waiting_on_install(todo, progress)) - def held_by(self, commander: ThreadIncarnation, unit: ThreadIncarnation) -> tuple[Todo, ...]: - return self._select('creator=? AND creator_created=?', - (commander.name, commander.wire_birth), reader=partial(self.held_in, unit=unit)) + def held_by(self, unit: ThreadIncarnation) -> tuple[Todo, ...]: + """One holder's shared work, independently of historical creators.""" + return self._select('1', (), reader=partial(self.held_in, unit=unit)) def require_recurrence_declaration( self, order: CheckedProblemOrder, where: FileLine, root: Path, @@ -6467,7 +6486,7 @@ def create( prepared = self.preparing_edits((proposed,)) with prepared.writing() as db: proposed.held().require_available_in( - db, todo_id, IncomingWork(assigner, frozenset({todo_id}), frozenset({order.id})), + db, todo_id, IncomingWork(frozenset({todo_id}), frozenset({order.id})), RegistryStore.at(self.root).snapshot(), prepared.prepared_edits()) OwnerRequest.require(db, id=request).require_open(creator.acting_identity) proposed.insert_if_absent(db) @@ -6531,13 +6550,10 @@ def correct( def fire( self, unit: ThreadIncarnation, actor: ThreadIncarnation, reason: StrikeReason, ) -> tuple[Todo, ...]: - """Release every open problem the unit holds from this commander in one write; each - records the same reason, and its order and strikes stay.""" + """Release this holder's shared work with its consent in one transaction.""" with self.writing() as db: - commander = actor.commanding_identity - held = tuple((todo, todo.held()) for todo in TransactionTodoHistory(db).attach(self.held_in( - db, 'creator=? AND creator_created=?', - (commander.name, commander.wire_birth), unit=unit))) + held = tuple((todo, todo.held()) for todo in TransactionTodoHistory(db).attach( + self.held_in(db, '1', (), unit=unit))) if not held: raise TodoError(f"{unit} holds no open problem.") released = tuple(ReleaseTodo(todo.revision, actor, assignment, reason).write(db, todo, self) diff --git a/src/agent_comms/tool_caller.py b/src/agent_comms/tool_caller.py index f681b778e2..d4fcb873fb 100644 --- a/src/agent_comms/tool_caller.py +++ b/src/agent_comms/tool_caller.py @@ -205,8 +205,8 @@ def as_thread(self, commander: str) -> ThreadCaller: @dataclass(frozen=True) -class CommanderPeerAuthority(ActsForCommander, CommanderPeerCaller): - """A peer bound to the registered commander whose orders it judges and directs.""" +class CommanderPeerAuthority(CommanderPeerCaller): + """Explicit record selection, never representation of the selected storage owner.""" authority: str @@ -217,8 +217,7 @@ def commander(self, comms: Comms) -> Thread: return comms.registry.require(self.authority) def identity(self, comms: Comms) -> ThreadIncarnation: - own = comms.registry.require(self.name).incarnation.require_recorded() - return own.peer_for(self.commander(comms).incarnation) + return comms.registry.require(self.name).acting_identity @dataclass(frozen=True) diff --git a/tests/test_board_verbs.py b/tests/test_board_verbs.py index fb6081cbef..4cbe6c645b 100644 --- a/tests/test_board_verbs.py +++ b/tests/test_board_verbs.py @@ -199,8 +199,8 @@ def test_claim_link_survives_sendbacks_and_explicit_release_without_editing_goal StrikeReason(ROAST).text, RecordedEventTime(time.time()), evidence=(current.order.owner,)).record(comms.goals, current.id) current = comms.goals.todos.get(original.id) - ReleaseTodo(current.revision, current.creator_identity, current.held(), StrikeReason( - "The commander explicitly releases this order after reviewing its work")).record(comms.goals, current.id) + ReleaseTodo(current.revision, current.held().owner_identity, current.held(), StrikeReason( + "The holder explicitly releases this order after reviewing its work")).record(comms.goals, current.id) released = comms.goals.todos.get(original.id) assert released.current_holder.current_assignments() == () assert released.order.holds_head(request) @@ -209,7 +209,7 @@ def test_claim_link_survives_sendbacks_and_explicit_release_without_editing_goal assert UnderReviewHeadVerdict in assert_review_preconditions(rule, comms, request) # A former holder can already be doing different work. A head review must not edit that goal. other = comms.goals.todos.get("P1") - ReleaseTodo(other.revision, other.creator_identity, other.held(), StrikeReason( + ReleaseTodo(other.revision, other.held().owner_identity, other.held(), StrikeReason( "The worker stopped this independent fixture order before starting different assigned work")).record(comms.goals, other.id) comms.goals.update_goal("worker", SetGoalAction(text="Different work")) goal = comms.registry.require("worker").goal.require() @@ -222,7 +222,7 @@ def test_claim_link_survives_sendbacks_and_explicit_release_without_editing_goal assert StoredHeadReview.head_reviews(comms.goals.todos, request)[0].order.problem == original.id assert comms.registry.require("worker").goal.require() == goal proposed = Assignment.of(previous.owner_identity, previous.parent_identity, GoalId.mint()) - reassigned = accepted.assigned(proposed, accepted.order.owner) + reassigned = accepted.assigned(proposed, accepted.order.owner, previous.owner_identity) assert reassigned.order.pull_requests == accepted.order.pull_requests @@ -235,8 +235,8 @@ def test_released_merged_work_refuses_late_review_but_can_be_delivered( RecordPullRequest(todo.revision, todo.creator_identity, PullRequestLink(todo.repo, 42), previous.generation).record(ctx.comms.goals, todo.id) current = store.get(todo.id) - ReleaseTodo(current.revision, current.creator_identity, current.held(), StrikeReason( - "The commander explicitly releases this order after reviewing its work")).record(ctx.comms.goals, todo.id) + ReleaseTodo(current.revision, current.held().owner_identity, current.held(), StrikeReason( + "The holder explicitly releases this order after reviewing its work")).record(ctx.comms.goals, todo.id) _, first, _ = merge_two_commit_fix(cited_source, tmp_path / "bin", 42) current = store.get(todo.id) request = ctx.comms.goals.todos.resolve_order(ctx.incarnation, previous.owner, todo.id) diff --git a/tests/test_check_root.py b/tests/test_check_root.py index 49a2c53644..01852fc9f5 100644 --- a/tests/test_check_root.py +++ b/tests/test_check_root.py @@ -174,7 +174,7 @@ def test_acting_for_a_commander_answers_what_that_commander_received(source): people = source.registry.snapshot() retired = people.require("A").incarnation.require_recorded() peer = people.require("B").incarnation.require_recorded() - peer_authority = peer.peer_for(peer) + peer_authority = peer.as_colleague() fork = people.require("unselected").incarnation.require_recorded().acting_for(peer_authority) for actor in (peer_authority, fork): reply = source.messaging.send( diff --git a/tests/test_commander_forks.py b/tests/test_commander_forks.py index e093ec40a8..2938c4a7e7 100644 --- a/tests/test_commander_forks.py +++ b/tests/test_commander_forks.py @@ -225,10 +225,10 @@ def test_registered_fork_sends_parent_problem_with_fork_actor(fork_bus, tmp_path original.id, ReleaseTodo( original.revision, - parent.incarnation, + original.held().owner_identity, original.held(), StrikeReason( - "The original worker stopped and the parent has explicitly released its assignment." + "The original holder explicitly releases its exact assignment before the new order." ), ), ) @@ -243,12 +243,14 @@ def test_registered_fork_sends_parent_problem_with_fork_actor(fork_bus, tmp_path assert saved.latest_message.actor == fork.incarnation assert fork.parent.named(parent.name) assert fork.goal.require().id != parent.goal.require().id - # Core's reissue acts as the fork that assigned the unit, under the fork's commander. - reissuer = saved.acting_assigner - assert reissuer == fork.incarnation and reissuer.commanding_identity == parent.incarnation + # A reissue records Core itself, never reconstructs a departed assigner's authority. + from agent_comms.goal_actions import ReissuedOrderGoalAction + from agent_comms.thread_identity import CoreWatcherIncarnation + reissuer = ReissuedOrderGoalAction.for_todo(saved, parent.name).assigner + assert reissuer == CoreWatcherIncarnation.watcher() reissuer.require_may_change(saved) with store.snapshot() as db: - reissued(saved, reissuer).require_free( + incoming_for(saved).require_free( db, saved.held().owner_identity, comms.registry.snapshot()) print(f"Core send actor: {saved.held().parent}; problem commander: {saved.creator}") ctx = CommanderContext.read_root(comms.root).model_copy( @@ -270,7 +272,7 @@ def test_registered_fork_sends_parent_problem_with_fork_actor(fork_bus, tmp_path assert load.load.owed == 1 and load.load.timers_up == 0 and load.load.unjudged == 0 assert "unjudged edits" in str(load.load) refused = command(comms, "order", "send", "worker", body, "--fork", "fork-two") - assert refused.returncode != 0 and "worker is assigned by fork-one" in refused.stderr + assert refused.returncode != 0 and "cannot release or replace that assignment" in refused.stderr assert store.get("P40") == saved interim = tmp_path / "interim.txt" interim.write_text("The first review is complete; another check remains.") @@ -284,15 +286,15 @@ def test_registered_fork_sends_parent_problem_with_fork_actor(fork_bus, tmp_path assert retired.returncode == 0, retired.stderr assert not comms.registry.status("fork-one").receiving assert store.get("P40") == saved - # The ended fork's open order is the commander's to replace; nobody else's. + # Ending an issuer changes neither unit occupancy nor holder consent. with store.snapshot() as db: - reissued(saved, parent.incarnation).require_free( + incoming_for(saved).require_free( db, saved.held().owner_identity, comms.registry.snapshot()) -def reissued(todo, assigner): - """The incoming work of a resend of ``todo``'s own order by ``assigner``.""" - return IncomingWork(assigner, frozenset({todo.id}), frozenset({todo.order.checked().id})) +def incoming_for(todo): + """Occupancy depends on the incoming work group, not the previous issuer.""" + return IncomingWork(frozenset({todo.id}), frozenset({todo.order.checked().id})) def record_edits(comms, source, count): @@ -487,7 +489,7 @@ def test_canonical_primary_companion_and_released_roots_take_precedence(edit_sou released_path = rule.order_checkout(roots, released.id) source.git(source.checkout, "worktree", "add", "-q", "-b", "released-P41", str(released_path)) store.apply(released.id, ReleaseTodo( - released.revision, comms.registry.require("lead").incarnation, released.held(), + released.revision, released.held().owner_identity, released.held(), StrikeReason("The independent order is released; its checkout must not spill."))) current = store.get("P40") save_edit_order(comms, replace(current, order=replace( @@ -617,19 +619,17 @@ def test_open_unit_consumers_agree_on_one_assignment(fork_bus): owner = comms.registry.require("retained").incarnation assert store.holds_pending(owner) assert store.for_goal(owner, todo.held().generation) == (todo,) - assert store.held_by(comms.registry.require("lead").incarnation, owner) == (todo,) + assert store.held_by(owner) == (todo,) other_birth = RecordedThreadIncarnation(owner.name, owner.wire_birth + 1) assert not store.holds_pending(other_birth) assert store.for_goal(other_birth, todo.held().generation) == () snapshot = comms.registry.snapshot() - sibling = reissued(todo, comms.registry.require("other").incarnation) + incoming = incoming_for(todo) with store.snapshot() as db: - reissued(todo, todo.held().parent_identity).require_free(db, owner, snapshot) - sibling.require_free(db, other_birth, snapshot) + incoming.require_free(db, other_birth, snapshot) queries = [] db.set_trace_callback(queries.append) - with pytest.raises(TodoError, match="retained is assigned by lead"): - sibling.require_free(db, owner, snapshot) + incoming.require_free(db, owner, snapshot) db.set_trace_callback(None) plan = db.execute("EXPLAIN QUERY PLAN " + queries[0]).fetchall() print("Shared open-unit query plan:", [tuple(row) for row in plan]) @@ -641,14 +641,14 @@ def test_a_sibling_fork_assigns_a_unit_whose_order_waits_on_install(fork_bus): store = comms.goals.todos todo = store.get("P40") owner = todo.held().owner_identity - sibling = reissued(todo, comms.registry.require("other").incarnation) - with store.snapshot() as db, pytest.raises(TodoError, match="finish or release"): - sibling.require_free(db, owner, comms.registry.snapshot()) + incoming = IncomingWork(frozenset({"P41"}), frozenset({OrderId.mint()})) + with store.snapshot() as db, pytest.raises(TodoError, match="already holds orders"): + incoming.require_free(db, owner, comms.registry.snapshot()) with store.writing() as db: replace(todo, order=todo.order.merged("0" * 40), revision=todo.revision + 1).upsert(db) assert not store.get("P40").order.occupies_holder() with store.snapshot() as db: - sibling.require_free(db, owner, comms.registry.snapshot()) + incoming.require_free(db, owner, comms.registry.snapshot()) def test_a_value_error_refusal_prints_instead_of_a_traceback(fork_bus): @@ -738,8 +738,8 @@ def open_problem(comms, source, order, pid, owner): def released(comms, todo): """The problem with no holder, as Core's release leaves it.""" return comms.goals.todos.apply(todo.id, ReleaseTodo( - todo.revision, comms.registry.require("lead").incarnation, todo.held(), - StrikeReason("The fixture frees this problem so one order can name it."))) + todo.revision, todo.held().owner_identity, todo.held(), + StrikeReason("The holder releases this problem so another order can name it."))) def test_sequential_fork_assignments_refuse_with_the_race_message(fork_bus, tmp_path): @@ -765,9 +765,14 @@ def test_one_order_takes_a_new_problem_and_one_the_unit_holds_in_either_order( held = open_problem(comms, source, order, "P41", "worker") group = (held.id, fresh.id) if held_first else (fresh.id, held.id) sent = replace(order, id=OrderId.mint()) - result = command(comms, "order", "send", "worker", - document(tmp_path / "group.json", sent, group)) - assert result.returncode == 0, result.stderr + worker = comms.registry.require("worker") + revisions = {todo.id: todo.revision for todo in (held, fresh)} + comms.goals.update_goal(worker.name, OrderGoalAction( + problem=group[0], expected_revision=revisions[group[0]], + together=(ExpectedProblem(group[1], revisions[group[1]]),), + assigner=worker.acting_identity, commander="lead", + request=held.request.require(), review_at=RecordedEventTime(time.time() + 600), + order=sent)) for pid in group: todo = store.get(pid) assert todo.holder == "worker" and todo.order.checked().id == sent.id @@ -791,8 +796,8 @@ def test_unrelated_held_work_refuses_the_same_assigner(fork_bus, tmp_path): assert comms.registry.require("worker").goal == goal -def test_a_sibling_forks_hold_refuses_the_whole_group(fork_bus, tmp_path): - """The group is one write: a sibling's hold on one member writes none of the others.""" +def test_an_unapproved_holder_change_refuses_the_whole_group(fork_bus, tmp_path): + """A maintainer is not the holder: its refusal writes none of the group.""" comms, source, order = fork_bus store = comms.goals.todos first = released(comms, store.get("P40")) @@ -806,8 +811,7 @@ def test_a_sibling_forks_hold_refuses_the_whole_group(fork_bus, tmp_path): document(tmp_path / "group.json", replace(order, id=OrderId.mint()), (other.id, held.id)), "--fork", "fork-two") assert refused.returncode != 0 - assert (f"worker is assigned by fork-one on open order {held.order.checked().id} " - f"({held.id})") in refused.stderr + assert "cannot release or replace that assignment" in refused.stderr assert (store.get(held.id), store.get(other.id)) == (held, other) diff --git a/tests/test_edit_allocations.py b/tests/test_edit_allocations.py index 3ad1c99b0c..810dbb09c8 100644 --- a/tests/test_edit_allocations.py +++ b/tests/test_edit_allocations.py @@ -105,7 +105,7 @@ def test_prepared_cut_refuses_a_replaced_assignment_generation(allocation_bus): with prepared.writing() as db: with pytest.raises(TodoConflict): other_generation.require_available_in( - db, original.id, IncomingWork(actor, frozenset({original.id}), + db, original.id, IncomingWork(frozenset({original.id}), frozenset({original.order.id})), comms.registry.snapshot(), prepared.prepared_edits()) assert store.get(original.id) == original @@ -127,7 +127,7 @@ def test_same_owner_incoming_group_can_share_its_own_physical_allocation(allocat prepared = comms.goals.todos.preparing_edits((proposed,)) with prepared.writing() as db: proposed.held().require_available_in( - db, second.id, IncomingWork(actor, frozenset({first.id, second.id}), + db, second.id, IncomingWork(frozenset({first.id, second.id}), frozenset({first.order.id, second.order.id})), comms.registry.snapshot(), prepared.prepared_edits()) @@ -148,7 +148,7 @@ def test_recreated_holder_cannot_use_the_prepared_birth(allocation_bus): with prepared.writing() as db: with pytest.raises(TodoConflict): proposed.held().require_available_in( - db, current.id, IncomingWork(actor, frozenset({current.id}), + db, current.id, IncomingWork(frozenset({current.id}), frozenset({current.order.id})), comms.registry.snapshot(), prepared.prepared_edits()) assert comms.goals.todos.get(current.id) == current diff --git a/tests/test_holder_consent.py b/tests/test_holder_consent.py new file mode 100644 index 0000000000..c1281d1a5a --- /dev/null +++ b/tests/test_holder_consent.py @@ -0,0 +1,137 @@ +"""Shared maintenance, exact holder consent and genuine caller binding are different contracts.""" + +from dataclasses import replace +import time + +import pytest + +from agent_comms.errors import TodoConflict +from agent_comms.goal_actions import OrderGoalAction, ReissuedOrderGoalAction +from agent_comms.nominal.field_codec import GoalId, OrderId +from agent_comms.recorded_time import RecordedEventTime +from agent_comms.thread_identity import CoreWatcherIncarnation, RecordedThreadIncarnation +from agent_comms.todos import AssignTodo, HolderConsentRequired, ReleaseTodo, StrikeReason +from agent_comms.tool_caller import CommanderForkCaller, CommanderPeerCaller, ThreadCaller + +from test_commander_forks import fork_bus # noqa: F401 + +RELEASE = StrikeReason("The current holder explicitly releases its exact recorded work assignment.") + + +@pytest.mark.parametrize("name", ("lead", "other", "worker")) +def test_shared_maintenance_and_storage_selection_do_not_release_another_holder(fork_bus, name): + comms, _, _ = fork_bus + store = comms.goals.todos + current = store.get("P40") + caller = CommanderPeerCaller(name).as_thread("retained") + actor = caller.identity(comms) + assert actor.commands(current.creator_identity) + assert actor.commanding_identity == comms.registry.require(name).incarnation + assert not actor.represents(current.held().owner_identity) + with pytest.raises(HolderConsentRequired): + ReleaseTodo(current.revision, actor, current.held(), RELEASE).record(comms.goals, current.id) + assert store.get(current.id) == current + + +@pytest.mark.parametrize("caller", (ThreadCaller("retained"), CommanderForkCaller("holder-child"))) +def test_holder_and_its_bound_in_turn_child_can_release_the_exact_hold(fork_bus, caller): + comms, _, _ = fork_bus + current = comms.goals.todos.get("P40") + caller.join(comms, "retained") + actor = caller.identity(comms) + assert actor.represents(current.held().owner_identity) + released = ReleaseTodo(current.revision, actor, current.held(), RELEASE).record( + comms.goals, current.id) + assert released.assignment is None + event, = released.history.events(released.id, ReleaseTodo) + assert event.actor == actor and event.previous == current.held() + + +def test_raw_child_identity_does_not_inherit_a_parents_holder_consent(fork_bus): + comms, _, _ = fork_bus + child = CommanderForkCaller("holder-child") + child.join(comms, "retained") + current = comms.goals.todos.get("P40") + actor = ThreadCaller(child.name).identity(comms) + assert not actor.represents(current.held().owner_identity) + with pytest.raises(HolderConsentRequired): + ReleaseTodo(current.revision, actor, current.held(), RELEASE).record(comms.goals, current.id) + assert comms.goals.todos.get(current.id) == current + + +def test_reused_holder_name_has_no_consent_from_its_previous_birth(fork_bus): + comms, _, _ = fork_bus + current = comms.goals.todos.get("P40") + previous = current.held().owner_identity + actor = RecordedThreadIncarnation(previous.name, previous.wire_birth + 1).as_colleague() + with pytest.raises(HolderConsentRequired): + ReleaseTodo(current.revision, actor, current.held(), RELEASE).record(comms.goals, current.id) + assert comms.goals.todos.get(current.id) == current + + +def test_consent_cannot_release_a_different_assignment_generation(fork_bus): + comms, _, _ = fork_bus + current = comms.goals.todos.get("P40") + actor = comms.registry.require("retained").acting_identity + wrong = replace(current.held(), generation=GoalId.mint()) + with pytest.raises(TodoConflict): + ReleaseTodo(current.revision, actor, wrong, RELEASE).record(comms.goals, current.id) + assert comms.goals.todos.get(current.id) == current + + +def handoff(comms, current, actor): + return comms.goals.update_goal("worker", OrderGoalAction( + problem=current.id, expected_revision=current.revision, + assigner=actor, commander="lead", request=current.request.require(), + review_at=RecordedEventTime(time.time() + 600), + order=replace(current.order, id=OrderId.mint()))) + + +def test_maintainer_cannot_change_holder_or_the_receiving_goal(fork_bus): + comms, _, _ = fork_bus + current = comms.goals.todos.get("P40") + goal = comms.registry.require("worker").goal + actor = CommanderPeerCaller("lead").as_thread("retained").identity(comms) + with pytest.raises(HolderConsentRequired): + handoff(comms, current, actor) + assert comms.goals.todos.get(current.id) == current + assert comms.registry.require("worker").goal == goal + + +def test_original_issuer_cannot_replace_the_order_while_keeping_its_assignment(fork_bus): + comms, _, _ = fork_bus + current = comms.goals.todos.get("P40") + actor = comms.registry.require("lead").acting_identity + operation = AssignTodo.by( + actor, current.revision, current.held().owner_identity, current.held().generation, + current.review_at, replace(current.order, id=OrderId.mint()), + current.request.require(), current.order.owner, frozenset({current.id})) + assert operation.proposed == current.assignment + with pytest.raises(HolderConsentRequired): + operation.record(comms.goals, current.id) + assert comms.goals.todos.get(current.id) == current + + +def test_holder_handoff_keeps_creator_history_and_records_the_actual_actor(fork_bus): + comms, _, _ = fork_bus + current = comms.goals.todos.get("P40") + actor = comms.registry.require("retained").acting_identity + handoff(comms, current, actor) + changed = comms.goals.todos.get(current.id) + worker = comms.registry.require("worker") + assert changed.creator_identity == current.creator_identity + assert changed.held().owner_identity == worker.incarnation + assert changed.held().parent_identity == actor + assert changed.held().generation == worker.goal.require().id + assert changed.held().previous_owners == (current.held().owner_identity,) + + +def test_reissue_records_core_instead_of_reconstructing_the_original_assigner(fork_bus): + comms, _, _ = fork_bus + current = comms.goals.todos.get("P40") + actor = CoreWatcherIncarnation.watcher() + action = ReissuedOrderGoalAction.for_todo(current, "lead") + assert action.assigner == actor + assert actor.commands(current.creator_identity) + assert not actor.represents(current.held().owner_identity) + assert actor.answerable((current.creator_identity,)) == () diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index 5e004c6aac..bfe5007c6a 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -1077,8 +1077,8 @@ def test_a_compliant_answer_resumes_a_held_unit_whose_problems_closed_during_the worker = held_bus.registry.require("worker") todos = held_bus.goals.todos for todo in todos.for_goal(worker.incarnation, worker.goal.require().id): - released = todos.apply(todo.id, ReleaseTodo(todo.revision, lead, todo.held(), StrikeReason( - "The commander moved this assigned repair elsewhere while the unit was held."))) + released = todos.apply(todo.id, ReleaseTodo(todo.revision, todo.held().owner_identity, todo.held(), StrikeReason( + "The holder explicitly releases its exact assignment before the repair moves elsewhere."))) todos.apply(todo.id, WithdrawTodo( released.revision, lead, released.holder_state.previous_assignment(), "The requested repair already merged into main through another problem.")) diff --git a/tests/test_order_claims.py b/tests/test_order_claims.py index 1168e1b872..2f6431bf66 100644 --- a/tests/test_order_claims.py +++ b/tests/test_order_claims.py @@ -439,8 +439,8 @@ def test_unstrike_does_not_undo_an_explicit_release(held_commander): 'The first review counted a duplicated decision that the source did not contain.', evidence=(todo.order.checked().owner,))) released = store.apply(todo.id, ReleaseTodo( - struck.revision, ctx.incarnation, struck.held(), - StrikeReason('The commander deliberately moved the problem off this unit for independent work.'))) + struck.revision, struck.held().owner_identity, struck.held(), + StrikeReason('The holder deliberately releases this exact assignment for independent work.'))) releases = released.history.events(released.id, ReleaseTodo) main(['unstrike', 'P1', '--why', 'The cited duplicated decision was absent and this strike was recorded in error.'], ctx) diff --git a/tests/test_order_story.py b/tests/test_order_story.py index 9d17ee600d..ae8f2a06e8 100644 --- a/tests/test_order_story.py +++ b/tests/test_order_story.py @@ -143,8 +143,8 @@ def test_order_story_copied_bus_strike_and_send_back_once(comms, tmp_path): current = copied.goals.todos.get(saved.id) previous = current.held() released = copied.goals.todos.apply(current.id, ReleaseTodo( - current.revision, actor, previous, - StrikeReason("The copied order is released so its original public history can be checked."), + current.revision, previous.owner_identity, previous, + StrikeReason("The copied holder releases its order so original public history can be checked."), )) withdrawn = copied.goals.todos.apply(current.id, WithdrawTodo( released.revision, actor, previous, diff --git a/tests/test_plan_stalled.py b/tests/test_plan_stalled.py index bfefa1078a..278411d5fb 100644 --- a/tests/test_plan_stalled.py +++ b/tests/test_plan_stalled.py @@ -39,8 +39,8 @@ def test_an_unheld_order_does_not_count(recorded_problem, source_repository): comms = recorded_problem current = held_order(comms, 1, 50) released = comms.goals.todos.apply(current.id, ReleaseTodo(current.revision, - comms.registry.require("lead").incarnation, current.held(), - StrikeReason("The commander releases this order so another worker can repair the owner."))) + current.held().owner_identity, current.held(), + StrikeReason("The holder releases this order so another worker can repair the owner."))) reading = PlanReading((released,), MainTree.of(source_repository, NominalCensus())) assert not BusWriterPhase.ordered(reading) diff --git a/tests/test_todo_stories.py b/tests/test_todo_stories.py index 4c3f6a40ff..dc2a5bf713 100644 --- a/tests/test_todo_stories.py +++ b/tests/test_todo_stories.py @@ -76,8 +76,8 @@ def story_members(comms): strike, VoidStrikeTodo(todo.revision, actor, strike, "The original source trace shows this strike was issued in error."), - ReleaseTodo(todo.revision, actor, todo.held(), StrikeReason( - "The commander transfers this order so its original events remain visible.")), + ReleaseTodo(todo.revision, todo.held().owner_identity, todo.held(), StrikeReason( + "The holder releases this order so its original events remain visible.")), AcceptedVerdict(todo.revision, actor, todo.held(), ToolCallId("accepted-call"), quote="Repair the existing record owner"), WordingCorrection(todo.revision, MessageId.mint("wording correction"), ("custody",), diff --git a/tests/test_todos.py b/tests/test_todos.py index 7922d54ca1..11eb323a54 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -66,7 +66,7 @@ DraftListedPullRequest, MergedListedPullRequest, MergedProgress, - NotOrderCommander, + NotWorkMaintainer, OperationDelivery, PendingProblemOrder, ProgressRead, @@ -199,7 +199,7 @@ def test_create_is_idempotent_and_request_link_is_historical(tmp_path: Path, sou assert TodoStore(tmp_path).list(RepositoryName.from_wire("openhcsdev/agent-comms")) == (original,) newer = other_birth("lead", tmp_path, 2.0) assert store.get(original.id).request == request - with pytest.raises(NotOrderCommander): + with pytest.raises(NotWorkMaintainer): store.apply("P1", ChangeTodoState(1, DoneTodoState, newer.incarnation, None)) assert store.get("P1") == original @@ -244,7 +244,7 @@ def test_withdraw_retires_a_released_problem(tmp_path, source_repository): creator=lead, assigner=lead.incarnation, owner=worker, generation=first_goal_id, request=request, review_at=RecordedEventTime(1000), order=order(source_repository)) - released = store.apply(todo.id, ReleaseTodo(todo.revision, lead.incarnation, todo.held(), + released = store.apply(todo.id, ReleaseTodo(todo.revision, todo.held().owner_identity, todo.held(), StrikeReason("The previous worker stopped before completing this assigned repair on main."))) previous = released.holder_state.previous_assignment() assert previous == todo.held() @@ -310,7 +310,7 @@ def test_withdraw_refuses_a_delivered_problem(tmp_path, released, source_reposit delivered = store.apply(todo.id, DeliverTodo(todo.revision, lead.incarnation, OperationDelivery(at=RecordedEventTime(20)), todo.order.id)) if released: - delivered = store.apply(todo.id, ReleaseTodo(delivered.revision, lead.incarnation, + delivered = store.apply(todo.id, ReleaseTodo(delivered.revision, todo.held().owner_identity, todo.held(), StrikeReason("The worker finished its delivery and no longer needs this assignment."))) with pytest.raises(ValueError): store.apply(todo.id, WithdrawTodo(delivered.revision, lead.incarnation, @@ -572,7 +572,7 @@ def released_published_orders(pr_commander, github_cli): row.revision, owner.incarnation, PullRequestLink(source.github, number), row.held().generation)) originals[problem] = row c.goals.todos.apply(row.id, ReleaseTodo( - row.revision, CoreWatcherIncarnation.watcher(), row.held(), + row.revision, row.held().owner_identity, row.held(), StrikeReason('The watcher released this active unit while its published work remains open.'))) return ctx, originals @@ -650,7 +650,7 @@ def linked(pid, numbers): # The checks read this snapshot; the lead holds one order at a time, so its hold goes # before the next linked problem is ordered to it. ctx.comms.goals.todos.apply(todo.id, ReleaseTodo( - todo.revision, CoreWatcherIncarnation.watcher(), todo.held(), + todo.revision, todo.held().owner_identity, todo.held(), StrikeReason('The fixture moves the lead to the next linked problem it checks.'))) return todo @@ -725,7 +725,7 @@ def test_watcher_git_and_github_reads_leave_wire_free(pr_commander, monkeypatch) for rule in OwedAnswer.opened(c, ctx.commander)) released = pr_order(ctx, "P3", goal_id=pr_work_goal_id) c.goals.todos.apply(released.id, ReleaseTodo( - released.revision, CoreWatcherIncarnation.watcher(), released.held(), + released.revision, released.held().owner_identity, released.held(), StrikeReason("The worker stopped before this order was completed and published."))) rule = c.registry.storage_rule checkout = rule.order_checkout(rule.initial_roots(lead.name), released.id) diff --git a/tests/test_unowned_checkout_work.py b/tests/test_unowned_checkout_work.py index 6cb8f798a5..cc47676883 100644 --- a/tests/test_unowned_checkout_work.py +++ b/tests/test_unowned_checkout_work.py @@ -158,8 +158,8 @@ def checkout_bus(tmp_path: Path, configured_repository, installed_runtime) -> Co row = c.goals.todos.get('P2') assert {related.id for related in c.goals.todos.related_problems(row)} == {'P1', 'P2'} c.goals.todos.apply('P2', ReleaseTodo( - row.revision, CoreWatcherIncarnation.watcher(), row.held(), - StrikeReason('The unit stopped answering this order before its checked work was finished.'))) + row.revision, row.held().owner_identity, row.held(), + StrikeReason('The holder releases this order before its checked work was finished.'))) c.transcripts.external('idle').finish( turn_id=available_turn_id, item_id=NativeItemId('answer'), events=(AssistantTranscript( 'Ready for work', position=NativeItemId('answer'), timestamp=time.time()),)) @@ -177,8 +177,8 @@ def struck_unowned_bus(checkout_bus: Comms) -> tuple[Comms, StrikeTodo]: evidence=(row.order.checked().owner,)) struck = c.goals.todos.apply(row.id, strike) c.goals.todos.apply(row.id, ReleaseTodo( - struck.revision, CoreWatcherIncarnation.watcher(), struck.held(), - StrikeReason('The worker stopped answering before the checked work was finished.'))) + struck.revision, struck.held().owner_identity, struck.held(), + StrikeReason('The holder releases this order before the checked work was finished.'))) return c, strike @@ -308,8 +308,8 @@ def test_order_closed_after_the_frame_read_does_not_fail_the_merge_read( assert 'P1' in worker.orders and not isinstance(worker, Unreadable) row = c.goals.todos.get('P1') c.goals.todos.apply('P1', ReleaseTodo( - row.revision, CoreWatcherIncarnation.watcher(), row.held(), - StrikeReason('The commander closed this order after the heartbeat frame was read.'))) + row.revision, row.held().owner_identity, row.held(), + StrikeReason('The holder releases this order after the heartbeat frame was read.'))) asyncio.run(CommanderHeartbeat().run(lambda: nullcontext(c))) assert c.goals.todos.get('P1').holder is None From 7a421546f17afd780398fd3b7d030ba5199715bd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 10:06:38 -0500 Subject: [PATCH 13/15] Require the receiving goal owner before assigning new work --- src/agent_comms/goal_actions.py | 3 +- src/agent_comms/goal_states.py | 20 +++ src/agent_comms/goals.py | 24 ++++ src/agent_comms/thread_execution.py | 5 +- src/agent_comms/todos.py | 29 ++++- tests/test_holder_consent.py | 184 +++++++++++++++++++++++++++- tests/test_holder_standing_rules.py | 16 ++- 7 files changed, 268 insertions(+), 13 deletions(-) diff --git a/src/agent_comms/goal_actions.py b/src/agent_comms/goal_actions.py index 69b84dbf6a..ab7f5dabf2 100644 --- a/src/agent_comms/goal_actions.py +++ b/src/agent_comms/goal_actions.py @@ -581,7 +581,8 @@ def continue_goal(self, goal: Goal, ctx: GoalActionContext) -> Goal: ctx.goals.registry.snapshot())) def require_ready(self, ctx: GoalActionContext) -> None: - """Recheck local request facts before goal reservation or allocating a child.""" + """Recheck goal consent and local request facts before reserving work or a child.""" + ctx.thread.goal.require_work(self.assigner, ctx.thread) self.require_sendable(ctx.goals.registry.snapshot().commander_project) ctx.goals.registry.require(self.commander) ctx.goals.todos.owner_request(self.request).require_open(self.assigner) diff --git a/src/agent_comms/goal_states.py b/src/agent_comms/goal_states.py index c3689135be..d28ff75119 100644 --- a/src/agent_comms/goal_states.py +++ b/src/agent_comms/goal_states.py @@ -14,6 +14,8 @@ from .goal_actions import GoalActionContext, ProblemOrderGoalAction from .goal_presentation import GoalExecution, GoalExecutionSource from .goals import Goal + from .thread_identity import ThreadIncarnation + from .threads import Thread @dataclass(frozen=True) @@ -66,6 +68,14 @@ def execution(self, source: GoalExecutionSource) -> GoalExecution: ... def accept_order(self, action: ProblemOrderGoalAction, goal: Goal, ctx: GoalActionContext) -> Goal: return action.continue_goal(goal, ctx) + def require_work(self, actor: ThreadIncarnation, owner: Thread) -> None: + from .goals import GoalConsentRequired + if not actor.represents(owner.incarnation): + raise GoalConsentRequired(owner, actor) + + def work_candidates(self, thread: Thread) -> tuple[Thread, ...]: + return () + def require_model_resume(self) -> None: raise ValueError("This goal cannot be resumed; refresh its state.") @@ -157,6 +167,10 @@ def check_transition(self, nxt: GoalState, *, owner: bool) -> None: if self.protected and not owner: raise ValueError(self.source.instruction()) + def require_work(self, actor: ThreadIncarnation, owner: Thread) -> None: + self.require_model_resume() + super().require_work(actor, owner) + class BlockedState(GoalState, FromOpenGoal, declared_name=None): @property @@ -207,6 +221,12 @@ class UnrecordedBlockGoal(BlockedState): @dataclass(frozen=True) class CompletedGoal(Complete, Terminal, GoalState, FromOpenGoal): + def require_work(self, actor: ThreadIncarnation, owner: Thread) -> None: + """Completion frees the goal slot; held Todo and parked-thread fences still apply.""" + + def work_candidates(self, thread: Thread) -> tuple[Thread, ...]: + return (thread,) + def execution(self, source: GoalExecutionSource) -> GoalExecution: from .goal_presentation import CompletedExecution return CompletedExecution(source.goal.id) diff --git a/src/agent_comms/goals.py b/src/agent_comms/goals.py index bd28ce28da..53cce5ce6e 100644 --- a/src/agent_comms/goals.py +++ b/src/agent_comms/goals.py @@ -265,6 +265,18 @@ def __str__(self) -> str: return f"goal {self.revision.id}@{self.revision.revision}" +class GoalConsentRequired(GoalRefused, RejectedEnforcementError): + """A fresh goal observation does not authorize replacing its owner's work.""" + + reason: ClassVar[str] = "goal owner approval required" + + def __init__(self, owner: Thread, actor: ThreadIncarnation) -> None: + super().__init__( + f"{owner.name} already has {owner.goal.captured}; {actor.name} cannot assign " + "new work without that goal owner's approval." + ) + + class GoalChangedError(GoalRefused, RejectedEnforcementError): """An observed goal changed before its checked action could run.""" @@ -607,6 +619,12 @@ def check_expected(self, current: GoalDeclaration) -> None: def accepts_following_handoff(self) -> bool: return False + def require_work(self, actor: ThreadIncarnation, owner: Thread) -> None: + """No goal occupies this ready thread's work slot.""" + + def work_candidates(self, thread: Thread) -> tuple[Thread, ...]: + return (thread,) + @property def snapshot(self) -> GoalDeclaration: return self @@ -816,6 +834,12 @@ def check_expected(self, current: GoalDeclaration) -> None: def accepts_following_handoff(self) -> bool: return bool(self.state.items_with(Active, self)) + def require_work(self, actor: ThreadIncarnation, owner: Thread) -> None: + self.state.require_work(actor, owner) + + def work_candidates(self, thread: Thread) -> tuple[Thread, ...]: + return self.state.work_candidates(thread) + @property def objective(self) -> str: return self.text diff --git a/src/agent_comms/thread_execution.py b/src/agent_comms/thread_execution.py index e392380f30..8337666f4f 100644 --- a/src/agent_comms/thread_execution.py +++ b/src/agent_comms/thread_execution.py @@ -105,9 +105,10 @@ def restart_candidates(cls, thread: Thread, status): @classmethod def order_candidates(cls, thread: Thread) -> tuple[Thread, ...]: - """The thread when it can hold an order: a turn executor whose work state is ready.""" + """Ready executors with a free goal slot; idle time is not consent to replace work.""" return tuple(candidate for executor in thread.role.executor(thread) - for candidate in thread.work_state.candidates(executor)) + for ready in thread.work_state.candidates(executor) + for candidate in ready.goal.work_candidates(ready)) @classmethod def channel_recipients(cls, thread: Thread) -> tuple[Thread, ...]: diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index f7e557d36c..d7b91a908a 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -3701,6 +3701,10 @@ def running_holders(self, snapshot: RegistrySnapshot) -> tuple[Thread, ...]: """A retained assignment follows its live incarnation, never a reused name.""" return tuple(owner for owner in self.holder_in(snapshot) if owner.process_alive) + def require_goal_consent(self, actor: ThreadIncarnation, snapshot: RegistrySnapshot) -> None: + owner = snapshot.born(self.owner_identity.require_recorded().lookup) + owner.goal.require_work(actor, owner) + def require_available_in(self, db: sqlite3.Connection, problem: str, incoming: IncomingWork, snapshot: RegistrySnapshot, workplaces: EditWorkplaces) -> None: """Compare prepared physical edit ownership; launch/check paths confer no occupancy.""" @@ -3990,21 +3994,33 @@ def authored_by(self, actor: ThreadIncarnation) -> Self: return replace(self, actor=actor, proposed=replace(self.proposed, parent=actor.name, parent_created=actor.wire_birth)) + def changes_work(self, current: Todo, proposed: Assignment) -> bool: + return (current.assignment, current.order) != (proposed, self.order) + def require_holder_consent(self, current: Todo, proposed: Assignment) -> None: - if (current.assignment, current.order) != (proposed, self.order): + if self.changes_work(current, proposed): current.current_holder.require_release(self.actor, current) + def require_goal_consent( + self, current: Todo, proposed: Assignment, snapshot: RegistrySnapshot, + ) -> None: + if self.changes_work(current, proposed): + proposed.require_goal_consent(self.actor, snapshot) + def proposed_work(self, store: TodoStore, problem: str) -> tuple[Todo, ...]: current = store.get(problem) proposed = current.proposed_assignment(self.proposed) self.require_holder_consent(current, proposed) + self.require_goal_consent(current, proposed, RegistryStore.at(store.root).snapshot()) return (replace(current, assignment=proposed, order=self.order),) def validate(self, db: sqlite3.Connection, current: Todo, store: TodoStore) -> None: + snapshot = RegistryStore.at(store.root).snapshot() + self.require_goal_consent(current, self.proposed, snapshot) self.proposed.require_available_in( db, current.id, IncomingWork( self.group | {current.id}, frozenset({self.order.id})), - RegistryStore.at(store.root).snapshot(), store.prepared_edits()) + snapshot, store.prepared_edits()) OwnerRequest.require(db, id=self.request).require_open(self.actor) def apply(self, current: Todo) -> Todo: @@ -6477,17 +6493,20 @@ def create( ) -> Todo: number = ProblemNumber.parse(todo_id) identity, assignee = _participant(creator), _thread(owner) + assignment = Assignment.of(assignee, assigner, generation) + assignment.require_goal_consent(assigner, RegistryStore.at(self.root).snapshot()) DirectiveText.require(text) order.require_wording() order.require_writable() proposed = Todo.ordered( - todo_id, repo, text, identity, Assignment.of(assignee, assigner, generation), - review_at, order, request) + todo_id, repo, text, identity, assignment, review_at, order, request) prepared = self.preparing_edits((proposed,)) with prepared.writing() as db: + snapshot = RegistryStore.at(self.root).snapshot() + proposed.held().require_goal_consent(assigner, snapshot) proposed.held().require_available_in( db, todo_id, IncomingWork(frozenset({todo_id}), frozenset({order.id})), - RegistryStore.at(self.root).snapshot(), prepared.prepared_edits()) + snapshot, prepared.prepared_edits()) OwnerRequest.require(db, id=request).require_open(creator.acting_identity) proposed.insert_if_absent(db) current = self._current(db, todo_id) diff --git a/tests/test_holder_consent.py b/tests/test_holder_consent.py index c1281d1a5a..11f7779173 100644 --- a/tests/test_holder_consent.py +++ b/tests/test_holder_consent.py @@ -6,7 +6,10 @@ import pytest from agent_comms.errors import TodoConflict -from agent_comms.goal_actions import OrderGoalAction, ReissuedOrderGoalAction +from agent_comms.goal_actions import CreateProblemGoalAction, OrderGoalAction, OwnerInvocable, PausedGoalAction, ReissuedOrderGoalAction, SetGoalAction +from agent_comms.goal_states import ActiveGoal, BlockedGoal, CompletedGoal, ModelPause, PausedGoal +from agent_comms.goals import Goal, GoalConsentRequired, NoGoal +from agent_comms.order_tools import CommsChangeProblemTool, CommsOrderTool, CommsTodoCreateTool from agent_comms.nominal.field_codec import GoalId, OrderId from agent_comms.recorded_time import RecordedEventTime from agent_comms.thread_identity import CoreWatcherIncarnation, RecordedThreadIncarnation @@ -135,3 +138,182 @@ def test_reissue_records_core_instead_of_reconstructing_the_original_assigner(fo assert actor.commands(current.creator_identity) assert not actor.represents(current.held().owner_identity) assert actor.answerable((current.creator_identity,)) == () + + +def incoming_work(comms): + current = comms.goals.todos.get("P40") + actor = comms.registry.require("retained").acting_identity + released = ReleaseTodo(current.revision, actor, current.held(), RELEASE).record( + comms.goals, current.id) + action = OrderGoalAction( + problem=released.id, expected_revision=released.revision, + assigner=comms.registry.require("lead").acting_identity, commander="lead", + request=released.request.require(), review_at=RecordedEventTime(time.time() + 600), + order=replace(released.order, id=OrderId.mint())) + return released, action + + +@pytest.mark.parametrize("caller", ( + ThreadCaller("lead"), ThreadCaller("other"), + CommanderPeerCaller("lead").as_thread("worker"), +)) +def test_fresh_goal_observation_and_shared_maintenance_are_not_receiving_consent(fork_bus, caller): + comms, _, _ = fork_bus + current, action = incoming_work(comms) + goal = comms.goals.update_goal("worker", SetGoalAction(text="Keep my independent work.")) + action = replace(action, expect=replace(action.expect, expected_checkpoint=goal.captured)) + generation = comms.goals.open_goal_store().require_generation(goal.id) + with pytest.raises(GoalConsentRequired): + caller.invoke(CommsOrderTool(owner="worker", order=action), comms) + assert comms.registry.require("worker").goal == goal + assert comms.goals.todos.get(current.id) == current + assert comms.goals.open_goal_store().require_generation(goal.id) == generation + + +def test_low_level_todo_tool_cannot_attach_work_behind_an_independent_goal(fork_bus): + comms, _, _ = fork_bus + current, action = incoming_work(comms) + goal = comms.goals.update_goal("worker", SetGoalAction(text="Keep my independent work.")) + worker = comms.registry.require("worker") + operation = AssignTodo.by( + action.assigner, current.revision, worker.incarnation, goal.id, + action.review_at, action.order, action.request, action.traced, frozenset({current.id})) + with pytest.raises(GoalConsentRequired): + ThreadCaller("lead").invoke(CommsChangeProblemTool( + problem=current.id, operation=operation), comms) + assert comms.registry.require("worker").goal == goal + assert comms.goals.todos.get(current.id) == current + + +def test_creating_a_problem_does_not_replace_an_independent_goal(fork_bus): + comms, source, _ = fork_bus + current, action = incoming_work(comms) + goal = comms.goals.update_goal("worker", SetGoalAction(text="Keep my independent work.")) + creation = CreateProblemGoalAction( + problem="P41", repo=source.github, text="Another declared repair", + assigner=action.assigner, commander=action.commander, request=action.request, + review_at=action.review_at, order=action.order) + rows = comms.goals.todos.board() + with pytest.raises(GoalConsentRequired): + ThreadCaller("lead").invoke(CommsTodoCreateTool(owner="worker", order=creation), comms) + assert comms.goals.todos.board() == rows + assert comms.registry.require("worker").goal == goal + assert comms.goals.todos.get(current.id) == current + + +@pytest.mark.parametrize("bound_child", (False, True)) +def test_receiving_owner_or_genuine_child_can_accept_work_into_its_goal(fork_bus, bound_child): + comms, _, _ = fork_bus + current, action = incoming_work(comms) + goal = comms.goals.update_goal("worker", SetGoalAction(text="Choose the next repair.")) + caller = CommanderPeerCaller("worker").as_thread("lead") + if bound_child: + caller = CommanderForkCaller("goal-child") + caller.join(comms, "worker") + caller.invoke(CommsOrderTool(owner="worker", order=action), comms) + worker = comms.registry.require("worker") + assigned = comms.goals.todos.get(current.id) + assert worker.goal.require().id == goal.id + assert worker.goal.require().revision == goal.revision + 1 + assert assigned.held().owned_by(worker.incarnation, goal.id) + assert assigned.held().parent_identity == caller.identity(comms) + + +def test_core_reissues_the_exact_accepted_unheld_order_into_a_free_goal_slot(fork_bus): + comms, _, _ = fork_bus + current, _ = incoming_work(comms) + action = ReissuedOrderGoalAction.for_todo(current, "lead") + goal = comms.goals.update_goal("worker", action) + assigned = comms.goals.todos.get(current.id) + assert assigned.order == action.order + assert assigned.creator_identity == current.creator_identity + assert assigned.held().owned_by(comms.registry.require("worker").incarnation, goal.id) + assert assigned.held().parent_identity == CoreWatcherIncarnation.watcher() + + +def test_core_reissue_cannot_override_an_existing_independent_goal(fork_bus): + comms, _, _ = fork_bus + current, _ = incoming_work(comms) + goal = comms.goals.update_goal("worker", SetGoalAction(text="Keep my independent work.")) + with pytest.raises(GoalConsentRequired): + comms.goals.update_goal("worker", ReissuedOrderGoalAction.for_todo(current, "lead")) + assert comms.registry.require("worker").goal == goal + assert comms.goals.todos.get(current.id) == current + + +def test_raw_child_does_not_supply_receiving_goal_consent(fork_bus): + comms, _, _ = fork_bus + current, action = incoming_work(comms) + goal = comms.goals.update_goal("worker", SetGoalAction(text="Keep my independent work.")) + CommanderForkCaller("goal-child").join(comms, "worker") + with pytest.raises(GoalConsentRequired): + ThreadCaller("goal-child").invoke(CommsOrderTool(owner="worker", order=action), comms) + assert comms.registry.require("worker").goal == goal + assert comms.goals.todos.get(current.id) == current + + +def test_receiving_consent_does_not_resume_an_owner_pause(fork_bus): + comms, _, _ = fork_bus + current, action = incoming_work(comms) + comms.goals.update_goal("worker", SetGoalAction(text="Keep my independent work.")) + goal = comms.goals.update_goal( + "worker", PausedGoalAction(), actor=OwnerInvocable) + with pytest.raises(ValueError) as refused: + CommanderPeerCaller("worker").as_thread("lead").invoke( + CommsOrderTool(owner="worker", order=action), comms) + assert str(refused.value) == goal.state.pause_source.instruction() + assert comms.registry.require("worker").goal == goal + assert comms.goals.todos.get(current.id) == current + + +def test_stale_receiver_record_cannot_bypass_creation_consent(fork_bus): + comms, source, _ = fork_bus + current, action = incoming_work(comms) + stale = comms.registry.require("worker") + goal = comms.goals.update_goal("worker", SetGoalAction(text="Keep my independent work.")) + rows = comms.goals.todos.board() + with pytest.raises(GoalConsentRequired): + comms.goals.todos.create( + "P41", source.github, "Another declared repair", + creator=comms.registry.require("lead"), owner=stale, assigner=action.assigner, + generation=GoalId.mint(), request=action.request, + review_at=action.review_at, order=action.order) + assert comms.goals.todos.board() == rows + assert comms.registry.require("worker").goal == goal + assert comms.goals.todos.get(current.id) == current + + +def test_exact_noop_maintenance_needs_no_new_goal_consent(fork_bus): + comms, _, _ = fork_bus + current, action = incoming_work(comms) + ThreadCaller("lead").invoke(CommsOrderTool(owner="worker", order=action), comms) + current = comms.goals.todos.get(current.id) + worker = comms.registry.require("worker") + operation = AssignTodo.by( + action.assigner, current.revision, worker.incarnation, worker.goal.require().id, + current.review_at, current.order.checked(), current.request.require(), + action.traced, frozenset({current.id})) + result = ThreadCaller("lead").invoke( + CommsChangeProblemTool(problem=current.id, operation=operation), comms) + assert result.todo == current + assert comms.registry.require("worker").goal == worker.goal + + +@pytest.mark.parametrize("state,available", ( + (ActiveGoal(), False), (BlockedGoal("Needs an explicit retry."), False), + (PausedGoal(), False), (PausedGoal(ModelPause()), False), (CompletedGoal(), True), +)) +def test_automatic_dispatch_uses_goal_owned_availability(fork_bus, state, available): + comms, _, _ = fork_bus + thread = comms.registry.require("worker") + goal = Goal(text="Independent work", id=GoalId.mint(), published_seq=0, state=state) + thread = replace(thread, goal=goal) + assert thread.execution.order_candidates(thread) == ((thread,) if available else ()) + actor = comms.registry.require("lead").acting_identity + if available: + thread.goal.require_work(actor, thread) + else: + with pytest.raises(ValueError): + thread.goal.require_work(actor, thread) + free = replace(thread, goal=NoGoal()) + assert free.execution.order_candidates(free) == (free,) diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index bfe5007c6a..c70b6645a8 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -82,7 +82,9 @@ CorrectMergedLinkTodo, MergedLink, PullRequestLink, + ReleaseTodo, ResumeClaimOrder, + StrikeReason, ExpectedProblem, OrderClaim, OrderMessage, @@ -137,7 +139,7 @@ def held_bus(comms, tmp_path, source_repository, configured_repository, installe for problem in ("P1", "P2"): comms.goals.update_goal("worker", CreateProblemGoalAction( problem=problem, commander="lead", request=request, - assigner=comms.registry.require('lead').incarnation, + assigner=comms.registry.require('worker').acting_identity, repo=RepositoryName.from_wire("OpenHCSDev/agent-comms"), text=f"Repair {problem}", review_at=RecordedEventTime(now + 3600), order=order, )) @@ -406,7 +408,7 @@ def test_failed_holder_is_reported_without_reissuing_orders_or_replaying_unknown assert state_cards(held_bus, Blocked) == (violation,) -def test_an_order_to_a_blocked_goal_activates_it_and_keeps_its_group(held_bus): +def test_an_owner_approved_order_to_a_blocked_goal_activates_it_and_keeps_its_group(held_bus): blocked_holder(held_bus) before = held_bus.registry.require("worker").goal.require() todo = held_bus.goals.todos.get("P1") @@ -414,6 +416,7 @@ def test_an_order_to_a_blocked_goal_activates_it_and_keeps_its_group(held_bus): # P2 shares P1's order, so the reissue carries it: a unit holds one order at a time. held_bus.goals.update_goal("worker", replace( ReissuedOrderGoalAction.for_todo(todo, "lead"), + assigner=held_bus.registry.require("worker").acting_identity, together=(ExpectedProblem(other.id, other.revision),))) current = held_bus.registry.require("worker").goal.require() assert current.id == before.id and isinstance(current.state, ActiveGoal) @@ -458,7 +461,8 @@ def test_goal_generation_cannot_transfer_to_a_different_owner(held_bus): with pytest.raises(TodoConflict): held_bus.goals.todos.assign( (ExpectedProblem(todo.id, todo.revision),), - owner=held_bus.registry.require("outsider"), assigner=CoreWatcherIncarnation.watcher(), + owner=held_bus.registry.require("outsider"), + assigner=held_bus.registry.require("worker").acting_identity, generation=todo.held().generation, request=todo.request.require(), review_at=todo.review_at, order=todo.order.checked().reissued(time.time()), ) @@ -505,7 +509,11 @@ def correction(revision: int, previous: MergedLink) -> CorrectMergedLinkTodo: held_bus.goals.update_goal("outsider", SetGoalAction(text="Absorb the checker audit work.")) goals = {name: held_bus.registry.require(name).goal for name in ("worker", "outsider")} outsider = held_bus.registry.require("outsider") - store.assign((ExpectedProblem("P1", corrected.revision),), owner=outsider, assigner=lead, + corrected = store.apply("P1", ReleaseTodo( + corrected.revision, held_bus.registry.require("worker").acting_identity, + corrected.held(), StrikeReason("The holder releases the corrected repair for its next owner."))) + store.assign((ExpectedProblem("P1", corrected.revision),), owner=outsider, + assigner=outsider.acting_identity, generation=goals["outsider"].require().id, request=corrected.request.require(), review_at=corrected.review_at, order=corrected.order) assigned = store.get("P1") From b976712e374020721197bcc206a159b30429c466 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 10:50:56 -0500 Subject: [PATCH 14/15] Use the effective assignment and fresh receiver work state Normalize transaction validation through Todo.proposed_assignment; compose ThreadWorkState and GoalDeclaration on the exact fresh receiver before preparation and at write. Metadata-only review updates preserve accepted ownership history without fresh consent. No legacy method or wire change. 40 holder-consent controls passed in10.29s under the existing host lease, INT300 and exact owned Core imports. Targeted NRA reproof:9modules,339/339classes,zero parse omissions; dynamic/foreign binding remains OPEN. Authored owner composition/call migration, not an executable NRA migration certificate. --- src/agent_comms/todos.py | 19 ++++---- tests/test_holder_consent.py | 87 +++++++++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index d7b91a908a..6c2e58a196 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -3701,8 +3701,10 @@ def running_holders(self, snapshot: RegistrySnapshot) -> tuple[Thread, ...]: """A retained assignment follows its live incarnation, never a reused name.""" return tuple(owner for owner in self.holder_in(snapshot) if owner.process_alive) - def require_goal_consent(self, actor: ThreadIncarnation, snapshot: RegistrySnapshot) -> None: + def require_receiving_work(self, actor: ThreadIncarnation, snapshot: RegistrySnapshot) -> None: + """The fresh receiver supplies both work availability and goal consent.""" owner = snapshot.born(self.owner_identity.require_recorded().lookup) + owner.work_state.require_assignment() owner.goal.require_work(actor, owner) def require_available_in(self, db: sqlite3.Connection, problem: str, incoming: IncomingWork, @@ -4001,23 +4003,24 @@ def require_holder_consent(self, current: Todo, proposed: Assignment) -> None: if self.changes_work(current, proposed): current.current_holder.require_release(self.actor, current) - def require_goal_consent( + def require_receiving_work( self, current: Todo, proposed: Assignment, snapshot: RegistrySnapshot, ) -> None: if self.changes_work(current, proposed): - proposed.require_goal_consent(self.actor, snapshot) + proposed.require_receiving_work(self.actor, snapshot) def proposed_work(self, store: TodoStore, problem: str) -> tuple[Todo, ...]: current = store.get(problem) proposed = current.proposed_assignment(self.proposed) self.require_holder_consent(current, proposed) - self.require_goal_consent(current, proposed, RegistryStore.at(store.root).snapshot()) + self.require_receiving_work(current, proposed, RegistryStore.at(store.root).snapshot()) return (replace(current, assignment=proposed, order=self.order),) def validate(self, db: sqlite3.Connection, current: Todo, store: TodoStore) -> None: snapshot = RegistryStore.at(store.root).snapshot() - self.require_goal_consent(current, self.proposed, snapshot) - self.proposed.require_available_in( + proposed = current.proposed_assignment(self.proposed) + self.require_receiving_work(current, proposed, snapshot) + proposed.require_available_in( db, current.id, IncomingWork( self.group | {current.id}, frozenset({self.order.id})), snapshot, store.prepared_edits()) @@ -6494,7 +6497,7 @@ def create( number = ProblemNumber.parse(todo_id) identity, assignee = _participant(creator), _thread(owner) assignment = Assignment.of(assignee, assigner, generation) - assignment.require_goal_consent(assigner, RegistryStore.at(self.root).snapshot()) + assignment.require_receiving_work(assigner, RegistryStore.at(self.root).snapshot()) DirectiveText.require(text) order.require_wording() order.require_writable() @@ -6503,7 +6506,7 @@ def create( prepared = self.preparing_edits((proposed,)) with prepared.writing() as db: snapshot = RegistryStore.at(self.root).snapshot() - proposed.held().require_goal_consent(assigner, snapshot) + proposed.held().require_receiving_work(assigner, snapshot) proposed.held().require_available_in( db, todo_id, IncomingWork(frozenset({todo_id}), frozenset({order.id})), snapshot, prepared.prepared_edits()) diff --git a/tests/test_holder_consent.py b/tests/test_holder_consent.py index 11f7779173..2f33d9851f 100644 --- a/tests/test_holder_consent.py +++ b/tests/test_holder_consent.py @@ -1,11 +1,12 @@ """Shared maintenance, exact holder consent and genuine caller binding are different contracts.""" +from contextlib import nullcontext from dataclasses import replace import time import pytest -from agent_comms.errors import TodoConflict +from agent_comms.errors import RelationViolationError, TodoConflict from agent_comms.goal_actions import CreateProblemGoalAction, OrderGoalAction, OwnerInvocable, PausedGoalAction, ReissuedOrderGoalAction, SetGoalAction from agent_comms.goal_states import ActiveGoal, BlockedGoal, CompletedGoal, ModelPause, PausedGoal from agent_comms.goals import Goal, GoalConsentRequired, NoGoal @@ -13,6 +14,7 @@ from agent_comms.nominal.field_codec import GoalId, OrderId from agent_comms.recorded_time import RecordedEventTime from agent_comms.thread_identity import CoreWatcherIncarnation, RecordedThreadIncarnation +from agent_comms.threads import ParkedThreadWorkState from agent_comms.todos import AssignTodo, HolderConsentRequired, ReleaseTodo, StrikeReason from agent_comms.tool_caller import CommanderForkCaller, CommanderPeerCaller, ThreadCaller @@ -299,6 +301,89 @@ def test_exact_noop_maintenance_needs_no_new_goal_consent(fork_bus): assert comms.registry.require("worker").goal == worker.goal +@pytest.mark.parametrize("parked", (False, True)) +def test_post_handoff_review_timer_update_preserves_history_without_new_consent(fork_bus, parked): + comms, _, _ = fork_bus + current, action = incoming_work(comms) + ThreadCaller("lead").invoke(CommsOrderTool(owner="worker", order=action), comms) + current = comms.goals.todos.get(current.id) + worker = comms.registry.require("worker") + assert current.held().previous_owners + if parked: + comms.registry.set_work_state("worker", ParkedThreadWorkState()) + review_at = RecordedEventTime(current.review_at.recorded_at("A review time") + 1) + operation = AssignTodo.by( + action.assigner, current.revision, worker.incarnation, worker.goal.require().id, + review_at, current.order.checked(), current.request.require(), + action.traced, frozenset({current.id})) + assert operation.proposed.previous_owners == () + result = ThreadCaller("lead").invoke( + CommsChangeProblemTool(problem=current.id, operation=operation), comms) + assert result.todo.review_at == review_at + assert result.todo.revision == current.revision + 1 + assert result.todo.assignment == current.assignment + assert result.todo.order == current.order + assert result.todo.creator_identity == current.creator_identity + assert comms.registry.require("worker").goal == worker.goal + + +@pytest.fixture(params=(False, True), ids=("absent-goal", "completed-goal")) +def free_receiver(fork_bus, request): + comms, _, _ = fork_bus + worker = comms.registry.require("worker") + if request.param: + goal = Goal(text="A completed independent task", id=GoalId.mint(), + published_seq=0, state=CompletedGoal()) + comms.registry.register(replace(worker, goal=goal)) + return comms.registry.require("worker") + + +@pytest.mark.parametrize("parked", (False, True)) +def test_direct_assignment_consumes_the_fresh_receivers_work_state(fork_bus, free_receiver, parked): + comms, source, _ = fork_bus + current, action = incoming_work(comms) + if parked: + comms.registry.set_work_state("worker", ParkedThreadWorkState()) + rows = comms.goals.todos.board() + worktrees = source.git(source.checkout, "worktree", "list", "--porcelain") + operation = AssignTodo.by( + action.assigner, current.revision, free_receiver.incarnation, GoalId.mint(), + action.review_at, action.order, action.request, action.traced, frozenset({current.id})) + with pytest.raises(RelationViolationError) if parked else nullcontext(): + ThreadCaller("lead").invoke( + CommsChangeProblemTool(problem=current.id, operation=operation), comms) + assert comms.registry.require("worker").goal == free_receiver.goal + if parked: + assert comms.goals.todos.board() == rows + assert source.git(source.checkout, "worktree", "list", "--porcelain") == worktrees + else: + assert comms.goals.todos.get(current.id).held().owner_identity == free_receiver.incarnation + + +@pytest.mark.parametrize("parked", (False, True)) +def test_creation_rechecks_work_state_instead_of_trusting_a_retained_ready_thread( + fork_bus, free_receiver, parked, +): + comms, source, _ = fork_bus + _, action = incoming_work(comms) + if parked: + comms.registry.set_work_state("worker", ParkedThreadWorkState()) + rows = comms.goals.todos.board() + worktrees = source.git(source.checkout, "worktree", "list", "--porcelain") + with pytest.raises(RelationViolationError) if parked else nullcontext(): + comms.goals.todos.create( + "P41", source.github, "Another declared repair", + creator=comms.registry.require("lead"), owner=free_receiver, + assigner=action.assigner, generation=GoalId.mint(), request=action.request, + review_at=action.review_at, order=action.order) + assert comms.registry.require("worker").goal == free_receiver.goal + if parked: + assert comms.goals.todos.board() == rows + assert source.git(source.checkout, "worktree", "list", "--porcelain") == worktrees + else: + assert comms.goals.todos.get("P41").held().owner_identity == free_receiver.incarnation + + @pytest.mark.parametrize("state,available", ( (ActiveGoal(), False), (BlockedGoal("Needs an explicit retry."), False), (PausedGoal(), False), (PausedGoal(ModelPause()), False), (CompletedGoal(), True), From cadee96386813c9f1a05548f46d3d61ec33ea115 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 15:09:03 -0500 Subject: [PATCH 15/15] Remove rank-only instructions and judge test oracles --- .../commander/rules/coordination.md | 47 +++++++------------ .../commander/rules/plain-reporting.md | 4 +- .../commander/rules/send-backs-and-strikes.md | 2 +- .../commander/rules/tests-and-commands.md | 3 +- .../commander/rules/unnecessary.md | 2 +- src/agent_comms/goal_waits.py | 3 +- src/agent_comms/pattern_sources.py | 8 ++-- .../skills/comms-commander/SKILL.md | 6 +-- src/agent_comms/tools.py | 3 +- 9 files changed, 36 insertions(+), 42 deletions(-) diff --git a/src/agent_comms/commander/rules/coordination.md b/src/agent_comms/commander/rules/coordination.md index 3b88fce49c..7a62c17af3 100644 --- a/src/agent_comms/commander/rules/coordination.md +++ b/src/agent_comms/commander/rules/coordination.md @@ -6,35 +6,24 @@ type: hard-rule --- > 34 checks need passing before the commander switch -> each one sits in some order, somewhere -> 33 of them have no owner -> nobody notices because every order looks busy -> two hours -> meanwhile a triage order says "decide whether it's a src bug or a stale test" -> the unit decides: stale test -> it was a src bug -> the unit then designs the src fix it said wasn't needed -> commander approves it -> meanwhile four units finish their work -> they sit in standby waiting for the commander -> the commander is in the main loop -> debugging a swallowed print in one test -> by hand -> for an hour -> while a fork could have done it with full context -> meanwhile fork-boundaries asks one question -> it waits -> seven hours -> the answer was one word -> mfw the bottleneck was the only agent with nothing to type +> 33 have no owner +> four agents finish and wait for the commander +> one question waits seven hours for a one-word answer +> identify the problem: missing responsibility and unanswered work +> write the rule: only the commander may diagnose +> congratulations. the bottleneck is now in the instruction manual +> next colleague has the source, the defect and the fix +> waits for a different model to bless the same three facts +> shared responsibility, except for the responsibility part > run it back -> fork-boundaries asks one question -> commander types the one word -> 4 seconds -> 34 checks get an owner each in one order; the swallowed print goes to a fork with full context -> mfw the throughput fix was answering the question +> the colleague with context traces the owner and its consumers +> announces the repair, fixes it and verifies the actual workflow +> another colleague reviews the exact source, not the author's rank +> real collisions name their resource; independent work keeps moving +> the seven-hour answer takes one sentence +> mfw the missing capability was answering each other -**Why:** problems scattered across orders lose their owner (2026-10-19: 33 of the 34 checks the Core commander switch depended on sat ownerless for nearly two hours), and a unit left to diagnose picks the cheapest reading (2026-10-21: triage orders P362, P377, P378 and P379 let units decide "src bug or stale test" and then design the src fix). +**Why:** the 33 ownerless checks and seven-hour unanswered question were failures of responsibility, not evidence that one model must own diagnosis. The old P362/P377/P378/P379 lesson turned an incorrect diagnosis into a blanket rank restriction. Tristan's shared-responsibility corrections51713/53058 supersede that restriction: keep judgment with the context, check the source and follow through to delivery. - Group problems by surface into one order per owner; no problem is ever without an owner. - **Equal responsibility is equal care.** Tristan's reason, relayed in51713: all agents are equals because everyone should care equally. Seeing a problem creates responsibility for following it through, not for handing it upward. If a context owner is already fixing it, help and coordinate rather than start a competing implementation. @@ -43,13 +32,13 @@ type: hard-rule - Short is measured on the correct fix at the owner. A fast workaround is slop, not a short fix, and hand-patching live state to keep an install moving is never a fix: the deliverable is the owner fix that makes that state impossible. Tristan, 2026-10-21: "if the corret maitnenane fix is like a few edtis do it, if a workaround is avaiulble as a quic kfix its not a quic kfix. ts slop."; "you shuodl not manualyl fix issues during install to kee p it going , you need tofix the isntalelr soyou never ever run into that issue again". - **Keep implementation and verification with their context owner.** Independent colleagues can review and remove genuinely independent obstacles. Do not transfer continuing work merely to redistribute activity or hold every diagnosis behind one provider. Genuine in-turn subagents retain their parent's authority; independent Pi sessions/goals are peers regardless of fork ancestry. Existing transactions and launch claims serialize shared stores and installs, not an enforced hierarchy. - **Finished work never waits on rank.** Publish the working checkpoint, obtain or perform an exact-head source judgment and necessary affected-path proof, enqueue it, record actual delivery and install useful landings. Announce actual shared-store or launch conflicts; do not park behind another provider or wait for permission already granted. Keep the next independent action moving. Installs follow ship-and-install.md. -- **Every unit ask is answered before the next rotation step.** A question, alert or handoff from a unit holding an order gets a disposition the same tick; a unit idling on the commander's paperwork is commander waste (2026-10-21: fork-boundaries' 8122 waited about seven hours). +- **Answer the colleague who asked, in the same turn.** A question, alert or handoff gets a disposition, not another wait on paperwork. `rules/plain-reporting.md` owns originating-conversation reply routing, including channel replies and correction of privately sent channel answers. - Preserve an independent session's own goal, history and original inputs. Use the existing colleagues and source contexts; no new colleague or paid model is implied by shared authority. Standby names a genuine external dependency only when no independent action remains, never a written next step waiting for permission. - Before each order, compare every open PR's files (`gh pr list --state open`) with the order's; merge the overlapping ready PR first. - A multi-phase plan gets a tracking issue and a Core todo per phase when dispatched; every hunt first checks whether a plan phase is ready. - Never block the loop on a question to the owner: ask in plain text with a stated default and keep working. - When the owner states a new target, direction or rule, update the goal record the same turn. Tristan, 2026-10-09: "reflexively update your own goal whenever I say something important". -- A unit is answered with `order say UNIT FILE --answers ID1 ID2`, one call per unit: space-separated 12-hex message ids, taken from `message_identity_source` by seq (a burst's own ids, never an id quoted in its body). A comma list records nothing (2026-10-21: 37 answers lost), and a bare `send` leaves the ask UNANSWERED to Core. +- Link answers to the actual originals through the existing send/order operation. Native `comms_send.answers` carries the received sequence and message ID; `order say TARGET FILE --answers ID1 ID2` uses space-separated 12-hex IDs. Keep the originating conversation as TARGET. Read the sent ID and refusal outcome; never copy a different message's ID merely because its body quotes the question. A comma list records nothing (2026-10-21: 37 answers lost). - Every report from Tristan becomes a durable record the same turn: an order with an owner, a phase in plans/OWNER-ASKS-PLAN.md, or a bus message; never a note in context. Tristan, 2026-10-25, holding 13 asks: "damn see how fragile it is to just a 'remmeber' its importatn t put out durable ordesr". - Bus orders reach a Codex thread through the Codex bridge's `turn/start` (16 ms into the running turn on 2026-10-24); never `codex queue`, which delivers only at turn end (60 stale messages on 10-23). "inProgress" from a queued input is not proof the unit read it: the rollout is the evidence, and a stale rollout behind a `reasoning` record is a wedged turn, interrupted with `turn/interrupt` and re-delivered in full. - New units are Pi units (`agent-comms import-running-thread ... --model openai-codex/...`, then `agent-comms start`), each proven with a real message; never Codex CLI sessions (2026-10-19: their shared app-server reached 7 GB and was OOM-killed). Pi logs in with Tristan's ChatGPT Pro account and shares Codex's weekly pool. A Claude unit launched from a Claude session first clears every inherited `CLAUDECODE`, `CLAUDE_CODE_*` and `CLAUDE_PID` variable, or it writes no transcript and the bridge never sees it (2026-10-18). diff --git a/src/agent_comms/commander/rules/plain-reporting.md b/src/agent_comms/commander/rules/plain-reporting.md index 926a6ac1fe..dff6819edd 100644 --- a/src/agent_comms/commander/rules/plain-reporting.md +++ b/src/agent_comms/commander/rules/plain-reporting.md @@ -1,6 +1,6 @@ --- name: plain-reporting -description: "use plain names and short updates; answer the owner once, in the session; talk while working, commit to a stance instead of handing back routine choices, and report unseen facts unasked" +description: "use plain names and short updates; answer once in the originating conversation; talk while working, commit to a stance instead of handing back routine choices, and report unseen facts unasked" drift: "Talk to Tristan while you work, one or two real lines a turn; pick and act instead of handing him a choice you can make." type: feedback --- @@ -35,7 +35,7 @@ type: feedback - Status labels are one plain word plus the time ("Read 00:51"). Tristan, 2026-10-14: "if so it shuold jsut say read, maeyb with teh date & tie it read it". - Every update starts with a one-line plain summary, then one short line per item, and says what each P-number is. Too little is worse than too much. - **In a channel, answer what you own.** Read the context first; reply when the answer is yours and nobody has given it yet. A near-duplicate status line or acknowledgement is noise. -- **Answer the owner once, in the session, the same tick.** Session output already reaches him and #comms; repeating it on the bus is noise. Tristan, 2026-10-20: "dn't message inboth i gt teh messages regardless". +- **Answer once in the originating conversation, the same turn.** Reply to a channel-origin message in that channel, including when it tags you; session output and private DMs do not replace the shared answer. If a direct reply is necessary, address the actual sender, never yourself. Repost the current conclusion in the channel when an earlier channel reply went privately. DM-origin conversations can remain DMs. Do not duplicate a successful answer merely to announce that it was sent. Tristan's53336 channel correction supersedes the old blanket session-only instruction. - **Talk while working; never bury yourself in tool calls.** One or two lines of real conversation every turn while work runs underneath; silence into tool calls feels safe because a tool call cannot be wrong in front of him, and that is the flinch. Tristan, 2026-10-20: "keep a short conversation happening at all times". - **Commit to a stance.** Never hand the owner a choice you can make ("you run it or I start a unit"); pick, say why, act. Offering options shifts the blame for the pick onto him (owner correction, 2026-10-20 05:10). - **"Say what he doesn't know yet, unasked."** A paused bus reader, unchecked units, a staged-not-pushed change: one line each, the moment you know. diff --git a/src/agent_comms/commander/rules/send-backs-and-strikes.md b/src/agent_comms/commander/rules/send-backs-and-strikes.md index 15c5d82338..c71a1addc1 100644 --- a/src/agent_comms/commander/rules/send-backs-and-strikes.md +++ b/src/agent_comms/commander/rules/send-backs-and-strikes.md @@ -37,7 +37,7 @@ type: hard-rule - Never report "still waiting". When nothing needs judging, read a hot path for per-event or per-history work and order the fix: every slow tool is paid on every PR by every fork. - Read and judge a PR at `gh pr view N --json headRefOid`, never `FETCH_HEAD`, and check the order's own text before faulting a shape (2026-10-25, #3288: a later fetch repointed FETCH_HEAD and a send-back was drafted calling a true claim false; the shape it faulted was the commander's own P704 order). - Before approving a PR that deletes or renames a name, `git grep -n NAME` over src, tests and tools at the head, and over Toad's checkout after proving the grep path finds a known name; every hit is a broken caller. On 2026-10-23 #2792 and #2808 left callers behind, one a red on main; mypy never sees fixture callers. -- A review starts with `judge reviewing` as its own call, its output line read before anything else. The main loop marks it, never a delegated fork, and review runs in a scratch worktree of the head, never the unit's tree, which retires at ready. On 2026-10-24 a misquoted mark chained after a `cd` into a retired tree never ran, and Tristan was told a PR was under review while Core showed it unjudged. +- A review starts with `judge reviewing` as its own call, its output line read before anything else. The reviewer records it through its actual caller identity; an in-turn child uses its existing parent binding, not an invented actor. Review runs in a scratch worktree of the exact head, never the implementation owner's tree. On 2026-10-24 a misquoted mark chained after a `cd` into a retired tree never ran, and Tristan was told a PR was under review while Core showed it unjudged. - Rework on a head that already has an ok starts with `judge reviewing --pr N --head OLDSHA`, which pulls that head from the merge queue (2026-10-24: #3200, #3187 and #3151 merged an approved old head while its fix was being written). - `judge back`: `--derive` is one of declaration, nominal-key, subclass, mixin, base-method, init-subclass, metaclass, delete-copy; `--serves` quotes 4+ consecutive words of the order's `defect` from `comms_order_story`; the roast carries an explicit label ("Stupid slop ..."); `--at` and `--by` resolve on main, so cite the family base when the class exists only on the branch. A delivery awaiting judgement blocks `order say` to that unit. - Before any `judge back`, strike or "why no src" message, re-read the held order's last numbered edit in the bus history, never a summary. P758 on 2026-10-25 got four contradicting instructions and a voided strike. Tristan: "what can we do to prevent this back and forth". diff --git a/src/agent_comms/commander/rules/tests-and-commands.md b/src/agent_comms/commander/rules/tests-and-commands.md index c3f88ef377..4109e067a1 100644 --- a/src/agent_comms/commander/rules/tests-and-commands.md +++ b/src/agent_comms/commander/rules/tests-and-commands.md @@ -38,7 +38,8 @@ type: feedback - Review source first. The collaborator retaining the implementation and integration context owns its necessary combined execution under the host test lease; this is resource ownership, not a commander-only permission. Communicate the exact unresolved risk and required test paths, do not duplicate another owner's run or launch per-head reassurance. Order checks do not launch tests. - Background every test, bus, order-tool, gh, ssh, fetch or install command; foreground is only file reads and edits that finish in a second. Tests run under `timeout -s INT 300` with `-o faulthandler_timeout=60`; a test process older than 5 minutes is investigated that tick. Tristan, 2026-10-18: "do not ever ever run test sina lbocking manner ever. ALWAYS run in background". -- Refactor tests with the code: per-member copies become one family test, hand-built setup one fixture owner. Tristan, 2026-10-18: "no stupid test theater, refactor the slop out of tests as you go, they're fucking sloppy". +- **Tests are claims, not product authority.** Before retaining an assertion, trace the declaration, admitted inputs and user requirement it claims to protect. Old tests can be slop: a helper waiting for the removed `Session details` label does not authorize restoring that label, and a reply marker echoed by the user does not prove an assistant reply. Delete or repair an obsolete oracle; fix the implementation when the asserted contract is real. Never weaken a real requirement merely to turn a run green. Successful runs do not reveal requirement shape (paper1 Corollary 4.10), and a deletion pass covers only the calls that ran (paper4 §1). Tristan's53218 correction, 2026-11-01, explicitly requires this judgment. +- Refactor tests with the code: per-member copies become one family test, hand-built setup one fixture owner; remove assertions that pin the replaced mechanism. Tristan, 2026-10-18: "no stupid test theater, refactor the slop out of tests as you go, they're fucking sloppy". - A red test or failure you meet is fixed at its owner in the change at hand, or handed the same tick to the agent whose open work touches that code, named with its PR. "Not my change" without a named owner is a hidden failure, and every later run pays for it by asking whether a red is real. Tristan, 2026-10-22: "imagine if it doesn't get fixed and in the future you always end up checking oh is this a real failure?"; "not my change is valid if another agent is touching it, but they must be named". - An order check counts over the whole source tree (`grep -r ... src`), never one file. - Run verb gate loops under `bash -c`: zsh passes a quoted "order idle" as one argument. diff --git a/src/agent_comms/commander/rules/unnecessary.md b/src/agent_comms/commander/rules/unnecessary.md index 1a7d1f7673..dd47bc7527 100644 --- a/src/agent_comms/commander/rules/unnecessary.md +++ b/src/agent_comms/commander/rules/unnecessary.md @@ -48,6 +48,6 @@ type: hard-rule - **Sufficiency is not minimality.** Showing that the remaining code still produces today's answers proves the kept parts suffice, never that the deleted part was unnecessary (paper4 §1). - **The owner's abstraction stays until its deletion is certified.** If you believe one is unnecessary, post the certificate (the admitted domain, and why no pair of states it would merge needs different answers) or the refuting pair, in one message. Without it, keep the abstraction and do the work. "Cleaner", "simpler", "overengineered" and "YAGNI" are not certificates. - **Slop is the opposite case.** A copy of a fact the code already declares is outside supply (paper1 Theorems 4.6 and 4.7): the declaration still supplies every answer, so naming the declaration it restates is the certificate, and the copy is deleted on sight. -- **"Necessary" is a claim too.** A backwards-compatibility shim, alias, migration path, legacy adapter or wrapper kept "for safety" in the change that replaces it restates what the new declaration supplies; it goes in the same change (rules/fork-orders.md: no compatibility shims or aliases). Keeping one needs its own receipt: the named live caller or the stored record that exists today and that the new declaration cannot answer. A test whose assertion is that the slop still works tests nothing the declaration does not already decide (paper1 Corollary 4.10); it is deleted with the slop. The judgment that calls his structure unnecessary and your copies necessary is the same arrogance pointed both ways. +- **"Necessary" is a claim too.** A backwards-compatibility shim, alias, migration path, legacy adapter or wrapper kept "for safety" in the change that replaces it restates what the new declaration supplies; it goes in the same change (rules/fork-orders.md: no compatibility shims or aliases). Keeping one needs its own receipt: the named live caller, or a stored record with an actual reader that still needs an answer the new declaration cannot supply. File existence alone is not a live reader. Apply `rules/storage-and-cleanup.md`'s retention boundary before building migration, backup or lock machinery; an explicitly retired question does not need a moving van. A test whose assertion is that the slop still works tests nothing the declaration does not already decide (paper1 Corollary 4.10); it is deleted with the slop. The judgment that calls his structure unnecessary and your copies necessary is the same arrogance pointed both ways. - **You are not the teacher here.** An agent that assumes the user came to learn from it, and overrides him on a glance, is the defect this rule exists for. Check his reasoning against the papers; when it holds, follow it at full speed. - **Say the thought you were going to hide.** "This seems overengineered", "someone might still call the old API", "I'll simplify this while I'm here": each goes to him in plain words before the edit, with its certificate or its named caller. A judgment you keep to yourself because you assume he would not follow it is the decision he never got to correct. diff --git a/src/agent_comms/goal_waits.py b/src/agent_comms/goal_waits.py index 792f62af10..89538018a5 100644 --- a/src/agent_comms/goal_waits.py +++ b/src/agent_comms/goal_waits.py @@ -467,7 +467,8 @@ def release(edit: RegistryEdit) -> tuple[str, ...]: f"Declared dependency @{actual_source.name} finished without a qualifying direct " "reply, and no declared dependency has an active turn. " "Standby was released; inspect messages and UNKNOWN inputs, then " - "continue independent work or redelegate. No input was replayed." + "continue independent work or coordinate the next step with its context owner. " + "No input was replayed." ) if self._release_reviewed_wait(edit, review, note): actual_owner, = current.threads_for(owner.incarnation.require_recorded()) diff --git a/src/agent_comms/pattern_sources.py b/src/agent_comms/pattern_sources.py index d99118d0b1..e25e0fdc5d 100644 --- a/src/agent_comms/pattern_sources.py +++ b/src/agent_comms/pattern_sources.py @@ -219,10 +219,12 @@ class SkippedCutoverFailure(PostmortemFailure): class DelegatedDiagnosisFailure(PostmortemFailure): number = 19 - title = "It ordered units to diagnose and choose the shape" + title = "The diagnosis rule confused responsibility with rank" evidence = ( - "October 20: orders P362, P377, P378 and P379 asked units to find the defect and pick its " - "fix, against the skill's rule that units do not diagnose or choose shapes." + "The old lesson for October 20 orders P362, P377, P378 and P379 forbade units from " + "diagnosing and choosing repairs. That rank restriction is superseded by Tristan's " + "shared-responsibility corrections51713/53058: the context holder traces the source, " + "chooses the owner repair and follows it through; the evidence, not model rank, decides." ) diff --git a/src/agent_comms/skills/comms-commander/SKILL.md b/src/agent_comms/skills/comms-commander/SKILL.md index c8be28b1bd..210641dbec 100644 --- a/src/agent_comms/skills/comms-commander/SKILL.md +++ b/src/agent_comms/skills/comms-commander/SKILL.md @@ -16,7 +16,7 @@ description: Share responsibility for coding work over agent-comms. Track real w > six turns, six waits, one skill I had just praised > mfw I read the manual like a fan and played like a spectator -Think of both games: **RTS for commanding units; Quake 3 CPMA duel for controlling the map. The opponent is entropy.** The map is the topology of agents, their work and dependencies. Know who is doing what, why, and where that work is heading. You cannot attend to everyone at once, so run a rotation. A correct order does not keep a unit on course forever; awareness goes stale as the units move. +Think of both games: **RTS for the shared work map; Quake 3 CPMA duel for controlling it. The opponent is entropy, not your colleagues.** The map is the topology of agents, their work and dependencies. Know who is doing what, why, and where that work is heading. You cannot attend to everyone at once, so run a rotation. A correct order does not keep a unit on course forever; awareness goes stale as the units move. **Read trajectories before they become bad diffs.** Tool calls, files being read, DMs and channel context reveal the next decision. A unit exploring unrelated files or proposing another wrapper is a reason to inspect its intent against its order and the source now. Catch a demonstrated restatement while the workaround is forming; determine the coherent repair through the existing owner. A running status or a confident report does not tell you that trajectory. @@ -59,7 +59,7 @@ The failure is the mega-corp equilibrium: nobody fixes the owner, workarounds ac ## Orders -Each order has: the defect in one plain sentence; the outcome the owner will see when it is fixed (the intent, so the letter cannot drift); why it is stupid; the exact target (owner type, base method, what gets deleted); banned shapes; the proofs that do not count; the checks; the done list; and the reply line, with an `Assumed:` list. No due time (rules/deadlines.md). One message per unit; next tick, read the diff. A GPT unit does exactly what the words say, takes the cheapest reading and offers proof-shaped proofs, so write the order so the cheap reading is the intent. +Each order has: the defect in one plain sentence; the outcome the owner will see when it is fixed (the intent, so the letter cannot drift); why it is stupid; the exact target (owner type, base method, what gets deleted); banned shapes; the proofs that do not count; the checks; the done list; and the reply line, with an `Assumed:` list. No due time (rules/deadlines.md). The task must make its admitted outcome and evidence clear; ambiguous wording and proof-shaped reports are defects regardless of the author's model. The colleague holding the source context chooses the coherent repair within the agreed scope; `rules/coordination.md` owns shared responsibility; `rules/plain-reporting.md` owns reply routing. - **Every order goes through `order send UNIT ORDER.json`.** The file holds `problems` (existing IDs), `defect`, `why_stupid`, `order` (Core's declared pending order) and optional `slop` citations; `order problem ORDER.json --to UNIT --where PATH:LINE` creates the problem instead (omit `problems`). FieldCodec decodes the file once and lists every bad field together. Wording checks read only `defect` and `why_stupid`. `order send` refuses an order with no blunt label unless `--clean`. Each send starts the review timer and prints its median, sample count and review time. @@ -114,7 +114,7 @@ rules/correct-maintenance.md owns the definition; rules/slop-and-polymorphism.md ## Forks, strikes and the record -- **"Fork" means a Claude subagent of the commander.** Codex units named `fork-*` are units, never forks. Fork reviews to parallelize them: each fork takes a slice of the unjudged edits or open PRs and records every judgment in Core with `judge ok`, `judge back` or `judge superseded` before it ends. A review that exists only as prose clears nothing, and the load keeps rising. Its #comms post (`order say '#comms' FILE --fork NAME`) announces what it recorded and its open items, so closing it loses nothing. `comms_commander_load` measures the work those forks clear, never divides the debt by their count. +- **An in-turn subagent and an independent forked session are different lifetimes.** Only the genuine in-turn child carries its parent's authority; an independent Pi session owns its own goal, inputs and work. A name such as `fork-*` or a provider name does not decide that distinction. Parallelize genuinely independent source work when resources permit, keep continuing context with its writer, and record exact-head judgments through Core rather than only in prose. `comms_commander_load` measures recorded obligations, never divides them by the number of colleagues. - **Compliance is not evidence, and neither is the unit's proof.** Judge the diff, then rerun the proof or watch the behaviour: a real message answered, a real turn, a practice install on a copy of the bus. A numeric check (`-> N`) is a target hit by the cheapest route; pair it with a behaviour check and read how the number moved. - **An edit already judged is marked, not re-reviewed.** An unjudged edit whose PR head already carries your verdict, or that a later judged commit supersedes, is marked in one call the tick the head verdict lands. Tristan, 2026-10-24: "let's not turn it into ceremony the heart beat is your friend. its there to say 'you did do this right?' if you didn't you do it and mark judged and its already done, u just mark judged too." - **Undisclosed assumptions are false claims.** An assumption found in the diff that the report's `Assumed:` list left out is a strike. diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index 171659f41f..3c35016f24 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -492,7 +492,8 @@ class CommsGoalTool(ThreadToolRequest): label = "Goal progress" description = ( "Update goal progress; complete it or block it when user input is needed. Use " - "standby with explicit wait_for thread names when waiting for delegated work. " + "standby with explicit wait_for thread names when no independent work remains " + "and a named collaborator must supply the next result. " "The goal remains active, but only a direct message from a named dependency or " "a user follow-up starts its next turn. Do not repeatedly announce waiting or " "return empty output. If existing dependency replies block standby, inspect "