Files
hermes-webui/tests/test_bg_task_complete_ab_coexistence.py
nesquena-hermes 26e133e3e8
Some checks failed
Release & Docker / release (push) Has been cancelled
[HELD — independent review pending] Release v0.51.340 — bg_task agent wakeup (trio #2968+#2971+#2979) (#3867)
* stage bg_task trio combined (#2979 superset) on master for deep review

* fix(bg_task): unsubscribe SessionChannel on header-write failure (Codex deep-review catch) + regression test

* test: realign on-subscribe-recovery anchor to subscribe_to_session_channel after leak fix

* CHANGELOG: bg_task trio as v0.51.340 LD (HELD pending independent review)

* bg_task trio: apply 3 independent-review (greptile) fixes

1. start_session_turn now threads the session PROFILE model defaults
   (_read_profile_model_config) into the wakeup model-resolve, so a brand-new
   session with an empty model falls back to the profile default not global
   DEFAULT_MODEL. Updated the white-box spy test signature accordingly.
2. /api/session/stream omits the Connection header (HTTP/1.1 keep-alive
   default) to match the #3103 long-lived-SSE pattern.
3. Reaper now prunes _LAST_EMIT_TS for collected sessions so the coalesce
   timestamp map can't grow one permanent entry per session forever.

nesquena APPROVED the PR; these are the 3 non-blocking greptile suggestions.

* test: realign _start_session_turn adapter stub lambda to new profile-defaults signature
2026-06-08 22:36:18 -07:00

320 lines
13 KiB
Python

"""Integration tests: the merged upstream PR #2279 (next-turn drain, A) +
our-original Option B SSE/server-side drain coexist without duplicating
wakeups for the same background process_id.
These tests verify the shared dedupe contract via the REAL merged upstream
key — process_registry._completion_consumed (checked by
process_registry.is_completion_consumed()):
- If B's drain fires first (proactive case), it marks the registry
consumed-marker so A's next-turn drain skips the same process_id.
- If A's (real merged #2279) drain fires first (SSE-disconnected case), it
marks the same registry consumed-marker so B's drain early-returns.
api.config.BG_TASK_COMPLETE_EVENTS_SEEN remains as B's own private
secondary dedupe (duplicate enqueue within this module) but is NOT the
cross-A/B contract — the real merged #2279 never writes it.
The two paths run in *different* hot paths (background thread vs. agent turn
start) but share process_registry._completion_consumed, so a wakeup can only
happen once.
"""
from __future__ import annotations
import threading
import pytest
# The fake process-registry stub + its installer were duplicated verbatim in
# three bg_task_complete suites; they now live once in tests/_wakeup_helpers.py
# (Greptile review on PR #2979). Import under the legacy local names so the
# rest of this module is unchanged. ``threading`` is still imported above for
# the _RenamedRegistry stub further down.
from tests._wakeup_helpers import FakeProcessRegistry as _FakeProcessRegistry
from tests._wakeup_helpers import install_fake_registry as _install_fake_registry
def _reset_cfg_state():
from api import config as _cfg
from api import background_process as bp
with _cfg.PROCESS_SESSION_INDEX_LOCK:
_cfg.PROCESS_SESSION_INDEX.clear()
_cfg.PENDING_BG_TASK_COMPLETIONS.clear()
_cfg.BG_TASK_COMPLETE_EVENTS_SEEN.clear()
with _cfg.STREAMS_LOCK:
_cfg.STREAMS.clear()
if hasattr(_cfg, "ACTIVE_RUNS"):
with _cfg.ACTIVE_RUNS_LOCK:
_cfg.ACTIVE_RUNS.clear()
if hasattr(bp, "_LAST_EMIT_TS"):
bp._LAST_EMIT_TS.clear()
if hasattr(bp, "_PENDING_EMIT_PAYLOADS"):
bp._PENDING_EMIT_PAYLOADS.clear()
if hasattr(bp, "_PENDING_EMIT_TIMERS"):
bp._PENDING_EMIT_TIMERS.clear()
def test_b_sse_first_then_a_drain_skips_same_process_id(monkeypatch):
"""B emits SSE for process_id=p1, then user types a new turn — A must skip p1."""
fake = _FakeProcessRegistry()
fake.register("p1", "sess-1")
_install_fake_registry(monkeypatch, fake)
_reset_cfg_state()
from api import background_process as bp
from api import streaming as st
from api import config as _cfg
# Map session_key -> WebUI session_id
bp.register_process_session("sess-1", "sess-1")
evt = {
"type": "completion",
"session_id": "p1",
"session_key": "sess-1",
"command": "sleep 1",
"exit_code": 0,
"output": "done",
}
# B path: process the event
bp._process_one(evt)
# B must have marked the (session, process) seen and registry-consumed
assert "p1" in _cfg.BG_TASK_COMPLETE_EVENTS_SEEN["sess-1"]
assert fake.is_completion_consumed("p1")
# Now simulate A's next-turn drain. Put a *new* event onto the queue for the
# same process_id (e.g. a kill_process race). A must skip because B already
# delivered.
fake.completion_queue.put(evt)
notifications = st._drain_webui_process_notifications("sess-1")
assert notifications == [], "A must NOT re-fire when B already woke the agent for p1"
def test_a_drain_first_marks_seen_so_b_would_skip(monkeypatch):
"""A (the REAL merged upstream #2279 next-turn drain) drains and wakes the
agent; later B's queue read of the same id is a no-op because the SHARED
upstream dedupe key (process_registry._completion_consumed) already
contains it.
Re-pointed for the rebase: the real merged #2279 drain dedupes ONLY via
process_registry.is_completion_consumed() — it does NOT populate
api.config.BG_TASK_COMPLETE_EVENTS_SEEN (that set is ours-original and
private to api.background_process). So the cross-A/B contract is the
registry consumed-marker, not BG_TASK_COMPLETE_EVENTS_SEEN.
"""
fake = _FakeProcessRegistry()
fake.register("p2", "sess-2")
_install_fake_registry(monkeypatch, fake)
_reset_cfg_state()
from api import background_process as bp
from api import streaming as st
from api import config as _cfg
bp.register_process_session("sess-2", "sess-2")
evt = {
"type": "completion",
"session_id": "p2",
"session_key": "sess-2",
"command": "echo hi",
"exit_code": 0,
"output": "hi",
}
# A path: queue carried over from a closed-tab session, drain at next turn
fake.completion_queue.put(evt)
notifications = st._drain_webui_process_notifications("sess-2")
assert len(notifications) == 1
assert "Background process p2 completed" in notifications[0]
# The REAL merged #2279 A-drain marks the SHARED upstream dedupe key
# (registry consumed-marker) — NOT our private BG_TASK_COMPLETE_EVENTS_SEEN.
assert fake.is_completion_consumed("p2")
assert "sess-2" not in _cfg.BG_TASK_COMPLETE_EVENTS_SEEN, (
"real upstream #2279 A-drain must NOT populate our private "
"BG_TASK_COMPLETE_EVENTS_SEEN set"
)
# Now if B's drain thread sees another spurious event for the same id
# (duplicate enqueue), _process_one must early-return on the SHARED
# registry consumed-marker that A set — no double wakeup.
bp._process_one(evt) # second time
assert fake.is_completion_consumed("p2")
# B early-returned on the shared key BEFORE reaching its own seen-set, so
# BG_TASK_COMPLETE_EVENTS_SEEN stays unpopulated for this session (proves
# the cross-A/B dedupe used the real upstream key, not ours).
assert "sess-2" not in _cfg.BG_TASK_COMPLETE_EVENTS_SEEN
# And no duplicate wakeup marker was queued by the second B pass.
assert "sess-2" not in _cfg.PENDING_BG_TASK_COMPLETIONS
def test_registry_completion_consumed_contract():
"""Copilot #2242 review #4 — fail CI LOUD if the agent ProcessRegistry
private cross-A/B dedupe surface is renamed/retyped upstream.
The WebUI B-drain has no public ``mark_completion_consumed`` to call, so
it reaches into ``ProcessRegistry._completion_consumed`` (under
``._lock``) to set the shared marker that the public
``is_completion_consumed`` reads. If a future upstream refactor renames
any of these, the double-wakeup bug would silently come back. This test
pins the contract so the rename breaks HERE (visibly) instead.
"""
pytest.importorskip("tools.process_registry", reason="hermes-agent not installed")
from tools.process_registry import ProcessRegistry
from api import background_process as bp
pr = ProcessRegistry()
for attr in bp._REGISTRY_CONSUMED_CONTRACT:
assert hasattr(pr, attr), (
f"ProcessRegistry.{attr} is gone — the WebUI cross-A/B wakeup "
f"dedupe coupling (Copilot #2242 #4) is broken. Either restore it "
f"or add a PUBLIC mark_completion_consumed() upstream and switch "
f"api/background_process._mark_registry_completion_consumed to it."
)
# Shape contract: the write target must be a set-like (supports .add) and
# the guard must be a usable context manager (supports `with`).
assert hasattr(pr._completion_consumed, "add"), (
"ProcessRegistry._completion_consumed is no longer a set-like "
"(.add gone) — cross-A/B wakeup dedupe write would fail."
)
assert hasattr(pr._lock, "__enter__") and hasattr(pr._lock, "__exit__"), (
"ProcessRegistry._lock is no longer a context manager — the guarded "
"marker write in _mark_registry_completion_consumed would fail."
)
assert callable(pr.is_completion_consumed), (
"ProcessRegistry.is_completion_consumed must stay a public method "
"(the cross-A/B dedupe READ side depends on it)."
)
# End-to-end: the public read sees what the guarded private write sets
# (the exact mechanism _mark_registry_completion_consumed relies on).
pid = "proc_contract_test"
assert pr.is_completion_consumed(pid) is False
with pr._lock:
pr._completion_consumed.add(pid)
assert pr.is_completion_consumed(pid) is True
def test_mark_registry_completion_consumed_fails_loud_on_rename(monkeypatch, caplog):
"""A renamed private attr must log ERROR (contract violation), NOT be
swallowed silently at DEBUG (the pre-Copilot-#4 behavior)."""
import logging
class _RenamedRegistry:
# Simulates an upstream rename: _completion_consumed -> _consumed_v2.
def __init__(self):
self._lock = threading.Lock()
self._consumed_v2: set[str] = set()
def is_completion_consumed(self, pid: str) -> bool:
return pid in self._consumed_v2
fake = _RenamedRegistry()
_install_fake_registry(monkeypatch, fake)
from api import background_process as bp
with caplog.at_level(logging.ERROR, logger="api.background_process"):
bp._mark_registry_completion_consumed("p-renamed")
assert any(
"coupling contract VIOLATED" in r.message and r.levelno >= logging.ERROR
for r in caplog.records
), "a renamed registry private attr must surface as an ERROR, not a silent DEBUG"
# The marker was NOT set (the bug it guards against), but it failed LOUD so
# CI / monitoring catches it instead of double-firing wakeups silently.
assert not fake.is_completion_consumed("p-renamed")
def test_emit_uses_new_event_name_with_trimmed_payload_and_event_id(monkeypatch):
"""T1 + T2 contract: emit is named ``bg_task_complete`` (canonical) AND
``process_complete`` (dual-emit shim until PR (b)); payload matches the
minimal shape ``{session_id, task_id, completed_at, summary?, event_id}``;
both emits carry the same payload + the same ``event_id``.
"""
fake = _FakeProcessRegistry()
fake.register("task-evt-1", "sess-evt-1")
_install_fake_registry(monkeypatch, fake)
_reset_cfg_state()
from api import background_process as bp
bp.register_process_session("sess-evt-1", "sess-evt-1")
# Capture every (event, data) tuple the emitter pushes to streams.
emits: list[tuple[str, dict]] = []
def _capture(session_id: str, event: str, data: dict) -> int:
emits.append((event, data))
return 1
monkeypatch.setattr(bp, "_emit_to_session_streams", _capture)
evt = {
"type": "completion",
"session_id": "task-evt-1",
"session_key": "sess-evt-1",
"command": "sleep 1",
"exit_code": 0,
"output": "done",
}
bp._process_one(evt)
# Dual-emit shim: both names fire, same payload, same event_id.
names = [e[0] for e in emits]
assert "bg_task_complete" in names, f"canonical event missing: {names}"
assert "process_complete" in names, f"dual-emit shim missing: {names}"
payloads = [e[1] for e in emits if e[0] in ("bg_task_complete", "process_complete")]
assert len({p["event_id"] for p in payloads}) == 1, (
"dual-emit must share a single event_id so consumers can dedupe"
)
payload = payloads[0]
# Minimal shape per maintainer (R2 §Q1).
expected_required = {"session_id", "task_id", "completed_at", "event_id"}
allowed = expected_required | {"summary"}
assert expected_required <= set(payload), f"missing required keys: {payload}"
assert set(payload) <= allowed, f"unexpected keys in trimmed payload: {payload}"
# Dropped keys must NOT be present.
for dropped in ("command", "exit_code", "type", "stdout_preview", "wakeup_prompt", "emitted_at", "process_id"):
assert dropped not in payload, f"{dropped!r} should be dropped by T1 trim"
# Field-rename invariants:
assert payload["session_id"] == "sess-evt-1"
assert payload["task_id"] == "task-evt-1" # was process_id
assert isinstance(payload["completed_at"], float) # was emitted_at
assert isinstance(payload["event_id"], str) and len(payload["event_id"]) >= 8
def test_event_id_is_unique_per_emit(monkeypatch):
"""T2: every emit gets a fresh event_id; two completions for two distinct
processes produce two distinct ids.
"""
fake = _FakeProcessRegistry()
fake.register("task-a", "sess-evt-2")
fake.register("task-b", "sess-evt-2")
_install_fake_registry(monkeypatch, fake)
_reset_cfg_state()
from api import background_process as bp
bp.register_process_session("sess-evt-2", "sess-evt-2")
monkeypatch.setattr(bp, "_EMIT_COALESCE_WINDOW_SECS", 0.0)
emits: list[tuple[str, dict]] = []
def _capture(session_id: str, event: str, data: dict) -> int:
emits.append((event, data))
return 1
monkeypatch.setattr(bp, "_emit_to_session_streams", _capture)
bp._process_one({"type": "completion", "session_id": "task-a", "session_key": "sess-evt-2", "exit_code": 0})
bp._process_one({"type": "completion", "session_id": "task-b", "session_key": "sess-evt-2", "exit_code": 0})
canonical_payloads = [d for ev, d in emits if ev == "bg_task_complete"]
assert len(canonical_payloads) == 2
assert canonical_payloads[0]["event_id"] != canonical_payloads[1]["event_id"]