From 2ed04dee0ecf6a930b582f86abe5df54c9e6cf02 Mon Sep 17 00:00:00 2001 From: Lucas de Castro Zanoni Date: Tue, 25 Aug 2026 16:43:32 -0300 Subject: [PATCH] fix(clawde): revive an agent whose tab is the last one in its workspace The herdr backend replaced a dead agent's tab by closing it and then creating a fresh one. herdr refuses to close a workspace's last tab, so once every other agent in the workspace had been removed, the survivor's relaunch failed on the close and never reached the create. Its tab stayed a bare shell and the supervisor retried the same failing close every ten seconds, forever. Kira's steward sat dead that way for forty hours behind 8.8 MB of identical errors. Create the replacement tab first and discard the superseded one after, so the workspace never drops to zero tabs and the last-tab rule can no longer block a relaunch. A failed discard now leaves a stale tab and a logged error instead of a dead agent. The tmux backend was never affected: respawn-window -k reuses the window in place and never removes it. Agent-Machine: kira Agent-Resume: claude --resume efee1316-8d94-438e-951e-e9c978626531 --- .../supervisor_backend_herdr.py | 28 +++++++----- .../tests/unit/test_clawde_service_herdr.py | 45 +++++++++++++++++-- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/module/scripts/clawde-service/supervisor_backend_herdr.py b/module/scripts/clawde-service/supervisor_backend_herdr.py index dc83b76..5bc4363 100644 --- a/module/scripts/clawde-service/supervisor_backend_herdr.py +++ b/module/scripts/clawde-service/supervisor_backend_herdr.py @@ -118,21 +118,29 @@ def create_agent_window( return False return self.run_wrapper_in_pane(pane_id, wrapper_command) + def discard_superseded_tab( + self, session_name: str, agent_name: str, tab_id: str + ) -> None: + close_result = self.run_herdr_command("tab", "close", tab_id) + if close_result.returncode == 0: + return + print( + f"Error: failed to close the superseded herdr tab {tab_id!r} of " + f"{agent_name!r} in workspace {session_name!r}: " + f"{close_result.stderr.strip()}", + file=sys.stderr, + ) + def relaunch_wrapper_in_window( self, session_name: str, agent_name: str, wrapper_command: str ) -> bool: - tab = self.find_agent_tab(session_name, agent_name) - if tab is None: + superseded_tab = self.find_agent_tab(session_name, agent_name) + if superseded_tab is None: return self.create_agent_window(session_name, agent_name, wrapper_command) - close_result = self.run_herdr_command("tab", "close", tab["tab_id"]) - if close_result.returncode != 0: - print( - f"Error: failed to replace herdr tab {agent_name!r} in workspace " - f"{session_name!r}: {close_result.stderr.strip()}", - file=sys.stderr, - ) + if not self.create_agent_window(session_name, agent_name, wrapper_command): return False - return self.create_agent_window(session_name, agent_name, wrapper_command) + self.discard_superseded_tab(session_name, agent_name, superseded_tab["tab_id"]) + return True def ensure_host_ready(self, session_name: str) -> bool: if not self.ensure_server_running(): diff --git a/module/scripts/tests/unit/test_clawde_service_herdr.py b/module/scripts/tests/unit/test_clawde_service_herdr.py index e887318..bf5ba4d 100644 --- a/module/scripts/tests/unit/test_clawde_service_herdr.py +++ b/module/scripts/tests/unit/test_clawde_service_herdr.py @@ -4,6 +4,7 @@ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) from herdr_backend_test_support import ( + CompletedProcessStub, TAB_CREATE_JSON, TAB_LIST_WP, TAB_LIST_WP_ONLY_BOOTSTRAP, @@ -130,7 +131,7 @@ def test_create_agent_window_creates_the_workspace_when_missing(): ) in issued -def test_relaunch_replaces_the_existing_tab_before_starting_the_wrapper(): +def test_relaunch_starts_the_replacement_tab_before_discarding_the_stale_one(): issued = [] backend = backend_with_responses( issued, @@ -143,8 +144,7 @@ def test_relaunch_replaces_the_existing_tab_before_starting_the_wrapper(): assert backend.relaunch_wrapper_in_window( "clawde", "bronze", "exec /nix/store/x-agent" ) - assert ("tab", "close", "wP:t7") in issued - assert ( + creation = ( "tab", "create", "--workspace", @@ -152,7 +152,8 @@ def test_relaunch_replaces_the_existing_tab_before_starting_the_wrapper(): "--label", "bronze", "--no-focus", - ) in issued + ) + assert issued.index(creation) < issued.index(("tab", "close", "wP:t7")) assert ( "pane", "run", @@ -162,6 +163,42 @@ def test_relaunch_replaces_the_existing_tab_before_starting_the_wrapper(): assert not any(command[:3] == ("pane", "run", "wP:p7") for command in issued) +def test_relaunch_revives_an_agent_whose_tab_is_the_last_one_in_its_workspace(): + issued = [] + backend = backend_with_responses( + issued, + [ + (("workspace", "list"), WORKSPACE_LIST_WITH_CLAWDE), + (("tab", "list", "--workspace"), TAB_LIST_WP), + (("tab", "create"), TAB_CREATE_JSON), + ], + ) + creating_run = backend.run_herdr_command + + def run_refusing_to_close_the_last_tab(*arguments): + if arguments[:2] == ("tab", "close"): + issued.append(arguments) + refusal = CompletedProcessStub(1, "") + refusal.stderr = ( + '{"error":{"code":"tab_close_failed",' + '"message":"cannot close the last tab in a workspace"}}' + ) + return refusal + return creating_run(*arguments) + + backend.run_herdr_command = run_refusing_to_close_the_last_tab + + assert backend.relaunch_wrapper_in_window( + "clawde", "bronze", "exec /nix/store/x-agent" + ) + assert ( + "pane", + "run", + "wZ:p9", + "CLAWDE_MULTIPLEXER=herdr exec /nix/store/x-agent", + ) in issued + + def test_remove_agent_window_closes_the_agent_tab(): issued = [] backend = backend_with_responses(