Skip to content

Commit 47380f6

Browse files
nickrociclaude
andcommitted
test(decay): add bookkeeping + defensive-path tests to hold 90% coverage gate
Slice 1 added ~250 lines of new code (``decay.py``) plus the ``_post_render_bookkeeping`` helper in ``priming_rpc.py``. Adds 24 focused tests covering bookkeeping wiring and the deeper defensive paths so the 90% gate holds: missing knowledge dir, parse-iso-date invariants, reinforced-count input validation, both arousal-pin field-name variants, source-unlink failure during archive, destination resolution failure, mkdir failure, sweep continuing after a bad entry, lock-held early-bail in maybe_run_sweep, sweep-state file with wrong field type / non-dict / unparseable ISO date, atomic-write swallowing OSError, top-level admin files (``index.md``/``log.md``) excluded from sweep. Coverage: 89.04% (pre-slice-1) → 90.05%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f400cca commit 47380f6

2 files changed

Lines changed: 361 additions & 0 deletions

File tree

daemon/tests/test_decay.py

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,3 +325,246 @@ def test_maybe_run_sweep_runs_after_cooldown(home: Path) -> None:
325325
out = decay.maybe_run_sweep(k, now=when_now)
326326
assert out is not None
327327
assert out.archived == 1
328+
329+
330+
# ── Defensive paths (rarely-hit error branches) ──────────────────────
331+
332+
333+
def test_run_sweep_uses_default_knowledge_dir_when_omitted(home: Path) -> None:
334+
"""Calling without a kwarg should resolve to the env-pointed home."""
335+
# ``home`` fixture sets AGENT_MEM_HOME and creates knowledge/.
336+
result = decay.run_sweep()
337+
# Empty library → no archives, but the sweep should still write state.
338+
assert result.archived == 0
339+
assert decay.read_last_sweep_at() is not None
340+
341+
342+
def test_run_sweep_missing_knowledge_dir_returns_empty_result(
343+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
344+
) -> None:
345+
"""Knowledge dir doesn't exist — should be a no-op but still update state."""
346+
monkeypatch.setenv("AGENT_MEM_HOME", str(tmp_path))
347+
# Note: no `knowledge/` dir created.
348+
result = decay.run_sweep()
349+
assert result.archived == 0
350+
assert result.kept == 0
351+
# Sweep-state still written so the cooldown ticks.
352+
assert decay.read_last_sweep_at() is not None
353+
354+
355+
def test_archive_destination_returns_none_for_outside_path(home: Path) -> None:
356+
k = home / "knowledge"
357+
outside = home / "outside" / "foo.md"
358+
outside.parent.mkdir(parents=True, exist_ok=True)
359+
outside.write_text("---\nid: x\n---\n\nbody\n")
360+
assert decay._archive_destination(outside, k) is None
361+
362+
363+
def test_run_sweep_continues_after_unreadable_entry(
364+
home: Path, monkeypatch: pytest.MonkeyPatch
365+
) -> None:
366+
"""An entry that can't be parsed shouldn't abort the sweep — it
367+
just counts toward `errored` and the loop continues to other
368+
entries."""
369+
k = home / "knowledge"
370+
_write_entry(k / "global" / "good.md", id_="good", title="Good", created="2026-01-01")
371+
bad = k / "global" / "bad.md"
372+
bad.parent.mkdir(parents=True, exist_ok=True)
373+
bad.write_text("not even close to a markdown entry\n") # no frontmatter
374+
375+
when = datetime(2026, 5, 27, tzinfo=timezone.utc)
376+
result = decay.run_sweep(knowledge_dir=k, now=when)
377+
# Good one archived, bad one counted as errored.
378+
assert result.archived == 1
379+
assert result.errored == 1
380+
381+
382+
def test_run_sweep_catches_unexpected_exception_per_entry(
383+
home: Path, monkeypatch: pytest.MonkeyPatch
384+
) -> None:
385+
"""A truly-unexpected exception (e.g. permissions) on one entry
386+
shouldn't crash the sweep — it goes into the errored bucket."""
387+
k = home / "knowledge"
388+
_write_entry(k / "global" / "good.md", id_="good", title="Good", created="2026-01-01")
389+
390+
# Patch the parser to raise mid-loop.
391+
original = decay._read_and_parse_frontmatter
392+
calls: list[Path] = []
393+
394+
def _flaky(path):
395+
calls.append(path)
396+
if "good" in str(path):
397+
raise RuntimeError("simulated read failure")
398+
return original(path)
399+
400+
monkeypatch.setattr(decay, "_read_and_parse_frontmatter", _flaky)
401+
when = datetime(2026, 5, 27, tzinfo=timezone.utc)
402+
result = decay.run_sweep(knowledge_dir=k, now=when)
403+
assert result.errored == 1
404+
assert result.archived == 0
405+
406+
407+
def test_maybe_run_sweep_short_circuits_when_lock_held(home: Path) -> None:
408+
"""If another sweep is in flight, the second caller bails out."""
409+
k = home / "knowledge"
410+
with decay._SWEEP_LOCK:
411+
# Inside the lock, maybe_run_sweep should refuse to run.
412+
out = decay.maybe_run_sweep(k)
413+
assert out is None
414+
415+
416+
def test_parse_iso_date_handles_invalid_strings() -> None:
417+
assert decay._parse_iso_date(None) is None
418+
assert decay._parse_iso_date("") is None
419+
assert decay._parse_iso_date("not-a-date") is None
420+
assert decay._parse_iso_date("2026-13-99") is None # invalid month/day
421+
assert decay._parse_iso_date("2026-05-27") == date(2026, 5, 27)
422+
423+
424+
def test_reinforced_count_handles_bad_values() -> None:
425+
assert decay._reinforced_count({}) == 0
426+
assert decay._reinforced_count({"reinforced": None}) == 0
427+
assert decay._reinforced_count({"reinforced": "not a number"}) == 0
428+
assert decay._reinforced_count({"reinforced": -3}) == 0 # clamped to 0
429+
assert decay._reinforced_count({"reinforced": 7}) == 7
430+
431+
432+
def test_arousal_pinned_accepts_both_field_names() -> None:
433+
"""``arousal_pin`` and ``arousal_pinned`` both work — the design
434+
doc switches between them so accept either."""
435+
assert decay._is_arousal_pinned({"arousal_pin": True}) is True
436+
assert decay._is_arousal_pinned({"arousal_pinned": True}) is True
437+
assert decay._is_arousal_pinned({}) is False
438+
assert decay._is_arousal_pinned({"arousal_pin": False}) is False
439+
440+
441+
def test_write_last_sweep_at_swallows_oserror(home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
442+
"""If the sweep-state file can't be written, the function returns
443+
silently — bookkeeping must never crash the sweep itself."""
444+
monkeypatch.setattr(Path, "mkdir", _raise_oserror)
445+
# Should not raise.
446+
decay.write_last_sweep_at(datetime(2026, 5, 27, tzinfo=timezone.utc))
447+
448+
449+
def _raise_oserror(*args, **kwargs):
450+
raise OSError("simulated")
451+
452+
453+
def test_atomic_write_entry_swallows_oserror(home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
454+
"""The atomic-write helper returns False on disk error (rather than
455+
propagating) so callers can degrade gracefully."""
456+
p = home / "knowledge" / "global" / "foo.md"
457+
_write_entry(p, id_="foo", title="Foo")
458+
monkeypatch.setattr(Path, "write_text", _raise_oserror)
459+
ok = decay._atomic_write_entry(p, {"id": "foo"}, "body")
460+
assert ok is False
461+
462+
463+
def test_archive_entry_returns_false_on_destination_write_failure(
464+
home: Path, monkeypatch: pytest.MonkeyPatch
465+
) -> None:
466+
"""If the archive destination write fails, the source must stay
467+
in place (no partial archive)."""
468+
k = home / "knowledge"
469+
p = k / "global" / "old.md"
470+
_write_entry(p, id_="old", title="Old", created="2026-01-01")
471+
parsed = decay._read_and_parse_frontmatter(p)
472+
assert parsed is not None
473+
fm, _raw, body = parsed
474+
monkeypatch.setattr(Path, "write_text", _raise_oserror)
475+
ok = decay._archive_entry(p, k, fm, body, today_iso="2026-05-27")
476+
assert ok is False
477+
# Source untouched.
478+
assert p.exists()
479+
480+
481+
def test_iter_entries_skips_top_level_admin_files(home: Path) -> None:
482+
"""index.md and log.md at the top level are catalog files, not
483+
user entries — the sweep doesn't churn them."""
484+
k = home / "knowledge"
485+
(k / "index.md").write_text("---\nid: index\n---\n\n# Index\n")
486+
(k / "log.md").write_text("---\nid: log\n---\n\n# Log\n")
487+
_write_entry(k / "global" / "real.md", id_="real", title="Real", created="2026-01-01")
488+
entries = decay._iter_entries(k)
489+
names = {e.name for e in entries}
490+
assert "real.md" in names
491+
assert "index.md" not in names
492+
assert "log.md" not in names
493+
494+
495+
def test_read_last_sweep_at_returns_none_for_wrong_field_type(home: Path) -> None:
496+
"""JSON with last_sweep_at as a non-string should be rejected."""
497+
(home / "sweep-state.json").write_text('{"last_sweep_at": 12345}')
498+
assert decay.read_last_sweep_at() is None
499+
500+
501+
def test_read_last_sweep_at_returns_none_for_unparseable_iso(home: Path) -> None:
502+
"""A string that isn't ISO format should be rejected."""
503+
(home / "sweep-state.json").write_text('{"last_sweep_at": "tuesday"}')
504+
assert decay.read_last_sweep_at() is None
505+
506+
507+
def test_read_last_sweep_at_returns_none_when_top_level_not_dict(home: Path) -> None:
508+
"""JSON array at top level is not a sweep-state record."""
509+
(home / "sweep-state.json").write_text('["not", "a", "dict"]')
510+
assert decay.read_last_sweep_at() is None
511+
512+
513+
def test_archive_entry_returns_true_even_when_source_unlink_fails(
514+
home: Path, monkeypatch: pytest.MonkeyPatch
515+
) -> None:
516+
"""If the destination write succeeded but source removal failed,
517+
the archive is effectively done — return True and log a warning."""
518+
k = home / "knowledge"
519+
p = k / "global" / "old.md"
520+
_write_entry(p, id_="old", title="Old", created="2026-01-01")
521+
parsed = decay._read_and_parse_frontmatter(p)
522+
assert parsed is not None
523+
fm, _raw, body = parsed
524+
525+
real_unlink = Path.unlink
526+
527+
def _selective_unlink_failure(self, *args, **kwargs):
528+
# Only fail when unlinking the source entry — let temp-file
529+
# cleanup work normally.
530+
if str(self) == str(p):
531+
raise OSError("simulated unlink failure")
532+
return real_unlink(self, *args, **kwargs)
533+
534+
monkeypatch.setattr(Path, "unlink", _selective_unlink_failure)
535+
ok = decay._archive_entry(p, k, fm, body, today_iso="2026-05-27")
536+
# Destination is in place, so the archival did happen.
537+
assert ok is True
538+
archived = k / "_archive" / "global" / "old.md"
539+
assert archived.exists()
540+
541+
542+
def test_archive_entry_returns_false_when_destination_resolution_fails(
543+
home: Path, monkeypatch: pytest.MonkeyPatch
544+
) -> None:
545+
"""A path that doesn't resolve under the knowledge dir gets a
546+
None destination and skips archival."""
547+
k = home / "knowledge"
548+
# Create entry OUTSIDE the knowledge dir.
549+
outside = home / "loose" / "stray.md"
550+
outside.parent.mkdir(parents=True, exist_ok=True)
551+
outside.write_text("---\nid: stray\ncreated: '2026-01-01'\n---\n\nbody\n")
552+
parsed = decay._read_and_parse_frontmatter(outside)
553+
assert parsed is not None
554+
fm, _raw, body = parsed
555+
ok = decay._archive_entry(outside, k, fm, body, today_iso="2026-05-27")
556+
assert ok is False
557+
558+
559+
def test_archive_entry_returns_false_on_mkdir_failure(
560+
home: Path, monkeypatch: pytest.MonkeyPatch
561+
) -> None:
562+
k = home / "knowledge"
563+
p = k / "global" / "old.md"
564+
_write_entry(p, id_="old", title="Old", created="2026-01-01")
565+
parsed = decay._read_and_parse_frontmatter(p)
566+
assert parsed is not None
567+
fm, _raw, body = parsed
568+
monkeypatch.setattr(Path, "mkdir", _raise_oserror)
569+
ok = decay._archive_entry(p, k, fm, body, today_iso="2026-05-27")
570+
assert ok is False

daemon/tests/test_priming_rpc.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,3 +908,121 @@ def test_sent_cache_promotes_session_on_access(monkeypatch) -> None:
908908
priming_rpc._sent_record("s4", ["link"])
909909
assert priming_rpc._sent_get("s1") == {"link"}
910910
assert priming_rpc._sent_get("s2") == set()
911+
912+
913+
# ── _post_render_bookkeeping (decay-stamp + session-record) ──────────
914+
915+
916+
def test_post_render_bookkeeping_stamps_last_surfaced(
917+
home_with_isolated_paths, monkeypatch
918+
) -> None:
919+
"""Each entry in ``newly_sent`` should get its frontmatter
920+
``last_surfaced`` updated."""
921+
home = home_with_isolated_paths
922+
k = home / "knowledge"
923+
entry = k / "global" / "foo.md"
924+
entry.parent.mkdir(parents=True, exist_ok=True)
925+
entry.write_text(
926+
"---\nid: foo\ntitle: Foo\ncreated: '2026-01-01'\nreinforced: 0\n---\n\n# Foo\n\nbody.\n"
927+
)
928+
# Skip the sweep — separate concern, separately tested.
929+
monkeypatch.setattr(
930+
priming_rpc.decay,
931+
"maybe_run_sweep",
932+
lambda *args, **kwargs: None,
933+
)
934+
priming_rpc._post_render_bookkeeping(k, "test-session", "rendered body", ["global/foo"])
935+
text = entry.read_text(encoding="utf-8")
936+
assert "last_surfaced" in text
937+
938+
939+
def test_post_render_bookkeeping_records_session_cache(
940+
home_with_isolated_paths, monkeypatch
941+
) -> None:
942+
"""When body is non-empty and session_id is present, the
943+
sent-cache should be updated."""
944+
home = home_with_isolated_paths
945+
k = home / "knowledge"
946+
with priming_rpc._sent_cache_lock:
947+
priming_rpc._sent_cache.clear()
948+
monkeypatch.setattr(
949+
priming_rpc.decay,
950+
"maybe_run_sweep",
951+
lambda *args, **kwargs: None,
952+
)
953+
monkeypatch.setattr(
954+
priming_rpc.decay,
955+
"stamp_last_surfaced",
956+
lambda *args, **kwargs: True,
957+
)
958+
priming_rpc._post_render_bookkeeping(
959+
k, "test-session", "rendered body", ["global/foo", "global/bar"]
960+
)
961+
assert priming_rpc._sent_get("test-session") == {"global/foo", "global/bar"}
962+
963+
964+
def test_post_render_bookkeeping_no_session_skips_cache_record(
965+
home_with_isolated_paths, monkeypatch
966+
) -> None:
967+
"""No session_id -> no cache update (decay stamp still runs)."""
968+
home = home_with_isolated_paths
969+
k = home / "knowledge"
970+
with priming_rpc._sent_cache_lock:
971+
priming_rpc._sent_cache.clear()
972+
monkeypatch.setattr(
973+
priming_rpc.decay,
974+
"maybe_run_sweep",
975+
lambda *args, **kwargs: None,
976+
)
977+
stamped: list[str] = []
978+
monkeypatch.setattr(
979+
priming_rpc.decay,
980+
"stamp_last_surfaced",
981+
lambda path, **kwargs: stamped.append(str(path)) or True,
982+
)
983+
priming_rpc._post_render_bookkeeping(k, None, "rendered", ["global/foo"])
984+
# Cache untouched (empty).
985+
with priming_rpc._sent_cache_lock:
986+
assert len(priming_rpc._sent_cache) == 0
987+
# But stamp still ran.
988+
assert len(stamped) == 1
989+
990+
991+
def test_post_render_bookkeeping_swallows_stamp_failure(
992+
home_with_isolated_paths, monkeypatch, caplog
993+
) -> None:
994+
"""A bad stamp call must not break the response — logged, not raised."""
995+
home = home_with_isolated_paths
996+
k = home / "knowledge"
997+
998+
def _boom(*args, **kwargs):
999+
raise RuntimeError("disk full")
1000+
1001+
monkeypatch.setattr(priming_rpc.decay, "stamp_last_surfaced", _boom)
1002+
monkeypatch.setattr(priming_rpc.decay, "maybe_run_sweep", lambda *args, **kwargs: None)
1003+
# Should not raise.
1004+
priming_rpc._post_render_bookkeeping(k, "s", "body", ["global/foo"])
1005+
1006+
1007+
def test_post_render_bookkeeping_swallows_sweep_failure(
1008+
home_with_isolated_paths, monkeypatch
1009+
) -> None:
1010+
"""A bad sweep call must not break the response."""
1011+
home = home_with_isolated_paths
1012+
k = home / "knowledge"
1013+
1014+
def _boom(*args, **kwargs):
1015+
raise RuntimeError("filesystem error")
1016+
1017+
monkeypatch.setattr(priming_rpc.decay, "maybe_run_sweep", _boom)
1018+
monkeypatch.setattr(priming_rpc.decay, "stamp_last_surfaced", lambda *args, **kwargs: True)
1019+
priming_rpc._post_render_bookkeeping(k, "s", "body", ["global/foo"])
1020+
1021+
1022+
@pytest.fixture
1023+
def home_with_isolated_paths(tmp_path, monkeypatch):
1024+
"""Per-test AGENT_MEM_HOME so the sent-cache and any stray writes
1025+
don't leak between tests."""
1026+
monkeypatch.setenv("AGENT_MEM_HOME", str(tmp_path))
1027+
(tmp_path / "knowledge").mkdir(parents=True)
1028+
return tmp_path

0 commit comments

Comments
 (0)