Some checks failed
Release & Docker / release (push) Has been cancelled
* 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
324 lines
11 KiB
Python
324 lines
11 KiB
Python
"""Wakeup tests for the renamed ``bg_task_complete`` SSE event.
|
|
|
|
This file replaces the legacy ``test_process_complete_wakeup.py`` after the
|
|
R2 §Q1 / Q4 contract update with the maintainer:
|
|
|
|
- Q1: the canonical SSE event is now ``bg_task_complete`` carrying the
|
|
minimal ``{session_id, task_id, completed_at, summary?, event_id}``
|
|
payload (the legacy ``process_complete`` name is dual-emitted under
|
|
PR (a) only as a 1-PR-cycle compatibility shim and is removed in
|
|
PR (b)).
|
|
- Q4: each emit must carry a fresh server-side ``event_id`` so the WebUI
|
|
can build a consumer-side TTL ring buffer for cross-disconnect
|
|
dedupe in a follow-up PR.
|
|
|
|
The tests below cover the wakeup-emit hot path end to end:
|
|
|
|
1. A completion event flowing through ``_process_one`` produces the
|
|
canonical ``bg_task_complete`` SSE emission with the trimmed payload
|
|
and a non-empty ``event_id``.
|
|
2. The same call also emits the legacy ``process_complete`` shim with the
|
|
same payload + same ``event_id``, so a consumer running an old
|
|
listener still wakes exactly once.
|
|
3. When ``_process_one`` runs while no per-session emit-coalesce window
|
|
is pending, the event is emitted immediately (i.e. wakeup is not
|
|
dropped by the throttle gate on a single completion).
|
|
4. The previous file name ``tests/test_process_complete_wakeup.py`` must
|
|
remain absent in the BACKEND-tier slice.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
# 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.
|
|
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 _capture_emits(monkeypatch):
|
|
"""Replace the per-session emit fan-out with a capturing list."""
|
|
from api import background_process as bp
|
|
|
|
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)
|
|
# Run the coalesce gate in pass-through mode so a single completion
|
|
# exercises the immediate-emit branch (the throttle behaviour itself is
|
|
# covered exhaustively by tests/test_bg_task_complete_throttle.py).
|
|
monkeypatch.setattr(bp, "_EMIT_COALESCE_WINDOW_SECS", 0.0)
|
|
return emits
|
|
|
|
|
|
def test_bg_task_complete_wakeup_emits_canonical_event_with_event_id(monkeypatch):
|
|
"""``_process_one`` emits the canonical ``bg_task_complete`` SSE event
|
|
with the R2 §Q1 trimmed payload and a fresh server-side ``event_id``
|
|
(R2 §Q4).
|
|
"""
|
|
fake = _FakeProcessRegistry()
|
|
fake.register("task-wakeup-1", "sess-wakeup-1")
|
|
_install_fake_registry(monkeypatch, fake)
|
|
_reset_cfg_state()
|
|
|
|
from api import background_process as bp
|
|
|
|
bp.register_process_session("sess-wakeup-1", "sess-wakeup-1")
|
|
emits = _capture_emits(monkeypatch)
|
|
monkeypatch.setattr(bp, "_start_server_side_wakeup_turn", lambda *_args, **_kwargs: None)
|
|
|
|
evt = {
|
|
"type": "completion",
|
|
"session_id": "task-wakeup-1",
|
|
"session_key": "sess-wakeup-1",
|
|
"command": "sleep 1",
|
|
"exit_code": 0,
|
|
"output": "done",
|
|
}
|
|
bp._process_one(evt)
|
|
|
|
names = [e[0] for e in emits]
|
|
assert "bg_task_complete" in names, (
|
|
f"canonical bg_task_complete emit missing: {names}"
|
|
)
|
|
|
|
canonical_payloads = [d for ev, d in emits if ev == "bg_task_complete"]
|
|
assert canonical_payloads, "no canonical bg_task_complete payload captured"
|
|
payload = canonical_payloads[0]
|
|
|
|
expected_required = {"session_id", "task_id", "completed_at", "event_id"}
|
|
allowed = expected_required | {"summary"}
|
|
assert expected_required <= set(payload), (
|
|
f"missing required keys in bg_task_complete payload: {payload}"
|
|
)
|
|
assert set(payload) <= allowed, (
|
|
f"unexpected keys in trimmed bg_task_complete payload: {payload}"
|
|
)
|
|
|
|
# The legacy/dropped keys must NOT survive the T1 trim.
|
|
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 from bg_task_complete payload"
|
|
)
|
|
|
|
# Field-rename invariants.
|
|
assert payload["session_id"] == "sess-wakeup-1"
|
|
assert payload["task_id"] == "task-wakeup-1"
|
|
assert isinstance(payload["completed_at"], float)
|
|
# R2 §Q4: ``event_id`` is a non-empty string (uuid4().hex => 32 chars).
|
|
assert isinstance(payload["event_id"], str)
|
|
assert len(payload["event_id"]) >= 8
|
|
|
|
|
|
class _FakeHandler:
|
|
"""Minimal handler stub for exercising ``handle_post`` directly.
|
|
|
|
Mirrors the pattern in ``tests/test_issue1909_csp_report_only.py`` —
|
|
captures status + response headers + body without spinning a real HTTP
|
|
server.
|
|
"""
|
|
|
|
def __init__(self, body: bytes = b"{}", headers: dict | None = None):
|
|
import io as _io
|
|
self.headers = {
|
|
"Content-Length": str(len(body)),
|
|
"Content-Type": "application/json",
|
|
**(headers or {}),
|
|
}
|
|
self.rfile = _io.BytesIO(body)
|
|
self.wfile = _io.BytesIO()
|
|
self.client_address = ("127.0.0.1", 12345)
|
|
self.status: int | None = None
|
|
self.sent_headers: dict[str, str] = {}
|
|
|
|
def send_response(self, status: int) -> None:
|
|
self.status = status
|
|
|
|
def send_header(self, key: str, value: str) -> None:
|
|
self.sent_headers[key] = value
|
|
|
|
def end_headers(self) -> None:
|
|
pass
|
|
|
|
|
|
def test_legacy_process_complete_ack_returns_410_gone_with_x_replaced_by():
|
|
"""T1 deprecation alias: the old ``/api/process-complete-ack`` POST path
|
|
must return HTTP 410 Gone and an ``X-Replaced-By`` header pointing at
|
|
``/api/bg-task-complete-ack`` (V-a-final criterion #6 + D-a-fix item #1).
|
|
"""
|
|
import json as _json
|
|
from urllib.parse import urlparse
|
|
|
|
import api.routes as routes
|
|
|
|
handler = _FakeHandler()
|
|
parsed = urlparse("/api/process-complete-ack")
|
|
|
|
result = routes.handle_post(handler, parsed)
|
|
|
|
assert result is True, "deprecated ack endpoint must claim the request"
|
|
assert handler.status == 410, (
|
|
f"expected HTTP 410 Gone for deprecated ack path, got {handler.status}"
|
|
)
|
|
assert handler.sent_headers.get("X-Replaced-By") == "/api/bg-task-complete-ack", (
|
|
f"X-Replaced-By header missing or wrong: {handler.sent_headers}"
|
|
)
|
|
|
|
body = handler.wfile.getvalue()
|
|
# Body is gzip-wrapped only if Accept-Encoding allowed it; the fake
|
|
# handler does not set Accept-Encoding so the body is plain JSON.
|
|
payload = _json.loads(body.decode("utf-8"))
|
|
assert payload.get("replaced_by") == "/api/bg-task-complete-ack"
|
|
assert "gone" in payload.get("error", "").lower()
|
|
|
|
|
|
def test_bg_task_complete_ack_marks_process_id_alias_deprecated(monkeypatch):
|
|
"""The diagnostic ack endpoint keeps ``process_id`` as a transitional
|
|
request alias, but makes that legacy usage visible via ``Deprecation``.
|
|
"""
|
|
import json as _json
|
|
import types as _types
|
|
|
|
import api.routes as routes
|
|
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda sid: _types.SimpleNamespace(session_id=sid),
|
|
)
|
|
handler = _FakeHandler()
|
|
|
|
routes._handle_bg_task_complete_ack(
|
|
handler,
|
|
{"session_id": "sess-legacy-alias", "process_id": "proc-legacy-1"},
|
|
)
|
|
|
|
assert handler.status == 200
|
|
assert handler.sent_headers.get("Deprecation") == "true"
|
|
payload = _json.loads(handler.wfile.getvalue().decode("utf-8"))
|
|
assert payload["task_id"] == "proc-legacy-1"
|
|
|
|
|
|
def test_bg_task_complete_ack_marks_mixed_process_id_presence_deprecated(monkeypatch):
|
|
"""If a request still includes ``process_id``, surface the transitional
|
|
alias even when the canonical ``task_id`` is also present.
|
|
"""
|
|
import json as _json
|
|
import types as _types
|
|
|
|
import api.routes as routes
|
|
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda sid: _types.SimpleNamespace(session_id=sid),
|
|
)
|
|
handler = _FakeHandler()
|
|
|
|
routes._handle_bg_task_complete_ack(
|
|
handler,
|
|
{
|
|
"session_id": "sess-mixed-alias",
|
|
"task_id": "task-canonical-1",
|
|
"process_id": "proc-legacy-1",
|
|
},
|
|
)
|
|
|
|
assert handler.status == 200
|
|
assert handler.sent_headers.get("Deprecation") == "true"
|
|
payload = _json.loads(handler.wfile.getvalue().decode("utf-8"))
|
|
assert payload["task_id"] == "task-canonical-1"
|
|
|
|
|
|
def test_bg_task_complete_ack_canonical_task_id_has_no_deprecation_header(monkeypatch):
|
|
"""Canonical ``task_id`` requests should not be marked deprecated."""
|
|
import types as _types
|
|
|
|
import api.routes as routes
|
|
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda sid: _types.SimpleNamespace(session_id=sid),
|
|
)
|
|
handler = _FakeHandler()
|
|
|
|
routes._handle_bg_task_complete_ack(
|
|
handler,
|
|
{"session_id": "sess-canonical", "task_id": "task-canonical-1"},
|
|
)
|
|
|
|
assert handler.status == 200
|
|
assert "Deprecation" not in handler.sent_headers
|
|
|
|
|
|
def test_bg_task_complete_ack_empty_process_id_alias_has_no_deprecation_header(monkeypatch):
|
|
"""An empty transitional alias key should not signal real legacy usage."""
|
|
import types as _types
|
|
|
|
import api.routes as routes
|
|
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda sid: _types.SimpleNamespace(session_id=sid),
|
|
)
|
|
handler = _FakeHandler()
|
|
|
|
routes._handle_bg_task_complete_ack(
|
|
handler,
|
|
{
|
|
"session_id": "sess-canonical-empty-alias",
|
|
"task_id": "task-canonical-1",
|
|
"process_id": "",
|
|
},
|
|
)
|
|
|
|
assert handler.status == 200
|
|
assert "Deprecation" not in handler.sent_headers
|
|
|
|
|
|
def test_old_process_complete_wakeup_test_file_is_absent():
|
|
"""The legacy filename ``tests/test_process_complete_wakeup.py`` must
|
|
remain absent on this branch — the rename is part of the BACKEND-tier
|
|
T1 contract and is required by V-a-final criterion #9.
|
|
"""
|
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
legacy = os.path.join(here, "test_process_complete_wakeup.py")
|
|
assert not os.path.exists(legacy), (
|
|
f"legacy {legacy!r} must not exist after the T1 rename"
|
|
)
|