Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/agent_comms/commander/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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: ...

Expand Down Expand Up @@ -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,
Expand All @@ -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),),
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions src/agent_comms/commander/standing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}, "
Expand Down
5 changes: 5 additions & 0 deletions src/agent_comms/standing_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
13 changes: 13 additions & 0 deletions tests/test_holder_standing_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading