9f7f2f28c0
The gateway asked the user questions (approval, clarify, sudo, secret,
vault, MCP setup, the desktop read/act bridges) by emitting a
`<x>.request` EVENT carrying a hand-minted request_id, blocking the
agent thread on a module dict keyed by that id, and exposing a paired
`<x>.respond` METHOD per kind — thirteen pairs, four registries
(`_pending`, `_answers`, `_batch_clarify`, `_EXPIRING_REQUESTS`) and a
per-kind reconnect snapshot (`pending_clarify` / `pending_approval`)
that only two of the thirteen kinds ever got. JSON-RPC already has the
primitive: the server sends a request frame with an id and the client
answers with a response frame bearing the same id.
`tui_gateway/server_requests.py` owns the one mechanism:
send() block the agent thread until the response frame
(`srq-<n>` ids; ints belong to the client)
send_async() fire-and-callback variant (bot relay)
cancel*() withdraw with ONE `request.cancel {id, method, reason}`
event (timeout / interrupt / process exit /
answered elsewhere) instead of per-kind *.expire
open_requests() the still-open frames, replayed by session.resume,
session.activate and session.events.since so a
reconnecting client re-renders every kind, not two
clarify.lock stays a real client→server RPC (locks one batch
answer early); locked answers merge into the final
set even when the closing response carries only the
tail the user answered last
A client that does not implement a method answers -32601 and the agent
fails fast (the old fixed-timeout "unavailable" probes for tour/preview
still work — a wire error IS an answer). Approval: the queue entry's
settle hook withdraws the request when `/approve` from another surface,
a timeout or an interrupt resolves it first, so no window keeps a dead
card. Compute-host children own their waits; the parent mirrors their
open frames for replay and relays `clarify.lock` + response frames.
Clients: `JsonRpcRequestChannel` gains `onRequest` (unhandled → -32601,
dedup by id) and `JsonRpcGatewayClient` re-delivers `open_requests`
from the replay result. Desktop gets `gateway-event/server-requests.ts`
(one handler per method, replacing the request branches of
`input-requests.ts` / `desktop-bridge.ts`) and a `store/server-requests`
registry so every answer site calls `respondToServerRequest(id, result)`
synchronously; the TUI gets `createServerRequestHandler.ts` +
`serverRequestStore.ts`. `gateway-events.json` now pins both halves
(events + server request methods); the two contract tests check both.
Live (real stdio gateway, real `clarify_callback` on the agent thread):
before, `clarify.request` event + `clarify.respond` RPC, batch final
answers lost ('' returned); after, `{"id":"srq-…","method":"clarify"}`
frame, `session.events.since.open_requests` replays it, response frame
`{"answer":"yes"}` reaches the agent, batch lock + final response
merge to `{"q0":"1","q1":"free text"}`.
340 lines
12 KiB
Python
340 lines
12 KiB
Python
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tui_gateway import compute_host, server
|
|
from tui_gateway.compute_host import ComputeHost, _default_workers
|
|
from tui_gateway.host_supervisor import (
|
|
MUTATOR_ROUTE_TABLE,
|
|
HostSupervisor,
|
|
append_log_record,
|
|
)
|
|
|
|
|
|
def _json_lines(out: io.StringIO) -> list[dict]:
|
|
frames = []
|
|
for line in out.getvalue().splitlines():
|
|
if line.strip():
|
|
frames.append(json.loads(line))
|
|
return frames
|
|
|
|
|
|
def _wait_for_frame(out: io.StringIO, predicate, timeout: float = 2.0) -> dict:
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
for frame in _json_lines(out):
|
|
if predicate(frame):
|
|
return frame
|
|
time.sleep(0.01)
|
|
raise AssertionError(f"timed out waiting for frame; saw={_json_lines(out)}")
|
|
|
|
|
|
def test_compute_host_workers_inherit_tui_pool_env_or_8(monkeypatch):
|
|
monkeypatch.delenv("HERMES_TUI_RPC_POOL_WORKERS", raising=False)
|
|
monkeypatch.delenv("HERMES_COMPUTE_HOST_WORKERS", raising=False)
|
|
assert _default_workers() == 8
|
|
|
|
monkeypatch.setenv("HERMES_TUI_RPC_POOL_WORKERS", "11")
|
|
assert _default_workers() == 11
|
|
|
|
# Dead-RC tombstone: malformed env falls back to 8, not the old except-branch 4.
|
|
monkeypatch.setenv("HERMES_TUI_RPC_POOL_WORKERS", "not-an-int")
|
|
assert _default_workers() == 8
|
|
|
|
|
|
def test_compute_host_routes_relayed_response_and_lock_to_its_open_request(monkeypatch):
|
|
"""The child owns the server request's wait: a relayed client response frame resolves it in-process,
|
|
and a relayed ``clarify.lock`` is answered with that method's result for the parent to ack."""
|
|
from tui_gateway import server_requests
|
|
out = io.StringIO()
|
|
host = ComputeHost(stdout=out, heartbeat_secs=0)
|
|
sid = "host-clarify"
|
|
server._sessions[sid] = {"history_lock": threading.Lock()}
|
|
req = server_requests.ServerRequest(sid, "clarify", {"question": "?"})
|
|
with server_requests._lock:
|
|
server_requests._open[req.id] = req
|
|
locks = []
|
|
monkeypatch.setitem(server._methods, "clarify.lock",
|
|
lambda rid, params: locks.append((rid, dict(params))) or {"result": {"status": "ok", "remaining": []}})
|
|
|
|
try:
|
|
host._handle_respond({"sid": sid, "request_id": "relay-lock",
|
|
"params": {"lock": {"request_id": req.id, "question_id": "q0", "answer": "a"}}})
|
|
assert locks == [("relay-lock", {"request_id": req.id, "question_id": "q0", "answer": "a"})]
|
|
assert _json_lines(out)[-1]["response"] == {"result": {"status": "ok", "remaining": []}}
|
|
|
|
host._handle_respond({"sid": sid, "request_id": "relay-response",
|
|
"params": {"frame": {"jsonrpc": "2.0", "id": req.id, "result": {"answer": "yes"}}}})
|
|
assert req.answered and req.result == {"answer": "yes"} and req.event.is_set()
|
|
frame = _json_lines(out)[-1]
|
|
assert frame["type"] == "respond.ack" and frame["response"]["result"] == {"status": "ok"}
|
|
finally:
|
|
server._sessions.pop(sid, None)
|
|
server_requests.reset_for_tests()
|
|
host.close()
|
|
|
|
|
|
def test_mutator_route_table_matches_prd_inventory():
|
|
assert MUTATOR_ROUTE_TABLE == {
|
|
"prompt.submit": "turn-path",
|
|
"session.interrupt": "turn-path",
|
|
"reload.mcp": "run-concurrent",
|
|
"session.save": "run-concurrent",
|
|
"session.compress": "idle-gated",
|
|
"prompt.submit.truncate": "idle-gated",
|
|
"slash.model": "idle-gated",
|
|
"slash.personality": "idle-gated",
|
|
"slash.prompt": "idle-gated",
|
|
"slash.compress": "idle-gated",
|
|
"session.reset": "idle-gated",
|
|
"session.history.reload": "idle-gated",
|
|
"slash.retry": "idle-gated",
|
|
}
|
|
|
|
|
|
def test_append_log_record_single_write_lines(tmp_path):
|
|
path = tmp_path / "agent.log"
|
|
|
|
def writer(i: int) -> None:
|
|
append_log_record(path, f"line-{i:03d}-" + ("x" * 2000))
|
|
|
|
threads = [threading.Thread(target=writer, args=(i,)) for i in range(32)]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join()
|
|
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
assert len(lines) == 32
|
|
assert sorted(line.split("-", 2)[1] for line in lines) == [f"{i:03d}" for i in range(32)]
|
|
assert all(line.endswith("x" * 2000) for line in lines)
|
|
|
|
|
|
def test_supervisor_startup_reconcile_pid_reuse_guard(tmp_path, monkeypatch):
|
|
registry = tmp_path / "dashboard-compute-host.json"
|
|
registry.write_text(json.dumps({"host_pid": os.getpid(), "boot_id": "stale"}), encoding="utf-8")
|
|
|
|
killed: list[int] = []
|
|
supervisor = HostSupervisor(registry_path=registry, argv=[sys.executable, "-c", ""], autostart=False)
|
|
monkeypatch.setattr(supervisor, "_pid_matches_compute_host", lambda _pid: False)
|
|
monkeypatch.setattr(supervisor, "_terminate_pid", lambda pid, **_kw: killed.append(pid))
|
|
|
|
result = supervisor.reconcile_startup_orphan()
|
|
|
|
assert result == "pid-reuse-ignored"
|
|
assert killed == []
|
|
assert not registry.exists()
|
|
|
|
|
|
def _make_compress_host_session(events: list) -> dict:
|
|
class _Agent:
|
|
model = "host-model"
|
|
provider = "host-provider"
|
|
tools = []
|
|
_cached_system_prompt = ""
|
|
session_input_tokens = 1
|
|
session_output_tokens = 1
|
|
session_prompt_tokens = 1
|
|
session_completion_tokens = 1
|
|
session_total_tokens = 2
|
|
session_api_calls = 1
|
|
session_id = "rotated-id"
|
|
|
|
agent = _Agent()
|
|
agent.context_compressor = type("ContextEngineStub", (), {})()
|
|
agent.context_compressor.on_session_start = (
|
|
lambda *_args, **_kwargs: events.append("notify")
|
|
)
|
|
return {
|
|
"agent": agent,
|
|
"session_key": "before-key",
|
|
"history": [
|
|
{"role": "user", "content": "before"},
|
|
{"role": "assistant", "content": "before"},
|
|
],
|
|
"history_lock": threading.Lock(),
|
|
"history_version": 2,
|
|
"running": False,
|
|
"manual_compression_lock": threading.Lock(),
|
|
}
|
|
|
|
|
|
def _record_finalize(monkeypatch, events: list[str], *sids: str) -> None:
|
|
"""Give ``flush_all_sessions`` sessions and record which ones finalize."""
|
|
keys = sids or ("s1",)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_sessions",
|
|
{sid: {"session_key": sid} for sid in keys},
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
server,
|
|
"_finalize_session",
|
|
lambda _session, end_reason="tui_close": events.append(
|
|
f"finalize:{_session['session_key']}:{end_reason}"
|
|
),
|
|
raising=False,
|
|
)
|
|
|
|
|
|
def _register_turn(host: ComputeHost, fn, sid: str = "s1") -> None:
|
|
"""Submit a turn exactly the way ``_handle_turn_start`` does."""
|
|
host._track_turn_future(host._executor.submit(fn), sid)
|
|
|
|
|
|
def test_shutdown_drains_in_flight_turn_before_finalizing_sessions(monkeypatch):
|
|
events: list[str] = []
|
|
_record_finalize(monkeypatch, events)
|
|
|
|
host = ComputeHost(stdout=io.StringIO(), heartbeat_secs=0)
|
|
running = threading.Event()
|
|
|
|
def _turn() -> None:
|
|
running.set()
|
|
time.sleep(0.3)
|
|
events.append("turn_end")
|
|
|
|
_register_turn(host, _turn, sid="s1")
|
|
assert running.wait(timeout=5.0)
|
|
|
|
host.shutdown(reason="sigterm", wait=3.0)
|
|
|
|
# ``_finalize_session`` latches on ``session["_finalized"]``, so its single
|
|
# run has to observe the finished turn or the tail is unpersistable. A turn
|
|
# that *did* drain must still finalize — the live-turn skip must not
|
|
# over-reach into sessions whose work is done.
|
|
assert events == ["turn_end", "finalize:s1:compute_host_sigterm"]
|
|
|
|
# The done-callback still has to remove the entry now that the container is
|
|
# a dict: ``set.discard`` was a valid bare callback, ``dict.pop`` is not.
|
|
deadline = time.monotonic() + 2.0
|
|
while host._turn_futures and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
assert host._turn_futures == {}, "in-flight turns must not accumulate"
|
|
|
|
|
|
def test_shutdown_retains_a_live_turns_session_when_the_drain_deadline_expires(monkeypatch):
|
|
wait = 1.0
|
|
events: list[str] = []
|
|
_record_finalize(monkeypatch, events, "live", "idle")
|
|
|
|
host = ComputeHost(stdout=io.StringIO(), heartbeat_secs=0)
|
|
release = threading.Event()
|
|
running = threading.Event()
|
|
|
|
def _stuck_turn() -> None:
|
|
running.set()
|
|
release.wait(timeout=30.0)
|
|
|
|
_register_turn(host, _stuck_turn, sid="live")
|
|
assert running.wait(timeout=5.0)
|
|
|
|
try:
|
|
started = time.monotonic()
|
|
host.shutdown(reason="sigterm", wait=wait)
|
|
elapsed = time.monotonic() - started
|
|
finally:
|
|
release.set()
|
|
|
|
# ``_finalize_session`` is one-shot, and the ``shutdown(wait=False)`` that
|
|
# follows does not join the turn. Spending "live"'s single latch mid-turn
|
|
# would leave it permanently un-finalizable and release its active-session
|
|
# lease out from under running work — the same lifecycle race the drain
|
|
# exists to close, just moved past the deadline. It is retained unfinalized
|
|
# for recovery instead. A turn outliving the window must not cost the flush
|
|
# for anyone else, so "idle" still finalizes in the same pass.
|
|
assert events == ["finalize:idle:compute_host_sigterm"]
|
|
assert elapsed < wait
|
|
|
|
|
|
def test_shutdown_retains_live_sessions_within_the_stdin_closed_budget(monkeypatch):
|
|
"""The tightest real budget any caller uses is ``wait=2.0``.
|
|
|
|
``run_host`` finalizes through ``host.shutdown(reason="stdin_closed",
|
|
wait=2.0)``, which is where the reserve — ``wait`` minus
|
|
``min(_FLUSH_RESERVE_SECS, wait / 2)`` — has the least room to work with.
|
|
The retain-live-sessions rule must hold there without costing the flush for
|
|
idle sessions and without pushing the call past the budget the supervisor's
|
|
kill escalation is timed against.
|
|
"""
|
|
wait = 2.0
|
|
drain_budget = wait - min(compute_host._FLUSH_RESERVE_SECS, wait / 2.0)
|
|
|
|
events: list[str] = []
|
|
_record_finalize(monkeypatch, events, "live", "idle")
|
|
|
|
host = ComputeHost(stdout=io.StringIO(), heartbeat_secs=0)
|
|
release = threading.Event()
|
|
running = threading.Event()
|
|
|
|
def _stuck_turn() -> None:
|
|
running.set()
|
|
release.wait(timeout=30.0)
|
|
|
|
_register_turn(host, _stuck_turn, sid="live")
|
|
assert running.wait(timeout=5.0)
|
|
|
|
try:
|
|
started = time.monotonic()
|
|
host.shutdown(reason="stdin_closed", wait=wait)
|
|
elapsed = time.monotonic() - started
|
|
finally:
|
|
release.set()
|
|
|
|
assert events == ["finalize:idle:compute_host_stdin_closed"]
|
|
assert elapsed >= drain_budget - 1e-6, "the drain must use its full window"
|
|
assert elapsed < wait
|
|
|
|
|
|
def test_shutdown_drain_sleep_never_overshoots_the_reserve(monkeypatch):
|
|
"""The drain's per-tick sleep must be bounded by the time left to it.
|
|
|
|
A flat tick overshoots the drain deadline by up to one tick, eating the
|
|
reserve held back for ``flush_all_sessions``; for a small ``wait`` that is
|
|
the whole reserve. Asserting on the *requested* sleep totals rather than on
|
|
wall-clock keeps this deterministic: each sleep is clamped to the remaining
|
|
time, so the sum can never exceed the drain budget however the scheduler
|
|
interleaves.
|
|
"""
|
|
wait = 0.34
|
|
drain_budget = wait - min(compute_host._FLUSH_RESERVE_SECS, wait / 2.0)
|
|
|
|
events: list[str] = []
|
|
_record_finalize(monkeypatch, events, "idle")
|
|
|
|
slept: list[float] = []
|
|
real_sleep = time.sleep
|
|
|
|
def _recording_sleep(seconds: float) -> None:
|
|
slept.append(seconds)
|
|
real_sleep(seconds)
|
|
|
|
monkeypatch.setattr(compute_host.time, "sleep", _recording_sleep)
|
|
|
|
host = ComputeHost(stdout=io.StringIO(), heartbeat_secs=0)
|
|
release = threading.Event()
|
|
running = threading.Event()
|
|
|
|
def _stuck_turn() -> None:
|
|
running.set()
|
|
release.wait(timeout=30.0)
|
|
|
|
_register_turn(host, _stuck_turn, sid="live")
|
|
assert running.wait(timeout=5.0)
|
|
|
|
try:
|
|
host.shutdown(reason="sigterm", wait=wait)
|
|
finally:
|
|
release.set()
|
|
|
|
assert events == ["finalize:idle:compute_host_sigterm"]
|
|
assert slept, "the drain loop should have ticked at least once"
|
|
assert sum(slept) <= drain_budget + 1e-6
|