288 lines
14 KiB
Python
288 lines
14 KiB
Python
"""Completed work remains retrievable when its finite CLI owner exits."""
|
|
|
|
from collections import Counter
|
|
import http.server
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
import threading
|
|
import time
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def _new_background_notifications(messages, seen_counts):
|
|
"""Count new history occurrences, not positions shifted by request assembly."""
|
|
counts = Counter(
|
|
m["content"] for m in messages
|
|
if m["role"] == "user"
|
|
and isinstance(m.get("content"), str)
|
|
and m["content"].startswith("[IMPORTANT: Background process ")
|
|
)
|
|
new = []
|
|
for content, count in counts.items():
|
|
new.extend([content] * max(0, count - seen_counts[content]))
|
|
seen_counts[content] = max(seen_counts[content], count)
|
|
return new
|
|
|
|
|
|
def test_background_notification_history_replay_is_not_new_delivery():
|
|
notice = "[IMPORTANT: Background process proc_a exited (exit code 7).]"
|
|
seen_counts = Counter()
|
|
first = [{"role": "system", "content": "first"}, {"role": "user", "content": notice}]
|
|
shifted = [{"role": "system", "content": "second"}, {"role": "user", "content": "query"}, *first[1:]]
|
|
assert _new_background_notifications(first, seen_counts) == [notice]
|
|
assert _new_background_notifications(shifted, seen_counts) == []
|
|
assert _new_background_notifications([*shifted, first[1]], seen_counts) == [notice]
|
|
|
|
|
|
def test_headless_terminal_result_survives_cli_exit(tmp_path):
|
|
"""Real CLI, tool dispatch, shell child and fresh reader; only the LLM is local."""
|
|
home = tmp_path / "profile"
|
|
home.mkdir()
|
|
(home / "config.yaml").write_text(
|
|
"model:\n provider: custom\n api_mode: chat_completions\n"
|
|
"terminal:\n env: local\n oneshot_completion_wait_seconds: 10\n"
|
|
"memory:\n memory_enabled: false\n user_profile_enabled: false\n",
|
|
encoding="utf-8",
|
|
)
|
|
release = tmp_path / "release"
|
|
child = tmp_path / "review.py"
|
|
child.write_text(textwrap.dedent('''
|
|
import pathlib, sys, time
|
|
deadline = time.monotonic() + 15
|
|
while not pathlib.Path(sys.argv[1]).exists():
|
|
if time.monotonic() > deadline:
|
|
sys.exit(91)
|
|
time.sleep(0.02)
|
|
print("SYNTHETIC_REVIEW_COMPLETE")
|
|
print("review stderr", file=sys.stderr)
|
|
sys.exit(7)
|
|
'''), encoding="utf-8")
|
|
# The local terminal backend uses bash, including Git Bash on Windows.
|
|
command = shlex.join(path.as_posix() for path in (Path(sys.executable), child, release))
|
|
observed = []
|
|
seen_tool = set()
|
|
seen_follow_up_counts = Counter()
|
|
follow_ups = []
|
|
completed_during_provider_reply = threading.Event()
|
|
|
|
class Provider(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
self.send_error(404)
|
|
|
|
def do_POST(self):
|
|
request = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
|
if "messages" not in request:
|
|
self.send_error(404)
|
|
return
|
|
tool_results = [m for m in request["messages"] if m["role"] == "tool"]
|
|
follow_ups.extend(_new_background_notifications(request["messages"], seen_follow_up_counts))
|
|
has_terminal = any(t.get("function", {}).get("name") == "terminal"
|
|
for t in request.get("tools", []))
|
|
message = {"role": "assistant", "content": "Coordinator finished."}
|
|
if has_terminal and not tool_results:
|
|
message.update(content=None, tool_calls=[{
|
|
"id": "call_review", "type": "function", "function": {
|
|
"name": "terminal", "arguments": json.dumps({
|
|
"command": command, "background": True, "notify": True,
|
|
}),
|
|
},
|
|
}])
|
|
elif tool_results:
|
|
# Requests accumulate history, so a follow-up turn re-carries the start
|
|
# receipt: count each distinct tool message once.
|
|
for m in tool_results:
|
|
key = json.dumps(m, sort_keys=True)
|
|
if key not in seen_tool:
|
|
seen_tool.add(key)
|
|
observed.append(json.loads(m["content"]))
|
|
release.touch()
|
|
# Hold the tool-result response until the real child has exited: on a
|
|
# loaded runner completion can race this provider request.
|
|
deadline = time.monotonic() + 25
|
|
receipt = home / "logs" / "process-results" / f"{observed[0]['session_id']}.json"
|
|
while not receipt.is_file() and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
if receipt.is_file():
|
|
completed_during_provider_reply.set()
|
|
response = {
|
|
"id": "chatcmpl-local", "object": "chat.completion", "created": 1,
|
|
"model": "test-model", "choices": [{
|
|
"index": 0, "message": message,
|
|
"finish_reason": "tool_calls" if "tool_calls" in message else "stop",
|
|
}], "usage": {"prompt_tokens": 10, "completion_tokens": 10, "total_tokens": 20},
|
|
}
|
|
content_type = "application/json"
|
|
if request.get("stream"):
|
|
response["object"] = "chat.completion.chunk"
|
|
response["choices"][0]["delta"] = response["choices"][0].pop("message")
|
|
for index, tool in enumerate(message.get("tool_calls", [])):
|
|
tool["index"] = index
|
|
raw = ("data: " + json.dumps(response) + "\n\ndata: [DONE]\n\n").encode()
|
|
content_type = "text/event-stream"
|
|
else:
|
|
raw = json.dumps(response).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(raw)))
|
|
self.end_headers()
|
|
self.wfile.write(raw)
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Provider)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
url = f"http://127.0.0.1:{server.server_port}/v1"
|
|
env = {**os.environ, "HERMES_HOME": str(home), "HOME": str(tmp_path),
|
|
"USERPROFILE": str(tmp_path), "TERMINAL_CWD": str(tmp_path),
|
|
"OPENAI_BASE_URL": url, "OPENAI_API_KEY": "local-test-only",
|
|
"PYTHONPATH": str(REPO_ROOT)}
|
|
try:
|
|
producer = subprocess.run([
|
|
sys.executable, "-c",
|
|
"import cli; cli.main(query='Run the background review', quiet=True, "
|
|
"oneshot=True, provider='custom', model='test-model', api_key='local-test-only', "
|
|
f"base_url={url!r}, toolsets='terminal', max_turns=3, ignore_rules=True)",
|
|
], cwd=tmp_path, env=env, stdin=subprocess.DEVNULL,
|
|
capture_output=True, text=True, encoding="utf-8", timeout=60)
|
|
finally:
|
|
release.touch()
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=5)
|
|
assert producer.returncode == 0, producer.stdout + producer.stderr
|
|
assert completed_during_provider_reply.is_set(), producer.stdout + producer.stderr
|
|
assert "Coordinator finished." in producer.stdout
|
|
assert len(observed) == 1, (observed, producer.stdout, producer.stderr)
|
|
process_id = observed[0]["session_id"]
|
|
assert observed[0].get("notify_on_complete") is True, observed
|
|
# A terminal completion is an [IMPORTANT: Background process ...] event,
|
|
# not an [ASYNC DELEGATION ...] event. The same history entry can appear in
|
|
# several provider requests without being delivered a second time.
|
|
assert len(follow_ups) == 1, follow_ups
|
|
assert follow_ups[0].startswith(f"[IMPORTANT: Background process {process_id} exited (exit code 7).")
|
|
assert "SYNTHETIC_REVIEW_COMPLETE" in follow_ups[0]
|
|
|
|
consumer = textwrap.dedent('''
|
|
import json, sys
|
|
import tools.process_registry as pr
|
|
from tools.registry import registry
|
|
result = registry.get_entry("process_manage").handler(
|
|
{"action": "log", "session_id": sys.argv[1]})
|
|
status = registry.get_entry("process_manage").handler(
|
|
{"action": "poll", "session_id": sys.argv[1]})
|
|
print(json.dumps({"result": json.loads(result), "status": json.loads(status),
|
|
"replayed": not pr.process_registry.completion_queue.empty()}))
|
|
''')
|
|
def read_result(profile):
|
|
result = subprocess.run([sys.executable, "-c", consumer, process_id],
|
|
cwd=tmp_path, env={**env, "HERMES_HOME": str(profile)},
|
|
check=True, stdin=subprocess.DEVNULL, capture_output=True,
|
|
text=True, encoding="utf-8", timeout=30)
|
|
return json.loads(result.stdout)
|
|
|
|
receipt = json.loads((home / "logs" / "process-results" / f"{process_id}.json").read_text(encoding="utf-8"))
|
|
assert receipt["parent_session_id"] # CLI owner must be stamped before its reader starts.
|
|
env["HERMES_SESSION_ID"] = receipt["parent_session_id"]
|
|
recovered = read_result(home)
|
|
assert recovered["result"]["status"] == "exited", recovered
|
|
assert recovered["status"]["exit_code"] == 7, recovered
|
|
assert "SYNTHETIC_REVIEW_COMPLETE" in recovered["result"]["output"]
|
|
assert "review stderr" in recovered["result"]["output"]
|
|
assert recovered["replayed"] is False
|
|
assert read_result(tmp_path / "other-profile")["result"]["status"] == "not_found"
|
|
|
|
|
|
def test_receipts_are_bounded_redacted_and_session_scoped(tmp_path, monkeypatch):
|
|
import time
|
|
from tools import process_registry_results as receipts
|
|
from tools.process_registry import MAX_OUTPUT_CHARS, ProcessRegistry, ProcessSession
|
|
|
|
from agent import redact
|
|
monkeypatch.setattr(redact, "_REDACT_ENABLED", False)
|
|
monkeypatch.setattr(receipts, "MAX_RETAINED_RESULTS", 2)
|
|
secret = "sk-" + "aB2cD3eF4gH5iJ6kL7mN8pQ9rS0tU1vW2xY3zA4bC5dE6fG7"
|
|
from gateway.session_context import scoped_current_session_id
|
|
from tools.process_registry_results import load_completed_results
|
|
monkeypatch.setenv("HERMES_SESSION_ID", "owner-session")
|
|
sessions = []
|
|
registry = ProcessRegistry()
|
|
for index in range(3):
|
|
session = ProcessSession(
|
|
id=f"proc_{index:012x}", command=f"echo {secret}", task_id=f"owner-{index}",
|
|
owner_task_id=f"owner-{index}", session_key=f"chat-{index}",
|
|
parent_session_id="owner-session",
|
|
started_at=time.time() - receipts.RESULT_RETENTION_SECONDS * 2,
|
|
output_buffer="x" * MAX_OUTPUT_CHARS + "\nréponse 世界\n" + secret,
|
|
exited=True, exit_code=index,
|
|
)
|
|
registry._running[session.id] = session
|
|
registry._move_to_finished(session)
|
|
sessions.append(session)
|
|
from hermes_constants import get_hermes_home
|
|
paths = list((get_hermes_home() / "logs" / "process-results").glob("*.json"))
|
|
assert len(paths) == 2
|
|
assert all(secret not in path.read_text(encoding="utf-8") for path in paths)
|
|
for path in paths:
|
|
raw = path.read_bytes()
|
|
assert not raw.startswith(b"\xef\xbb\xbf")
|
|
path.write_bytes(b"\xef\xbb\xbf" + raw)
|
|
fresh = ProcessRegistry()
|
|
assert fresh.get(sessions[0].id) is None
|
|
recovered = fresh.get(sessions[-1].id)
|
|
assert recovered is not None
|
|
assert "réponse 世界" in recovered.output_buffer
|
|
assert recovered.exited and recovered._completion_event.is_set()
|
|
assert recovered.owner_task_id == sessions[-1].owner_task_id
|
|
assert len(recovered.output_buffer) <= MAX_OUTPUT_CHARS
|
|
assert fresh.list_sessions() == [] # Status/liveness scans stay in memory.
|
|
assert [s["session_id"] for s in fresh.list_sessions(
|
|
task_id="owner-2", include_retained=True)] == [recovered.id]
|
|
assert fresh.list_sessions(task_id="unrelated", session_key="unrelated", include_retained=True) == []
|
|
assert fresh.get("proc_0000") is None # Ambiguous across durable results.
|
|
with scoped_current_session_id("unrelated-session"):
|
|
assert load_completed_results(recovered.id) == {}
|
|
assert fresh.get(recovered.id) is None
|
|
from hermes_state import SessionDB
|
|
db = SessionDB()
|
|
try:
|
|
db.create_session("owner-session", "cli")
|
|
db.create_session("delegated-child", "subagent", parent_session_id="owner-session")
|
|
with scoped_current_session_id("delegated-child"):
|
|
assert fresh.get(recovered.id) is None
|
|
db.end_session("owner-session", end_reason="compression")
|
|
db.create_session("owner-tip", "cli", parent_session_id="owner-session")
|
|
with scoped_current_session_id("owner-tip"):
|
|
assert fresh.get(recovered.id).output_buffer == recovered.output_buffer
|
|
finally:
|
|
db.close()
|
|
assert fresh.completion_queue.empty()
|
|
for path in paths:
|
|
expired = time.time() - receipts.RESULT_RETENTION_SECONDS - 1
|
|
os.utime(path, (expired, expired))
|
|
assert fresh.get(recovered.id) is None
|
|
|
|
# Multiplex readers must keep the producer's profile on native threads.
|
|
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
|
|
profile = tmp_path / "thread-profile"
|
|
token = set_hermes_home_override(profile)
|
|
try:
|
|
with scoped_current_session_id("thread-owner"):
|
|
child = registry.spawn_local(
|
|
shlex.join([Path(sys.executable).as_posix(), "-c", "print('SCOPED_RESULT')"]),
|
|
cwd=str(tmp_path), task_id="thread-task")
|
|
child._reader_thread.join(timeout=20)
|
|
assert not child._reader_thread.is_alive()
|
|
assert (profile / "logs" / "process-results" / f"{child.id}.json").exists()
|
|
assert "SCOPED_RESULT" in ProcessRegistry().read_log(child.id)["output"]
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
assert not (get_hermes_home() / "logs" / "process-results" / f"{child.id}.json").exists()
|