|
| 1 | +"""Real bash regressions: a shared terminal snapshot is not execution identity.""" |
| 2 | +import os |
| 3 | +import json |
| 4 | +import shlex |
| 5 | +import sys |
| 6 | +import threading |
| 7 | +from concurrent.futures import ThreadPoolExecutor |
| 8 | +from pathlib import Path |
| 9 | +from types import SimpleNamespace |
| 10 | + |
| 11 | +import pytest |
| 12 | + |
| 13 | +from agent.delegation_context import delegated_child_context, is_delegated_child_context |
| 14 | +from tools.environments.local import LocalEnvironment |
| 15 | + |
| 16 | + |
| 17 | +@pytest.mark.parametrize("child_first", [False, True]) |
| 18 | +def test_parent_identity_survives_child_snapshot(monkeypatch, tmp_path, child_first): |
| 19 | + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) |
| 20 | + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fixture_parent") |
| 21 | + env = LocalEnvironment(cwd=str(tmp_path), timeout=5) |
| 22 | + try: |
| 23 | + if not child_first: |
| 24 | + env.init_session() |
| 25 | + with delegated_child_context(): |
| 26 | + if child_first: |
| 27 | + env.init_session() |
| 28 | + child = env.execute('printf "CHILD=%s\\n" "$HERMES_DELEGATED_CHILD_CONTEXT"; export FIXTURE_SHARED=kept', timeout=5) |
| 29 | + assert "CHILD=1" in child["output"] |
| 30 | + assert not is_delegated_child_context() |
| 31 | + assert os.environ.get("HERMES_DELEGATED_CHILD_CONTEXT") is None |
| 32 | + parent = env.execute('printf "PARENT=%s SHARED=%s\\n" "${HERMES_DELEGATED_CHILD_CONTEXT:-absent}" "$FIXTURE_SHARED"', timeout=5) |
| 33 | + assert "PARENT=absent SHARED=kept" in parent["output"] |
| 34 | + finally: |
| 35 | + env.cleanup() |
| 36 | + |
| 37 | + |
| 38 | +@pytest.mark.parametrize("inherited_child", [False, True]) |
| 39 | +def test_stale_snapshot_cannot_override_spawn_identity(monkeypatch, tmp_path, inherited_child): |
| 40 | + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) |
| 41 | + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fixture_parent") |
| 42 | + env = LocalEnvironment(cwd=str(tmp_path), timeout=5) |
| 43 | + try: |
| 44 | + env.init_session() |
| 45 | + # Old snapshots exist across code updates. Their identity is not authority. |
| 46 | + with Path(env._snapshot_path).open("a") as snapshot: |
| 47 | + snapshot.write('declare -x HERMES_DELEGATED_CHILD_CONTEXT="stale"\n') |
| 48 | + snapshot.write('declare -x HERMES_KANBAN_TASK="t_stale"\n') |
| 49 | + if inherited_child: |
| 50 | + monkeypatch.setenv("HERMES_DELEGATED_CHILD_CONTEXT", "1") |
| 51 | + result = env.execute('printf "IDENTITY=%s TASK=%s\\n" "${HERMES_DELEGATED_CHILD_CONTEXT:-absent}" "${HERMES_KANBAN_TASK:-absent}"', timeout=5) |
| 52 | + expected = "IDENTITY=1 TASK=absent" if inherited_child else "IDENTITY=absent TASK=t_fixture_parent" |
| 53 | + assert expected in result["output"] |
| 54 | + finally: |
| 55 | + env.cleanup() |
| 56 | + |
| 57 | + |
| 58 | +def test_native_delegation_timeout_parent_cli_and_child_guard(monkeypatch, tmp_path): |
| 59 | + """Real delegate timeout + bash + CLI/DB, with no model or live board.""" |
| 60 | + from hermes_cli import kanban_db as kb |
| 61 | + from tools import delegate_tool |
| 62 | + from tests.tools.test_delegate_timeout_cleanup import _SlowUnwindingChild |
| 63 | + |
| 64 | + repo = Path(__file__).resolve().parents[2] |
| 65 | + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) |
| 66 | + db = kb.init_db() |
| 67 | + assert db.resolve().is_relative_to(tmp_path.resolve()) |
| 68 | + with kb.connect() as conn: |
| 69 | + tid = kb.create_task(conn, title="fixture", assignee="fixture") |
| 70 | + monkeypatch.setenv("HERMES_KANBAN_TASK", tid) |
| 71 | + env = LocalEnvironment(cwd=str(repo), timeout=10) |
| 72 | + |
| 73 | + def cli(action): |
| 74 | + # -c keeps cwd as the import root, avoiding the editable live install. |
| 75 | + code = ( |
| 76 | + "import argparse; from pathlib import Path; " |
| 77 | + "from hermes_cli import kanban; " |
| 78 | + f"assert Path(kanban.__file__).resolve().is_relative_to(Path({str(repo)!r}).resolve()); " |
| 79 | + "p=argparse.ArgumentParser(); s=p.add_subparsers(dest='cmd'); " |
| 80 | + "kanban.build_parser(s); " |
| 81 | + f"a=p.parse_args(['kanban', {action!r}, '--json']); " |
| 82 | + "raise SystemExit(kanban.kanban_command(a))" |
| 83 | + ) |
| 84 | + return env.execute(f"{shlex.quote(sys.executable)} -c {shlex.quote(code)}", timeout=10) |
| 85 | + |
| 86 | + child = _SlowUnwindingChild() |
| 87 | + probes = [] |
| 88 | + parent = SimpleNamespace(session_id="fixture-parent", _current_task_id=None, |
| 89 | + _active_children=[child], _active_children_lock=threading.Lock()) |
| 90 | + monkeypatch.setattr(delegate_tool, "_get_child_timeout", lambda: 5) |
| 91 | + monkeypatch.setattr(delegate_tool, "_get_worktree_isolation", lambda: False) |
| 92 | + # Hold the fake LLM on an event, but use native timeout/ContextVar machinery. |
| 93 | + def child_turn(**kwargs): |
| 94 | + probes.append(cli("stats")) |
| 95 | + child.started.set() |
| 96 | + try: |
| 97 | + assert child.allow_finish.wait(30) |
| 98 | + return {"final_response": "", "completed": False, "api_calls": 1, "messages": []} |
| 99 | + finally: |
| 100 | + child.finished.set() |
| 101 | + |
| 102 | + child.run_conversation = child_turn |
| 103 | + try: |
| 104 | + env.init_session() |
| 105 | + with ThreadPoolExecutor(max_workers=1) as pool: |
| 106 | + future = pool.submit(delegate_tool._run_single_child, 0, "fixture timeout", child, parent) |
| 107 | + assert child.started.wait(15) |
| 108 | + result = future.result(timeout=15) |
| 109 | + assert result["status"] == "timeout" |
| 110 | + assert probes[0]["returncode"] == 1 |
| 111 | + assert "could not initialize database" in probes[0]["output"] |
| 112 | + assert "delegate_task child contexts cannot mutate" in probes[0]["output"] |
| 113 | + assert not is_delegated_child_context() |
| 114 | + assert os.environ.get("HERMES_DELEGATED_CHILD_CONTEXT") is None |
| 115 | + # Still concurrent with the timed-out child, not only after its cleanup. |
| 116 | + for action in ("stats", "diagnostics"): |
| 117 | + result = cli(action) |
| 118 | + assert result["returncode"] == 0, result |
| 119 | + json.loads(result["output"]) |
| 120 | + with kb.connect() as conn: |
| 121 | + with delegated_child_context(): |
| 122 | + with pytest.raises(PermissionError, match="delegate_task child"): |
| 123 | + kb.create_task(conn, title="forbidden", assignee="fixture") |
| 124 | + task = kb.get_task(conn, tid) |
| 125 | + assert task is not None and task.title == "fixture" |
| 126 | + assert conn.execute("SELECT count(*) FROM tasks").fetchone()[0] == 1 |
| 127 | + finally: |
| 128 | + child.allow_finish.set() |
| 129 | + child.finished.wait(5) |
| 130 | + child.closed.wait(5) |
| 131 | + env.cleanup() |
0 commit comments