Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions integrations/claude-code/scripts/_plugin_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -1427,11 +1427,23 @@ def pop_pending_prompt(session_id: str, *, turn_id: str = "") -> dict:
"""Return and remove the prompt saved for this Codex turn."""
if not session_id:
return {"prompt": "", "context": ""}
data = _load_json_file(_pending_file(session_id))
pending_path = _pending_file(session_id)
data = _load_json_file(pending_path)
turn_key, session_key = _pending_keys(session_id, turn_id)
entry = data.pop(turn_key, None) or data.get(session_key) or {}
data.pop(session_key, None)
_write_json_file(_pending_file(session_id), data)
if data:
_write_json_file(pending_path, data)
else:
# Last entry consumed: remove the file rather than write ``{}`` back.
# Writing the emptied dict left one 2-byte husk per session, forever
# (80 of 88 files in one pending/ dir were husks — SDK-469).
try:
pending_path.unlink()
except FileNotFoundError:
pass
except OSError as exc:
hook_log("pending_unlink_failed", {"path": str(pending_path), "error": str(exc)[:200]})
if not isinstance(entry, dict):
return {"prompt": "", "context": ""}
return {
Expand Down Expand Up @@ -4130,6 +4142,22 @@ def _sweep_dir_by_age(directory: Path, max_age: float, now: float, counts: dict,
_sweep_remove(path, counts, key)


def _sweep_pending_husks(counts: dict) -> None:
"""Empty ``{}`` pending files left by older versions of ``pop_pending_prompt``.
Nothing is in flight for an empty buffer, so age is irrelevant; a live
session that needs the file again simply recreates it."""
try:
entries = list(_PENDING_DIR.glob("*.json"))
except OSError:
return
for path in entries:
try:
if path.stat().st_size <= 2 and not _load_json_file(path):
_sweep_remove(path, counts, "pending_husks")
except OSError:
continue


def _sweep_launch_records(now: float, counts: dict) -> None:
try:
entries = list(_SESSIONS_MAP_DIR.glob("*.json"))
Expand Down Expand Up @@ -4211,6 +4239,7 @@ def sweep_stale_state(now: Optional[float] = None) -> dict:
(_PENDING_DIR, "pending"),
):
_sweep_dir_by_age(directory, _SWEEP_SESSION_FILE_MAX_AGE_SECONDS, now, counts, key)
_sweep_pending_husks(counts)
_sweep_launch_records(now, counts)
_sweep_improve_locks(now, counts)
_sweep_expired_improve_marker(now, counts)
Expand Down
37 changes: 23 additions & 14 deletions integrations/claude-code/scripts/session-context-lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,20 +439,29 @@ async def _run(prompt: str, cwd: str = "") -> dict | None:
verdict = SLOW # pre-3.11 asyncio.TimeoutError isn't TimeoutError
else:
verdict = classify_transport_exception(exc)
if isinstance(exc, _urlerr.HTTPError):
scopes_answered_err += 1
if exc.code in (401, 403):
auth_rejected = True
elif exc.code >= 500:
server_errors += 1
elif verdict == SLOW:
scope_timeouts += 1
elif verdict == DOWN:
server_down = True
hook_log(
"recall_error",
{"scope": scope_list, "error": str(exc)[:200], "verdict": verdict},
)
if isinstance(exc, _urlerr.HTTPError) and exc.code == 404 and scope_list == ["graph"]:
Comment thread
siillee marked this conversation as resolved.
# A dataset nobody has written to yet has no graph, and the
# server answers the graph scope with 404 (DatasetNotFound)
# until the first cognify lands. On a fresh install that is
# every prompt of the first session — expected, not an error:
# keep it out of recall_error and the health accounting
# (scopes_answered_err) so real failures stay visible (SDK-469).
hook_log("recall_graph_not_built", {"scope": scope_list, "dataset": scope_dataset})
else:
if isinstance(exc, _urlerr.HTTPError):
scopes_answered_err += 1
if exc.code in (401, 403):
auth_rejected = True
elif exc.code >= 500:
server_errors += 1
elif verdict == SLOW:
scope_timeouts += 1
elif verdict == DOWN:
server_down = True
hook_log(
"recall_error",
{"scope": scope_list, "error": str(exc)[:200], "verdict": verdict},
)
finally:
# hits = raw count from this scope's call (pre-bucketing/filtering);
# elapsed_ms measured around the call, recorded even when it errored.
Expand Down
33 changes: 31 additions & 2 deletions integrations/codex/plugins/cognee/scripts/_plugin_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -1396,11 +1396,23 @@ def pop_pending_prompt(session_id: str, *, turn_id: str = "") -> dict:
"""Return and remove the prompt saved for this Codex turn."""
if not session_id:
return {"prompt": "", "context": ""}
data = _load_json_file(_pending_file(session_id))
pending_path = _pending_file(session_id)
data = _load_json_file(pending_path)
turn_key, session_key = _pending_keys(session_id, turn_id)
entry = data.pop(turn_key, None) or data.get(session_key) or {}
data.pop(session_key, None)
_write_json_file(_pending_file(session_id), data)
if data:
_write_json_file(pending_path, data)
else:
# Last entry consumed: remove the file rather than write ``{}`` back.
# Writing the emptied dict left one 2-byte husk per session, forever
# (80 of 88 files in one pending/ dir were husks — SDK-469).
try:
pending_path.unlink()
except FileNotFoundError:
pass
except OSError as exc:
hook_log("pending_unlink_failed", {"path": str(pending_path), "error": str(exc)[:200]})
if not isinstance(entry, dict):
return {"prompt": "", "context": ""}
return {
Expand Down Expand Up @@ -4044,6 +4056,22 @@ def _sweep_dir_by_age(directory: Path, max_age: float, now: float, counts: dict,
_sweep_remove(path, counts, key)


def _sweep_pending_husks(counts: dict) -> None:
"""Empty ``{}`` pending files left by older versions of ``pop_pending_prompt``.
Nothing is in flight for an empty buffer, so age is irrelevant; a live
session that needs the file again simply recreates it."""
try:
entries = list(_PENDING_DIR.glob("*.json"))
except OSError:
return
for path in entries:
try:
if path.stat().st_size <= 2 and not _load_json_file(path):
_sweep_remove(path, counts, "pending_husks")
except OSError:
continue


def _sweep_launch_records(now: float, counts: dict) -> None:
try:
entries = list(_SESSIONS_MAP_DIR.glob("*.json"))
Expand Down Expand Up @@ -4125,6 +4153,7 @@ def sweep_stale_state(now: Optional[float] = None) -> dict:
(_PENDING_DIR, "pending"),
):
_sweep_dir_by_age(directory, _SWEEP_SESSION_FILE_MAX_AGE_SECONDS, now, counts, key)
_sweep_pending_husks(counts)
_sweep_launch_records(now, counts)
_sweep_improve_locks(now, counts)
_sweep_expired_improve_marker(now, counts)
Expand Down
37 changes: 23 additions & 14 deletions integrations/codex/plugins/cognee/scripts/session-context-lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,20 +473,29 @@ async def _run(prompt: str, cwd: str = "") -> dict | None:
verdict = SLOW # pre-3.11 asyncio.TimeoutError isn't TimeoutError
else:
verdict = classify_transport_exception(exc)
if isinstance(exc, _urlerr.HTTPError):
scopes_answered_err += 1
if exc.code in (401, 403):
auth_rejected = True
elif exc.code >= 500:
server_errors += 1
elif verdict == SLOW:
scope_timeouts += 1
elif verdict == DOWN:
server_down = True
hook_log(
"recall_error",
{"scope": scope_list, "error": str(exc)[:200], "verdict": verdict},
)
if isinstance(exc, _urlerr.HTTPError) and exc.code == 404 and scope_list == ["graph"]:
# A dataset nobody has written to yet has no graph, and the
# server answers the graph scope with 404 (DatasetNotFound)
# until the first cognify lands. On a fresh install that is
# every prompt of the first session — expected, not an error:
# keep it out of recall_error and the health accounting
# (scopes_answered_err) so real failures stay visible (SDK-469).
hook_log("recall_graph_not_built", {"scope": scope_list, "dataset": scope_dataset})
else:
if isinstance(exc, _urlerr.HTTPError):
scopes_answered_err += 1
if exc.code in (401, 403):
auth_rejected = True
elif exc.code >= 500:
server_errors += 1
elif verdict == SLOW:
scope_timeouts += 1
elif verdict == DOWN:
server_down = True
hook_log(
"recall_error",
{"scope": scope_list, "error": str(exc)[:200], "verdict": verdict},
)
finally:
# hits = raw count from this scope's call (pre-bucketing/filtering);
# elapsed_ms measured around the call, recorded even when it errored.
Expand Down
60 changes: 60 additions & 0 deletions integrations/tests/tests/e2e/test_recall_graph_not_built.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""A fresh dataset's graph scope answers 404 — that is not a recall error (SDK-469, part 2).

Until the first cognify lands, the server has no graph for the dataset and
answers the graph scope with 404. On a fresh install that is every prompt of the
first session, and it used to log ``recall_error {verdict: unknown}`` each time —
pure noise that also fed the health accounting. The hook now records it as
``recall_graph_not_built`` and leaves ``recall_error`` for real failures.

The mock's forced 404 applies to every scope, which is what makes the assertion
sharp: the graph scope must be the only one *not* reported as an error.
"""

from __future__ import annotations

import json

from utils.suites import state_dir


def _events(suite, home):
log = state_dir(suite, home) / "hook.log"
if not log.exists():
return []
out = []
for line in log.read_text(encoding="utf-8").splitlines():
try:
entry = json.loads(line)
except ValueError:
continue
out.append((entry.get("event"), entry.get("detail") or {}))
return out


def test_graph_404_is_not_a_recall_error(
suite, run_hook, mock_server, payloads, temp_home, assert_clean_real_home
):
mock_server.force_response("POST", "/api/v1/recall", 404, {"detail": "DatasetNotFoundError"})
result = run_hook(
suite,
"session-context-lookup.py",
stdin=payloads.user_prompt(prompt="what did we decide about the retry policy?"),
service_url=mock_server.url,
# The graph scope runs last, and the hook stops dispatching scopes once its
# per-prompt budget (default 4s) is spent. On the Windows runner every
# request to the mock takes ~2s, so with the defaults only two scopes ran
# and the graph scope — the one this test is about — was never attempted.
# The budget is a production latency guard, not the behaviour under test.
env={"COGNEE_RECALL_TIMEOUT": "30", "COGNEE_RECALL_BUDGET": "120"},
)
assert result.returncode == 0, result.stderr

events = _events(suite, temp_home)
assert not [d for e, d in events if e == "recall_budget_exceeded"], (
"the budget must not cut the scope loop short in this test"
)
not_built = [d for e, d in events if e == "recall_graph_not_built"]
errors = [d for e, d in events if e == "recall_error"]
assert not_built and not_built[0]["scope"] == ["graph"]
assert errors, "the other scopes still 404 in this forced setup and must still be reported"
assert all(d["scope"] != ["graph"] for d in errors), errors
57 changes: 57 additions & 0 deletions integrations/tests/tests/unit/test_pending_prompt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""The pending-prompt buffer leaves nothing behind (SDK-469, part 1).

``remember_pending_prompt`` parks a prompt until the Stop hook pops it. The pop
used to write the emptied dict back as ``{}``, leaving one 2-byte husk per
session forever (80 of 88 files in one pending/ dir). Now the last pop removes
the file, a pop against nothing creates nothing, and the SessionStart sweep
clears husks older versions left — immediately, since an empty buffer has
nothing in flight.
"""

from __future__ import annotations

import pytest


@pytest.fixture
def pc(suite, isolated_modules, monkeypatch):
module = isolated_modules(suite, "_plugin_common")
monkeypatch.setattr(module, "hook_log", lambda *a, **k: None)
monkeypatch.setenv("COGNEE_SESSION_KEY", "host-abc")
return module


def test_last_pop_removes_the_file(pc):
pc.remember_pending_prompt("s1", "what did we decide?", turn_id="t1")
path = pc._pending_file("s1")
assert path.exists()
popped = pc.pop_pending_prompt("s1", turn_id="t1")
assert popped["prompt"] == "what did we decide?"
assert not path.exists(), "an emptied buffer must not stay behind as {}"


def test_pop_keeps_the_file_while_other_turns_are_pending(pc):
pc.remember_pending_prompt("s1", "first", turn_id="t1")
pc.remember_pending_prompt("s1", "second", turn_id="t2")
path = pc._pending_file("s1")
assert pc.pop_pending_prompt("s1", turn_id="t1")["prompt"] == "first"
assert path.exists()
assert pc.pop_pending_prompt("s1", turn_id="t2")["prompt"] == "second"
assert not path.exists()


def test_pop_against_nothing_creates_nothing(pc):
path = pc._pending_file("s1")
assert pc.pop_pending_prompt("s1", turn_id="t9") == {"prompt": "", "context": ""}
assert not path.exists()


def test_sweep_removes_husks_regardless_of_age(pc):
pc._PENDING_DIR.mkdir(parents=True, exist_ok=True)
husk = pc._PENDING_DIR / "old-session.json"
husk.write_text("{}", encoding="utf-8")
live = pc._PENDING_DIR / "live.json"
live.write_text('{"host:t1": {"prompt": "x"}}', encoding="utf-8")
counts = pc.sweep_stale_state()
assert counts.get("pending_husks") == 1
assert not husk.exists() and live.exists()
Loading