diff --git a/shared/principles/fail-fast.md b/shared/principles/fail-fast.md index f91e4a50b..0bd94cd2d 100644 --- a/shared/principles/fail-fast.md +++ b/shared/principles/fail-fast.md @@ -280,6 +280,72 @@ published in a PR body as `0 issues in 0 files` was about to be re-reported as a check that examined nothing. Re-running it printed `Linting: 439 files` above the same summary.) +### A background watcher reports failure as silence by default + +The cases above are all checks you read the output of. +A watcher is one you deliberately stop reading, which is its whole purpose --- +so its output channel is a *notification*, and the absence of one is +indistinguishable from the thing still running. + +That inverts the usual economics of this bug. +A silent `|| echo "none"` at least sits in front of you. +A watcher's silence is what you asked for: quiet means nothing to report, +which is exactly what a healthy long-running job looks like. +So the failure is not merely unnoticed, it is *reassuring*. + +The shape is a poll loop that emits only on the happy path: + +```sh +for i in $(seq 1 25); do + pending=$(...) + if [ "$pending" = 0 ]; then echo "settled: ..."; break; fi + sleep 60 +done # <- falls out silently when it never settles +``` + +Every iteration finds work still pending, the loop exhausts its range, and the +script exits 0 having printed nothing. +Nothing failed, so nothing is reported, and the watcher's silence gets read as +"not finished yet" indefinitely. + +Two fixes, and take both. +Give the loop a **terminal else**, so exhausting the range says so out loud and +names what it was waiting for. +And emit on **every** state you would act on, not just the one you hope for --- +a failed check, a blocking verdict, a job that vanished. + +Note the second is the same instruction the Monitor tool's own documentation +gives ("if this process crashed right now, would my filter emit anything?"), +which is worth saying because reading that guidance is evidently not sufficient +to follow it. + +- **Do:** end a bounded poll loop with an explicit timeout message naming the + condition that never arrived. +- **Do:** widen the filter to every terminal state, then confirm by asking what + the watcher would have printed had the job died at the start. +- **Don't:** read a watcher's quiet as evidence the work is still in flight. +- **Don't:** treat "I read the tool's guidance about coverage" as having applied + it. + +A second route to the same silence, with a different cause, is recorded in +[`memories/claude-code.md`](../../memories/claude-code.md): a pipe stage that +consumes the content a later stage was meant to read (`grep -q`, `-l`, or `-c` +upstream of something that greps stdout) starves the loop of anything to emit. +That one is about what reaches the filter and this one is about what the filter +is written to match, so the fixes differ --- but the symptom is identical, and +in both cases the discrepancy surfaced only by running the underlying query by +hand. + +(2026-08-01, a `UCD-SERG/ucd-serg.github.io` session: two successive monitors +watching a PR's checks exited silently after 25 minutes, both written to print +only when zero checks were pending. +The first hid a red `validate`; the second hid nothing but was equally +uninformative. +Both were caught by querying the PR directly rather than by anything the +watchers did, and the second was armed *after* writing a status note about the +first --- so knowing the failure mode did not prevent repeating it within the +hour.) + ### The pattern itself is the other half, and it fails without erroring Everything above is about a check that *cannot report* its own failure.