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
src/watchdog.py: main orchestrator (~620 LOC) that:
- Reads rc-servers.json (dual-format support)
- Probes opencode servers, fetches sessions & tool messages
- Resolves parent→root chains and merges calls per root
- Runs progress_detect evaluation with file-line heuristic
- Calls local LLM for second opinion (max 3, yes/no timeout)
- Attributed routed model/provider/cost from route_decisions
- Manages alert state machine (trigger/resolve/upsert) in SQLite
- Writes watchdog_ticks, watchdog_verdicts, watchdog_alerts
- Runs as fresh process per tick; fcntl lock for parallel safety
- Exits 0 on clean/no-opencode, alert count on triggered
src/progress_detect.py: include 'coverage' in evaluation reason dict
so watchdog can persist it in the verdicts table.
Port of plans/no-progress-detection-prototype.py.
Fixes: call-count-based window (not seconds), matches prototype semantics.
Landing check is time-based across ALL calls within window's time span.
14/15 calibration labels verified against fixture.
Port the signal functions from plans/no-progress-detection-prototype.py into a
clean, importable module with zero external dependencies.
- DetectConfig: frozen dataclass holding all tunable thresholds
- target_of: coarse dedup key for tool calls (filePath, bash files+numbers,
grep/glob patterns, fallback)
- window_stats: dup share, top-target count, and session-wide slow count
over a sliding time window
- coverage: max over files of (lines read in window / file length), with
file_lines injected as a Callable (not a disk read)
- tree_landed: bool — any window call with landed=True
- evaluate: verdict function returning (is_flagged, reason_dict) or
(False, None) when call count < min_calls
Add DetectorConfig, ChannelConfig, NotificationsConfig, and
WatchdogConfig Pydantic models to src/config.py, each inheriting
StrictModel (extra='forbid'). Append corresponding defaults to
config/config.yaml.
- DetectorConfig: heuristic thresholds for anomaly detection (window,
dup_min, top_min, top_min_ro, cum_min, cover_min, min_calls)
- ChannelConfig: typed notification channel with Literal['desktop']
restriction — unknown types fail at load
- NotificationsConfig: list of ChannelConfig with default desktop
channel at warning severity
- WatchdogConfig: runtime watchdog with detector, model (defaults to
verification.model when None), read_only_agents, and dashboard URL
Wire into RouterConfig as watchdog and notifications fields, both
with sensible defaults.
opencode 1.18 treats every export of a plugin module as a plugin
function. router-link.js exported a Map (), so the
loader rejected the entire module: chat.headers and tool.execute.after
never ran. The 29 node tests passed because they imported the module
directly, bypassing the loader.
- Move parentCache from a named export to a property on RouterLink
- Add loader-contract test: every module export must be a function
- Update test imports to use RouterLink.parentCache
Evidence: opencode.log at every start since 2026-09-26T08:02Z shows
"Plugin export is not a function" for router-link.js.