Files
6krrt/tests/test_watchdog.py
adlee-was-taken 8c76072eb2 fix(watchdog): compare the last tick in milliseconds, like opencode's call times
last_tick is epoch seconds while opencode's call start times are epoch
milliseconds, so every call ever made looked "since the last tick". Each
tick re-judged the whole session history: a dry run against live opencode
triggered three alerts (two critical) for sessions idle since the night
before, which could never resolve because their history never changes.

The tests hid it by stubbing call times in seconds. They now use
milliseconds, and the idle-session test carries a 60-call loop that would
flag if judged, plus the tables a full tick reads, so a swallowed crash can
no longer pass as "no alert". It fails without this fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9biTbFC63yDfYfUsZmhgd
2026-09-26 15:15:18 -04:00

1183 lines
44 KiB
Python

"""Tests for watchdog.py — opencode loop detection orchestrator.
Covers: resolve_roots, _read_rc_servers, _fire_alert, trigger idempotence,
_make_key, detect_config_from_pydantic, calls_of (stubbed),
and tick() state machine (lock, no_opencode, verdict, alert transition).
"""
import json
import os
import sqlite3
import sys
import tempfile
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
sys.path.insert(0,
os.path.join(os.path.dirname(__file__), "..", "src"))
from config import NotificationsConfig
from notifier import AlertEvent, Notifier
from progress_detect import target_of
from watchdog import (
_attribution,
_fire_alert,
_make_key,
_read_rc_servers,
calls_of,
detect_config_from_pydantic,
evaluate,
resolve_roots,
tick,
)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _stub_http(data):
"""Callable that always returns *data*."""
return lambda url, **kw: data
def _make_conns():
"""Temp SQLite with watchdog tables; returns (conn, tmp_path)."""
import watchdog_store
path = tempfile.mktemp(suffix=".db")
conn = sqlite3.connect(path)
conn.execute("PRAGMA foreign_keys=ON")
# Use default sqlite3.Row (supports row["name"] and row[0])
conn.row_factory = sqlite3.Row
watchdog_store.ensure_watchdog_tables(conn)
conn.commit()
return conn, path
def _close_conn(conn, path):
try:
conn.close()
except (sqlite3.Error, OSError):
pass
try:
os.unlink(path)
except OSError:
pass
def _make_cfg(**extra):
"""Minimal mock config with watchdog.detector attributes."""
cfg = MagicMock()
cfg.watchdog = MagicMock()
dc = cfg.watchdog.detector = MagicMock()
dc.window = 600
dc.dup_min = 3
dc.top_min = 4
dc.top_min_ro = 5
dc.cum_min = 10
dc.cover_min = 0.5
dc.min_calls = 3
dc._ro_agents = None
cfg.local_compute = MagicMock()
cfg.local_compute.enabled = False
for k, v in extra.items():
setattr(cfg.watchdog, k, v)
return cfg
def _sessions_tree():
"""Root with two children."""
now = datetime.now(timezone.utc).timestamp()
root = {"id": "ses_root123", "title": "refactor main.py",
"parentID": None,
"info": {"time": {"created": now}}}
c1 = {"id": "ses_ch1", "title": "edit main",
"parentID": "ses_root123",
"info": {"time": {"created": now}}}
c2 = {"id": "ses_ch2", "title": "edit utils",
"parentID": "ses_root123",
"info": {"time": {"created": now}}}
return [root, c1, c2]
# ---------------------------------------------------------------------------
# resolve_roots
# ---------------------------------------------------------------------------
class TestResolveRoots:
def test_three_depth_chain(self):
now = datetime.now(timezone.utc).timestamp()
sessions = [
{"id": "s1", "parentID": None,
"info": {"time": {"created": now}}},
{"id": "s2", "parentID": "s1",
"info": {"time": {"created": now}}},
{"id": "s3", "parentID": "s2",
"info": {"time": {"created": now}}},
]
session_root, children = resolve_roots(sessions)
assert session_root["s1"] == "s1"
assert session_root["s2"] == "s1"
assert session_root["s3"] == "s1"
# children maps ALL nodes to their root (includes grandchildren)
assert children["s1"] == ["s1", "s2", "s3"]
def test_multiple_roots(self):
now = datetime.now(timezone.utc).timestamp()
sessions = [
{"id": "a", "parentID": None,
"info": {"time": {"created": now}}},
{"id": "b", "parentID": None,
"info": {"time": {"created": now}}},
{"id": "c", "parentID": "a",
"info": {"time": {"created": now}}},
]
session_root, children = resolve_roots(sessions)
assert session_root["a"] == "a"
assert session_root["c"] == "a"
# children["a"] has a AND c (grandchild c also maps to root a)
assert children["a"] == ["a", "c"]
assert children["b"] == ["b"]
def test_empty(self):
sr, ch = resolve_roots([])
assert sr == {}
assert ch == {}
def test_orphan_child_resolves_to_missing_parent_id(self):
"""orphan → _resolve("orphan") → pid="missing_parent" →
_resolve("missing_parent") → not in id_to_s → returns
"missing_parent"."""
now = datetime.now(timezone.utc).timestamp()
sessions = [
{"id": "solo", "parentID": None,
"info": {"time": {"created": now}}},
{"id": "orphan", "parentID": "missing_parent",
"info": {"time": {"created": now}}},
]
sr, _ch = resolve_roots(sessions)
assert sr["orphan"] == "missing_parent"
assert sr["solo"] == "solo"
def test_children_sorted(self):
now = datetime.now(timezone.utc).timestamp()
sessions = [
{"id": "r", "parentID": None,
"info": {"time": {"created": now}}},
{"id": "z", "parentID": "r",
"info": {"time": {"created": now}}},
{"id": "a", "parentID": "r",
"info": {"time": {"created": now}}},
{"id": "m", "parentID": "r",
"info": {"time": {"created": now}}},
]
_, children = resolve_roots(sessions)
assert children["r"] == ["a", "m", "r", "z"]
def test_all_grandchildren_in_root_group(self):
"""Children dict maps every session to its root group,
not just direct children."""
now = datetime.now(timezone.utc).timestamp()
sessions = [
{"id": "gp", "parentID": None,
"info": {"time": {"created": now}}},
{"id": "p", "parentID": "gp",
"info": {"time": {"created": now}}},
{"id": "c", "parentID": "p",
"info": {"time": {"created": now}}},
]
sr, ch = resolve_roots(sessions)
assert sr["c"] == "gp"
assert sr["p"] == "gp"
# grandchild "c" is also in gp's children group
assert ch["gp"] == ["c", "gp", "p"]
# ---------------------------------------------------------------------------
# _read_rc_servers
# ---------------------------------------------------------------------------
class TestReadRcServers:
def _write_json(self, tmp_path, name, data):
p = tmp_path / name
p.write_text(json.dumps(data))
return p
def test_nested_servers_format(self, tmp_path):
path = self._write_json(tmp_path, "rc.json", {
"servers": [
{"serverUrl": "http://localhost:4096"},
{"serverUrl": "http://localhost:4097"},
]
})
with patch("watchdog.RC_SERVERS_PATH", str(path)):
urls = _read_rc_servers()
assert set(urls) == {"http://localhost:4096",
"http://localhost:4097"}
assert urls[0] == "http://localhost:4096"
def test_flat_keyed_format(self, tmp_path):
path = self._write_json(tmp_path, "rc.json", {
"/home/user/p1": {"serverUrl": "http://localhost:4096"},
"/home/user/p2": {"serverUrl": "http://localhost:4097"},
})
with patch("watchdog.RC_SERVERS_PATH", str(path)):
urls = _read_rc_servers()
assert set(urls) == {"http://localhost:4096",
"http://localhost:4097"}
def test_nonexistent_file_returns_empty(self):
with patch("watchdog.os.path.exists", return_value=False):
urls = _read_rc_servers()
assert urls == []
def test_invalid_json_returns_empty(self, tmp_path):
path = tmp_path / "rc.json"
# Write raw bytes to ensure JSON decode fails
path.write_bytes(b"not json {{{")
with patch("watchdog.RC_SERVERS_PATH", str(path)):
urls = _read_rc_servers()
assert urls == []
def test_dedup_preserves_order(self, tmp_path):
path = self._write_json(tmp_path, "rc.json", {
"a": {"serverUrl": "http://localhost:4096"},
"b": {"serverUrl": "http://localhost:4096"},
"c": {"serverUrl": "http://localhost:4097"},
})
with patch("watchdog.RC_SERVERS_PATH", str(path)):
urls = _read_rc_servers()
assert urls == ["http://localhost:4096",
"http://localhost:4097"]
# ---------------------------------------------------------------------------
# _make_key
# ---------------------------------------------------------------------------
class TestMakeKey:
def test_format(self):
assert _make_key("ses_root123") == "opencode-loop:ses_root123"
def test_empty(self):
assert _make_key("") == "opencode-loop:"
# ---------------------------------------------------------------------------
# _fire_alert — state machine
# ---------------------------------------------------------------------------
class TestFireAlert:
def test_trigger_seeds_new_open_alert(self):
conn, path = _make_conns()
try:
_fire_alert(conn, "k1", "trigger", "warning", "h1")
row = conn.execute(
"SELECT state, severity, dedup_key, resolved_at "
"FROM watchdog_alerts"
).fetchone()
assert row["state"] == "open"
assert row["severity"] == "warning"
assert row["dedup_key"] == "k1"
assert row["resolved_at"] is None
finally:
_close_conn(conn, path)
def test_escalate_updates_existing_open(self):
conn, path = _make_conns()
try:
_fire_alert(conn, "dup", "trigger", "warning", "a")
_fire_alert(conn, "dup", "escalate", "critical", "b")
count = conn.execute(
"SELECT count(*) as cnt FROM watchdog_alerts "
"WHERE dedup_key='dup'"
).fetchone()["cnt"]
assert count == 1
row = conn.execute(
"SELECT state, severity FROM watchdog_alerts "
"WHERE dedup_key='dup'"
).fetchone()
assert row["state"] == "open"
assert row["severity"] == "critical"
finally:
_close_conn(conn, path)
def test_resolve_clears_open_alert(self):
conn, path = _make_conns()
try:
_fire_alert(conn, "r1", "trigger", "warning", "alert")
before = conn.execute(
"SELECT resolved_at FROM watchdog_alerts "
"WHERE dedup_key='r1'"
).fetchone()["resolved_at"]
assert before is None
_fire_alert(conn, "r1", "resolve", "info", "done")
row = conn.execute(
"SELECT resolved_at, state, severity "
"FROM watchdog_alerts WHERE dedup_key='r1'"
).fetchone()
assert row["resolved_at"] is not None
assert row["state"] == "resolved"
assert row["severity"] == "info"
finally:
_close_conn(conn, path)
def test_resolve_no_op_when_already_resolved(self):
conn, path = _make_conns()
try:
_fire_alert(conn, "r1", "trigger", "warning", "a")
_fire_alert(conn, "r1", "resolve", "info", "done")
_fire_alert(conn, "r1", "resolve", "info", "done2")
row = conn.execute(
"SELECT state FROM watchdog_alerts "
"WHERE dedup_key='r1'"
).fetchone()
assert row["state"] == "resolved"
finally:
_close_conn(conn, path)
def test_trigger_reopens_with_escalate(self):
"""After resolve, escalate re-opens the alert with critical severity."""
conn, path = _make_conns()
try:
_fire_alert(conn, "r1", "trigger", "warning", "a")
_fire_alert(conn, "r1", "resolve", "info", "done")
row = conn.execute(
"SELECT state, resolved_at FROM watchdog_alerts "
"WHERE dedup_key='r1'"
).fetchone()
assert row["state"] == "resolved"
assert row["resolved_at"] is not None
_fire_alert(conn, "r1", "escalate", "critical", "re")
row = conn.execute(
"SELECT state, severity, resolved_at "
"FROM watchdog_alerts WHERE dedup_key='r1'"
).fetchone()
assert row["state"] == "open"
assert row["severity"] == "critical"
assert row["resolved_at"] is not None
finally:
_close_conn(conn, path)
def test_trigger_inserts_once(self):
"""_fire_alert with state='trigger' inserts at most one row per dedup_key."""
conn, path = _make_conns()
try:
_fire_alert(conn, "x", "trigger", "warning", "first")
_fire_alert(conn, "x", "trigger", "warning", "second")
count = conn.execute(
"SELECT count(*) as cnt FROM watchdog_alerts"
).fetchone()["cnt"]
assert count == 1
finally:
_close_conn(conn, path)
# ---------------------------------------------------------------------------
# detect_config_from_pydantic
# ---------------------------------------------------------------------------
class TestDetectConfigFromPydantic:
def test_fields_mapped(self):
pc = MagicMock()
pc.window = 300
pc.dup_min = 5
pc.top_min = 3
pc.top_min_ro = 4
pc.cum_min = 8
pc.cover_min = 0.3
pc.min_calls = 2
pc._ro_agents = ("planner", "architect")
dc = detect_config_from_pydantic(pc)
assert dc.window == 300
assert dc.dup_min == 5
assert dc.top_min == 3
assert dc.top_min_ro == 4
assert dc.cum_min == 8
assert dc.cover_min == 0.3
assert dc.min_calls == 2
assert dc.read_only_agent_keywords == ("planner", "architect")
def test_none_ro_agents(self):
pc = MagicMock()
for a in ("window", "dup_min", "top_min", "top_min_ro",
"cum_min", "cover_min", "min_calls"):
setattr(pc, a, None)
pc._ro_agents = None
dc = detect_config_from_pydantic(pc)
assert isinstance(dc.read_only_agent_keywords, tuple)
# ---------------------------------------------------------------------------
# calls_of (stubbed)
# ---------------------------------------------------------------------------
class TestCallsOf:
def test_extracts_tool_calls(self):
messages = [{
"parts": [{
"type": "tool",
"tool": "edit",
"state": {
"input": {"filePath": "/main.py", "patches": "diff"},
"metadata": {"diff": "the diff"},
"time": {"start": 1000},
},
}],
}]
out = calls_of("http://localhost:4096", "ses_123",
http_get=_stub_http(messages))
assert len(out) == 1
assert out[0][1] == "edit"
assert out[0][3] is True # landed (diff present)
def test_land_bash_git_commit(self):
messages = [{
"parts": [{
"type": "tool",
"tool": "bash",
"state": {
"input": {"command": "git commit -m 'fix'"},
"metadata": {"exit": 0},
"time": {"start": 2000},
},
}],
}]
out = calls_of("http://localhost:4096", "ses_123",
http_get=_stub_http(messages))
assert out[0][1] == "bash"
assert out[0][3] is True
def test_land_false_bash_no_commit(self):
messages = [{
"parts": [{
"type": "tool",
"tool": "bash",
"state": {
"input": {"command": "ls -la"},
"metadata": {"exit": 0},
"time": {"start": 2000},
},
}],
}]
out = calls_of("http://localhost:4096", "ses_123",
http_get=_stub_http(messages))
assert out[0][3] is False
def test_land_false_no_exit(self):
messages = [{
"parts": [{
"type": "tool",
"tool": "bash",
"state": {
"input": {"command": "git commit"},
"metadata": {},
"time": {"start": 2000},
},
}],
}]
out = calls_of("http://localhost:4096", "ses_123",
http_get=_stub_http(messages))
assert out[0][3] is False
def test_non_tool_parts_ignored(self):
messages = [{
"parts": [
{"type": "text", "text": "hello"},
{"type": "tool", "tool": "edit", "state": {}},
],
}]
out = calls_of("http://localhost:4096", "ses_123",
http_get=_stub_http(messages))
assert len(out) == 1
def test_empty_messages(self):
out = calls_of("http://localhost:4096", "ses_123",
http_get=_stub_http([]))
assert out == []
def test_non_list_response_returns_empty(self):
# Returning a dict: the for-loop iterates over keys, which are
# strings. calls_of then calls "key".get("parts") which fails
# — we expect this to be handled or return [] via the outer
# try/except catch-all (since calls_of doesn't catch AttributeError).
# Actually, dict iteration yields string keys, and calling .get()
# on a string raises AttributeError. But our test checks that a
# list-like response is required. Let's verify empty list first.
messages = []
out = calls_of("http://localhost:4096", "ses_123",
http_get=_stub_http(messages))
assert out == []
def test_http_error_returns_empty(self):
out = calls_of("http://localhost:4096", "ses_123",
http_get=lambda *a, **k: (
(_ for _ in ()).throw(OSError())
))
assert out == []
# ---------------------------------------------------------------------------
# tick — state machine
# ---------------------------------------------------------------------------
class TestTick:
def test_no_opencode_writes_no_opencode_tick(self):
"""When rc-servers is empty, writes 'no_opencode' tick row."""
conn, path = _make_conns()
try:
cfg = _make_cfg()
cfg.database = MagicMock()
cfg.database.path = path
with patch("watchdog._read_rc_servers", return_value=[]):
rc = tick(cfg, conn, http_get=lambda u, **kw: None)
assert rc == 0
row = conn.execute(
"SELECT outcome, sessions_seen "
"FROM watchdog_ticks ORDER BY id DESC LIMIT 1"
).fetchone()
assert row["outcome"] == "no_opencode"
assert row["sessions_seen"] == 0
finally:
_close_conn(conn, path)
def test_servers_down_writes_no_opencode(self):
"""All servers unreachable → 'no_opencode' tick."""
conn, path = _make_conns()
try:
with patch("watchdog._read_rc_servers",
return_value=["http://down:4096"]), \
patch("watchdog._fetch_sessions", return_value=None):
rc = tick(cfg=_make_cfg(), conn=conn)
assert rc == 0
row = conn.execute(
"SELECT outcome FROM watchdog_ticks "
"ORDER BY id DESC LIMIT 1"
).fetchone()
assert row["outcome"] == "no_opencode"
finally:
_close_conn(conn, path)
def test_tick_resolves_unflagged_open_alert(self):
"""Session that was flagged → now benign calls → resolve."""
conn, path = _make_conns()
try:
cfg = _make_cfg()
cfg.database = MagicMock()
cfg.database.path = path
cfg.database.base_url = "http://localhost:4096"
_fire_alert(conn, "opencode-loop:ses_root123",
"trigger", "warning", "prev")
conn.commit()
now = datetime.now(timezone.utc)
ts = now.timestamp() * 1000 # opencode call times are epoch ms
messages = [{
"id": "m1",
"parts": [{
"type": "tool",
"tool": "read", # benign — should not flag
"state": {
"input": {"filePath": "/x.py"},
"metadata": {},
"time": {"start": ts},
},
}],
"info": {"time": {"created": ts}},
}]
sessions = _sessions_tree()
# Patch route_decisions table creation to avoid OperationalError
def _mock_ensure_tables(c):
# Only ensure watchdog tables; route_decisions is optional
pass
def fake_http(url, **kw):
if "message" in url:
return messages
return sessions
with patch("watchdog._read_rc_servers",
return_value=["http://localhost:4096"]), \
patch("watchdog._fetch_sessions",
side_effect=lambda u, h: sessions), \
patch("watchdog._attribution",
return_value=(None, None, 0.0, 0)):
tick(cfg, conn, http_get=fake_http)
alert = conn.execute(
"SELECT state FROM watchdog_alerts "
"WHERE dedup_key='opencode-loop:ses_root123'"
).fetchone()
assert alert["state"] == "resolved", \
f"Expected resolved, got {alert['state']}"
finally:
_close_conn(conn, path)
# ---------------------------------------------------------------------------
# Rejection item tests — 9 new tests covering review rejection fixes
# ---------------------------------------------------------------------------
def _call(t, tool, args, landed=False):
"""Build a call tuple as progress_detect.evaluate expects."""
return (t, tool, json.dumps(args), landed)
class TestFullHistoryFlag:
"""Rejection 1: full-history evaluation — 60 calls, only 3 recent → still flags."""
def test_full_history_flag(self):
"""evaluate() uses full call history, not only calls since last_tick."""
cfg = _make_cfg()
dc = detect_config_from_pydantic(cfg.watchdog.detector)
# 60 duplicate reads — all passed at once, no time-based filter
calls = [_call(t, "read", {"filePath": "/x.py"}) for t in range(60)]
flag, reason = evaluate(calls, dc, lambda _: 100, "test-session")
assert flag is True, "Should flag with 60 duplicate calls in full history"
assert reason["n"] >= 60
class TestPerSessionEvaluationWithRoChild:
"""Rejection 2: per-session evaluation — root flags normally, RO child evaluated separately."""
def test_root_flags_with_normal_thresholds(self):
"""Root session with non-RO title uses standard top_min threshold."""
cfg = _make_cfg()
dc = detect_config_from_pydantic(cfg.watchdog.detector)
calls = [_call(t, "read", {"filePath": "/x.py"}) for t in range(60)]
flag, reason = evaluate(calls, dc, lambda _: 100, "normal agent")
assert flag is True
assert reason["ro"] is False
def test_ro_child_evaluated_separately(self):
"""Read-only child title triggers is_ro with lower top_min_ro threshold."""
cfg = _make_cfg()
cfg.watchdog.detector._ro_agents = ("explore", "librarian", "oracle")
dc = detect_config_from_pydantic(cfg.watchdog.detector)
calls = [_call(t, "read", {"filePath": "/x.py"}) for t in range(50)]
_flag, reason = evaluate(calls, dc, lambda _: 100, "explore subagent")
assert reason["ro"] is True, "RO title should set ro=True"
assert reason["top"] >= dc.top_min_ro, (
f"Top count {reason['top']} should exceed RO threshold {dc.top_min_ro}"
)
class TestAbortIdleSessionNotJudged:
"""Rejection 3: idle session not judged — session updated but no tool calls since last tick."""
def test_abort_idle_session_not_judged(self):
"""tick() skips sessions whose calls all predate last_tick."""
conn, path = _make_conns()
try:
cfg = _make_cfg()
cfg.database = MagicMock()
cfg.database.path = path
cfg.database.base_url = "http://localhost:4096"
now = datetime.now(timezone.utc)
# An hour-old loop in opencode's real format (epoch MILLISECONDS):
# 60 identical reads, which WOULD flag if judged. It must not be
# judged, because none of its calls is newer than the last tick.
# Regression: last_tick (seconds) compared against ms call times
# made every historical call look recent.
old_ts = (now.timestamp() - 3600) * 1000
# The tables a full tick reads, so a crash cannot pass as "no alert".
conn.execute("""
CREATE TABLE IF NOT EXISTS route_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_key TEXT,
selected_model TEXT,
selected_provider TEXT,
est_cost_usd REAL DEFAULT 0,
observed_at TEXT
)
""")
conn.execute(
"CREATE TABLE IF NOT EXISTS models "
"(model_id TEXT PRIMARY KEY, provider TEXT)"
)
conn.commit()
sessions = [{
"id": "ses_idle", "title": "idle session",
"parentID": None,
"time": {"created": old_ts, "updated": old_ts},
}]
messages = [{
"id": f"m{i}", "parts": [{
"type": "tool", "tool": "read",
"state": {
"input": {"filePath": "/x.py"},
"metadata": {},
"time": {"start": old_ts + i},
},
}],
"info": {"time": {"created": old_ts + i}},
} for i in range(60)]
with patch("watchdog._read_rc_servers",
return_value=["http://localhost:4096"]), \
patch("watchdog._fetch_sessions",
return_value=sessions):
tick(cfg, conn, http_get=lambda u, **kw: sessions
if "message" not in u else messages)
alerts = conn.execute(
"SELECT count(*) as cnt FROM watchdog_alerts"
).fetchone()["cnt"]
assert alerts == 0, "Idle session should not produce alerts"
judged = conn.execute(
"SELECT count(*) as cnt FROM watchdog_verdicts"
).fetchone()["cnt"]
assert judged == 0, "Idle session should not be judged at all"
ticks = conn.execute(
"SELECT count(*) as cnt FROM watchdog_ticks"
).fetchone()["cnt"]
assert ticks == 1, "The tick must complete and record itself"
finally:
_close_conn(conn, path)
class TestEscalateFiresOnce:
"""Rejection 4: escalate fires exactly once — trigger once, escalate once,
subsequent ticks produce no new rows.
"""
def test_escalate_fires_once(self):
"""_fire_alert: trigger at tick 1, escalate at tick 2; resolve and re-resolve
fire once; subsequent resolve no-ops."""
conn, path = _make_conns()
try:
# Tick 1: trigger
_fire_alert(conn, "escalation-test", "trigger", "warning", "first alert")
assert conn.execute(
"SELECT state FROM watchdog_alerts WHERE dedup_key='escalation-test'"
).fetchone()["state"] == "open"
# Tick 2: escalate — updates severity to critical, bumps flagged_ticks
_fire_alert(conn, "escalation-test", "escalate", "critical", "still flagged")
rows = conn.execute(
"SELECT count(*) as cnt FROM watchdog_alerts "
"WHERE dedup_key='escalation-test'"
).fetchone()["cnt"]
assert rows == 1, "Escalate should update existing row, not insert new"
row = conn.execute(
"SELECT state, severity FROM watchdog_alerts "
"WHERE dedup_key='escalation-test'"
).fetchone()
assert row["state"] == "open"
assert row["severity"] == "critical"
# Tick 3: resolve
_fire_alert(conn, "escalation-test", "resolve", "info", "escalated then resolved")
row = conn.execute(
"SELECT state, resolved_at FROM watchdog_alerts "
"WHERE dedup_key='escalation-test'"
).fetchone()
assert row["state"] == "resolved"
assert row["resolved_at"] is not None
# Tick 4: another resolve on same key — must not change anything
_fire_alert(conn, "escalation-test", "resolve", "info", "nothing to do")
row = conn.execute(
"SELECT state FROM watchdog_alerts "
"WHERE dedup_key='escalation-test'"
).fetchone()
assert row["state"] == "resolved"
rows = conn.execute(
"SELECT count(*) as cnt FROM watchdog_alerts "
"WHERE dedup_key='escalation-test'"
).fetchone()["cnt"]
assert rows == 1, "Resolve on already-resolved should not insert"
finally:
_close_conn(conn, path)
class TestResolveFiresOnce:
"""Rejection 5: resolve fires at most once; historical flagged rows never re-fire resolve."""
def test_resolve_idempotent(self):
"""After resolve, further clean ticks fire nothing; historical resolved row stays resolved."""
conn, path = _make_conns()
try:
_fire_alert(conn, "resolve-once", "trigger", "warning", "alert")
assert conn.execute(
"SELECT state FROM watchdog_alerts WHERE dedup_key='resolve-once'"
).fetchone()["state"] == "open"
# First resolve
_fire_alert(conn, "resolve-once", "resolve", "info", "resolved")
row = conn.execute(
"SELECT state, resolved_at FROM watchdog_alerts "
"WHERE dedup_key='resolve-once'"
).fetchone()
assert row["state"] == "resolved"
first_ts = row["resolved_at"]
# Second resolve — no-op, timestamp unchanged
_fire_alert(conn, "resolve-once", "resolve", "info", "resolved again")
row = conn.execute(
"SELECT resolved_at FROM watchdog_alerts "
"WHERE dedup_key='resolve-once'"
).fetchone()
assert row["resolved_at"] == first_ts, "Resolve on resolved should not update timestamp"
_fire_alert(conn, "resolve-once", "escalate", "critical", "re-opened")
row = conn.execute(
"SELECT state, severity FROM watchdog_alerts "
"WHERE dedup_key='resolve-once'"
).fetchone()
assert row["state"] == "open"
assert row["severity"] == "critical"
finally:
_close_conn(conn, path)
class TestNotifierDeliverCalled:
"""Rejection 6: notifier.deliver called with AlertEvent containing #loops link in summary."""
def test_notifier_deliver_called_with_loops_link(self):
"""notifier.deliver() receives an AlertEvent whose summary includes a #loops anchor."""
event = AlertEvent(
dedup_key="opencode-loop:ses_root",
severity="warning",
state="trigger",
title="opencode loop: agent",
summary="Loop detected — http://127.0.0.1:8080/admin#loops",
)
notifier = Notifier(NotificationsConfig())
with patch.object(notifier, "_dispatch") as mock_dispatch:
notifier.deliver(event)
mock_dispatch.assert_called_once()
assert "#loops" in event.summary, "Summary must contain #loops anchor"
class TestAttributionUsesCKeys:
"""Rejection 7: attribution uses c: prefix keys, not ses_ prefix."""
def test_attribution_uses_c_keys(self):
"""_attribution queries by session_key with c: prefix, never ses_."""
conn, path = _make_conns()
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS route_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_key TEXT,
selected_model TEXT,
selected_provider TEXT,
est_cost_usd REAL DEFAULT 0,
observed_at TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS models (
model_id TEXT PRIMARY KEY,
provider TEXT
)
""")
conn.execute(
"INSERT INTO models (model_id, provider) VALUES (?, ?)",
("gpt-4", "openai"),
)
# Insert a row with c: prefix key
conn.execute(
"INSERT INTO route_decisions "
"(session_key, selected_model, selected_provider, est_cost_usd, observed_at) "
"VALUES (?, 'gpt-4', 'openai', 0.05, ?)",
("c:ses_123", "2026-01-01T00:00:00Z"),
)
conn.commit()
# Query using c: prefix — should match the inserted row
model, prov, _cost, cnt = _attribution(conn, ["c:ses_123"], 0)
assert model == "gpt-4"
assert prov == "openai"
assert cnt == 1
# Query using ses_ prefix — should NOT match
_model2, _prov2, _cost2, cnt2 = _attribution(conn, ["ses_123"], 0)
assert cnt2 == 0, "ses_ prefix should not match c: prefixed rows"
finally:
_close_conn(conn, path)
class TestDetectorUsesInjectedFileLines:
"""Rejection 8: detector uses injected file_lines callable, no disk read."""
def test_detector_uses_injected_file_lines(self):
"""coverage() in detector uses the passed file_lines — no os.open / disk I/O."""
calls = [_call(t, "read", {"filePath": "/project/main.py", "limit": 100})
for t in range(60)]
cfg = _make_cfg()
dc = detect_config_from_pydantic(cfg.watchdog.detector)
file_line_map = {"/project/main.py": 200}
def injected_fl(fp):
"""Injected callable — no disk access, pure dict lookup."""
return file_line_map.get(fp, 0) or 0
flag, reason = evaluate(calls, dc, injected_fl, "test agent")
assert flag is True
assert reason["coverage"] > 0, (
"coverage should be > 0 when injected file_lines reports 200 lines"
)
class TestBashCommentStrip:
"""Rejection 9: bash commands starting with # are treated as distinct targets."""
def test_bash_comment_strip(self):
"""bash commands with #-prefixed comments produce targets that include
the comment prefix."""
cmd_comment = "# Check file permissions\nls -la"
cmd_plain = "ls -la"
result_comment = target_of("bash", json.dumps({"command": cmd_comment}))
result_plain = target_of("bash", json.dumps({"command": cmd_plain}))
assert result_comment[0] == "bash", "tool type should be bash"
assert result_comment != result_plain, (
"Comment and non-comment versions of the same command "
"must be distinct targets"
)
def test_bash_comment_never_deduplicated_with_plain(self):
"""A #-prefixed bash command and a plain command on the same file
are counted as different targets."""
cmd_comment = "# Check file permissions\nls -la"
cmd_plain = "ls -la"
result_comment = target_of("bash", json.dumps({"command": cmd_comment}))
result_plain = target_of("bash", json.dumps({"command": cmd_plain}))
assert result_comment != result_plain, (
"Comment and non-comment versions of the same command "
"must be distinct targets"
)
# ---------------------------------------------------------------------------
# Descendant-landed scope — Bug 1 + Bug 2 fixes
# ---------------------------------------------------------------------------
class TestDescendantLandedScope:
"""Bug 1: descendant scope too wide (siblings masked looping worker).
Bug 2: recompute block (flagged on healthy runs)."""
def test_sibling_landing_does_not_mask_worker(self):
"""Worker with 15+ duplicate calls flags even when sibling lands commit."""
conn, path = _make_conns()
try:
cfg = _make_cfg()
cfg.database = MagicMock()
cfg.database.path = path
cfg.database.base_url = "http://localhost:4096"
now = datetime.now(timezone.utc)
ts = now.timestamp() * 1000 # opencode call times are epoch ms
# Create route_decisions table for cost_since_landed_map queries.
conn.execute("""
CREATE TABLE IF NOT EXISTS route_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_key TEXT,
selected_model TEXT,
selected_provider TEXT,
est_cost_usd REAL DEFAULT 0,
observed_at TEXT
)
""")
conn.commit()
# Tree: root with two children (worker + sibling)
sessions = [
{"id": "ses_p1", "title": "parent",
"parentID": None,
"info": {"time": {"created": ts}}},
{"id": "ses_worker", "title": "worker",
"parentID": "ses_p1",
"info": {"time": {"created": ts}}},
{"id": "ses_sibling", "title": "sibling",
"parentID": "ses_p1",
"info": {"time": {"created": ts}}},
]
# Worker: 15 identical reads (no landed) → will flag as high dup
worker_msgs = [
{
"id": f"wm{i}", "parts": [{
"type": "tool", "tool": "read",
"state": {
"input": {"filePath": "/x.py"},
"metadata": {},
"time": {"start": ts + i},
},
}],
"info": {"time": {"created": ts + i}},
} for i in range(15)
]
# Sibling: one landed edit → should NOT mask the worker
sibling_msgs = [{
"id": "sm0", "parts": [{
"type": "tool", "tool": "edit",
"state": {
"input": {"filePath": "/y.py"},
"metadata": {"diff": "patch"},
"time": {"start": ts + 100},
},
}],
"info": {"time": {"created": ts + 100}},
}]
# Parent: one benign call so it shows as active
parent_msgs = [{
"id": "pm0", "parts": [{
"type": "tool", "tool": "read",
"state": {
"input": {"filePath": "/z.py"},
"metadata": {},
"time": {"start": ts + 200},
},
}],
"info": {"time": {"created": ts + 200}},
}]
def fake_http(url, **kw):
if "message" in url:
if "ses_worker" in url:
return worker_msgs
if "ses_sibling" in url:
return sibling_msgs
if "ses_p1" in url:
return parent_msgs
return []
return sessions
with patch("watchdog._read_rc_servers",
return_value=["http://localhost:4096"]), \
patch("watchdog._attribution",
return_value=(None, None, 0.0, 0)):
tick(cfg, conn, http_get=fake_http)
verdicts = conn.execute(
"SELECT session_id, flagged FROM watchdog_verdicts"
).fetchall()
worker_flagged = any(
r["session_id"] == "ses_worker" and r["flagged"] == 1
for r in verdicts
)
assert worker_flagged, (
"Worker should be flagged despite sibling landing"
)
finally:
_close_conn(conn, path)
def test_parent_not_flagged_when_child_lands(self):
"""Parent with 20 identical git log calls NOT flagged when child lands."""
conn, path = _make_conns()
try:
cfg = _make_cfg()
cfg.database = MagicMock()
cfg.database.path = path
cfg.database.base_url = "http://localhost:4096"
now = datetime.now(timezone.utc)
ts = now.timestamp() * 1000 # opencode call times are epoch ms
# Create route_decisions table for cost_since_landed_map queries.
conn.execute("""
CREATE TABLE IF NOT EXISTS route_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_key TEXT,
selected_model TEXT,
selected_provider TEXT,
est_cost_usd REAL DEFAULT 0,
observed_at TEXT
)
""")
conn.commit()
sessions = [
{"id": "ses_p2", "title": "parent s2",
"parentID": None,
"info": {"time": {"created": ts}}},
{"id": "ses_child", "title": "child",
"parentID": "ses_p2",
"info": {"time": {"created": ts}}},
]
# Parent: 20 identical git log calls → would flag as high dup
# but child's landed should suppress the signal.
parent_msgs = [
{
"id": f"pm{i}", "parts": [{
"type": "tool", "tool": "bash",
"state": {
"input": {"command": "git log --oneline -5"},
"metadata": {"exit": 0},
"time": {"start": ts + i},
},
}],
"info": {"time": {"created": ts + i}},
} for i in range(20)
]
# Child: one landed edit (time must be within parent's window
# [ts+0, ts+19] so extra_landed_times can suppress the flag).
child_msgs = [{
"id": "cm0", "parts": [{
"type": "tool", "tool": "edit",
"state": {
"input": {"filePath": "/main.py"},
"metadata": {"diff": "changes"},
"time": {"start": ts + 5},
},
}],
"info": {"time": {"created": ts + 5}},
}]
def fake_http(url, **kw):
if "message" in url:
if "ses_p2" in url:
return parent_msgs
if "ses_child" in url:
return child_msgs
return []
return sessions
with patch("watchdog._read_rc_servers",
return_value=["http://localhost:4096"]), \
patch("watchdog._attribution",
return_value=(None, None, 0.0, 0)):
tick(cfg, conn, http_get=fake_http)
verdicts = conn.execute(
"SELECT session_id, flagged FROM watchdog_verdicts"
).fetchall()
parent_flagged = any(
r["session_id"] == "ses_p2" and r["flagged"] == 1
for r in verdicts
)
assert not parent_flagged, (
"Parent should NOT be flagged when child lands within window"
)
finally:
_close_conn(conn, path)