feat: watcher deltas + Chat sessions (plan 418) - #427
Conversation
clcollins
left a comment
There was a problem hiding this comment.
PR #427 Adversarial Review — Watcher Deltas + Chat
MUST-FIX
M1: Headline test does not test the gate — passes when the gate is removed
File: pkg/tui/watcher_test.go:493 (TestDeltaGate_IdenticalRefreshesProduceOneInvestigation)
File: pkg/tui/watcher.go:226
Mutation executed:
- if len(changes) == 0 {
+ if false && len(changes) == 0 {
Result: TestDeltaGate_IdenticalRefreshesProduceOneInvestigation PASSES. The test only exercises computeAndStoreDeltas() and asserts its return values. It never calls runDetectors() with empty changes and asserts that no commands are produced. The comment on line 517 says the gate "would block" — but that is a reading claim, not a test assertion.
The reverse mutation (if true || len(changes) == 0) IS caught — by TestRunDetectors_ModelPlumbing, not by the headline test. So one direction of the gate is tested, but the critical suppression direction (the entire point of this PR) is not.
Failure scenario: Someone removes the len(changes) == 0 guard. Every poll re-investigates every incident, exactly the problem this PR exists to fix. No test breaks.
Fix: Add a test that calls m.runDetectors([]delta.Change{}) on a model with 3+ incidents on the same service (so detectors would fire) and asserts the return is empty/nil.
M2: Spurious NoteAdded/AlertAdded changes on cache population
File: pkg/tui/watcher.go:290-309 (toSnapshots)
Confirmed by reading (not executed as mutation — requires mocking the lazy enrichment pipeline).
toSnapshots reads noteCount and alertCount from m.incidentCache. On the first poll after startup, the cache is empty, so counts are 0. Between polls 1 and 2, lazy enrichment populates the cache. On poll 2, counts jump from 0 to actual values, producing spurious NoteAdded and AlertAdded changes.
Failure scenario: Incident P123 has 5 notes and 3 alerts. Poll 1: snapshot has noteCount=0, alertCount=0. Lazy enrichment loads cache. Poll 2: snapshot has noteCount=5, alertCount=3. Diff produces NoteAdded(5) + AlertAdded(3) even though nothing changed — the data was just fetched for the first time. This fires the delta gate and triggers an investigation on unchanged data, partially defeating the purpose of the gate.
This will happen for EVERY incident on startup as their caches populate, producing a burst of false-positive changes. The watcherDedup secondary gate may catch some of these (if the observation text matches), but the delta gate — the one this PR adds — is bypassed.
Fix: Either initialize note/alert counts from the PagerDuty list response (which includes NumberOfAlerts in the Incident struct) so the first snapshot already has correct counts, or only emit NoteAdded/AlertAdded when a previous non-zero count changes (i.e., skip if p.NoteCount == 0).
M3: incidentList < 2 guard in runDetectors is untested
File: pkg/tui/watcher.go:219
Mutation executed:
- if len(m.incidentList) < 2 {
+ if len(m.incidentList) < 0 {
Result: All tests pass. No test exercises the single-incident case to prove the guard blocks.
Failure scenario: With a single incident, removing this guard would let detectors fire on every poll since detectServiceStorm and detectClusterStorm have their own thresholds. The practical risk is low (detectors already require 2-3 incidents), but this is the same "untested guard" pattern that bit #418 and #419. Since TestRunDetectors_ModelPlumbing uses 3 incidents, it incidentally avoids this code path rather than testing it.
NICE-TO-HAVE
N1: EscalationLevel is hardcoded to 0 in toSnapshots
File: pkg/tui/watcher.go:306
toSnapshots always passes 0 for escalationLevel. The Escalated change kind has full unit-test coverage in pkg/delta/delta_test.go, but can never fire in production because the wiring always supplies 0. The pagerduty.Incident struct does not directly expose escalation level (it's on the escalation policy response), so this may be intentionally deferred. But it means escalation events — something SREs very much care about — silently produce no diff.
Severity: Low. A real change an SRE cares about (escalation) produces no diff. But the escalation data isn't in the incident list response, so fixing it requires an additional API call.
N2: Snapshot.ClusterID is dead code
File: pkg/delta/delta.go:56
The field is declared in the Snapshot struct but never set (not in SnapshotFromFields, not anywhere else) and never compared in Diff. Pure dead weight.
N3: watcherDedup.seen grows unboundedly
File: pkg/tui/watcher.go:198-215
The seen map stores SHA-256 hashes with timestamps. Entries are added but never evicted, even after their cooldown expires. Over a long-running session (srepd runs all day for SREs), this map grows without bound. The growth rate is slow (~88 bytes per unique observation), so this won't cause problems in practice for hours of use, but a 24-hour session with many detector fires could accumulate thousands of entries.
The PR was "supposed to supersede" watcherDedup with the delta layer, but both mechanisms remain active — the dedup is a secondary rate-limiter on top of the delta gate.
N4: Title and Service changes produce no diff
File: pkg/delta/delta.go:107-143
Diff compares Status, Urgency, EscalationLevel (increase only), NoteCount (increase only), and AlertCount (increase only). It does NOT compare Title or Service, even though these are stored in Snapshot. If PagerDuty updates an incident's title (e.g., an operator renames it to reflect new findings), no diff is generated.
This is a design choice rather than a bug — titles rarely change and comparing them would fire on cosmetic edits. But it means a meaningful title update (e.g., "SilenceExpired" → "ClusterDown") is invisible to the delta layer.
CLEAN
- Plan doc numbering:
418-watcher-deltas-chat.md— no collision with anything on main. Confirmed. - All cited test names exist. All 10 test names in the PR body match real
func Test...declarations. No fabrications. Diffis pure. Takes values, returns values, no I/O, no globals, notime.Now().Narratetakestime.Timeas a parameter. Clean.- D2 scoping is correct.
buildObservationContextdoes NOT accessm.selectedIncident. It iterates only overobs.IncidentIDsand callsfindIncidentByID.buildWatcherContext(user-directed path) correctly readsm.selectedIncident. The two paths are cleanly separated. get_recent_eventstool: ClassRead (tested), goes through the policy gate viaGatedBetaTools, output bounded at 200 events and truncated at 8KB viaTruncate(). Input validated (invalid JSON returns error). Clean.- No new Go modules.
go.mod/go.sumunchanged from main. No dependency additions to review. - Race detection: Clean (no DATA RACE) with
-race -count=2on bothpkg/deltaandpkg/tui. go vet ./...: Clean.go test ./pkg/... -count=1: All pass.- Deadcode: Only
SupportsChat/AsChatfrom this PR appear in deadcode output — intentionally staged per plan (landed-but-not-yet-wired). - No persistence added: Confirmed in-memory only. No file I/O, JSONL, or schema.
prevSnapshotsreplaced each poll (bounded by incident list size).recentChangescapped at 200. - First sighting:
Diff(nil, curr)correctly producesIncidentNewfor every incident. Tested inTestDiff_FirstSighting. IncomputeAndStoreDeltas,m.prevSnapshotsstarts as nil, so the first poll produces first-sighting changes.TestRunDetectors_ModelPlumbingconfirms this flows through torunDetectors. First-sighting is not silently skipped.
Mutations executed
| Mutation | Compiles? | Test result | Conclusion |
|---|---|---|---|
Delta gate: if false && (never suppresses) |
Yes | All pass | Gate suppression is UNTESTED |
Delta gate: if true || (always suppresses) |
Yes | TestRunDetectors_ModelPlumbing fails |
Gate pass-through IS tested |
incidentList < 0 (disable minimum) |
Yes | All pass | Minimum guard is UNTESTED |
Introduce the delta package for incident-state diffing. Diff(prev, curr) computes typed changes between consecutive poll snapshots. Narrate formats changes into a compact narrative for the LLM. Both are pure functions with no I/O, enabling future persistence without redesign. First-sighting semantics: incidents with no prior state produce IncidentNew. Handles: new, resolved, status change, urgency change, escalation, note and alert count changes. Reordering-only produces no changes. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Land the Chat interface following the established optional-interface pattern (HealthChecker, ModelReporter). Chat.Send accumulates history; Chat.History exposes it. SupportsChat/AsChat helpers mirror the existing SupportsHealthCheck/ResolvedModel pattern. Does NOT add methods to Provider — that would break every implementation and mock. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…ection D2: buildObservationContext derives context from the observation's triggering incident IDs instead of m.selectedIncident. Detectors now carry incident IDs on watcherObservation. buildAskFromVerdict accepts originating incident IDs and uses them in preference to the live UI selection, completing the deferred item from PR #419. Design choice (c): triggering incidents are foregrounded with sibling alerts labelled as background; queue summary remains for correlation. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…n changes D1 wiring: compute incident-state deltas on each poll via pkg/delta.Diff. Gate runDetectors on len(changes) > 0 so unchanged alerts are never re-investigated. Keep cooldown dedup as secondary rate limit. Feed delta narrative into investigation context so the model receives "since last check: 2 new alerts, urgency raised" rather than re-scanning a snapshot. Bounded in-memory event log (max 200 changes) on the model. No persistence — restarting srepd is a fresh start. Headline test: two consecutive refreshes with identical data produce exactly one investigation (first-sighting), zero on the second. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
D5: Register get_recent_events in the tool registry so the AI can query recent incident-state changes during investigation. Returns the bounded in-memory change log (new, resolved, status/urgency changes, new alerts and notes). Handler tests match the pattern of the other seven tools. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
TestToSnapshots_UnloadedCacheSuppressesFalseChanges FAILS: when the lazy enrichment cache loads between polls, toSnapshots treats "not loaded" as 0, producing false NoteAdded/AlertAdded changes for every incident on startup. TestToSnapshots_GenuineNoteAdditionAfterCacheLoad PASSES: genuine note additions after cache load are correctly detected. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
When the lazy enrichment cache loads between polls, toSnapshots was treating "not loaded" as 0 for NoteCount/AlertCount. This produced false NoteAdded/AlertAdded changes for every incident on startup — defeating the delta gate at the worst possible time. Fix: NoteCount and AlertCount are now *int. nil means "unknown/not yet loaded"; Diff skips note/alert comparisons when the previous value is nil. The genuine 0→1 transition (loaded cache, real new note) is still detected. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
M1: TestRunDetectors_DeltaGateBothDirections exercises runDetectors with non-empty changes (must fire), empty changes (must suppress), and changed data (must re-enable). FAILS if the gate is stubbed with `if false &&`. M3: TestRunDetectors_SingleIncidentNoInvestigation asserts that runDetectors returns nil for a single incident. FAILS if the guard is changed to `< 0`. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…onse The PagerDuty Incident struct from the list API has no escalation_level field (that field exists only on IncidentAlert). EscalationLevel was hardcoded to 0 in toSnapshots, so Escalated could never fire — a change kind that silently implied coverage it could not provide. Removed: Escalated from ChangeKind enum, EscalationLevel from Snapshot, escalation comparison from Diff, and all related tests. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
N2: ClusterID was set in the Snapshot struct but never compared in Diff and never populated by toSnapshots — removed. N4: Title and Service were stored in Snapshot but never compared, implying change coverage they did not provide. Added IncidentUpdated change kind and comparisons so Diff detects title/service changes. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
watcherDedup.seen grew unboundedly. Added eviction of expired entries (older than cooldown) when the map exceeds 100 entries. The dedup layer coexists with the delta layer because they serve different purposes: delta gates on incident state changes, dedup gates on repeated observation text within a cooldown window. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Added post-review fixes table (M1-M3, N1-N4), removed Escalated from change kinds, documented dedup+delta coexistence rationale, and updated NoteCount/AlertCount semantics (nil = unknown). Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
e7768fa to
b659871
Compare
Summary
Diff(prev, curr)function inpkg/delta/that computes typed changes between PagerDuty polls. Wire into watcher as primary investigation gate — unchanged alerts are no longer re-investigated.m.selectedIncident. Context now foregrounds triggering incidents with sibling alerts as background.delta.Narrate) into investigation seed context so the AI sees what changed.Chatinterface inpkg/aifollowing the optional-interface pattern (SupportsChat/AsChat).get_recent_eventstool as ClassRead — exposes recent incident-state changes to the AI agent.Binding decision
In-memory only. No TurnStore, JSONL, or file persistence. Restarting srepd = fresh start.
Post-review fixes
TestRunDetectors_DeltaGateBothDirectionsexercising both directions of the gate throughrunDetectorsif false && len(changes) == 0→ test FAILSNoteCount/AlertCountto*int; nil = unknown/unloaded,Diffskips comparison when prev is nilTestToSnapshots_UnloadedCacheSuppressesFalseChanges,TestDiff_NilNoteCountSkipsComparison,TestDiff_ZeroToNonZeroNoteCountDetectedincidentList < 2guard untestedTestRunDetectors_SingleIncidentNoInvestigationEscalationLevelhardcoded to 0Escalatedkind +EscalationLevelfrom Snapshot — PagerDutyIncidenton list response has noescalation_levelfieldTestDiff_Escalated,TestDiff_EscalationDecrease_NoChangeClusterIDset but never comparedtoSnapshots)watcherDedup.seenunboundedTestWatcherDedup/evicts_expired_entries_when_threshold_exceededTitle/Servicenever comparedIncidentUpdatedchange kindTestDiff_TitleChanged,TestDiff_ServiceChangedDedup + delta coexistence: the delta layer gates on whether incident STATE changed; the dedup layer gates on whether the same OBSERVATION TEXT was already investigated within the cooldown window. Both are needed — delta catches "nothing changed at all", dedup catches "same observation text despite unrelated state change".
Traceability
df22984pkg/delta— TestDiff_{NoChange,NewIncident,IncidentResolved,StatusChange,UrgencyChange,NoteAdded,AlertAdded,MultipleChanges,FirstSighting,ReorderingOnly,EmptyBoth,NilNoteCountSkipsComparison,NilAlertCountSkipsComparison,ZeroToNonZeroNoteCountDetected,TitleChanged,ServiceChanged}5883b99TestRunDetectors_DeltaGateBothDirectionse0c41c2TestBuildObservationContext_ScopedToTriggeringIncident,TestBuildAskFromVerdict_UsesOriginatingIncident5883b994c56dc0TestSupportsChat,TestAsChat47046e9TestGetRecentEvents_{HappyPath,EmptyChanges,WithLimit,InvalidInput,IsClassRead}0bd17edTestRunDetectors_DeltaGateBothDirections3041078→70e746fTestToSnapshots_UnloadedCacheSuppressesFalseChanges,TestToSnapshots_GenuineNoteAdditionAfterCacheLoad0bd17edTestRunDetectors_SingleIncidentNoInvestigation38d28e0bc01128TestDiff_TitleChanged,TestDiff_ServiceChanged208ba1eTestWatcherDedup/evicts_expired_entries_when_threshold_exceededRevert checks
M1 —
if false && len(changes) == 0M2 — revert toSnapshots to always use
&0for unloaded cacheAfter restore — all pass
Test plan
gofmt -s -l cmd pkg— no output (clean)go vet ./...— passesgolangci-lint run— 0 issuesgo test ./pkg/... -count=1— all passgo test -race ./pkg/... -count=1— all passdeadcode ./...— no new entries (only pre-existing mock/config/test helpers)make quickstart-verify— docs/quickstart.md is up to datemake plan-check— plan doc foundcmd/tests fail on missing config keys (same on cleanmain)Testing this live
make build./dist/srepd_linux_amd64_v1/srepd --devwto toggle watcher pane visibilityVisual validation
tui-mcp is unavailable in the container environment (no Node.js/npx). Golden snapshots and view_render integration tests are the only visual evidence. The orchestrator should validate on the host.
🤖 Generated with Claude Code