From ee602b7888db3a63d46f78fa3f173ec6e79ac551 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 15:38:03 -0400 Subject: [PATCH] Refuse --answers naming a unit ask the action never reaches `order say/send/strike/problem --answers ID` accepted a unit's question even when the action went to a different thread. The gate counted the ask as answered, AnsweredSource recorded it, and the asker never got a reply: on 2026-10-23 unit-mi-1a's question 3dce7e7182d8 was "answered" by an order to unit-reviewer and mi-1a sat blocked 22 minutes. OwedAnswer now owns whether naming a debt in an action that reaches given threads would record an answer its asker never receives (misaddressed). Only UnitAskUnanswered refuses: a unit's ask is answered by a message to that unit. Owner directives and escalation bursts keep their own answered_by rules. Each DirectiveVerb declares the threads its message is for, and the gate refuses before anything is sent, naming the real asker. Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/commander/order.py | 28 +++++++++++++++++++++++++++ src/agent_comms/commander/standing.py | 7 +++++++ src/agent_comms/standing_rules.py | 5 +++++ tests/test_holder_standing_rules.py | 13 +++++++++++++ 4 files changed, 53 insertions(+) diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index 89dc939114..f27e8d1e6c 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -809,6 +809,13 @@ def run(self, ctx: Commander) -> None: opened = OwedAnswer.opened(ctx.comms, self.who(ctx)) named = tuple(rule.message for rule in opened if rule.message.message_id in self.answers) + snapshot = ctx.comms.registry.snapshot() + reached = frozenset(snapshot.canonical_name(name) for name in self.addressed) + misaddressed = tuple(reason for rule in opened + if (reason := rule.misaddressed(named, reached))) + if misaddressed: + sys.exit('REFUSED, nothing recorded: --answers names a message this action never' + ' reaches:\n' + '\n'.join(misaddressed)) owed = tuple(rule for rule in opened if rule.due(now) and not rule.answered_by(named)) if owed: lines = '\n'.join(f'{rule.line()}: {rule.message.body[:80]}' for rule in owed) @@ -817,6 +824,11 @@ def run(self, ctx: Commander) -> None: 'that must wait is sent now: "waiting on X, owner Y".') self.respond(ctx, tuple(dict.fromkeys(message.reference for message in named))) + @property + @abstractmethod + def addressed(self) -> tuple[str, ...]: + """The threads this action's message is for; only their asks can it answer.""" + @abstractmethod def respond(self, ctx: Commander, references: tuple[MessageReference, ...]) -> None: ... @@ -870,6 +882,10 @@ class SendVerb(RequestedOrderVerb): ), ] = NoSourceReference() + @property + def addressed(self) -> tuple[str, ...]: + return (self.unit,) + def respond(self, ctx: Commander, references: tuple[MessageReference, ...]) -> None: ctx.send(self.unit, OrderFile.read(Path(self.body_file).read_text()), clean=self.clean, reassign=self.reassign, traced=self.traced, @@ -896,6 +912,10 @@ class StrikeVerb(OrderVerb, DirectiveVerb): ), ] = "review" + @property + def addressed(self) -> tuple[str, ...]: + return (self.unit,) + def respond(self, ctx: Commander, references: tuple[MessageReference, ...]) -> None: ctx.strike(self.unit, self.pid, self.why, self.caught, evidence=(FileLine.from_wire(self.where),), @@ -1101,6 +1121,10 @@ class SayVerb(OrderVerb, DirectiveVerb): str, Argument(flags=("body_file",), options={"metavar": "BODYFILE"}) ] + @property + def addressed(self) -> tuple[str, ...]: + return VerdictAudience(tuple(self.units.split(','))).threads + def respond(self, ctx: Commander, references: tuple[MessageReference, ...]) -> None: ctx.say(self.units, self.body_file, references) @@ -1129,6 +1153,10 @@ class ProblemVerb(RequestedOrderVerb): ] disk: Annotated[bool, Argument(flags=("--disk",))] = False + @property + def addressed(self) -> tuple[str, ...]: + return (self.to,) + def respond(self, ctx: Commander, references: tuple[MessageReference, ...]) -> None: document = OrderFile.read(Path(self.order).read_text()) location = FileLine.from_wire(self.where) diff --git a/src/agent_comms/commander/standing.py b/src/agent_comms/commander/standing.py index 8606f14073..bfd9cba299 100644 --- a/src/agent_comms/commander/standing.py +++ b/src/agent_comms/commander/standing.py @@ -207,6 +207,13 @@ def read_open(cls, source: BusReader, snapshot: RegistrySnapshot, def due(self, now: float) -> bool: return self.message.timestamp <= now - AwaitingCommander.seconds + def misaddressed(self, named: tuple[Message, ...], reached: frozenset[str]) -> str: + """A unit's ask is answered only by a message that reaches that unit.""" + if self.unit in reached or not self.answered_by(named): + return "" + return (f"{self.message.message_id} was asked by {self.unit}; " + f"answer it in a message to {self.unit}") + def line(self) -> str: age = max(0, int((time.time() - self.message.timestamp) // 60)) return (f"UNANSWERED {self.unit}: message {self.cited}, " diff --git a/src/agent_comms/standing_rules.py b/src/agent_comms/standing_rules.py index 118abbfa35..36d5cb37d0 100644 --- a/src/agent_comms/standing_rules.py +++ b/src/agent_comms/standing_rules.py @@ -362,6 +362,11 @@ def answered_by(self, named: tuple[Message, ...]) -> bool: """Whether an action naming these open messages answers this one.""" return any(message.message_id == self.message.message_id for message in named) + def misaddressed(self, named: tuple[Message, ...], reached: frozenset[str]) -> str: + """Why naming this debt in an action that reaches only these threads would record an + answer its asker never receives; empty when it would not.""" + return "" + class AnswersOwedDegradation(Degradation, BlocksRelease): """Messages to the commander are waiting past their window; each message is one sign.""" diff --git a/tests/test_holder_standing_rules.py b/tests/test_holder_standing_rules.py index b48667e00f..daf5791e60 100644 --- a/tests/test_holder_standing_rules.py +++ b/tests/test_holder_standing_rules.py @@ -516,6 +516,19 @@ def test_answering_one_owed_message_leaves_another_owed(held_bus, tmp_path): assert unanswered(held_bus) == () +def test_an_answer_sent_to_another_unit_is_refused_and_leaves_the_ask_owed(held_bus, tmp_path): + question = publish(held_bus, "worker", "lead", time.time() - AwaitingCommander.seconds - 1) + ctx = Commander.read_root(held_bus.root) + reply = tmp_path / "reply.txt" + reply.write_text("Waiting on the merge queue, owner unit-goalblock") + revision = held_bus.bus.bus.revision() + with pytest.raises(SystemExit, match=(f"{question.message_id} was asked by worker; " + "answer it in a message to worker")): + SayVerb(units="outsider", body_file=str(reply), answers=(question.message_id,)).apply(ctx) + assert held_bus.bus.bus.revision() == revision + assert [owed.message.message_id for owed in unanswered(held_bus)] == [question.message_id] + + def test_an_overdue_owner_directive_gates_until_answered(held_bus, tmp_path): directive = held_bus.bus.publisher.publish_ordinary( Message(sender="outsider", target="lead", type=InfoKind, body="Ship the install now",