Files
hermes-agent/tests/tools/test_persist_on_release.py
Hermes Agent ec5c9c738a fix(processes): persist_on_release keeps background jobs alive across lifecycle kill sweeps (#41225)
Background processes spawned with terminal(background=true) are killed from
three agent-lifecycle sweeps: agent release()'s kill_all, a gateway turn
timeout's kill_started_since, and agent close's owned-process loop. Jobs the
user explicitly wants to outlive the session (overnight batches, watchful
daemons) had no way to opt out.

Add terminal(background=true, persist_on_release=true):
- ProcessSession.persist_on_release, stamped by spawn_local/spawn_via_env,
  carried in crash-recovery checkpoints and exposed via list_sessions()
- kill_all skips persisted sessions only for lifecycle sources
  (_LIFECYCLE_KILL_SOURCES: kill_all, gateway_turn_timeout, agent_close);
  explicit operator stops (process_manage kill, /stop slash + RPC mirror,
  CLI /stop) now pass distinct sources so they still reach persisted jobs
- the agent_close owned-process loop in _close_task_resources skips
  persisted sessions the same way
- gateway shutdown keeps killing persisted jobs (source=gateway_shutdown):
  the host is going away and survivors would become PPID=1 orphans

Co-authored-by: salvaged from #109846 (persist_on_release plumbing) and
extended to the turn-timeout and agent_close paths.
2026-09-25 13:49:31 -05:00

155 lines
6.7 KiB
Python

"""Lifecycle kill sweeps must skip terminal(background=true, persist_on_release=true) jobs (#41225).
Background processes are killed from three agent-lifecycle paths — the release()
``kill_all`` sweep, a gateway turn timeout's ``kill_started_since``, and
``agent_close``'s owned-process loop. An explicitly persisted job must survive
all three, while an operator-driven stop still reaches it.
"""
import pytest
from unittest.mock import MagicMock, patch
from tools.process_registry import ProcessRegistry, ProcessSession, _CHECKPOINT_FIELDS
def _make_session(sid="proc_test123", task_id="t1", persist=False) -> ProcessSession:
s = ProcessSession(id=sid, command="sleep 60", task_id=task_id, started_at=0.0)
s.persist_on_release = persist
return s
@pytest.fixture()
def registry():
return ProcessRegistry()
def _fake_kill_collector(registry):
"""Replace kill_process so tests observe targeting without signalling."""
calls = []
def fake_kill(session_id, **kwargs):
calls.append((session_id, kwargs))
registry._running[session_id].exited = True
return {"status": "killed"}
registry.kill_process = fake_kill
return calls
def test_kill_all_release_sweep_skips_persisted_sessions(registry):
"""The default-source kill_all (agent release()) must not kill a
persist_on_release job owned by the task (#41225)."""
volatile = _make_session(sid="proc_volatile", task_id="session-a")
persisted = _make_session(sid="proc_persisted", task_id="session-a", persist=True)
registry._running[volatile.id] = volatile
registry._running[persisted.id] = persisted
calls = _fake_kill_collector(registry)
assert registry.kill_all("session-a") == 1
assert [c[0] for c in calls] == ["proc_volatile"]
assert persisted.exited is False
def test_gateway_turn_timeout_reap_skips_persisted_sessions(registry):
"""A timed-out turn's kill_started_since (source=gateway_turn_timeout) is a
lifecycle sweep: a persisted job started mid-turn must survive it (#41225)."""
persisted = _make_session(sid="proc_persisted", task_id="session-a", persist=True)
volatile = _make_session(sid="proc_new", task_id="session-a")
registry._running[persisted.id] = persisted
registry._running[volatile.id] = volatile
calls = _fake_kill_collector(registry)
assert registry.kill_started_since("session-a", frozenset(), source="gateway_turn_timeout") == 1
assert [c[0] for c in calls] == ["proc_new"]
assert persisted.exited is False
def test_agent_close_owned_loop_skips_persisted_sessions(registry, monkeypatch):
"""_close_task_resources' owned-process loop (source=agent_close) walks
list_sessions() directly, bypassing kill_all's filter: a persisted session
must be skipped there too (#41225)."""
from types import SimpleNamespace
from agent.client_lifecycle import ClientLifecycleMixin
import tools.process_registry as registry_module
persisted = _make_session(sid="proc_persisted", task_id="turn-1", persist=True)
volatile = _make_session(sid="proc_volatile", task_id="turn-1")
registry._running[persisted.id] = persisted
registry._running[volatile.id] = volatile
calls = _fake_kill_collector(registry)
agent = SimpleNamespace(_process_owner_task_ids=("turn-1",))
monkeypatch.setattr(registry_module, "process_registry", registry)
# _close_task_resources also runs cleanup_vm/cleanup_browser/release_computer_use;
# stub them so the loop under test is the only thing that runs.
import run_agent as ra
monkeypatch.setattr(ra, "cleanup_vm", lambda *a, **k: None)
monkeypatch.setattr(ra, "cleanup_browser", lambda *a, **k: None)
import tools.computer_use.tool as cu
monkeypatch.setattr(cu, "release_computer_use_session", lambda *a, **k: None)
ClientLifecycleMixin._close_task_resources(agent, "turn-1")
assert [c[0] for c in calls] == ["proc_volatile"]
assert all(c[1]["source"] == "agent_close" for c in calls)
assert persisted.exited is False
def test_explicit_operator_stop_still_reaches_persisted_sessions(registry):
"""persist_on_release is an agent-lifecycle opt-out, never a protection
against being stopped on purpose: a caller-driven source still kills."""
persisted = _make_session(sid="proc_persisted", task_id="session-a", persist=True)
registry._running[persisted.id] = persisted
calls = _fake_kill_collector(registry)
assert registry.kill_all("session-a", source="process.kill") == 1
assert [c[0] for c in calls] == ["proc_persisted"]
def test_list_sessions_flags_persist_on_release(registry):
"""The agent_close loop reads list_sessions(); the flag must be visible
there for it (and for the agent) to act on."""
persisted = _make_session(sid="proc_persisted", task_id="session-a", persist=True)
volatile = _make_session(sid="proc_volatile", task_id="session-a")
registry._running[persisted.id] = persisted
registry._running[volatile.id] = volatile
entries = {e["session_id"]: e for e in registry.list_sessions()}
assert entries["proc_persisted"].get("persist_on_release") is True
assert "persist_on_release" not in entries["proc_volatile"]
def test_spawn_local_stamps_persist_on_release(registry, monkeypatch, tmp_path):
"""spawn_local(persist_on_release=True) stamps the flag onto the minted
ProcessSession so every kill filter can see it (#41225)."""
import os
from tools import terminal_tool_sudo
# Stay off the real hermes home: the spawn-path env sanitizer resolves the
# real console-script install (_resolve_hermes_bin_dir), which the test
# suite's HomeIOGuard forbids. None == "no managed install found".
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from tools.environments import local as local_env
monkeypatch.setattr(local_env, "_resolve_hermes_bin_dir", lambda: None)
monkeypatch.setattr(registry, "_track_started", lambda *a, **k: None)
monkeypatch.setattr(terminal_tool_sudo, "_rewrite_compound_background", lambda c: c)
monkeypatch.setattr(ProcessRegistry, "_scope_argv", lambda *a, **k: None)
fake_popen = MagicMock()
fake_popen.pid = 4242
monkeypatch.setattr("subprocess.Popen", MagicMock(return_value=fake_popen))
session = registry.spawn_local(
"python -c 'import time; time.sleep(60)'", task_id="t1", persist_on_release=True)
assert session.persist_on_release is True
session = registry.spawn_local("echo hi", task_id="t1")
assert session.persist_on_release is False
def test_checkpoint_carries_persist_on_release():
"""A crash-recovery checkpoint must carry the flag, or a persisted job
resurrects as killable by the next session's release sweep (#41225)."""
assert "persist_on_release" in _CHECKPOINT_FIELDS