fix: log to stderr so model-call failures are diagnosable #454
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
2 participants
Notifications
Total time spent: 2 minutes 53 seconds
Due date
igor
2 minutes 53 seconds
No due date set.
Dependencies
No dependencies set
Reference
joshtronic/igor!454
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/453-log-to-stderr"
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?
Part of #453 — this makes the failure diagnosable; the timeout itself is the next PR.
The swallow
Nearly every helper that logs is called inside command substitution:
log()printf'd to stdout, so everythingclaude_calllogged got captured into$rawand thrown away with it. That's why #453 has nineteenreview call failedlines and not one cause —claude_calldoes log the reason, it just never reached the journal.rc=124would have named the timeout on the first occurrence.The change
log()now writes to stderr inbin/tick.shand 17 lib fallbacks, with an 18th added tolib/claude.sh— 19log()definitions in total.agent.servicesets neitherStandardOutputnorStandardError, so systemd routes both streams to the journal — nothing becomes less visible, the diagnostics just stop contaminating captured values.This also makes the harness consistent with itself:
site-work-block.sh,reading-pipeline.shandideation-pipeline.shalready logged to stderr. tick.sh and the lib fallbacks were the ones out of step.lib/claude.shhad nolog()fallback at all — its header requires the caller to supply one, and under tick.sh that holds. But it's the module that knows why a model call died, and sourced any other way those lines die aslog: command not found(which is what the new test hit on first run). Added the same guard the other 17 libs carry.Verified nothing depended on stdout
bin/agent-*.shuseslog()for user-facing output;agent-report.shalready writes its result to>&2tick.sh's stdoutcheck-sync.shall cleanTests
bin/test-log-stderr.sh— written as a property over everylog()in the repo, so a lib added later with a stdout fallback fails here instead of silently reintroducing the swallow. It also drives the realclaude_callwith a stubbed CLI executable (a shell function won't work —claude_callinvokes the CLI throughenv, which execs).Mutation-checked both ways. Reverting either
log()reproduces #453's exact signature:🤖 Generated with Claude Code
https://claude.ai/code/session_01QfDyMfn2Go73gw21a92wNB
Review round 1 — three of four findings were correct
Deleted the tautological test block.
test-log-stderr.sh's "the swallow itself" section defined its ownlog()/failing()stubs and asserted that>&2escapes command substitution — true on a fully reverted tree, so it could never fail. The grep property and theclaude_callblock are what actually mutation-check.Made the grep property general. It was a byte-match on
printf '[agent] %s\\n' "$*", not the property the description claimed. It now matches thelog()definition and asserts the line redirects, anchored to the exact function name (forgejo_action_job_log()was a false positive on my first attempt). Mutation-checked with an echo-based logger and a different-prefix logger — the old check missed both:Verified the stdout audit instead of asserting it. Five lib functions had a
log()line as their only stdout —_deploy_alert,browser_reap_sweep,_feedback_fail,http_reap_sweep,ensure_audit_tool. None is ever called inside command substitution; all five are statement calls, so no caller's captured value changes.Follow-up answered: no
claude_callsite redirects stderr to/dev/null, includingbin/tick.sh:3030.rc=124will reach the journal for the timeout work.Count corrected above, per the fourth finding. The BRE nit was real but, as the review noted, produced no false negative — moot now that the pattern is replaced.
🤖 Review —
COMMENT(automated)CI for
6eed261a: successMechanical change:
log()now printf's to stderr inbin/tick.sh+ 17 lib fallbacks, a new fallback is added tolib/claude.sh, plus a newbin/test-log-stderr.sh. The direction is clearly right (log lines emitted inside$( )were being captured into the caller's value instead of the journal), the diff is small and in scope, CI is green, and there's no security surface. Two things I want a human to eyeball before merge rather than block on.Findings
out=$(some_fn); [ -n "$out" ] && ...wheresome_fn's only stdout was alog()line. The description saysbin/agent-*.sh, logwatch andtick.sh's stdout were all checked — I can't confirm that from the diff, and the unit suite passing only covers what the suite exercises. Worth agrepfor captures of lib functions whose sole output path waslog().lib/claude.sh(18 libs), plusbin/tick.sh= 19log()definitions total. Cosmetic, but the "19 libs" phrasing doesn't match the diff.bin/test-log-stderr.sh:44-62("the swallow itself") tests bash, not the repo. It defines its ownlog()/failing()stubs and asserts that>&2output isn't captured by$( ). That's a tautology — it would pass on a fully reverted tree. The real coverage is the grep property (L34-41) and theclaude_callblock (L64-92); those two are what mutation-check.log()in the repo" (L34-36). It matches one exact byte sequence,log() { printf '[agent] %s\n' "$*"; }. A future lib with a multi-line body, a different format string, orechoinstead ofprintfslips through un-flagged. TheDEFS >= 15floor (L38-40) is a reasonable drift alarm, but the description oversells this as a general property. (Minor BRE nit:"$*"in the pattern is unescaped —*quantifies the literal$— so the pattern is looser than intended. It still matches the target form; no false negative.)Positives worth noting
claude_callblock stubsclaudeas a real executable rather than a shell function, with a comment explaining why (envexecs) — that's the kind of detail that usually gets a test silently passing for the wrong reason.lib/claude.shgaining the fallback is technically adjacent to the stated fix, but it's the module whose diagnostics matter most and it's required for the new test to source standalone; justified and documented inline.Follow-up
2>/dev/nullnow discards the diagnostic entirely instead of capturing it — same net loss as before, but if #453's timeout fix depends onrc=124reaching the journal, confirm the review path isn't one of those.Independent review by the harness on
claude-opus-5(effort: high). The human reviewer is requested once Igor has reviewed; a human still merges.🤖 Review —
COMMENT(automated)CI for
8ba89b2a: successMechanical, well-motivated fix:
log()now writes to stderr inbin/tick.sh+ 17 existing lib fallbacks, a missing fallback is added tolib/claude.sh, and a newbin/test-log-stderr.shlocks the property down. CI is green and the new test is picked up bycheck-sync.sh'sbin/test-*.shloop, so it actually runs. I found no defect in the diff — my only reservation is a blast radius the diff can't show, so I'm leaving this for a human glance rather than approving.Verified against the diff
bin/tick.sh) + 17 modified lib fallbacks + 1 new fallback inlib/claude.sh= 19. No fabricated claims spotted; every narrative point maps to a hunk.PATHstub is amktemp -dwith anEXITtrap and is test-only.log()definition rather than one byte sequence, with the anchor that avoids theforgejo_action_job_log()false positive.The reservation (why not APPROVE)
The diff changes 19 definitions but the behavior change lands at every
log()call site in the harness, none of which are in the diff. The specific inverse of #453 is now possible: any helper invoked ashelper 2>/dev/null(or with stderr otherwise discarded) will silently drop its[agent]lines from the journal — the same class of lost-diagnostic bug this PR is fixing, just relocated. The description audits this only forclaude_callsites ("noclaude_callsite redirects stderr to/dev/null") and for five functions whose only stdout was alog()line. I can't confirm from the diff that no other logging helper is called under a stderr-discarding redirection, and the new test's grep property covers definitions, not call sites.Concretely, worth one grep before merge:
2>/dev/null(and2>&-) applied to calls of internal functions that log, plus anybin/agent-*.shwhose stdout a human or script consumes.Test notes (non-blocking)
bin/test-log-stderr.sh:44— the pattern requires the literallog() {with exactly one space;log() {orlog () {would slip past. TheDEFS >= 20floor partially guards drift, but only against files disappearing from the match, not against a new file using different spacing.bin/test-log-stderr.sh:49— hard-coded floor of 20 is a magic number that will need bumping by hand as libs are added; consider deriving it or at least noting it in the failure message (it does say "did the grep drift?", which is good).log()definitions in total" reads as a contradiction until you remember the three already-stderr pipelines (site-work-block.sh,reading-pipeline.sh,ideation-pipeline.sh) are also counted. Harmless, but a reader will trip on it.log()bodies aren't covered — acknowledged honestly in the header comment rather than oversold, which I'd rather see than a silent gap.Follow-up: #453's actual fix (the timeout) is explicitly deferred; this PR only makes
rc=124visible. That staging is reasonable and stated up front.Independent review by the harness on
claude-opus-5(effort: high). The human reviewer is requested once Igor has reviewed; a human still merges.