fix: move logwatch to a daily pass behind a recurrence bar #432
No reviewers
Labels
No labels
Agent
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No assignees
1 participant
Notifications
Total time spent: 3 minutes 3 seconds
Due date
igor
3 minutes 3 seconds
No due date set.
Dependencies
No dependencies set
Reference
joshtronic/igor!432
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/432-logwatch-daily-recurrence-gate"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What this PR does
Logwatch ran hourly and filed on a single occurrence. Across five verified tickets that produced one false positive (an operator pause), one duplicate carrying a wrong cause, one real symptom with a wrong cause, and two good ones. Each ticket, right or wrong, then cost a full build → review → rework cycle -- so a ~40% diagnosis hit rate at the top of the funnel was setting the work rate for the whole fleet.
The watching was never the problem. The filing bar was: looking costs one model call, filing commits the fleet to an agentic cycle, and both were triggered by the same threshold.
1. Daily, over the full 24h
An hour of context cannot distinguish a transient from a chronic -- at that resolution they are identical, which is exactly why every blip got chased. A day can count, and the count is that discriminator.
Note it reads the whole 24h, not one hour of it. The original daily pass read only
00:00-01:00and left 23 hours unwatched; that blind spot is what drove the move to hourly, and re-introducing it would trade one failure mode for another.2. A recurrence gate before the model call
Failure signatures are extracted, normalized (pids, shas, durations flattened so one condition is one signature) and counted. Only signatures recurring >= 3 times reach the model; a one-off drops to a greppable digest line. A standing red state needs no special rule -- it recurs by definition. On a clean day the pass now returns without any model call at all.
Two filters make the count mean anything:
systemd[N]:and[agent]lines count as evidence. The Claude session's prose is streamed into this same journal and talks about failures constantly. Measured on a real day, without this filter 37 signatures matched and the only one to clear the bar wasinvoking claude for PR review (timeout Nm)-- routine narration -- while the two genuine exit-28s sat below it.timed out, not a baretimeout. The harness logs its own configured limit on every tick, which would clear any bar by itself.3. No more diagnoses
Findings used to carry a confident cause, and it was wrong often enough to steer fixes wrong: one ticket blamed a permission profile for what was a
bin/prefix on a command, and the obvious response to that cause would have been to widen a permission. A finding with no cause costs nothing when it is incomplete. A finding with a wrong cause costs a wrong fix.Validation against the real journal
Run against the actual
agent.servicejournal for 2026-07-25:The old pass filed three tickets that day. This gate files zero, and everything it sees is a genuine failure smell sitting correctly below the bar. All model prose is gone.
Performance
The first cut used per-line
grep/sed, which on 9,500 lines meant ~30,000 subprocesses and over two minutes for a single unit -- it would have eaten the tick budget whole. Rewritten as stream pipelines: 0.27s.The
tail -c 60000cap on the journal read is also gone. A byte cap would silently bias every count by discarding the early hours of the day.Known cost, accepted deliberately
Recall. A real problem visible ONLY in model narration no longer files. Both of this week's examples are in that class: the red
make lintbaseline (#423) and the silently dropped follow-up (#430). Both were genuinely useful tickets.I think that is still the right trade, and that those two want their own mechanism rather than a noisier logwatch -- CI gating lint, and a structured harness warning when a session reports it could not complete a requested side-effect. Both were already named in those tickets' own "fixed looks like" sections. Flagging it explicitly so the loss is a decision, not a surprise.
Test plan
bash bin/test-logwatch.shpasses -- 20 new assertions covering signature stability, the bar, the source filter, thetimeout/timed outdistinction, transient counting, and sample extractionmake testpasses (25 suites)make lintexits 0shellcheck lib/logwatch.sh bin/tick.shcleanbin/tick.shhunk falls inside the logwatch block (2385-2729)On the
igor#432references in the code comments: there is no issue #432 -- this PR took that number. The comments therefore point here, which is where the full rationale lives. No issue was filed on purpose: you asked me to close issues rather than open them, and this change came out of our conversation, not a ticket.Logwatch ran hourly and filed on a SINGLE occurrence. Over five verified tickets that produced one false positive (an operator pause), one duplicate carrying a wrong cause, one real symptom with a wrong cause, and two good ones -- and each ticket, right or wrong, then cost a full build/review/rework cycle. A ~40% diagnosis hit rate at the top of the funnel was setting the work rate for the whole fleet. Three changes, all aimed at the filing bar rather than the watching: 1. Daily, reading YESTERDAY whole. An hour of context cannot tell a transient from a chronic -- they are identical at that resolution -- which is why every blip got chased. A day can COUNT, and the count is exactly that discriminator. The full 24h is read, not one hour of it: the ORIGINAL daily pass read only 00:00-01:00 and left 23 hours unwatched, which is what drove the move to hourly to begin with. 2. A deterministic recurrence gate in front of the model. Failure signatures are extracted, normalized (pids, shas, durations flattened so one condition is one signature) and counted; only those recurring >= LOGWATCH_MIN_OCCURRENCES (3) reach the model. A one-off drops to a greppable digest line. A standing red state needs no separate rule -- it recurs by definition. On a clean day the pass now returns without any model call at all. Two filters make the count mean something: - Source: only `systemd[N]:` and `[agent]` lines are evidence. The Claude session's PROSE is streamed into this same journal and talks about failures constantly. Measured on 2026-07-25, without this filter 37 signatures matched and the only one to clear the bar was `invoking claude for PR review (timeout Nm)` -- routine narration -- while the two genuine exit-28s sat below it. - "timed out", not a bare "timeout": the harness logs its own configured limit every tick, which would clear any bar by itself. 3. The reviewer reports the observation, not a cause. Findings used to carry a confident diagnosis and it was wrong often enough to steer fixes wrong -- one ticket blamed a permission profile for what was a path prefix on a command, where the obvious response would have been to widen a permission. No cause costs nothing when incomplete; a wrong cause costs a wrong fix. Performance: the gate is stream pipelines, not per-line subprocesses. A real day is ~9,500 lines / 1.28MB; per-line greps took over two minutes and would have eaten the tick budget. It now runs in 0.27s. The `tail -c 60000` on the journal read is gone with it -- a byte cap would silently bias every count by discarding the early hours. Known cost, accepted deliberately: recall. A real problem visible ONLY in model narration -- a red lint baseline, a session reporting it could not do something -- no longer files. Both of this week's examples want their own mechanism (CI gating lint; a structured harness warning on a dropped side-effect), not a noisier logwatch. Validated against the real 2026-07-25 journal: the old pass filed three tickets that day; this gate files zero, and the only signatures it sees are the genuine exit-28 pair at 2x each, correctly below the bar.🤖 Review —
COMMENT(automated)CI for
3465fff8: successMoves the logwatch pass from hourly/file-on-first-occurrence to daily with a deterministic recurrence gate in front of the model call. The change is coherent, in scope, and the checklist is honest — every checked box maps to real code in the diff (daily window in
logwatch_window_day/logwatch_done_today, the gate inlib/logwatch.sh, the "no cause" rewrite of the system prompt). CI is green and the new tests are substantive, not decorative. I'm not blocking, but there are enough reservations that a human should glance at this rather than have me stamp it.Findings
bin/tick.sh~2436 — the function's contract comment is now wrong. The header still says "Returns 0 if a model call ran (the tick did real work), 1 if the unit was skipped." The new clean-day early return (if [ -z "$digest" ] && [ "$silence_finding" -eq 0 ]; then ... return 0) returns 0 having made no model call, which then incrementsreviewedand makesdo_logwatch_tickreportreviewed N service journal(s)and return "did work". Not a behavior regression (a clean hour also returned 0 before), but the comment now misdescribes the contract and thereviewedcounter no longer means what it says. Fixed looks like: update the comment, or return a distinct code for "gated, no call".bin/tick.sh~2488 — the digest is unbounded, and thetail -c 60000cap it replaced is gone. Thewhile IFS=$'\t' read -r cnt sigloop emits one block per recurring signature with no cap on signature count, and callslogwatch_samples "$journal" "$sig"per signature — each call re-normalizes the entire day's journal (twoseds +paste+awkover ~1.3 MB). On the measured good day that's ~4 signatures and irrelevant; on a genuinely bad day with dozens of distinct recurring signatures it's O(n) full-journal rescans and a model payload with no ceiling. Given the PR's own framing about the tick budget, a cap on signatures-per-digest (and/or a single normalization pass reused acrosslogwatch_samplescalls) seems worth having.lib/logwatch.shlogwatch_samples—awk -v w="$want"interprets backslash escapes in the value.awk -vprocesses escape sequences, so a signature containing a backslash (a Windows-ish path, a literal\nin an error message) won't compare equal to$1and the function silently returns nothing. The finding still files, just with an empty evidence fence. Safer: pass via an env var andENVIRON["want"], or-vwith the value pre-escaped.bin/test-logwatch.sh— one assertion is vacuous.eq "and none of them register as transients either" "0" "$(logwatch_distinct_transients "$PROSE" 1)"— withmin=1,$1+0 < m+0is false for every signature, sonis never set and the function prints0for any input, filtered or not. This test passes even if the source filter is deleted. Re-run it at the default bar (or withminhigh enough that unfiltered prose would register) to make it mean something.Undocumented recall loss: silence/tick-gap detection got materially weaker. The igor#420 silence finding now requires the unit to produce zero journal lines for a whole 24h (previously: zero for one hour). A unit that dies for 20 hours but logged once at 03:00 no longer trips it, and won't trip the recurrence gate either. The "Known cost, accepted deliberately" section covers the narration-only class (#423, #430) but not this one. Worth naming explicitly so it's a decision rather than a surprise, same as the others.
logwatch_samplesnormalizes the full journal, not the source-filtered subset (lib/logwatch.sh). Signatures are derived only fromsystemd[N]:/[agent]lines, but evidence samples are matched across everything — so a Claude prose line that happens to normalize identically can be quoted as evidence for a systemd failure. Low impact, but it partially undoes the source filter at the point where the human actually reads the output.Notes, non-blocking
igor#432references in code comments point at this PR, not an issue — disclosed in the description, and the rationale does live here. Flagging only so the human isn't surprised when the reference resolves to a PR._logwatch_smell_signaturesis right:grep SOURCEruns before normalization, so thesystemd[N]:provenance marker isn't stripped out from under the filter. Good. Note the signature itself drops provenance, so identical message text from two sources would merge — probably fine, worth knowing.silence_findingpath the digest placeholder is emitted under a header asserting "Each block below recurred at least 3x", which is false for that case. The placeholder text explains itself, so it likely reads fine, but it's a small contradiction in the prompt.Independent review by the harness on
claude-opus-5(effort: high). The human reviewer is requested once Igor has reviewed; a human still merges.