7d6d0709cd
Capture the exact UTC scheduled instant before either due scanning or the external fire claim advances jobs.json. Bind it to the durable attempt before worker handoff; manual and unclassified direct attempts stay null. Consult any retained completed matching row, independently of stale stamps, claim-time windows, and newer failed attempts. Preserve unknown and legacy attempt eligibility rather than guessing that a side effect completed. Real isolated restart probes reproduce duplicate script writes on base and suppress them on the fix for builtin tick and provider fire. Distinct and manual occurrences still execute. The campaign-serialized cron suite is queued; this progressive commit preserves the verified integration step. Credit holny's issue #104790 and guard proposal #104323; exact identity replaces the approximation rather than importing its legacy heuristic. Co-authored-by: holny <holny@foxmail.com>
610 lines
22 KiB
Python
610 lines
22 KiB
Python
"""Restart-safe cron worker handoff and ownership contracts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def execution_ledger(tmp_path, monkeypatch):
|
|
import cron.executions as executions
|
|
|
|
monkeypatch.setattr(executions, "EXECUTIONS_FILE", tmp_path / "executions.db")
|
|
return executions
|
|
|
|
|
|
def test_execution_owner_moves_to_external_worker_before_running(
|
|
execution_ledger, monkeypatch
|
|
):
|
|
record = execution_ledger.create_execution("job-1", source="builtin")
|
|
assert execution_ledger.mark_execution_handoff_pending(record["id"]) is not None
|
|
monkeypatch.setattr(execution_ledger.os, "getpid", lambda: 4242)
|
|
monkeypatch.setattr(execution_ledger, "_process_start_time", lambda pid: 9876)
|
|
|
|
adopted = execution_ledger.adopt_claimed_execution(record["id"])
|
|
|
|
assert adopted is not None
|
|
assert adopted["pid"] == 4242
|
|
assert adopted["process_started_at"] == 9876
|
|
assert adopted["status"] == "running"
|
|
assert execution_ledger.adopt_claimed_execution(record["id"]) is None
|
|
assert execution_ledger.mark_execution_running(record["id"]) is None
|
|
|
|
|
|
def test_external_worker_cannot_adopt_execution_without_handoff_fence(
|
|
execution_ledger, monkeypatch
|
|
):
|
|
record = execution_ledger.create_execution("job-unfenced", source="builtin")
|
|
monkeypatch.setattr(execution_ledger.os, "getpid", lambda: 4242)
|
|
monkeypatch.setattr(execution_ledger, "_process_start_time", lambda _pid: 9876)
|
|
|
|
assert execution_ledger.adopt_claimed_execution(record["id"]) is None
|
|
assert execution_ledger.get_execution(record["id"])["status"] == "claimed"
|
|
|
|
|
|
def test_genuine_external_worker_crash_is_recovered_unknown(
|
|
execution_ledger, monkeypatch
|
|
):
|
|
record = execution_ledger.create_execution("job-crash", source="builtin")
|
|
assert execution_ledger.mark_execution_handoff_pending(record["id"]) is not None
|
|
script = (
|
|
"import os\n"
|
|
"from pathlib import Path\n"
|
|
"import cron.executions as executions\n"
|
|
f"executions.EXECUTIONS_FILE = Path({str(execution_ledger.EXECUTIONS_FILE)!r})\n"
|
|
f"assert executions.adopt_claimed_execution({record['id']!r}) is not None\n"
|
|
"os._exit(9)\n"
|
|
)
|
|
|
|
crashed = subprocess.run([sys.executable, "-c", script], check=False)
|
|
assert crashed.returncode == 9
|
|
|
|
monkeypatch.setattr(execution_ledger, "_PROCESS_ID", "replacement-scheduler")
|
|
assert execution_ledger.recover_interrupted_executions() == 1
|
|
recovered = execution_ledger.latest_execution("job-crash")
|
|
assert recovered["status"] == "unknown"
|
|
assert "whether side effects ran is unknown" in recovered["error"]
|
|
|
|
|
|
@pytest.mark.linux_only
|
|
def test_restart_safe_gateway_child_fails_closed_without_scope(monkeypatch):
|
|
import tools.process_registry as process_registry
|
|
|
|
monkeypatch.setattr(process_registry, "_is_supervised_gateway_process", lambda: True)
|
|
monkeypatch.setenv("INVOCATION_ID", "managed-service")
|
|
monkeypatch.setattr(process_registry, "_systemd_run_user_scope_available", lambda: False)
|
|
|
|
with pytest.raises(RuntimeError, match="systemd-run --user --scope is unavailable"):
|
|
process_registry.restart_safe_gateway_child_argv(
|
|
["python", "worker.py"], unit_suffix="cron-job-1"
|
|
)
|
|
|
|
|
|
def test_restart_safe_gateway_child_is_unchanged_outside_managed_gateway(monkeypatch):
|
|
import tools.process_registry as process_registry
|
|
|
|
command = ["python", "worker.py"]
|
|
monkeypatch.setattr(process_registry, "_is_supervised_gateway_process", lambda: False)
|
|
|
|
assert process_registry.restart_safe_gateway_child_argv(
|
|
command, unit_suffix="cron-job-1"
|
|
) is command
|
|
|
|
|
|
def test_restart_safe_gateway_child_never_probes_systemd_off_linux(monkeypatch):
|
|
import tools.process_registry as process_registry
|
|
|
|
command = ["python", "worker.py"]
|
|
probe = Mock(side_effect=AssertionError("systemd probe ran off Linux"))
|
|
monkeypatch.setattr(process_registry, "_IS_LINUX", False)
|
|
monkeypatch.setattr(process_registry, "_is_supervised_gateway_process", lambda: True)
|
|
monkeypatch.setattr(process_registry, "_systemd_run_user_scope_available", probe)
|
|
monkeypatch.setenv("INVOCATION_ID", "managed-service")
|
|
|
|
assert process_registry.restart_safe_gateway_child_argv(
|
|
command, unit_suffix="cron-job-1"
|
|
) is command
|
|
probe.assert_not_called()
|
|
|
|
|
|
def test_external_worker_adopts_execution_and_runs_payload_once(
|
|
tmp_path, monkeypatch
|
|
):
|
|
import cron.scheduler as scheduler
|
|
|
|
payload = tmp_path / "payload.json"
|
|
ack = tmp_path / "ready.json"
|
|
payload.write_text(
|
|
json.dumps({
|
|
"job": {"id": "job-1", "execution_id": "exec-1"},
|
|
"profile_home": str(tmp_path / "profile"),
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
from hermes_constants import get_hermes_home
|
|
|
|
observed_homes = []
|
|
adopted = Mock(
|
|
side_effect=lambda execution_id: (
|
|
observed_homes.append(get_hermes_home().resolve())
|
|
or {"id": execution_id, "status": "running"}
|
|
)
|
|
)
|
|
run = Mock(
|
|
side_effect=lambda *_args, **_kwargs: (
|
|
observed_homes.append(get_hermes_home().resolve()) or True
|
|
)
|
|
)
|
|
monkeypatch.setattr("cron.executions.adopt_claimed_execution", adopted)
|
|
monkeypatch.setattr(scheduler, "run_one_job", run)
|
|
|
|
assert scheduler._run_external_worker_payload(payload, ack) is True
|
|
|
|
adopted.assert_called_once_with("exec-1")
|
|
run.assert_called_once()
|
|
assert run.call_args.args[0]["id"] == "job-1"
|
|
expected_home = (tmp_path / "profile").resolve()
|
|
assert observed_homes == [expected_home, expected_home]
|
|
assert ack.exists()
|
|
assert not payload.exists()
|
|
|
|
|
|
def test_external_worker_refuses_to_run_without_durable_ownership(
|
|
tmp_path, monkeypatch
|
|
):
|
|
import cron.scheduler as scheduler
|
|
|
|
payload = tmp_path / "payload.json"
|
|
ack = tmp_path / "ready.json"
|
|
payload.write_text(
|
|
json.dumps({
|
|
"job": {"id": "job-1", "execution_id": "exec-1"},
|
|
"profile_home": str(tmp_path / "profile"),
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr("cron.executions.adopt_claimed_execution", lambda _id: None)
|
|
run = Mock()
|
|
monkeypatch.setattr(scheduler, "run_one_job", run)
|
|
|
|
assert scheduler._run_external_worker_payload(payload, ack) is False
|
|
|
|
run.assert_not_called()
|
|
assert not ack.exists()
|
|
|
|
|
|
def test_launch_external_worker_uses_restart_safe_scope_and_acknowledges(
|
|
tmp_path, monkeypatch
|
|
):
|
|
import cron.scheduler as scheduler
|
|
|
|
job = {"id": "job-1", "execution_id": "exec-1", "prompt": "work"}
|
|
monkeypatch.setattr(scheduler, "_get_hermes_home", lambda: tmp_path)
|
|
wrapped_commands = []
|
|
|
|
def wrap(command, *, unit_suffix):
|
|
wrapped_commands.append((command, unit_suffix))
|
|
return ["scope", "--", *command]
|
|
|
|
monkeypatch.setattr(
|
|
"tools.process_registry.restart_safe_gateway_child_argv", wrap
|
|
)
|
|
|
|
class FakeProcess:
|
|
returncode = None
|
|
|
|
def poll(self):
|
|
return self.returncode
|
|
|
|
def wait(self, timeout=None):
|
|
if self.returncode is None:
|
|
raise subprocess.TimeoutExpired(cmd="worker", timeout=timeout)
|
|
return self.returncode
|
|
|
|
spawned = []
|
|
|
|
payloads = []
|
|
|
|
def popen(command, **kwargs):
|
|
spawned.append((command, kwargs))
|
|
payload_index = command.index("--external-worker-file") + 1
|
|
payloads.append(json.loads(Path(command[payload_index]).read_text()))
|
|
ack_index = command.index("--ack-file") + 1
|
|
Path(command[ack_index]).write_text(
|
|
json.dumps({"pid": 4321, "execution_id": "exec-1"}),
|
|
encoding="utf-8",
|
|
)
|
|
return FakeProcess()
|
|
|
|
handoff = Mock(return_value={"id": "exec-1", "handoff_pending": 1})
|
|
monkeypatch.setattr(scheduler, "mark_execution_handoff_pending", handoff)
|
|
monkeypatch.setattr(scheduler.subprocess, "Popen", popen)
|
|
observed_statuses = iter(
|
|
[
|
|
{"id": "exec-1", "status": "running"},
|
|
{"id": "exec-1", "status": "completed"},
|
|
]
|
|
)
|
|
get = Mock(side_effect=lambda _execution_id: next(observed_statuses))
|
|
monkeypatch.setattr(scheduler, "get_execution", get)
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "should-not-cross-profile")
|
|
from agent.secret_scope import set_multiplex_active
|
|
|
|
set_multiplex_active(True)
|
|
try:
|
|
assert scheduler._launch_external_cron_worker(job) is True
|
|
finally:
|
|
set_multiplex_active(False)
|
|
assert wrapped_commands[0][1] == "cron-job-1-exec-exec-1"
|
|
assert spawned[0][0][0:2] == ["scope", "--"]
|
|
assert spawned[0][1]["start_new_session"] is True
|
|
assert "ANTHROPIC_API_KEY" not in spawned[0][1]["env"]
|
|
handoff.assert_called_once_with("exec-1")
|
|
assert get.call_count == 2
|
|
assert payloads[0]["multiplex_active"] is True
|
|
# Once the attempt is terminal the parent reaps its own handoff artifacts.
|
|
assert not (tmp_path / "cron/external-workers/exec-1.json").exists()
|
|
|
|
|
|
def test_external_worker_exit_rechecks_exact_execution_before_failure(monkeypatch):
|
|
import cron.scheduler as scheduler
|
|
|
|
statuses = iter(
|
|
[
|
|
{"id": "exec-1", "status": "running"},
|
|
{"id": "exec-1", "status": "completed"},
|
|
]
|
|
)
|
|
get = Mock(side_effect=lambda _execution_id: next(statuses))
|
|
monkeypatch.setattr(scheduler, "get_execution", get, raising=False)
|
|
process = Mock()
|
|
process.poll.return_value = 0
|
|
process.wait.return_value = 0
|
|
|
|
assert scheduler._wait_for_external_cron_worker(
|
|
process, execution_id="exec-1"
|
|
) is True
|
|
assert get.call_count == 2
|
|
|
|
|
|
def test_external_worker_crash_recovers_uncertain_attempt(monkeypatch):
|
|
import cron.scheduler as scheduler
|
|
|
|
statuses = iter(
|
|
[
|
|
{"id": "exec-1", "status": "running"},
|
|
{"id": "exec-1", "status": "unknown"},
|
|
]
|
|
)
|
|
get = Mock(side_effect=lambda _execution_id: next(statuses))
|
|
recover = Mock(return_value=1)
|
|
monkeypatch.setattr(scheduler, "get_execution", get)
|
|
monkeypatch.setattr(
|
|
scheduler, "recover_interrupted_executions", recover, raising=False
|
|
)
|
|
process = Mock()
|
|
process.poll.return_value = 9
|
|
process.wait.return_value = 9
|
|
|
|
assert scheduler._wait_for_external_cron_worker(
|
|
process, execution_id="exec-1"
|
|
) is True
|
|
recover.assert_called_once_with()
|
|
assert get.call_count == 2
|
|
|
|
|
|
def test_launch_external_worker_stays_in_process_outside_managed_gateway(
|
|
monkeypatch,
|
|
):
|
|
import cron.scheduler as scheduler
|
|
|
|
command_calls = []
|
|
|
|
def unchanged(command, *, unit_suffix):
|
|
command_calls.append((command, unit_suffix))
|
|
return command
|
|
|
|
monkeypatch.setattr(
|
|
"tools.process_registry.restart_safe_gateway_child_argv", unchanged
|
|
)
|
|
popen = Mock()
|
|
monkeypatch.setattr(scheduler.subprocess, "Popen", popen)
|
|
|
|
assert scheduler._launch_external_cron_worker(
|
|
{"id": "job-1", "execution_id": "exec-1"}
|
|
) is False
|
|
assert command_calls
|
|
popen.assert_not_called()
|
|
|
|
|
|
def test_shared_run_path_hands_gateway_fire_to_external_worker(monkeypatch):
|
|
import cron.scheduler as scheduler
|
|
|
|
launch = Mock(return_value=True)
|
|
run = Mock(side_effect=AssertionError("agent ran inside gateway"))
|
|
monkeypatch.setattr(scheduler, "_launch_external_cron_worker", launch)
|
|
monkeypatch.setattr(scheduler, "run_job", run)
|
|
job = {"id": "job-1", "execution_id": "exec-1"}
|
|
|
|
assert scheduler.run_one_job(job, adapters={"discord": object()}) is True
|
|
|
|
launch.assert_called_once_with(job)
|
|
run.assert_not_called()
|
|
|
|
|
|
def test_shutdown_does_not_interrupt_restart_safe_waiter():
|
|
import cron.scheduler as scheduler
|
|
|
|
job_id = "external-waiter"
|
|
scheduler._running_job_ids.add(job_id)
|
|
scheduler._restart_safe_waiter_job_ids.add(job_id)
|
|
try:
|
|
assert scheduler.mark_running_jobs_interrupted("gateway restart") == []
|
|
assert job_id not in scheduler._interrupted_job_ids
|
|
finally:
|
|
scheduler._restart_safe_waiter_job_ids.discard(job_id)
|
|
scheduler._running_job_ids.discard(job_id)
|
|
scheduler._interrupted_job_ids.discard(job_id)
|
|
|
|
|
|
def test_worker_delivery_queue_is_keyed_by_the_delivering_jobs_own_execution(
|
|
monkeypatch, tmp_path
|
|
):
|
|
"""A nested in-process dispatch inside a worker (e.g. a script running
|
|
``hermes cron run <other>``) must not queue under the OUTER execution id."""
|
|
import cron.scheduler as scheduler
|
|
import cron.scheduler_delivery as scheduler_delivery
|
|
|
|
queued = []
|
|
monkeypatch.setattr(
|
|
"cron.delivery_queue.enqueue_and_wait",
|
|
lambda execution_id, job, content, **kw: (
|
|
queued.append(execution_id) or "queued-marker"
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
scheduler,
|
|
"_resolve_delivery_targets",
|
|
lambda job, for_failure=False: [{"platform": "telegram", "chat_id": "123"}],
|
|
)
|
|
monkeypatch.setattr(
|
|
scheduler_delivery,
|
|
"_resolve_delivery_targets",
|
|
lambda job, for_failure=False: [{"platform": "telegram", "chat_id": "123"}],
|
|
)
|
|
|
|
def _standalone(*_args, **_kwargs):
|
|
raise RuntimeError("standalone path reached")
|
|
|
|
# First call the standalone (non-queue) path makes after the guard; the
|
|
# failure is reported as the delivery error string.
|
|
monkeypatch.setattr("gateway.config.load_gateway_config", _standalone)
|
|
monkeypatch.setenv("_HERMES_CRON_EXTERNAL_WORKER", "exec-outer")
|
|
|
|
# Own attempt: routed through the durable queue.
|
|
assert scheduler._deliver_result(
|
|
{"id": "job-1", "execution_id": "exec-outer", "deliver": "telegram:123"},
|
|
"done",
|
|
adapters=None,
|
|
loop=None,
|
|
) == "queued-marker"
|
|
assert queued == ["exec-outer"]
|
|
|
|
# A different job's attempt: must NOT be queued under exec-outer; it falls
|
|
# through to the standalone path.
|
|
error = scheduler._deliver_result(
|
|
{"id": "job-2", "execution_id": "exec-inner", "deliver": "telegram:123"},
|
|
"done",
|
|
adapters=None,
|
|
loop=None,
|
|
)
|
|
assert error == "failed to load gateway config: standalone path reached"
|
|
assert queued == ["exec-outer"]
|
|
|
|
|
|
def test_gateway_tool_run_without_adapter_objects_hands_off(monkeypatch):
|
|
import cron.scheduler as scheduler
|
|
|
|
created = Mock(return_value={"id": "exec-tool"})
|
|
launch = Mock(return_value=True)
|
|
run = Mock(side_effect=AssertionError("agent ran inside gateway"))
|
|
monkeypatch.setattr(scheduler, "create_execution", created)
|
|
monkeypatch.setattr(scheduler, "_launch_external_cron_worker", launch)
|
|
monkeypatch.setattr(scheduler, "run_job", run)
|
|
job = {"id": "tool-job"}
|
|
|
|
assert scheduler.run_one_job(job, adapters=None) is True
|
|
|
|
created.assert_called_once_with("tool-job", source="direct", scheduled_instant=None)
|
|
assert job["execution_id"] == "exec-tool"
|
|
launch.assert_called_once_with(job)
|
|
run.assert_not_called()
|
|
|
|
|
|
def test_shared_run_path_creates_execution_before_managed_handoff(monkeypatch):
|
|
import cron.scheduler as scheduler
|
|
|
|
created = Mock(return_value={"id": "exec-new"})
|
|
launch = Mock(return_value=True)
|
|
monkeypatch.setattr(scheduler, "create_execution", created)
|
|
monkeypatch.setattr(scheduler, "_launch_external_cron_worker", launch)
|
|
job = {"id": "manual-job"}
|
|
|
|
assert scheduler.run_one_job(job, adapters={"discord": object()}) is True
|
|
|
|
created.assert_called_once_with("manual-job", source="direct", scheduled_instant=None)
|
|
assert job["execution_id"] == "exec-new"
|
|
launch.assert_called_once_with(job)
|
|
|
|
|
|
def test_lost_execution_start_cas_prevents_side_effects(monkeypatch):
|
|
import cron.scheduler as scheduler
|
|
|
|
run = Mock(side_effect=AssertionError("side effect ran without ownership"))
|
|
monkeypatch.setattr(scheduler, "claim_dispatch", lambda _job_id: True)
|
|
monkeypatch.setattr(scheduler, "mark_execution_running", lambda _execution_id: None)
|
|
monkeypatch.setattr(scheduler, "run_job", run)
|
|
|
|
assert scheduler.run_one_job(
|
|
{"id": "job-1", "execution_id": "exec-1"}, adapters=None
|
|
) is True
|
|
run.assert_not_called()
|
|
|
|
|
|
@pytest.mark.linux_only
|
|
@pytest.mark.live_system_guard_bypass
|
|
def test_managed_gateway_restart_preserves_active_worker_and_single_side_effect(
|
|
tmp_path, monkeypatch
|
|
):
|
|
import cron.delivery_queue as delivery_queue
|
|
import cron.executions as executions
|
|
import cron.scheduler as scheduler
|
|
from cron.jobs import create_job, use_cron_store
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.status import _pid_exists
|
|
from tools import process_registry
|
|
|
|
if not process_registry._systemd_run_user_scope_available():
|
|
pytest.skip("systemd-run --user --scope is unavailable on this host")
|
|
|
|
home = tmp_path / "profile"
|
|
scripts_dir = home / "scripts"
|
|
scripts_dir.mkdir(parents=True)
|
|
started = tmp_path / "started"
|
|
release = tmp_path / "release"
|
|
side_effect = tmp_path / "side-effect"
|
|
probe = scripts_dir / "restart_probe.py"
|
|
probe.write_text(
|
|
"import pathlib, time\n"
|
|
f"started = pathlib.Path({str(started)!r})\n"
|
|
f"release = pathlib.Path({str(release)!r})\n"
|
|
f"side_effect = pathlib.Path({str(side_effect)!r})\n"
|
|
"started.write_text('started')\n"
|
|
"deadline = time.monotonic() + 15\n"
|
|
"while not release.exists() and time.monotonic() < deadline:\n"
|
|
" time.sleep(0.05)\n"
|
|
"if not release.exists():\n"
|
|
" raise SystemExit('release timeout')\n"
|
|
"with side_effect.open('a') as handle:\n"
|
|
" handle.write('once\\n')\n"
|
|
"print('completed')\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
with use_cron_store(home):
|
|
job = create_job(
|
|
prompt=None,
|
|
schedule="every 1h",
|
|
name="restart probe",
|
|
script=probe.name,
|
|
no_agent=True,
|
|
deliver="telegram:123",
|
|
)
|
|
payload = tmp_path / "job.json"
|
|
launched = tmp_path / "launched.json"
|
|
payload.write_text(json.dumps(job), encoding="utf-8")
|
|
|
|
sent = []
|
|
adapter = Mock()
|
|
|
|
async def send(_chat_id, content, metadata=None):
|
|
sent.append((content, metadata))
|
|
return {"success": True, "message_id": "restart-delivery-1"}
|
|
|
|
adapter.send = send
|
|
gateway_config = Mock()
|
|
gateway_config.platforms = {
|
|
Platform.TELEGRAM: PlatformConfig(enabled=True),
|
|
}
|
|
gateway_config.get_home_channel = lambda _platform: None
|
|
monkeypatch.setattr(
|
|
"gateway.config.load_gateway_config", lambda: gateway_config
|
|
)
|
|
monkeypatch.setattr(
|
|
scheduler, "load_config", lambda: {"cron": {"wrap_response": False}}
|
|
)
|
|
replacement_loop = asyncio.new_event_loop()
|
|
replacement_thread = threading.Thread(
|
|
target=replacement_loop.run_forever,
|
|
daemon=True,
|
|
)
|
|
replacement_thread.start()
|
|
deadline = time.monotonic() + 2
|
|
while not replacement_loop.is_running() and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
assert replacement_loop.is_running()
|
|
|
|
harness = (
|
|
"import json, os, pathlib, time\n"
|
|
f"os.environ['HERMES_HOME'] = {str(home)!r}\n"
|
|
"os.environ['INVOCATION_ID'] = 'restart-fixture'\n"
|
|
"from cron import scheduler\n"
|
|
"from tools import process_registry\n"
|
|
"process_registry._is_supervised_gateway_process = lambda: True\n"
|
|
f"job = json.loads(pathlib.Path({str(payload)!r}).read_text())\n"
|
|
"if not scheduler.run_one_job(job, adapters=None, loop=None):\n"
|
|
" raise SystemExit('worker was not isolated')\n"
|
|
f"pathlib.Path({str(launched)!r}).write_text('returned')\n"
|
|
)
|
|
parent = subprocess.Popen([sys.executable, "-c", harness])
|
|
worker_pid = None
|
|
try:
|
|
deadline = time.monotonic() + 10
|
|
current = None
|
|
while time.monotonic() < deadline:
|
|
if parent.poll() is not None:
|
|
pytest.fail(f"gateway fixture exited early with {parent.returncode}")
|
|
current = executions.latest_execution(job["id"])
|
|
if started.exists() and current and current.get("pid") != os.getpid():
|
|
break
|
|
time.sleep(0.05)
|
|
assert started.exists()
|
|
assert current is not None
|
|
execution = current
|
|
worker_pid = int(current["pid"])
|
|
assert not launched.exists(), "handoff returned before execution completed"
|
|
|
|
# Replacing a managed gateway kills its old process tree. The active
|
|
# cron owner must remain in its transient scope and keep the same PID.
|
|
parent.terminate()
|
|
parent.wait(timeout=5)
|
|
assert _pid_exists(worker_pid)
|
|
|
|
release.write_text("go", encoding="utf-8")
|
|
deadline = time.monotonic() + 10
|
|
while time.monotonic() < deadline:
|
|
row = delivery_queue.get_status(execution["id"])
|
|
if row and row["status"] == "pending":
|
|
scheduler.drain_delivery_queue(
|
|
{Platform.TELEGRAM: adapter}, replacement_loop
|
|
)
|
|
current = executions.latest_execution(job["id"])
|
|
if current and current["status"] == "completed":
|
|
break
|
|
time.sleep(0.05)
|
|
assert executions.latest_execution(job["id"])["status"] == "completed"
|
|
assert side_effect.read_text(encoding="utf-8").splitlines() == ["once"]
|
|
assert delivery_queue.get_status(execution["id"])["status"] == "delivered"
|
|
assert len(sent) == 1
|
|
assert "completed" in sent[0][0]
|
|
finally:
|
|
replacement_loop.call_soon_threadsafe(replacement_loop.stop)
|
|
replacement_thread.join(timeout=2)
|
|
replacement_loop.close()
|
|
if parent.poll() is None:
|
|
parent.terminate()
|
|
parent.wait(timeout=5)
|
|
if worker_pid is not None and _pid_exists(worker_pid):
|
|
os.kill(worker_pid, signal.SIGKILL)
|