diff --git a/session_memory/detect/signals.py b/session_memory/detect/signals.py index f7b11be..f28839d 100644 --- a/session_memory/detect/signals.py +++ b/session_memory/detect/signals.py @@ -156,10 +156,28 @@ def sig_tool_thrash(digest, ctx) -> list[Signal]: return [] +def sig_recurring_error(digest, ctx) -> list[Signal]: + """Problem: a normalized error fingerprint (WP-0006) — one signal per distinct + error in the session, so the same error across sessions/repos/flavors clusters + into a candidate root-cause pattern (locus = fingerprint, magnitude = in-session + occurrences). This is the content-level 'why', not just a coarse error count. + """ + out: list[Signal] = [] + for snip in digest.get("error_snippets", []) or []: + fp = snip.get("fingerprint") + if not fp: + continue + out.append(_base(digest, "recurring_error", PROBLEM, fp, float(snip.get("count", 1)), + sample=snip.get("sample", ""), tool=snip.get("tool"), + occurrences=snip.get("count", 1))) + return out + + EXTRACTORS: list[Callable] = [ sig_retry_storm, sig_repeated_errors, sig_budget_overrun, sig_abandoned, sig_clean_pass, sig_error_then_recovery, sig_infra_overhead, sig_schema_thrash, sig_tool_thrash, + sig_recurring_error, ] diff --git a/tests/test_detect_recurring_error.py b/tests/test_detect_recurring_error.py new file mode 100644 index 0000000..437ff1e --- /dev/null +++ b/tests/test_detect_recurring_error.py @@ -0,0 +1,59 @@ +"""Recurring-error signal + clustering (WP-0006 T02).""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from session_memory.detect.cluster import cluster # noqa: E402 +from session_memory.detect.signals import ( # noqa: E402 + extract_signals, + sig_recurring_error, +) + + +def _digest(uid, repo, flavor="claude", snippets=None): + return { + "session_uid": uid, "flavor": flavor, "repo": repo, "outcome": "success", + "cost": {"input_tokens": 1, "output_tokens": 1}, + "markers": {"errors": 0, "retries": 0, "test_runs": 0}, + "tool_histogram": {}, "error_snippets": snippets or [], + } + + +_FP = "modulenotfounderror: no module named 'foo' at :" + + +def test_signal_per_distinct_fingerprint(): + d = _digest("claude:a", "r1", snippets=[ + {"fingerprint": _FP, "sample": "ModuleNotFoundError ...", "count": 3, "tool": "Bash"}, + {"fingerprint": "keyerror: ", "sample": "KeyError", "count": 1, "tool": None}, + ]) + sigs = sig_recurring_error(d, {}) + assert len(sigs) == 2 + top = [s for s in sigs if s.locus == _FP][0] + assert top.type == "recurring_error" + assert top.magnitude == 3.0 + assert top.detail["sample"].startswith("ModuleNotFound") + + +def test_clusters_across_sessions_and_flavors(): + # same fingerprint in a claude and a grok session -> cross-flavor candidate + digs = [ + _digest("claude:a", "r1", "claude", + [{"fingerprint": _FP, "sample": "ModuleNotFoundError", "count": 2, "tool": "Bash"}]), + _digest("grok:b", "r2", "grok", + [{"fingerprint": _FP, "sample": "ModuleNotFoundError", "count": 1, "tool": None}]), + ] + signals = extract_signals(digs) + pats = cluster([s for s in signals if s.type == "recurring_error"], min_frequency=2) + assert len(pats) == 1 + p = pats[0] + assert p.signal_type == "recurring_error" + assert p.cross_flavor is True + assert sorted(p.flavors) == ["claude", "grok"] + assert p.frequency == 2 + + +def test_no_snippets_no_signal(): + assert sig_recurring_error(_digest("claude:a", "r1"), {}) == [] diff --git a/workplans/AGENTIC-WP-0006-error-body-mining.md b/workplans/AGENTIC-WP-0006-error-body-mining.md index aee0adf..9c34e7a 100644 --- a/workplans/AGENTIC-WP-0006-error-body-mining.md +++ b/workplans/AGENTIC-WP-0006-error-body-mining.md @@ -48,7 +48,7 @@ sessions with repeated and varied errors. ```task id: AGENTIC-WP-0006-T02 -status: todo +status: done priority: high state_hub_task_id: "1a41b6f5-48bc-4080-bd18-94f2186ef566" ```