From 1468420293acdd86e6597fbe2560b0129c45c164 Mon Sep 17 00:00:00 2001 From: Lucas de Castro Zanoni Date: Tue, 25 Aug 2026 15:42:05 -0300 Subject: [PATCH] fix(clawde): restart the supervisor when its own code changes The unit carries `X-RestartIfChanged = false` so a rebuild never disturbs a healthy supervisor. The cost was never stated: home-manager rewrites the unit file and leaves the old process running, so new supervisor code stays dormant until someone restarts it by hand or reboots. On chise that ran for three days. The supervisor was executing a clawde-service store path that had already been garbage-collected, with a specification file that no longer existed, while every rebuild reported success. The sidecar supersession fix shipped in v0.12.7 was live in the store and dormant in the process that was supposed to run it. An activation now compares the running supervisor's command line against the one this generation deploys and restarts only on a mismatch, so a healthy supervisor is still never disturbed. The deployed command is read from a file rather than passed as an argument, because an argument carrying `clawde-service.py --specification-file` would make the check match itself the way a bridge pgrep does. Agent-Machine: kira Agent-Resume: claude --resume efee1316-8d94-438e-951e-e9c978626531 --- module/config/service.nix | 19 +++- module/scripts/clawde-supervisor-refresh.py | 73 ++++++++++++ .../unit/test_clawde_supervisor_refresh.py | 107 ++++++++++++++++++ 3 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 module/scripts/clawde-supervisor-refresh.py create mode 100644 module/scripts/tests/unit/test_clawde_supervisor_refresh.py diff --git a/module/config/service.nix b/module/config/service.nix index f97d96c..d63e55f 100644 --- a/module/config/service.nix +++ b/module/config/service.nix @@ -19,7 +19,7 @@ let clawdeServiceRestartCommand = if pkgs.stdenv.hostPlatform.isLinux then - "systemctl --user restart clawde" + "systemctl --user daemon-reload && systemctl --user restart clawde" else "launchctl kickstart -k gui/UID/org.nix-community.home.clawde"; @@ -55,6 +55,16 @@ let "${clawdeServiceSpecificationFile}" ]; + clawdeServiceDeployedCommandFile = pkgs.writeText "clawde-service-deployed-command" ( + lib.concatStringsSep " " clawdeServiceExecArguments + ); + + clawdeSupervisorRefresh = pkgs.writeShellScriptBin "clawde-supervisor-refresh" '' + exec ${pkgs.python312}/bin/python3 ${../scripts/clawde-supervisor-refresh.py} \ + --deployed-command-file ${clawdeServiceDeployedCommandFile} \ + --restart-command ${lib.escapeShellArg clawdeServiceRestartCommand} "$@" + ''; + linuxSystemdUnit = { Unit = { Description = "clawde persistent agents supervisor"; @@ -118,8 +128,15 @@ in clawdeSessionStarter clawdeGracefulRedeploy clawdeHeartbeatChangeGate + clawdeSupervisorRefresh ]; + home.activation.refreshClawdeSupervisorWhenItsCodeChanges = + lib.hm.dag.entryAfter [ "writeBoundary" ] + '' + run ${clawdeSupervisorRefresh}/bin/clawde-supervisor-refresh + ''; + xdg.dataFile."bash-completion/completions/clawde".source = ../scripts/completion/clawde.bash; systemd.user.services = lib.mkIf pkgs.stdenv.hostPlatform.isLinux { diff --git a/module/scripts/clawde-supervisor-refresh.py b/module/scripts/clawde-supervisor-refresh.py new file mode 100644 index 0000000..b7c085a --- /dev/null +++ b/module/scripts/clawde-supervisor-refresh.py @@ -0,0 +1,73 @@ +import argparse +import os +import subprocess + +SUPERVISOR_PROCESS_MATCH_PATTERN = "clawde-service.py --specification-file" + + +def find_supervisor_process_ids() -> list[int]: + pgrep_result = subprocess.run( + ["pgrep", "-f", SUPERVISOR_PROCESS_MATCH_PATTERN], + capture_output=True, + text=True, + ) + own_process_id = os.getpid() + return [ + int(line) + for line in pgrep_result.stdout.split() + if line.strip().isdigit() and int(line) != own_process_id + ] + + +def read_full_command_line(process_id: int) -> str: + ps_result = subprocess.run( + ["ps", "-ww", "-o", "command=", "-p", str(process_id)], + capture_output=True, + text=True, + ) + return ps_result.stdout.strip() + + +def read_deployed_command(deployed_command_file: str) -> str: + with open(deployed_command_file) as command_file: + return command_file.read().strip() + + +def supervisor_runs_superseded_code(deployed_command: str) -> bool: + for process_id in find_supervisor_process_ids(): + command_line = read_full_command_line(process_id) + if command_line and command_line != deployed_command: + return True + return False + + +def restart_the_supervisor(restart_command: str) -> int: + return subprocess.run(["sh", "-c", restart_command]).returncode + + +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="clawde-supervisor-refresh", + description="Restart the clawde supervisor when the code it is running is " + "no longer the code this generation deploys. The unit is marked " + "X-RestartIfChanged=false so a rebuild never disturbs a healthy supervisor, " + "which also means new supervisor code would otherwise stay dormant until " + "someone restarted it by hand.", + ) + parser.add_argument("--deployed-command-file", required=True) + parser.add_argument("--restart-command", required=True) + return parser.parse_args() + + +def main() -> int: + arguments = parse_arguments() + deployed_command = read_deployed_command(arguments.deployed_command_file) + if not supervisor_runs_superseded_code(deployed_command): + print("clawde supervisor already runs this generation's code.") + return 0 + print("clawde supervisor runs superseded code; restarting it.") + return restart_the_supervisor(arguments.restart_command) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/module/scripts/tests/unit/test_clawde_supervisor_refresh.py b/module/scripts/tests/unit/test_clawde_supervisor_refresh.py new file mode 100644 index 0000000..cdaab9a --- /dev/null +++ b/module/scripts/tests/unit/test_clawde_supervisor_refresh.py @@ -0,0 +1,107 @@ +import importlib.util +import pathlib + +CLAWDE_SCRIPTS_DIRECTORY = pathlib.Path(__file__).resolve().parent.parent.parent + + +def _load_supervisor_refresh_module(): + module_spec = importlib.util.spec_from_file_location( + "clawde_supervisor_refresh", + CLAWDE_SCRIPTS_DIRECTORY / "clawde-supervisor-refresh.py", + ) + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + +supervisor_refresh = _load_supervisor_refresh_module() + +DEPLOYED_COMMAND = "/nix/store/new-python3 /nix/store/new-clawde-service.py --specification-file /nix/store/new-spec.json" +SUPERSEDED_COMMAND = "/nix/store/old-python3 /nix/store/old-clawde-service.py --specification-file /nix/store/old-spec.json" + + +def stub_live_supervisors(monkeypatch, command_lines): + process_ids = list(range(100, 100 + len(command_lines))) + monkeypatch.setattr( + supervisor_refresh, "find_supervisor_process_ids", lambda: process_ids + ) + monkeypatch.setattr( + supervisor_refresh, + "read_full_command_line", + lambda process_id: command_lines[process_ids.index(process_id)], + ) + + +def test_a_supervisor_running_superseded_code_is_restarted(monkeypatch, tmp_path): + stub_live_supervisors(monkeypatch, [SUPERSEDED_COMMAND]) + + assert supervisor_refresh.supervisor_runs_superseded_code(DEPLOYED_COMMAND) + + +def test_a_supervisor_already_on_this_generation_is_left_alone(monkeypatch): + stub_live_supervisors(monkeypatch, [DEPLOYED_COMMAND]) + + assert not supervisor_refresh.supervisor_runs_superseded_code(DEPLOYED_COMMAND) + + +def test_no_running_supervisor_needs_no_restart(monkeypatch): + stub_live_supervisors(monkeypatch, []) + + assert not supervisor_refresh.supervisor_runs_superseded_code(DEPLOYED_COMMAND) + + +def test_an_unreadable_command_line_never_triggers_a_restart(monkeypatch): + stub_live_supervisors(monkeypatch, [""]) + + assert not supervisor_refresh.supervisor_runs_superseded_code(DEPLOYED_COMMAND) + + +def test_the_refresh_never_matches_its_own_process(monkeypatch): + class PgrepResult: + stdout = f"{__import__('os').getpid()}\n" + + monkeypatch.setattr( + supervisor_refresh.subprocess, "run", lambda *a, **k: PgrepResult() + ) + + assert supervisor_refresh.find_supervisor_process_ids() == [] + + +def test_the_deployed_command_is_read_from_its_file(tmp_path): + command_file = tmp_path / "clawde-service-deployed-command" + command_file.write_text(DEPLOYED_COMMAND + "\n") + + assert ( + supervisor_refresh.read_deployed_command(str(command_file)) == DEPLOYED_COMMAND + ) + + +def test_main_restarts_only_when_the_running_code_is_superseded(monkeypatch, tmp_path): + command_file = tmp_path / "clawde-service-deployed-command" + command_file.write_text(DEPLOYED_COMMAND) + restart_commands = [] + monkeypatch.setattr( + supervisor_refresh, + "parse_arguments", + lambda: type( + "Arguments", + (), + { + "deployed_command_file": str(command_file), + "restart_command": "restart-me", + }, + ), + ) + monkeypatch.setattr( + supervisor_refresh, + "restart_the_supervisor", + lambda command: restart_commands.append(command) or 0, + ) + + stub_live_supervisors(monkeypatch, [DEPLOYED_COMMAND]) + assert supervisor_refresh.main() == 0 + assert restart_commands == [] + + stub_live_supervisors(monkeypatch, [SUPERSEDED_COMMAND]) + assert supervisor_refresh.main() == 0 + assert restart_commands == ["restart-me"]