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
69 changes: 69 additions & 0 deletions module/scripts/clawde-service/sidecar_process_reconcile.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,11 @@
import pathlib
import signal
import subprocess
import time

SPAWNED_COMMAND_RECORD_SUFFIX = ".spawned-command"
TERMINATION_DEADLINE_SECONDS = 5.0
TERMINATION_POLL_SECONDS = 0.1


def find_sidecar_process_ids(process_match_pattern: str) -> list[int]:
Expand All@@ -20,6 +25,66 @@ def terminate_sidecar_process(process_id: int) -> None:
pass


def process_is_alive(process_id: int) -> bool:
try:
os.kill(process_id, 0)
except ProcessLookupError:
return False
return True


def termination_poll_attempts() -> int:
return int(TERMINATION_DEADLINE_SECONDS / TERMINATION_POLL_SECONDS)


def wait_for_process_to_exit(process_id: int) -> None:
for _attempt in range(termination_poll_attempts()):
if not process_is_alive(process_id):
return
time.sleep(TERMINATION_POLL_SECONDS)


def spawned_command_record_for(sidecar_specification: dict) -> pathlib.Path:
return pathlib.Path(
sidecar_specification["log_file"] + SPAWNED_COMMAND_RECORD_SUFFIX
)


def recorded_spawned_command(sidecar_specification: dict) -> str | None:
record_file = spawned_command_record_for(sidecar_specification)
try:
return record_file.read_text()
except OSError:
return None


def record_spawned_command(sidecar_specification: dict) -> None:
record_file = spawned_command_record_for(sidecar_specification)
record_file.parent.mkdir(parents=True, exist_ok=True)
record_file.write_text(sidecar_specification["command"])


def live_processes_run_the_current_command(sidecar_specification: dict) -> bool:
return (
recorded_spawned_command(sidecar_specification)
== (sidecar_specification["command"])
)


def replace_processes_running_superseded_code(
sidecar_specification: dict, live_process_ids: list[int]
) -> list[int]:
if not live_process_ids or live_processes_run_the_current_command(
sidecar_specification
):
return live_process_ids
for process_id in live_process_ids:
terminate_sidecar_process(process_id)
for process_id in live_process_ids:
wait_for_process_to_exit(process_id)
return []


def open_sidecar_log_file(log_file_path: str):
log_file = pathlib.Path(log_file_path)
log_file.parent.mkdir(parents=True, exist_ok=True)
Expand All@@ -31,6 +96,7 @@ def detach_from_supervisor(command: str) -> str:


def spawn_sidecar_process(sidecar_specification: dict) -> None:
record_spawned_command(sidecar_specification)
with open_sidecar_log_file(sidecar_specification["log_file"]) as log_file:
subprocess.run(
["sh", "-c", detach_from_supervisor(sidecar_specification["command"])],
Expand All@@ -51,6 +117,9 @@ def reconcile_one_sidecar_process(
for process_id in live_process_ids:
terminate_sidecar_process(process_id)
return False
live_process_ids = replace_processes_running_superseded_code(
sidecar_specification, live_process_ids
)
for duplicate_process_id in live_process_ids[1:]:
terminate_sidecar_process(duplicate_process_id)
if live_process_ids:
Expand Down
10 changes: 10 additions & 0 deletions module/scripts/tests/unit/sidecar_process_test_support.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,11 @@ def make_sidecar_specification_with_lifetime(tmp_path, lifetime, enabled=True):
}


def record_the_sidecar_as_launched_from_its_current_command(specification):
sidecar_process_reconcile.record_spawned_command(specification)
return specification


def make_session_specification(tmp_path):
return {
"name": "clawde",
Expand DownExpand Up@@ -54,4 +59,9 @@ def record_process_lookups(monkeypatch, live_process_ids):
"terminate_sidecar_process",
terminated_process_ids.append,
)
monkeypatch.setattr(
sidecar_process_reconcile,
"wait_for_process_to_exit",
lambda _process_id: None,
)
return spawned_specifications, terminated_process_ids
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
make_sidecar_specification_with_lifetime,
make_session_specification,
record_process_lookups,
record_the_sidecar_as_launched_from_its_current_command,
)


Expand All@@ -35,7 +36,10 @@ def test_a_live_sidecar_is_never_relaunched(tmp_path, monkeypatch):
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
make_sidecar_specification(tmp_path), True
record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path)
),
True,
)

assert spawned_specifications == []
Expand All@@ -50,7 +54,10 @@ def test_duplicate_sidecar_processes_are_culled_down_to_the_oldest(
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
make_sidecar_specification(tmp_path), True
record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path)
),
True,
)

assert terminated_process_ids == [450, 700]
Expand DownExpand Up@@ -175,7 +182,9 @@ def test_a_service_lifetime_sidecar_survives_its_agents_dormancy(tmp_path, monke
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [4321]
)
specification = make_sidecar_specification_with_lifetime(tmp_path, "service")
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification_with_lifetime(tmp_path, "service")
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
specification,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
sys.path.insert(
0, str(pathlib.Path(__file__).resolve().parent.parent.parent / "clawde-service")
)

import sidecar_process_reconcile
from sidecar_process_test_support import (
SIDECAR_NAME,
make_sidecar_specification,
record_process_lookups,
record_the_sidecar_as_launched_from_its_current_command,
)


def reconcile_a_running_sidecar(specification, monkeypatch, live_process_ids=(4321,)):
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, list(live_process_ids)
)
sidecar_process_reconcile.reconcile_one_sidecar_process(specification, True)
return spawned_specifications, terminated_process_ids


def test_a_sidecar_still_running_superseded_code_is_replaced(tmp_path, monkeypatch):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"

spawned_specifications, terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert terminated_process_ids == [4321]
assert [specification["name"] for specification in spawned_specifications] == [
SIDECAR_NAME
]


def test_every_process_of_a_superseded_sidecar_is_terminated_before_the_replacement(
tmp_path, monkeypatch
):
waited_for_process_ids = []
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [700, 120, 450]
)
monkeypatch.setattr(
sidecar_process_reconcile,
"wait_for_process_to_exit",
waited_for_process_ids.append,
)

sidecar_process_reconcile.reconcile_one_sidecar_process(specification, True)

assert terminated_process_ids == [120, 450, 700]
assert waited_for_process_ids == [120, 450, 700]
assert len(spawned_specifications) == 1


def test_a_sidecar_running_the_current_code_is_left_alone(tmp_path, monkeypatch):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/bridge.py")
)

spawned_specifications, terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert spawned_specifications == []
assert terminated_process_ids == []


def test_a_sidecar_launched_before_this_record_existed_is_replaced_exactly_once(
tmp_path, monkeypatch
):
specification = make_sidecar_specification(tmp_path, command="true")

_, first_terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)
sidecar_process_reconcile.record_spawned_command(specification)
_, second_terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert first_terminated_process_ids == [4321]
assert second_terminated_process_ids == []


def test_spawning_records_the_command_it_launched(tmp_path):
specification = make_sidecar_specification(tmp_path, command="true")

sidecar_process_reconcile.spawn_sidecar_process(specification)

assert sidecar_process_reconcile.recorded_spawned_command(specification) == "true"


def test_a_superseded_sidecar_that_should_stop_is_terminated_without_replacement(
tmp_path, monkeypatch
):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [4321]
)

sidecar_process_reconcile.reconcile_one_sidecar_process(specification, False)

assert terminated_process_ids == [4321]
assert spawned_specifications == []


def test_waiting_returns_as_soon_as_the_process_is_gone(monkeypatch):
monkeypatch.setattr(
sidecar_process_reconcile, "process_is_alive", lambda _process_id: False
)
monkeypatch.setattr(
sidecar_process_reconcile,
"time",
type("NeverSleeps", (), {"sleep": staticmethod(lambda _seconds: None)}),
)

sidecar_process_reconcile.wait_for_process_to_exit(4321)


def test_waiting_gives_up_on_a_process_that_refuses_to_exit(monkeypatch):
slept_seconds = []
monkeypatch.setattr(
sidecar_process_reconcile, "process_is_alive", lambda _process_id: True
)
monkeypatch.setattr(
sidecar_process_reconcile,
"time",
type("CountsSleeps", (), {"sleep": staticmethod(slept_seconds.append)}),
)

sidecar_process_reconcile.wait_for_process_to_exit(4321)

assert len(slept_seconds) == sidecar_process_reconcile.termination_poll_attempts()
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Replace a sidecar still running superseded code by Castrozan · Pull Request #20 · Castrozan/clawde · GitHub
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
69 changes: 69 additions & 0 deletions module/scripts/clawde-service/sidecar_process_reconcile.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,11 @@
import pathlib
import signal
import subprocess
import time

SPAWNED_COMMAND_RECORD_SUFFIX = ".spawned-command"
TERMINATION_DEADLINE_SECONDS = 5.0
TERMINATION_POLL_SECONDS = 0.1


def find_sidecar_process_ids(process_match_pattern: str) -> list[int]:
Expand All@@ -20,6 +25,66 @@ def terminate_sidecar_process(process_id: int) -> None:
pass


def process_is_alive(process_id: int) -> bool:
try:
os.kill(process_id, 0)
except ProcessLookupError:
return False
return True


def termination_poll_attempts() -> int:
return int(TERMINATION_DEADLINE_SECONDS / TERMINATION_POLL_SECONDS)


def wait_for_process_to_exit(process_id: int) -> None:
for _attempt in range(termination_poll_attempts()):
if not process_is_alive(process_id):
return
time.sleep(TERMINATION_POLL_SECONDS)


def spawned_command_record_for(sidecar_specification: dict) -> pathlib.Path:
return pathlib.Path(
sidecar_specification["log_file"] + SPAWNED_COMMAND_RECORD_SUFFIX
)


def recorded_spawned_command(sidecar_specification: dict) -> str | None:
record_file = spawned_command_record_for(sidecar_specification)
try:
return record_file.read_text()
except OSError:
return None


def record_spawned_command(sidecar_specification: dict) -> None:
record_file = spawned_command_record_for(sidecar_specification)
record_file.parent.mkdir(parents=True, exist_ok=True)
record_file.write_text(sidecar_specification["command"])


def live_processes_run_the_current_command(sidecar_specification: dict) -> bool:
return (
recorded_spawned_command(sidecar_specification)
== (sidecar_specification["command"])
)


def replace_processes_running_superseded_code(
sidecar_specification: dict, live_process_ids: list[int]
) -> list[int]:
if not live_process_ids or live_processes_run_the_current_command(
sidecar_specification
):
return live_process_ids
for process_id in live_process_ids:
terminate_sidecar_process(process_id)
for process_id in live_process_ids:
wait_for_process_to_exit(process_id)
return []


def open_sidecar_log_file(log_file_path: str):
log_file = pathlib.Path(log_file_path)
log_file.parent.mkdir(parents=True, exist_ok=True)
Expand All@@ -31,6 +96,7 @@ def detach_from_supervisor(command: str) -> str:


def spawn_sidecar_process(sidecar_specification: dict) -> None:
record_spawned_command(sidecar_specification)
with open_sidecar_log_file(sidecar_specification["log_file"]) as log_file:
subprocess.run(
["sh", "-c", detach_from_supervisor(sidecar_specification["command"])],
Expand All@@ -51,6 +117,9 @@ def reconcile_one_sidecar_process(
for process_id in live_process_ids:
terminate_sidecar_process(process_id)
return False
live_process_ids = replace_processes_running_superseded_code(
sidecar_specification, live_process_ids
)
for duplicate_process_id in live_process_ids[1:]:
terminate_sidecar_process(duplicate_process_id)
if live_process_ids:
Expand Down
10 changes: 10 additions & 0 deletions module/scripts/tests/unit/sidecar_process_test_support.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,11 @@ def make_sidecar_specification_with_lifetime(tmp_path, lifetime, enabled=True):
}


def record_the_sidecar_as_launched_from_its_current_command(specification):
sidecar_process_reconcile.record_spawned_command(specification)
return specification


def make_session_specification(tmp_path):
return {
"name": "clawde",
Expand DownExpand Up@@ -54,4 +59,9 @@ def record_process_lookups(monkeypatch, live_process_ids):
"terminate_sidecar_process",
terminated_process_ids.append,
)
monkeypatch.setattr(
sidecar_process_reconcile,
"wait_for_process_to_exit",
lambda _process_id: None,
)
return spawned_specifications, terminated_process_ids
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
make_sidecar_specification_with_lifetime,
make_session_specification,
record_process_lookups,
record_the_sidecar_as_launched_from_its_current_command,
)


Expand All@@ -35,7 +36,10 @@ def test_a_live_sidecar_is_never_relaunched(tmp_path, monkeypatch):
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
make_sidecar_specification(tmp_path), True
record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path)
),
True,
)

assert spawned_specifications == []
Expand All@@ -50,7 +54,10 @@ def test_duplicate_sidecar_processes_are_culled_down_to_the_oldest(
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
make_sidecar_specification(tmp_path), True
record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path)
),
True,
)

assert terminated_process_ids == [450, 700]
Expand DownExpand Up@@ -175,7 +182,9 @@ def test_a_service_lifetime_sidecar_survives_its_agents_dormancy(tmp_path, monke
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [4321]
)
specification = make_sidecar_specification_with_lifetime(tmp_path, "service")
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification_with_lifetime(tmp_path, "service")
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
specification,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
sys.path.insert(
0, str(pathlib.Path(__file__).resolve().parent.parent.parent / "clawde-service")
)

import sidecar_process_reconcile
from sidecar_process_test_support import (
SIDECAR_NAME,
make_sidecar_specification,
record_process_lookups,
record_the_sidecar_as_launched_from_its_current_command,
)


def reconcile_a_running_sidecar(specification, monkeypatch, live_process_ids=(4321,)):
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, list(live_process_ids)
)
sidecar_process_reconcile.reconcile_one_sidecar_process(specification, True)
return spawned_specifications, terminated_process_ids


def test_a_sidecar_still_running_superseded_code_is_replaced(tmp_path, monkeypatch):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"

spawned_specifications, terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert terminated_process_ids == [4321]
assert [specification["name"] for specification in spawned_specifications] == [
SIDECAR_NAME
]


def test_every_process_of_a_superseded_sidecar_is_terminated_before_the_replacement(
tmp_path, monkeypatch
):
waited_for_process_ids = []
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [700, 120, 450]
)
monkeypatch.setattr(
sidecar_process_reconcile,
"wait_for_process_to_exit",
waited_for_process_ids.append,
)

sidecar_process_reconcile.reconcile_one_sidecar_process(specification, True)

assert terminated_process_ids == [120, 450, 700]
assert waited_for_process_ids == [120, 450, 700]
assert len(spawned_specifications) == 1


def test_a_sidecar_running_the_current_code_is_left_alone(tmp_path, monkeypatch):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/bridge.py")
)

spawned_specifications, terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert spawned_specifications == []
assert terminated_process_ids == []


def test_a_sidecar_launched_before_this_record_existed_is_replaced_exactly_once(
tmp_path, monkeypatch
):
specification = make_sidecar_specification(tmp_path, command="true")

_, first_terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)
sidecar_process_reconcile.record_spawned_command(specification)
_, second_terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert first_terminated_process_ids == [4321]
assert second_terminated_process_ids == []


def test_spawning_records_the_command_it_launched(tmp_path):
specification = make_sidecar_specification(tmp_path, command="true")

sidecar_process_reconcile.spawn_sidecar_process(specification)

assert sidecar_process_reconcile.recorded_spawned_command(specification) == "true"


def test_a_superseded_sidecar_that_should_stop_is_terminated_without_replacement(
tmp_path, monkeypatch
):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [4321]
)

sidecar_process_reconcile.reconcile_one_sidecar_process(specification, False)

assert terminated_process_ids == [4321]
assert spawned_specifications == []


def test_waiting_returns_as_soon_as_the_process_is_gone(monkeypatch):
monkeypatch.setattr(
sidecar_process_reconcile, "process_is_alive", lambda _process_id: False
)
monkeypatch.setattr(
sidecar_process_reconcile,
"time",
type("NeverSleeps", (), {"sleep": staticmethod(lambda _seconds: None)}),
)

sidecar_process_reconcile.wait_for_process_to_exit(4321)


def test_waiting_gives_up_on_a_process_that_refuses_to_exit(monkeypatch):
slept_seconds = []
monkeypatch.setattr(
sidecar_process_reconcile, "process_is_alive", lambda _process_id: True
)
monkeypatch.setattr(
sidecar_process_reconcile,
"time",
type("CountsSleeps", (), {"sleep": staticmethod(slept_seconds.append)}),
)

sidecar_process_reconcile.wait_for_process_to_exit(4321)

assert len(slept_seconds) == sidecar_process_reconcile.termination_poll_attempts()
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Replace a sidecar still running superseded code by Castrozan · Pull Request #20 · Castrozan/clawde · GitHub
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
69 changes: 69 additions & 0 deletions module/scripts/clawde-service/sidecar_process_reconcile.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,11 @@
import pathlib
import signal
import subprocess
import time

SPAWNED_COMMAND_RECORD_SUFFIX = ".spawned-command"
TERMINATION_DEADLINE_SECONDS = 5.0
TERMINATION_POLL_SECONDS = 0.1


def find_sidecar_process_ids(process_match_pattern: str) -> list[int]:
Expand All@@ -20,6 +25,66 @@ def terminate_sidecar_process(process_id: int) -> None:
pass


def process_is_alive(process_id: int) -> bool:
try:
os.kill(process_id, 0)
except ProcessLookupError:
return False
return True


def termination_poll_attempts() -> int:
return int(TERMINATION_DEADLINE_SECONDS / TERMINATION_POLL_SECONDS)


def wait_for_process_to_exit(process_id: int) -> None:
for _attempt in range(termination_poll_attempts()):
if not process_is_alive(process_id):
return
time.sleep(TERMINATION_POLL_SECONDS)


def spawned_command_record_for(sidecar_specification: dict) -> pathlib.Path:
return pathlib.Path(
sidecar_specification["log_file"] + SPAWNED_COMMAND_RECORD_SUFFIX
)


def recorded_spawned_command(sidecar_specification: dict) -> str | None:
record_file = spawned_command_record_for(sidecar_specification)
try:
return record_file.read_text()
except OSError:
return None


def record_spawned_command(sidecar_specification: dict) -> None:
record_file = spawned_command_record_for(sidecar_specification)
record_file.parent.mkdir(parents=True, exist_ok=True)
record_file.write_text(sidecar_specification["command"])


def live_processes_run_the_current_command(sidecar_specification: dict) -> bool:
return (
recorded_spawned_command(sidecar_specification)
== (sidecar_specification["command"])
)


def replace_processes_running_superseded_code(
sidecar_specification: dict, live_process_ids: list[int]
) -> list[int]:
if not live_process_ids or live_processes_run_the_current_command(
sidecar_specification
):
return live_process_ids
for process_id in live_process_ids:
terminate_sidecar_process(process_id)
for process_id in live_process_ids:
wait_for_process_to_exit(process_id)
return []


def open_sidecar_log_file(log_file_path: str):
log_file = pathlib.Path(log_file_path)
log_file.parent.mkdir(parents=True, exist_ok=True)
Expand All@@ -31,6 +96,7 @@ def detach_from_supervisor(command: str) -> str:


def spawn_sidecar_process(sidecar_specification: dict) -> None:
record_spawned_command(sidecar_specification)
with open_sidecar_log_file(sidecar_specification["log_file"]) as log_file:
subprocess.run(
["sh", "-c", detach_from_supervisor(sidecar_specification["command"])],
Expand All@@ -51,6 +117,9 @@ def reconcile_one_sidecar_process(
for process_id in live_process_ids:
terminate_sidecar_process(process_id)
return False
live_process_ids = replace_processes_running_superseded_code(
sidecar_specification, live_process_ids
)
for duplicate_process_id in live_process_ids[1:]:
terminate_sidecar_process(duplicate_process_id)
if live_process_ids:
Expand Down
10 changes: 10 additions & 0 deletions module/scripts/tests/unit/sidecar_process_test_support.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,11 @@ def make_sidecar_specification_with_lifetime(tmp_path, lifetime, enabled=True):
}


def record_the_sidecar_as_launched_from_its_current_command(specification):
sidecar_process_reconcile.record_spawned_command(specification)
return specification


def make_session_specification(tmp_path):
return {
"name": "clawde",
Expand DownExpand Up@@ -54,4 +59,9 @@ def record_process_lookups(monkeypatch, live_process_ids):
"terminate_sidecar_process",
terminated_process_ids.append,
)
monkeypatch.setattr(
sidecar_process_reconcile,
"wait_for_process_to_exit",
lambda _process_id: None,
)
return spawned_specifications, terminated_process_ids
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
make_sidecar_specification_with_lifetime,
make_session_specification,
record_process_lookups,
record_the_sidecar_as_launched_from_its_current_command,
)


Expand All@@ -35,7 +36,10 @@ def test_a_live_sidecar_is_never_relaunched(tmp_path, monkeypatch):
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
make_sidecar_specification(tmp_path), True
record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path)
),
True,
)

assert spawned_specifications == []
Expand All@@ -50,7 +54,10 @@ def test_duplicate_sidecar_processes_are_culled_down_to_the_oldest(
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
make_sidecar_specification(tmp_path), True
record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path)
),
True,
)

assert terminated_process_ids == [450, 700]
Expand DownExpand Up@@ -175,7 +182,9 @@ def test_a_service_lifetime_sidecar_survives_its_agents_dormancy(tmp_path, monke
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [4321]
)
specification = make_sidecar_specification_with_lifetime(tmp_path, "service")
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification_with_lifetime(tmp_path, "service")
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
specification,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
sys.path.insert(
0, str(pathlib.Path(__file__).resolve().parent.parent.parent / "clawde-service")
)

import sidecar_process_reconcile
from sidecar_process_test_support import (
SIDECAR_NAME,
make_sidecar_specification,
record_process_lookups,
record_the_sidecar_as_launched_from_its_current_command,
)


def reconcile_a_running_sidecar(specification, monkeypatch, live_process_ids=(4321,)):
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, list(live_process_ids)
)
sidecar_process_reconcile.reconcile_one_sidecar_process(specification, True)
return spawned_specifications, terminated_process_ids


def test_a_sidecar_still_running_superseded_code_is_replaced(tmp_path, monkeypatch):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"

spawned_specifications, terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert terminated_process_ids == [4321]
assert [specification["name"] for specification in spawned_specifications] == [
SIDECAR_NAME
]


def test_every_process_of_a_superseded_sidecar_is_terminated_before_the_replacement(
tmp_path, monkeypatch
):
waited_for_process_ids = []
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [700, 120, 450]
)
monkeypatch.setattr(
sidecar_process_reconcile,
"wait_for_process_to_exit",
waited_for_process_ids.append,
)

sidecar_process_reconcile.reconcile_one_sidecar_process(specification, True)

assert terminated_process_ids == [120, 450, 700]
assert waited_for_process_ids == [120, 450, 700]
assert len(spawned_specifications) == 1


def test_a_sidecar_running_the_current_code_is_left_alone(tmp_path, monkeypatch):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/bridge.py")
)

spawned_specifications, terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert spawned_specifications == []
assert terminated_process_ids == []


def test_a_sidecar_launched_before_this_record_existed_is_replaced_exactly_once(
tmp_path, monkeypatch
):
specification = make_sidecar_specification(tmp_path, command="true")

_, first_terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)
sidecar_process_reconcile.record_spawned_command(specification)
_, second_terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert first_terminated_process_ids == [4321]
assert second_terminated_process_ids == []


def test_spawning_records_the_command_it_launched(tmp_path):
specification = make_sidecar_specification(tmp_path, command="true")

sidecar_process_reconcile.spawn_sidecar_process(specification)

assert sidecar_process_reconcile.recorded_spawned_command(specification) == "true"


def test_a_superseded_sidecar_that_should_stop_is_terminated_without_replacement(
tmp_path, monkeypatch
):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [4321]
)

sidecar_process_reconcile.reconcile_one_sidecar_process(specification, False)

assert terminated_process_ids == [4321]
assert spawned_specifications == []


def test_waiting_returns_as_soon_as_the_process_is_gone(monkeypatch):
monkeypatch.setattr(
sidecar_process_reconcile, "process_is_alive", lambda _process_id: False
)
monkeypatch.setattr(
sidecar_process_reconcile,
"time",
type("NeverSleeps", (), {"sleep": staticmethod(lambda _seconds: None)}),
)

sidecar_process_reconcile.wait_for_process_to_exit(4321)


def test_waiting_gives_up_on_a_process_that_refuses_to_exit(monkeypatch):
slept_seconds = []
monkeypatch.setattr(
sidecar_process_reconcile, "process_is_alive", lambda _process_id: True
)
monkeypatch.setattr(
sidecar_process_reconcile,
"time",
type("CountsSleeps", (), {"sleep": staticmethod(slept_seconds.append)}),
)

sidecar_process_reconcile.wait_for_process_to_exit(4321)

assert len(slept_seconds) == sidecar_process_reconcile.termination_poll_attempts()
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Replace a sidecar still running superseded code by Castrozan · Pull Request #20 · Castrozan/clawde · GitHub
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
69 changes: 69 additions & 0 deletions module/scripts/clawde-service/sidecar_process_reconcile.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,11 @@
import pathlib
import signal
import subprocess
import time

SPAWNED_COMMAND_RECORD_SUFFIX = ".spawned-command"
TERMINATION_DEADLINE_SECONDS = 5.0
TERMINATION_POLL_SECONDS = 0.1


def find_sidecar_process_ids(process_match_pattern: str) -> list[int]:
Expand All@@ -20,6 +25,66 @@ def terminate_sidecar_process(process_id: int) -> None:
pass


def process_is_alive(process_id: int) -> bool:
try:
os.kill(process_id, 0)
except ProcessLookupError:
return False
return True


def termination_poll_attempts() -> int:
return int(TERMINATION_DEADLINE_SECONDS / TERMINATION_POLL_SECONDS)


def wait_for_process_to_exit(process_id: int) -> None:
for _attempt in range(termination_poll_attempts()):
if not process_is_alive(process_id):
return
time.sleep(TERMINATION_POLL_SECONDS)


def spawned_command_record_for(sidecar_specification: dict) -> pathlib.Path:
return pathlib.Path(
sidecar_specification["log_file"] + SPAWNED_COMMAND_RECORD_SUFFIX
)


def recorded_spawned_command(sidecar_specification: dict) -> str | None:
record_file = spawned_command_record_for(sidecar_specification)
try:
return record_file.read_text()
except OSError:
return None


def record_spawned_command(sidecar_specification: dict) -> None:
record_file = spawned_command_record_for(sidecar_specification)
record_file.parent.mkdir(parents=True, exist_ok=True)
record_file.write_text(sidecar_specification["command"])


def live_processes_run_the_current_command(sidecar_specification: dict) -> bool:
return (
recorded_spawned_command(sidecar_specification)
== (sidecar_specification["command"])
)


def replace_processes_running_superseded_code(
sidecar_specification: dict, live_process_ids: list[int]
) -> list[int]:
if not live_process_ids or live_processes_run_the_current_command(
sidecar_specification
):
return live_process_ids
for process_id in live_process_ids:
terminate_sidecar_process(process_id)
for process_id in live_process_ids:
wait_for_process_to_exit(process_id)
return []


def open_sidecar_log_file(log_file_path: str):
log_file = pathlib.Path(log_file_path)
log_file.parent.mkdir(parents=True, exist_ok=True)
Expand All@@ -31,6 +96,7 @@ def detach_from_supervisor(command: str) -> str:


def spawn_sidecar_process(sidecar_specification: dict) -> None:
record_spawned_command(sidecar_specification)
with open_sidecar_log_file(sidecar_specification["log_file"]) as log_file:
subprocess.run(
["sh", "-c", detach_from_supervisor(sidecar_specification["command"])],
Expand All@@ -51,6 +117,9 @@ def reconcile_one_sidecar_process(
for process_id in live_process_ids:
terminate_sidecar_process(process_id)
return False
live_process_ids = replace_processes_running_superseded_code(
sidecar_specification, live_process_ids
)
for duplicate_process_id in live_process_ids[1:]:
terminate_sidecar_process(duplicate_process_id)
if live_process_ids:
Expand Down
10 changes: 10 additions & 0 deletions module/scripts/tests/unit/sidecar_process_test_support.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,11 @@ def make_sidecar_specification_with_lifetime(tmp_path, lifetime, enabled=True):
}


def record_the_sidecar_as_launched_from_its_current_command(specification):
sidecar_process_reconcile.record_spawned_command(specification)
return specification


def make_session_specification(tmp_path):
return {
"name": "clawde",
Expand DownExpand Up@@ -54,4 +59,9 @@ def record_process_lookups(monkeypatch, live_process_ids):
"terminate_sidecar_process",
terminated_process_ids.append,
)
monkeypatch.setattr(
sidecar_process_reconcile,
"wait_for_process_to_exit",
lambda _process_id: None,
)
return spawned_specifications, terminated_process_ids
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
make_sidecar_specification_with_lifetime,
make_session_specification,
record_process_lookups,
record_the_sidecar_as_launched_from_its_current_command,
)


Expand All@@ -35,7 +36,10 @@ def test_a_live_sidecar_is_never_relaunched(tmp_path, monkeypatch):
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
make_sidecar_specification(tmp_path), True
record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path)
),
True,
)

assert spawned_specifications == []
Expand All@@ -50,7 +54,10 @@ def test_duplicate_sidecar_processes_are_culled_down_to_the_oldest(
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
make_sidecar_specification(tmp_path), True
record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path)
),
True,
)

assert terminated_process_ids == [450, 700]
Expand DownExpand Up@@ -175,7 +182,9 @@ def test_a_service_lifetime_sidecar_survives_its_agents_dormancy(tmp_path, monke
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [4321]
)
specification = make_sidecar_specification_with_lifetime(tmp_path, "service")
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification_with_lifetime(tmp_path, "service")
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
specification,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
sys.path.insert(
0, str(pathlib.Path(__file__).resolve().parent.parent.parent / "clawde-service")
)

import sidecar_process_reconcile
from sidecar_process_test_support import (
SIDECAR_NAME,
make_sidecar_specification,
record_process_lookups,
record_the_sidecar_as_launched_from_its_current_command,
)


def reconcile_a_running_sidecar(specification, monkeypatch, live_process_ids=(4321,)):
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, list(live_process_ids)
)
sidecar_process_reconcile.reconcile_one_sidecar_process(specification, True)
return spawned_specifications, terminated_process_ids


def test_a_sidecar_still_running_superseded_code_is_replaced(tmp_path, monkeypatch):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"

spawned_specifications, terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert terminated_process_ids == [4321]
assert [specification["name"] for specification in spawned_specifications] == [
SIDECAR_NAME
]


def test_every_process_of_a_superseded_sidecar_is_terminated_before_the_replacement(
tmp_path, monkeypatch
):
waited_for_process_ids = []
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [700, 120, 450]
)
monkeypatch.setattr(
sidecar_process_reconcile,
"wait_for_process_to_exit",
waited_for_process_ids.append,
)

sidecar_process_reconcile.reconcile_one_sidecar_process(specification, True)

assert terminated_process_ids == [120, 450, 700]
assert waited_for_process_ids == [120, 450, 700]
assert len(spawned_specifications) == 1


def test_a_sidecar_running_the_current_code_is_left_alone(tmp_path, monkeypatch):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/bridge.py")
)

spawned_specifications, terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert spawned_specifications == []
assert terminated_process_ids == []


def test_a_sidecar_launched_before_this_record_existed_is_replaced_exactly_once(
tmp_path, monkeypatch
):
specification = make_sidecar_specification(tmp_path, command="true")

_, first_terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)
sidecar_process_reconcile.record_spawned_command(specification)
_, second_terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert first_terminated_process_ids == [4321]
assert second_terminated_process_ids == []


def test_spawning_records_the_command_it_launched(tmp_path):
specification = make_sidecar_specification(tmp_path, command="true")

sidecar_process_reconcile.spawn_sidecar_process(specification)

assert sidecar_process_reconcile.recorded_spawned_command(specification) == "true"


def test_a_superseded_sidecar_that_should_stop_is_terminated_without_replacement(
tmp_path, monkeypatch
):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [4321]
)

sidecar_process_reconcile.reconcile_one_sidecar_process(specification, False)

assert terminated_process_ids == [4321]
assert spawned_specifications == []


def test_waiting_returns_as_soon_as_the_process_is_gone(monkeypatch):
monkeypatch.setattr(
sidecar_process_reconcile, "process_is_alive", lambda _process_id: False
)
monkeypatch.setattr(
sidecar_process_reconcile,
"time",
type("NeverSleeps", (), {"sleep": staticmethod(lambda _seconds: None)}),
)

sidecar_process_reconcile.wait_for_process_to_exit(4321)


def test_waiting_gives_up_on_a_process_that_refuses_to_exit(monkeypatch):
slept_seconds = []
monkeypatch.setattr(
sidecar_process_reconcile, "process_is_alive", lambda _process_id: True
)
monkeypatch.setattr(
sidecar_process_reconcile,
"time",
type("CountsSleeps", (), {"sleep": staticmethod(slept_seconds.append)}),
)

sidecar_process_reconcile.wait_for_process_to_exit(4321)

assert len(slept_seconds) == sidecar_process_reconcile.termination_poll_attempts()
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Replace a sidecar still running superseded code by Castrozan · Pull Request #20 · Castrozan/clawde · GitHub
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
69 changes: 69 additions & 0 deletions module/scripts/clawde-service/sidecar_process_reconcile.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,11 @@
import pathlib
import signal
import subprocess
import time

SPAWNED_COMMAND_RECORD_SUFFIX = ".spawned-command"
TERMINATION_DEADLINE_SECONDS = 5.0
TERMINATION_POLL_SECONDS = 0.1


def find_sidecar_process_ids(process_match_pattern: str) -> list[int]:
Expand All@@ -20,6 +25,66 @@ def terminate_sidecar_process(process_id: int) -> None:
pass


def process_is_alive(process_id: int) -> bool:
try:
os.kill(process_id, 0)
except ProcessLookupError:
return False
return True


def termination_poll_attempts() -> int:
return int(TERMINATION_DEADLINE_SECONDS / TERMINATION_POLL_SECONDS)


def wait_for_process_to_exit(process_id: int) -> None:
for _attempt in range(termination_poll_attempts()):
if not process_is_alive(process_id):
return
time.sleep(TERMINATION_POLL_SECONDS)


def spawned_command_record_for(sidecar_specification: dict) -> pathlib.Path:
return pathlib.Path(
sidecar_specification["log_file"] + SPAWNED_COMMAND_RECORD_SUFFIX
)


def recorded_spawned_command(sidecar_specification: dict) -> str | None:
record_file = spawned_command_record_for(sidecar_specification)
try:
return record_file.read_text()
except OSError:
return None


def record_spawned_command(sidecar_specification: dict) -> None:
record_file = spawned_command_record_for(sidecar_specification)
record_file.parent.mkdir(parents=True, exist_ok=True)
record_file.write_text(sidecar_specification["command"])


def live_processes_run_the_current_command(sidecar_specification: dict) -> bool:
return (
recorded_spawned_command(sidecar_specification)
== (sidecar_specification["command"])
)


def replace_processes_running_superseded_code(
sidecar_specification: dict, live_process_ids: list[int]
) -> list[int]:
if not live_process_ids or live_processes_run_the_current_command(
sidecar_specification
):
return live_process_ids
for process_id in live_process_ids:
terminate_sidecar_process(process_id)
for process_id in live_process_ids:
wait_for_process_to_exit(process_id)
return []


def open_sidecar_log_file(log_file_path: str):
log_file = pathlib.Path(log_file_path)
log_file.parent.mkdir(parents=True, exist_ok=True)
Expand All@@ -31,6 +96,7 @@ def detach_from_supervisor(command: str) -> str:


def spawn_sidecar_process(sidecar_specification: dict) -> None:
record_spawned_command(sidecar_specification)
with open_sidecar_log_file(sidecar_specification["log_file"]) as log_file:
subprocess.run(
["sh", "-c", detach_from_supervisor(sidecar_specification["command"])],
Expand All@@ -51,6 +117,9 @@ def reconcile_one_sidecar_process(
for process_id in live_process_ids:
terminate_sidecar_process(process_id)
return False
live_process_ids = replace_processes_running_superseded_code(
sidecar_specification, live_process_ids
)
for duplicate_process_id in live_process_ids[1:]:
terminate_sidecar_process(duplicate_process_id)
if live_process_ids:
Expand Down
10 changes: 10 additions & 0 deletions module/scripts/tests/unit/sidecar_process_test_support.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,11 @@ def make_sidecar_specification_with_lifetime(tmp_path, lifetime, enabled=True):
}


def record_the_sidecar_as_launched_from_its_current_command(specification):
sidecar_process_reconcile.record_spawned_command(specification)
return specification


def make_session_specification(tmp_path):
return {
"name": "clawde",
Expand DownExpand Up@@ -54,4 +59,9 @@ def record_process_lookups(monkeypatch, live_process_ids):
"terminate_sidecar_process",
terminated_process_ids.append,
)
monkeypatch.setattr(
sidecar_process_reconcile,
"wait_for_process_to_exit",
lambda _process_id: None,
)
return spawned_specifications, terminated_process_ids
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
make_sidecar_specification_with_lifetime,
make_session_specification,
record_process_lookups,
record_the_sidecar_as_launched_from_its_current_command,
)


Expand All@@ -35,7 +36,10 @@ def test_a_live_sidecar_is_never_relaunched(tmp_path, monkeypatch):
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
make_sidecar_specification(tmp_path), True
record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path)
),
True,
)

assert spawned_specifications == []
Expand All@@ -50,7 +54,10 @@ def test_duplicate_sidecar_processes_are_culled_down_to_the_oldest(
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
make_sidecar_specification(tmp_path), True
record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path)
),
True,
)

assert terminated_process_ids == [450, 700]
Expand DownExpand Up@@ -175,7 +182,9 @@ def test_a_service_lifetime_sidecar_survives_its_agents_dormancy(tmp_path, monke
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [4321]
)
specification = make_sidecar_specification_with_lifetime(tmp_path, "service")
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification_with_lifetime(tmp_path, "service")
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
specification,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
sys.path.insert(
0, str(pathlib.Path(__file__).resolve().parent.parent.parent / "clawde-service")
)

import sidecar_process_reconcile
from sidecar_process_test_support import (
SIDECAR_NAME,
make_sidecar_specification,
record_process_lookups,
record_the_sidecar_as_launched_from_its_current_command,
)


def reconcile_a_running_sidecar(specification, monkeypatch, live_process_ids=(4321,)):
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, list(live_process_ids)
)
sidecar_process_reconcile.reconcile_one_sidecar_process(specification, True)
return spawned_specifications, terminated_process_ids


def test_a_sidecar_still_running_superseded_code_is_replaced(tmp_path, monkeypatch):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"

spawned_specifications, terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert terminated_process_ids == [4321]
assert [specification["name"] for specification in spawned_specifications] == [
SIDECAR_NAME
]


def test_every_process_of_a_superseded_sidecar_is_terminated_before_the_replacement(
tmp_path, monkeypatch
):
waited_for_process_ids = []
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [700, 120, 450]
)
monkeypatch.setattr(
sidecar_process_reconcile,
"wait_for_process_to_exit",
waited_for_process_ids.append,
)

sidecar_process_reconcile.reconcile_one_sidecar_process(specification, True)

assert terminated_process_ids == [120, 450, 700]
assert waited_for_process_ids == [120, 450, 700]
assert len(spawned_specifications) == 1


def test_a_sidecar_running_the_current_code_is_left_alone(tmp_path, monkeypatch):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/bridge.py")
)

spawned_specifications, terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert spawned_specifications == []
assert terminated_process_ids == []


def test_a_sidecar_launched_before_this_record_existed_is_replaced_exactly_once(
tmp_path, monkeypatch
):
specification = make_sidecar_specification(tmp_path, command="true")

_, first_terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)
sidecar_process_reconcile.record_spawned_command(specification)
_, second_terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert first_terminated_process_ids == [4321]
assert second_terminated_process_ids == []


def test_spawning_records_the_command_it_launched(tmp_path):
specification = make_sidecar_specification(tmp_path, command="true")

sidecar_process_reconcile.spawn_sidecar_process(specification)

assert sidecar_process_reconcile.recorded_spawned_command(specification) == "true"


def test_a_superseded_sidecar_that_should_stop_is_terminated_without_replacement(
tmp_path, monkeypatch
):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [4321]
)

sidecar_process_reconcile.reconcile_one_sidecar_process(specification, False)

assert terminated_process_ids == [4321]
assert spawned_specifications == []


def test_waiting_returns_as_soon_as_the_process_is_gone(monkeypatch):
monkeypatch.setattr(
sidecar_process_reconcile, "process_is_alive", lambda _process_id: False
)
monkeypatch.setattr(
sidecar_process_reconcile,
"time",
type("NeverSleeps", (), {"sleep": staticmethod(lambda _seconds: None)}),
)

sidecar_process_reconcile.wait_for_process_to_exit(4321)


def test_waiting_gives_up_on_a_process_that_refuses_to_exit(monkeypatch):
slept_seconds = []
monkeypatch.setattr(
sidecar_process_reconcile, "process_is_alive", lambda _process_id: True
)
monkeypatch.setattr(
sidecar_process_reconcile,
"time",
type("CountsSleeps", (), {"sleep": staticmethod(slept_seconds.append)}),
)

sidecar_process_reconcile.wait_for_process_to_exit(4321)

assert len(slept_seconds) == sidecar_process_reconcile.termination_poll_attempts()
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Replace a sidecar still running superseded code by Castrozan · Pull Request #20 · Castrozan/clawde · GitHub
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
69 changes: 69 additions & 0 deletions module/scripts/clawde-service/sidecar_process_reconcile.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,11 @@
import pathlib
import signal
import subprocess
import time

SPAWNED_COMMAND_RECORD_SUFFIX = ".spawned-command"
TERMINATION_DEADLINE_SECONDS = 5.0
TERMINATION_POLL_SECONDS = 0.1


def find_sidecar_process_ids(process_match_pattern: str) -> list[int]:
Expand All@@ -20,6 +25,66 @@ def terminate_sidecar_process(process_id: int) -> None:
pass


def process_is_alive(process_id: int) -> bool:
try:
os.kill(process_id, 0)
except ProcessLookupError:
return False
return True


def termination_poll_attempts() -> int:
return int(TERMINATION_DEADLINE_SECONDS / TERMINATION_POLL_SECONDS)


def wait_for_process_to_exit(process_id: int) -> None:
for _attempt in range(termination_poll_attempts()):
if not process_is_alive(process_id):
return
time.sleep(TERMINATION_POLL_SECONDS)


def spawned_command_record_for(sidecar_specification: dict) -> pathlib.Path:
return pathlib.Path(
sidecar_specification["log_file"] + SPAWNED_COMMAND_RECORD_SUFFIX
)


def recorded_spawned_command(sidecar_specification: dict) -> str | None:
record_file = spawned_command_record_for(sidecar_specification)
try:
return record_file.read_text()
except OSError:
return None


def record_spawned_command(sidecar_specification: dict) -> None:
record_file = spawned_command_record_for(sidecar_specification)
record_file.parent.mkdir(parents=True, exist_ok=True)
record_file.write_text(sidecar_specification["command"])


def live_processes_run_the_current_command(sidecar_specification: dict) -> bool:
return (
recorded_spawned_command(sidecar_specification)
== (sidecar_specification["command"])
)


def replace_processes_running_superseded_code(
sidecar_specification: dict, live_process_ids: list[int]
) -> list[int]:
if not live_process_ids or live_processes_run_the_current_command(
sidecar_specification
):
return live_process_ids
for process_id in live_process_ids:
terminate_sidecar_process(process_id)
for process_id in live_process_ids:
wait_for_process_to_exit(process_id)
return []


def open_sidecar_log_file(log_file_path: str):
log_file = pathlib.Path(log_file_path)
log_file.parent.mkdir(parents=True, exist_ok=True)
Expand All@@ -31,6 +96,7 @@ def detach_from_supervisor(command: str) -> str:


def spawn_sidecar_process(sidecar_specification: dict) -> None:
record_spawned_command(sidecar_specification)
with open_sidecar_log_file(sidecar_specification["log_file"]) as log_file:
subprocess.run(
["sh", "-c", detach_from_supervisor(sidecar_specification["command"])],
Expand All@@ -51,6 +117,9 @@ def reconcile_one_sidecar_process(
for process_id in live_process_ids:
terminate_sidecar_process(process_id)
return False
live_process_ids = replace_processes_running_superseded_code(
sidecar_specification, live_process_ids
)
for duplicate_process_id in live_process_ids[1:]:
terminate_sidecar_process(duplicate_process_id)
if live_process_ids:
Expand Down
10 changes: 10 additions & 0 deletions module/scripts/tests/unit/sidecar_process_test_support.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,11 @@ def make_sidecar_specification_with_lifetime(tmp_path, lifetime, enabled=True):
}


def record_the_sidecar_as_launched_from_its_current_command(specification):
sidecar_process_reconcile.record_spawned_command(specification)
return specification


def make_session_specification(tmp_path):
return {
"name": "clawde",
Expand DownExpand Up@@ -54,4 +59,9 @@ def record_process_lookups(monkeypatch, live_process_ids):
"terminate_sidecar_process",
terminated_process_ids.append,
)
monkeypatch.setattr(
sidecar_process_reconcile,
"wait_for_process_to_exit",
lambda _process_id: None,
)
return spawned_specifications, terminated_process_ids
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
make_sidecar_specification_with_lifetime,
make_session_specification,
record_process_lookups,
record_the_sidecar_as_launched_from_its_current_command,
)


Expand All@@ -35,7 +36,10 @@ def test_a_live_sidecar_is_never_relaunched(tmp_path, monkeypatch):
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
make_sidecar_specification(tmp_path), True
record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path)
),
True,
)

assert spawned_specifications == []
Expand All@@ -50,7 +54,10 @@ def test_duplicate_sidecar_processes_are_culled_down_to_the_oldest(
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
make_sidecar_specification(tmp_path), True
record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path)
),
True,
)

assert terminated_process_ids == [450, 700]
Expand DownExpand Up@@ -175,7 +182,9 @@ def test_a_service_lifetime_sidecar_survives_its_agents_dormancy(tmp_path, monke
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [4321]
)
specification = make_sidecar_specification_with_lifetime(tmp_path, "service")
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification_with_lifetime(tmp_path, "service")
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
specification,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
sys.path.insert(
0, str(pathlib.Path(__file__).resolve().parent.parent.parent / "clawde-service")
)

import sidecar_process_reconcile
from sidecar_process_test_support import (
SIDECAR_NAME,
make_sidecar_specification,
record_process_lookups,
record_the_sidecar_as_launched_from_its_current_command,
)


def reconcile_a_running_sidecar(specification, monkeypatch, live_process_ids=(4321,)):
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, list(live_process_ids)
)
sidecar_process_reconcile.reconcile_one_sidecar_process(specification, True)
return spawned_specifications, terminated_process_ids


def test_a_sidecar_still_running_superseded_code_is_replaced(tmp_path, monkeypatch):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"

spawned_specifications, terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert terminated_process_ids == [4321]
assert [specification["name"] for specification in spawned_specifications] == [
SIDECAR_NAME
]


def test_every_process_of_a_superseded_sidecar_is_terminated_before_the_replacement(
tmp_path, monkeypatch
):
waited_for_process_ids = []
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [700, 120, 450]
)
monkeypatch.setattr(
sidecar_process_reconcile,
"wait_for_process_to_exit",
waited_for_process_ids.append,
)

sidecar_process_reconcile.reconcile_one_sidecar_process(specification, True)

assert terminated_process_ids == [120, 450, 700]
assert waited_for_process_ids == [120, 450, 700]
assert len(spawned_specifications) == 1


def test_a_sidecar_running_the_current_code_is_left_alone(tmp_path, monkeypatch):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/bridge.py")
)

spawned_specifications, terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert spawned_specifications == []
assert terminated_process_ids == []


def test_a_sidecar_launched_before_this_record_existed_is_replaced_exactly_once(
tmp_path, monkeypatch
):
specification = make_sidecar_specification(tmp_path, command="true")

_, first_terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)
sidecar_process_reconcile.record_spawned_command(specification)
_, second_terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert first_terminated_process_ids == [4321]
assert second_terminated_process_ids == []


def test_spawning_records_the_command_it_launched(tmp_path):
specification = make_sidecar_specification(tmp_path, command="true")

sidecar_process_reconcile.spawn_sidecar_process(specification)

assert sidecar_process_reconcile.recorded_spawned_command(specification) == "true"


def test_a_superseded_sidecar_that_should_stop_is_terminated_without_replacement(
tmp_path, monkeypatch
):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [4321]
)

sidecar_process_reconcile.reconcile_one_sidecar_process(specification, False)

assert terminated_process_ids == [4321]
assert spawned_specifications == []


def test_waiting_returns_as_soon_as_the_process_is_gone(monkeypatch):
monkeypatch.setattr(
sidecar_process_reconcile, "process_is_alive", lambda _process_id: False
)
monkeypatch.setattr(
sidecar_process_reconcile,
"time",
type("NeverSleeps", (), {"sleep": staticmethod(lambda _seconds: None)}),
)

sidecar_process_reconcile.wait_for_process_to_exit(4321)


def test_waiting_gives_up_on_a_process_that_refuses_to_exit(monkeypatch):
slept_seconds = []
monkeypatch.setattr(
sidecar_process_reconcile, "process_is_alive", lambda _process_id: True
)
monkeypatch.setattr(
sidecar_process_reconcile,
"time",
type("CountsSleeps", (), {"sleep": staticmethod(slept_seconds.append)}),
)

sidecar_process_reconcile.wait_for_process_to_exit(4321)

assert len(slept_seconds) == sidecar_process_reconcile.termination_poll_attempts()
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Replace a sidecar still running superseded code by Castrozan · Pull Request #20 · Castrozan/clawde · GitHub
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
69 changes: 69 additions & 0 deletions module/scripts/clawde-service/sidecar_process_reconcile.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,11 @@
import pathlib
import signal
import subprocess
import time

SPAWNED_COMMAND_RECORD_SUFFIX = ".spawned-command"
TERMINATION_DEADLINE_SECONDS = 5.0
TERMINATION_POLL_SECONDS = 0.1


def find_sidecar_process_ids(process_match_pattern: str) -> list[int]:
Expand All@@ -20,6 +25,66 @@ def terminate_sidecar_process(process_id: int) -> None:
pass


def process_is_alive(process_id: int) -> bool:
try:
os.kill(process_id, 0)
except ProcessLookupError:
return False
return True


def termination_poll_attempts() -> int:
return int(TERMINATION_DEADLINE_SECONDS / TERMINATION_POLL_SECONDS)


def wait_for_process_to_exit(process_id: int) -> None:
for _attempt in range(termination_poll_attempts()):
if not process_is_alive(process_id):
return
time.sleep(TERMINATION_POLL_SECONDS)


def spawned_command_record_for(sidecar_specification: dict) -> pathlib.Path:
return pathlib.Path(
sidecar_specification["log_file"] + SPAWNED_COMMAND_RECORD_SUFFIX
)


def recorded_spawned_command(sidecar_specification: dict) -> str | None:
record_file = spawned_command_record_for(sidecar_specification)
try:
return record_file.read_text()
except OSError:
return None


def record_spawned_command(sidecar_specification: dict) -> None:
record_file = spawned_command_record_for(sidecar_specification)
record_file.parent.mkdir(parents=True, exist_ok=True)
record_file.write_text(sidecar_specification["command"])


def live_processes_run_the_current_command(sidecar_specification: dict) -> bool:
return (
recorded_spawned_command(sidecar_specification)
== (sidecar_specification["command"])
)


def replace_processes_running_superseded_code(
sidecar_specification: dict, live_process_ids: list[int]
) -> list[int]:
if not live_process_ids or live_processes_run_the_current_command(
sidecar_specification
):
return live_process_ids
for process_id in live_process_ids:
terminate_sidecar_process(process_id)
for process_id in live_process_ids:
wait_for_process_to_exit(process_id)
return []


def open_sidecar_log_file(log_file_path: str):
log_file = pathlib.Path(log_file_path)
log_file.parent.mkdir(parents=True, exist_ok=True)
Expand All@@ -31,6 +96,7 @@ def detach_from_supervisor(command: str) -> str:


def spawn_sidecar_process(sidecar_specification: dict) -> None:
record_spawned_command(sidecar_specification)
with open_sidecar_log_file(sidecar_specification["log_file"]) as log_file:
subprocess.run(
["sh", "-c", detach_from_supervisor(sidecar_specification["command"])],
Expand All@@ -51,6 +117,9 @@ def reconcile_one_sidecar_process(
for process_id in live_process_ids:
terminate_sidecar_process(process_id)
return False
live_process_ids = replace_processes_running_superseded_code(
sidecar_specification, live_process_ids
)
for duplicate_process_id in live_process_ids[1:]:
terminate_sidecar_process(duplicate_process_id)
if live_process_ids:
Expand Down
10 changes: 10 additions & 0 deletions module/scripts/tests/unit/sidecar_process_test_support.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,11 @@ def make_sidecar_specification_with_lifetime(tmp_path, lifetime, enabled=True):
}


def record_the_sidecar_as_launched_from_its_current_command(specification):
sidecar_process_reconcile.record_spawned_command(specification)
return specification


def make_session_specification(tmp_path):
return {
"name": "clawde",
Expand DownExpand Up@@ -54,4 +59,9 @@ def record_process_lookups(monkeypatch, live_process_ids):
"terminate_sidecar_process",
terminated_process_ids.append,
)
monkeypatch.setattr(
sidecar_process_reconcile,
"wait_for_process_to_exit",
lambda _process_id: None,
)
return spawned_specifications, terminated_process_ids
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
make_sidecar_specification_with_lifetime,
make_session_specification,
record_process_lookups,
record_the_sidecar_as_launched_from_its_current_command,
)


Expand All@@ -35,7 +36,10 @@ def test_a_live_sidecar_is_never_relaunched(tmp_path, monkeypatch):
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
make_sidecar_specification(tmp_path), True
record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path)
),
True,
)

assert spawned_specifications == []
Expand All@@ -50,7 +54,10 @@ def test_duplicate_sidecar_processes_are_culled_down_to_the_oldest(
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
make_sidecar_specification(tmp_path), True
record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path)
),
True,
)

assert terminated_process_ids == [450, 700]
Expand DownExpand Up@@ -175,7 +182,9 @@ def test_a_service_lifetime_sidecar_survives_its_agents_dormancy(tmp_path, monke
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [4321]
)
specification = make_sidecar_specification_with_lifetime(tmp_path, "service")
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification_with_lifetime(tmp_path, "service")
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
specification,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
sys.path.insert(
0, str(pathlib.Path(__file__).resolve().parent.parent.parent / "clawde-service")
)

import sidecar_process_reconcile
from sidecar_process_test_support import (
SIDECAR_NAME,
make_sidecar_specification,
record_process_lookups,
record_the_sidecar_as_launched_from_its_current_command,
)


def reconcile_a_running_sidecar(specification, monkeypatch, live_process_ids=(4321,)):
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, list(live_process_ids)
)
sidecar_process_reconcile.reconcile_one_sidecar_process(specification, True)
return spawned_specifications, terminated_process_ids


def test_a_sidecar_still_running_superseded_code_is_replaced(tmp_path, monkeypatch):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"

spawned_specifications, terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert terminated_process_ids == [4321]
assert [specification["name"] for specification in spawned_specifications] == [
SIDECAR_NAME
]


def test_every_process_of_a_superseded_sidecar_is_terminated_before_the_replacement(
tmp_path, monkeypatch
):
waited_for_process_ids = []
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [700, 120, 450]
)
monkeypatch.setattr(
sidecar_process_reconcile,
"wait_for_process_to_exit",
waited_for_process_ids.append,
)

sidecar_process_reconcile.reconcile_one_sidecar_process(specification, True)

assert terminated_process_ids == [120, 450, 700]
assert waited_for_process_ids == [120, 450, 700]
assert len(spawned_specifications) == 1


def test_a_sidecar_running_the_current_code_is_left_alone(tmp_path, monkeypatch):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/bridge.py")
)

spawned_specifications, terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert spawned_specifications == []
assert terminated_process_ids == []


def test_a_sidecar_launched_before_this_record_existed_is_replaced_exactly_once(
tmp_path, monkeypatch
):
specification = make_sidecar_specification(tmp_path, command="true")

_, first_terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)
sidecar_process_reconcile.record_spawned_command(specification)
_, second_terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert first_terminated_process_ids == [4321]
assert second_terminated_process_ids == []


def test_spawning_records_the_command_it_launched(tmp_path):
specification = make_sidecar_specification(tmp_path, command="true")

sidecar_process_reconcile.spawn_sidecar_process(specification)

assert sidecar_process_reconcile.recorded_spawned_command(specification) == "true"


def test_a_superseded_sidecar_that_should_stop_is_terminated_without_replacement(
tmp_path, monkeypatch
):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [4321]
)

sidecar_process_reconcile.reconcile_one_sidecar_process(specification, False)

assert terminated_process_ids == [4321]
assert spawned_specifications == []


def test_waiting_returns_as_soon_as_the_process_is_gone(monkeypatch):
monkeypatch.setattr(
sidecar_process_reconcile, "process_is_alive", lambda _process_id: False
)
monkeypatch.setattr(
sidecar_process_reconcile,
"time",
type("NeverSleeps", (), {"sleep": staticmethod(lambda _seconds: None)}),
)

sidecar_process_reconcile.wait_for_process_to_exit(4321)


def test_waiting_gives_up_on_a_process_that_refuses_to_exit(monkeypatch):
slept_seconds = []
monkeypatch.setattr(
sidecar_process_reconcile, "process_is_alive", lambda _process_id: True
)
monkeypatch.setattr(
sidecar_process_reconcile,
"time",
type("CountsSleeps", (), {"sleep": staticmethod(slept_seconds.append)}),
)

sidecar_process_reconcile.wait_for_process_to_exit(4321)

assert len(slept_seconds) == sidecar_process_reconcile.termination_poll_attempts()
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Replace a sidecar still running superseded code by Castrozan · Pull Request #20 · Castrozan/clawde · GitHub
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
69 changes: 69 additions & 0 deletions module/scripts/clawde-service/sidecar_process_reconcile.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,11 @@
import pathlib
import signal
import subprocess
import time

SPAWNED_COMMAND_RECORD_SUFFIX = ".spawned-command"
TERMINATION_DEADLINE_SECONDS = 5.0
TERMINATION_POLL_SECONDS = 0.1


def find_sidecar_process_ids(process_match_pattern: str) -> list[int]:
Expand All@@ -20,6 +25,66 @@ def terminate_sidecar_process(process_id: int) -> None:
pass


def process_is_alive(process_id: int) -> bool:
try:
os.kill(process_id, 0)
except ProcessLookupError:
return False
return True


def termination_poll_attempts() -> int:
return int(TERMINATION_DEADLINE_SECONDS / TERMINATION_POLL_SECONDS)


def wait_for_process_to_exit(process_id: int) -> None:
for _attempt in range(termination_poll_attempts()):
if not process_is_alive(process_id):
return
time.sleep(TERMINATION_POLL_SECONDS)


def spawned_command_record_for(sidecar_specification: dict) -> pathlib.Path:
return pathlib.Path(
sidecar_specification["log_file"] + SPAWNED_COMMAND_RECORD_SUFFIX
)


def recorded_spawned_command(sidecar_specification: dict) -> str | None:
record_file = spawned_command_record_for(sidecar_specification)
try:
return record_file.read_text()
except OSError:
return None


def record_spawned_command(sidecar_specification: dict) -> None:
record_file = spawned_command_record_for(sidecar_specification)
record_file.parent.mkdir(parents=True, exist_ok=True)
record_file.write_text(sidecar_specification["command"])


def live_processes_run_the_current_command(sidecar_specification: dict) -> bool:
return (
recorded_spawned_command(sidecar_specification)
== (sidecar_specification["command"])
)


def replace_processes_running_superseded_code(
sidecar_specification: dict, live_process_ids: list[int]
) -> list[int]:
if not live_process_ids or live_processes_run_the_current_command(
sidecar_specification
):
return live_process_ids
for process_id in live_process_ids:
terminate_sidecar_process(process_id)
for process_id in live_process_ids:
wait_for_process_to_exit(process_id)
return []


def open_sidecar_log_file(log_file_path: str):
log_file = pathlib.Path(log_file_path)
log_file.parent.mkdir(parents=True, exist_ok=True)
Expand All@@ -31,6 +96,7 @@ def detach_from_supervisor(command: str) -> str:


def spawn_sidecar_process(sidecar_specification: dict) -> None:
record_spawned_command(sidecar_specification)
with open_sidecar_log_file(sidecar_specification["log_file"]) as log_file:
subprocess.run(
["sh", "-c", detach_from_supervisor(sidecar_specification["command"])],
Expand All@@ -51,6 +117,9 @@ def reconcile_one_sidecar_process(
for process_id in live_process_ids:
terminate_sidecar_process(process_id)
return False
live_process_ids = replace_processes_running_superseded_code(
sidecar_specification, live_process_ids
)
for duplicate_process_id in live_process_ids[1:]:
terminate_sidecar_process(duplicate_process_id)
if live_process_ids:
Expand Down
10 changes: 10 additions & 0 deletions module/scripts/tests/unit/sidecar_process_test_support.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,11 @@ def make_sidecar_specification_with_lifetime(tmp_path, lifetime, enabled=True):
}


def record_the_sidecar_as_launched_from_its_current_command(specification):
sidecar_process_reconcile.record_spawned_command(specification)
return specification


def make_session_specification(tmp_path):
return {
"name": "clawde",
Expand DownExpand Up@@ -54,4 +59,9 @@ def record_process_lookups(monkeypatch, live_process_ids):
"terminate_sidecar_process",
terminated_process_ids.append,
)
monkeypatch.setattr(
sidecar_process_reconcile,
"wait_for_process_to_exit",
lambda _process_id: None,
)
return spawned_specifications, terminated_process_ids
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
make_sidecar_specification_with_lifetime,
make_session_specification,
record_process_lookups,
record_the_sidecar_as_launched_from_its_current_command,
)


Expand All@@ -35,7 +36,10 @@ def test_a_live_sidecar_is_never_relaunched(tmp_path, monkeypatch):
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
make_sidecar_specification(tmp_path), True
record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path)
),
True,
)

assert spawned_specifications == []
Expand All@@ -50,7 +54,10 @@ def test_duplicate_sidecar_processes_are_culled_down_to_the_oldest(
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
make_sidecar_specification(tmp_path), True
record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path)
),
True,
)

assert terminated_process_ids == [450, 700]
Expand DownExpand Up@@ -175,7 +182,9 @@ def test_a_service_lifetime_sidecar_survives_its_agents_dormancy(tmp_path, monke
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [4321]
)
specification = make_sidecar_specification_with_lifetime(tmp_path, "service")
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification_with_lifetime(tmp_path, "service")
)

sidecar_process_reconcile.reconcile_one_sidecar_process(
specification,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
sys.path.insert(
0, str(pathlib.Path(__file__).resolve().parent.parent.parent / "clawde-service")
)

import sidecar_process_reconcile
from sidecar_process_test_support import (
SIDECAR_NAME,
make_sidecar_specification,
record_process_lookups,
record_the_sidecar_as_launched_from_its_current_command,
)


def reconcile_a_running_sidecar(specification, monkeypatch, live_process_ids=(4321,)):
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, list(live_process_ids)
)
sidecar_process_reconcile.reconcile_one_sidecar_process(specification, True)
return spawned_specifications, terminated_process_ids


def test_a_sidecar_still_running_superseded_code_is_replaced(tmp_path, monkeypatch):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"

spawned_specifications, terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert terminated_process_ids == [4321]
assert [specification["name"] for specification in spawned_specifications] == [
SIDECAR_NAME
]


def test_every_process_of_a_superseded_sidecar_is_terminated_before_the_replacement(
tmp_path, monkeypatch
):
waited_for_process_ids = []
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [700, 120, 450]
)
monkeypatch.setattr(
sidecar_process_reconcile,
"wait_for_process_to_exit",
waited_for_process_ids.append,
)

sidecar_process_reconcile.reconcile_one_sidecar_process(specification, True)

assert terminated_process_ids == [120, 450, 700]
assert waited_for_process_ids == [120, 450, 700]
assert len(spawned_specifications) == 1


def test_a_sidecar_running_the_current_code_is_left_alone(tmp_path, monkeypatch):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/bridge.py")
)

spawned_specifications, terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert spawned_specifications == []
assert terminated_process_ids == []


def test_a_sidecar_launched_before_this_record_existed_is_replaced_exactly_once(
tmp_path, monkeypatch
):
specification = make_sidecar_specification(tmp_path, command="true")

_, first_terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)
sidecar_process_reconcile.record_spawned_command(specification)
_, second_terminated_process_ids = reconcile_a_running_sidecar(
specification, monkeypatch
)

assert first_terminated_process_ids == [4321]
assert second_terminated_process_ids == []


def test_spawning_records_the_command_it_launched(tmp_path):
specification = make_sidecar_specification(tmp_path, command="true")

sidecar_process_reconcile.spawn_sidecar_process(specification)

assert sidecar_process_reconcile.recorded_spawned_command(specification) == "true"


def test_a_superseded_sidecar_that_should_stop_is_terminated_without_replacement(
tmp_path, monkeypatch
):
specification = record_the_sidecar_as_launched_from_its_current_command(
make_sidecar_specification(tmp_path, command="python3 /nix/store/old-bridge.py")
)
specification["command"] = "python3 /nix/store/new-bridge.py"
spawned_specifications, terminated_process_ids = record_process_lookups(
monkeypatch, [4321]
)

sidecar_process_reconcile.reconcile_one_sidecar_process(specification, False)

assert terminated_process_ids == [4321]
assert spawned_specifications == []


def test_waiting_returns_as_soon_as_the_process_is_gone(monkeypatch):
monkeypatch.setattr(
sidecar_process_reconcile, "process_is_alive", lambda _process_id: False
)
monkeypatch.setattr(
sidecar_process_reconcile,
"time",
type("NeverSleeps", (), {"sleep": staticmethod(lambda _seconds: None)}),
)

sidecar_process_reconcile.wait_for_process_to_exit(4321)


def test_waiting_gives_up_on_a_process_that_refuses_to_exit(monkeypatch):
slept_seconds = []
monkeypatch.setattr(
sidecar_process_reconcile, "process_is_alive", lambda _process_id: True
)
monkeypatch.setattr(
sidecar_process_reconcile,
"time",
type("CountsSleeps", (), {"sleep": staticmethod(slept_seconds.append)}),
)

sidecar_process_reconcile.wait_for_process_to_exit(4321)

assert len(slept_seconds) == sidecar_process_reconcile.termination_poll_attempts()