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"]