From f52cb455768c2977691d7348a6aac1f7062df8fb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 18:00:03 -0400 Subject: [PATCH 1/2] Answer refused sends before recording them; start rehearsal copies open A refused send recorded itself under the wire lock before raising, so any install or rehearsal holding that lock made the refusal hang like a dead server. The record now waits on its own thread; the caller hears the refusal at once. copy_bus copied the live root's maintenance files into the copy, so the early client check refused every command on the copy. The copy skips them. Src-Budget: 4 Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/tools.py | 5 ++++- tools/upgrade_bus.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index f07fe9a1eb..9400763226 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import threading from abc import abstractmethod from collections.abc import Mapping from pathlib import Path @@ -1083,7 +1084,9 @@ def apply(self, comms: Comms, caller: ToolCaller) -> MessagePublished: body = VerdictAudience(self.audience).addressed(self.target, self.body) result = comms.messaging.send(self.sender, self.target, body, self.type, report=self.report) except ToolRefusedError as error: - SendRefusal.record(comms.root, self.sender, self.target, error) + # The caller hears the refusal now; the record waits for the wire lock on its own. + threading.Thread(target=SendRefusal.record, + args=(comms.root, self.sender, self.target, error)).start() raise caller.sent(comms, result.message) return MessagePublished.from_send(result) diff --git a/tools/upgrade_bus.py b/tools/upgrade_bus.py index 8bb34727e6..eaf39ed128 100644 --- a/tools/upgrade_bus.py +++ b/tools/upgrade_bus.py @@ -230,15 +230,19 @@ def carry_all( def copy_bus(root: Path, copy: Path) -> None: - """Copy under the bus's wire lock so no writer tears the copy; sockets are skipped.""" - def sockets(directory: str, names: list[str]) -> set[str]: + """Copy under the bus's wire lock so no writer tears the copy; sockets and maintenance files are skipped.""" + # The maintenance files describe the live root; a copy is a new root and starts open. + held = set(MaintenanceBarrier(root / "registry.json").witness_paths()) + + def skipped(directory: str, names: list[str]) -> set[str]: return {name for name in names - if stat.S_ISSOCK(os.lstat(os.path.join(directory, name)).st_mode)} + if Path(directory, name) in held + or stat.S_ISSOCK(os.lstat(os.path.join(directory, name)).st_mode)} from agent_comms.store_files import _store_lock with _store_lock(root / "wire"): - shutil.copytree(root, copy, symlinks=True, ignore=sockets) + shutil.copytree(root, copy, symlinks=True, ignore=skipped) def rehearsal_home(scratch: Path, root: Path, copy: Path) -> Path: From b9fda860cddc6720d5f7e2cf140903ae293f1523 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 18:07:18 -0400 Subject: [PATCH 2/2] witness_paths is a property Co-Authored-By: Claude Opus 5.5 --- tools/upgrade_bus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/upgrade_bus.py b/tools/upgrade_bus.py index eaf39ed128..ab7e53b27c 100644 --- a/tools/upgrade_bus.py +++ b/tools/upgrade_bus.py @@ -232,7 +232,7 @@ def carry_all( def copy_bus(root: Path, copy: Path) -> None: """Copy under the bus's wire lock so no writer tears the copy; sockets and maintenance files are skipped.""" # The maintenance files describe the live root; a copy is a new root and starts open. - held = set(MaintenanceBarrier(root / "registry.json").witness_paths()) + held = set(MaintenanceBarrier(root / "registry.json").witness_paths) def skipped(directory: str, names: list[str]) -> set[str]: return {name for name in names