fix(harness-context): the status-line tee degrades to silence, not raw JSON - #391
Merged
schickling-assistant merged 1 commit intoAug 30, 2026
Conversation
…w JSON
Live regression on dev3: every managed Claude seat rendered its own status-line
payload as its status line, a wall of
`{"session_id":…,"transcript_path":…}` repainting every five seconds. Neither
resolution path was present there — `~/.claude/statusline-renderer.json` is
managed by the dotfiles generation and had not been switched, and
`$ST_CLAUDE_STATUSLINE_RENDERER` comes from the login-shell session variables
while st2 launches seats from a systemd user service — so the tee took its
no-renderer arm, which passed stdin through unchanged.
The passthrough was a design defect, not a coding slip. HC-R18 called for
"transparency rather than silence" so the degraded case would still be a status
line. That is the right default for a channel a human reads and the wrong one
here: the payload is a machine serialization, so echoing it is strictly worse
for the operator than an empty row and carries nothing they can act on.
Recording happens either way; only the human-facing line was ever at stake.
Both degraded arms now write nothing to stdout, and so does the hook script's
own outermost fallback. A renderer that fails to spawn joins the one that exits
non-zero, so a permissions bug on the renderer file cannot spew JSON where a
missing renderer would not.
The diagnostic goes to stderr, which Claude routes to its debug log and never
to the rendered row — so it costs nothing visible at the 5-second cadence while
being the only channel that can tell an operator why their line went blank. It
names both resolution paths, because absent both is precisely the diagnosis
that was missing on dev3. The script fallback drains stdin (`exec cat
>/dev/null`) rather than exiting, since Claude writes the payload into that
process and an unread stdin would earn an EPIPE every five seconds.
VRS amended rather than rewritten: HC-R18 and the spec's tee section state the
corrected behavior and why the earlier rule was wrong, and decision 0014 gains
`## Amendment 1` recording what is withdrawn and the live failure that forced
it. The INVARIANTS row changes with them.
The degraded tests now assert an empty stdout and take their positive evidence
from stderr — an empty stdout alone is also what a tee that crashed instantly
would leave, which would have made the two collector-budget tests vacuous.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
agent-identity: dev3.direct.claude.5a5uzzdj
agent-persona: generalist
agent-supervisor: unavailable
agent-tool: Claude Code
agent-tool-version: 2.1.251
agent-runtime: Claude Code 2.1.251
tooling-profile: dotfiles@2161b9c
schickling-assistant
marked this pull request as ready for review
August 30, 2026 15:06
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Live regression, observed on
dev3on 2026-08-30. Every managed Claude seaton the host rendered its own status-line payload as its status line — a wall of
{"session_id":"…","transcript_path":"…",…}repainting every five seconds inplace of the operator's line.
The tee (
st2 driver claude-statusline,hooks/claude-statusline.sh) resolves theoperator's renderer from two paths in order. Both were absent on
dev3:$ST_CLAUDE_STATUSLINE_RENDERER— supplied throughhome.sessionVariables,which reaches a login shell. st2 launches seats from a systemd user service, so
it never reached them.
~/.claude/statusline-renderer.json— ahome.file-managed file on ageneration that had not been switched yet, so it did not exist.
With neither resolving, the tee took its no-renderer arm — which passed stdin
through unchanged.
That arm was a design defect, not a coding slip. HC-R18 required
"transparency rather than silence" so the degraded case would still be a status
line. That is the right default for a channel a human reads, and the wrong one
here: the payload is a machine serialization, so echoing it into the slot is
strictly worse for the operator than an empty row, and carries nothing they can
act on. Recording happened either way — only the human-facing line was ever at
stake.
Goal
No degraded path of the status-line tee can put bytes on stdout. A seat with no
renderer, a renderer that will not start, a renderer that exits non-zero, and a
seat whose
st2vanished mid-upgrade all render an empty status line and puttheir reason on stderr. Recording is untouched in every one of them, and a
resolved renderer still receives the payload byte-for-byte.
Decisions
transparency is wrong in general; it is that the status-line slot is not a
channel a human reads. The amended requirement says so explicitly, so the next
reader does not "restore" the passthrough.
command's stderr to its debug log and never to the rendered row, so it costs
nothing visible at the 5-second cadence — while being the only channel that can
explain a blank line. It names both resolution paths, because absent both is
precisely the diagnosis that was missing on
dev3. It ridestracing::warn!,matching the existing recording-failure warn; the tee's
Telemetry::local_only()installs the stderr layer without building an OTel pipeline, so
DQ-C13stillholds. (Verified empirically in the tests, not assumed.)
only the second left stdout untouched. Aligning them means a one-character
permissions bug on the renderer file cannot spew JSON where a missing renderer
would not.
scope call beyond the literal ask. Leaving
exec catthere would contradictthe amended HC-R18 in shipped code. It is now
exec cat >/dev/null: it drainsrather than exiting, because Claude writes the payload into that process and an
unread stdin would earn an EPIPE every five seconds. Resolving the renderer from
bash was rejected — no
jqfor the file path, and half-solving it asymmetricallyis worse than emitting nothing.
## Amendment 1recording what is withdrawn and the live failure that forced it.
requirements.mdand
spec.mdare timeless by convention, so they state the corrected behavior andwhy the earlier rule was wrong rather than carrying an amendment header.
Verification
Baseline measured on pristine
main(cd45d60) in this sandbox before anyedit, so no failure is assumed pre-existing:
main)The
claude_statuslinetarget goes 12 → 13 passing, 0 failing on both sides,which accounts for the flat pass count against one added test.
The suite is flaky under full parallel load in this sandbox, in both directions.
Between the two runs, 2 baseline failures flipped green
(
managed_agents_do_not_inherit_launcher_no_color_unless_declared,run::tests::the_group_kill_reaps_a_descendant_that_outlives_the_direct_child) and3 new ones appeared — all with sandbox signatures (pty
Session id "…" event log is busy,timed out waiting for …/publication-ready,Daemon process exited immediately). Each was characterized rather than waved off:agent_publish— failed on different tests in the full run than in anisolated re-run, then passed 3/3 isolated with the patch applied, and passed on a
pristine tree.
nomad_survival::manual_pty_restart_…— passed isolated with the patch, and on apristine tree; its failure was a pty daemon error.
No new failure survives isolation. Net test delta: +1 passing, 0 new failures.
The docs-lint test
tracked_product_surface_contains_only_native_namesfails onboth sides and its name diff is therefore zero — so its enumerated file+phrase
lines were diffed directly instead. Identical: the prose edits added no hits.
Clippy:
--all-targetsaborts onmain's twosrc/resync.rsdenials before thetest targets are analyzed, so an empty grep there would prove nothing. Run against
the targets that do build —
cargo clippy --lib --all-featuresandcargo clippy --test claude_statusline --all-features— both are clean.Complexity
Negative. A function is deleted (
passthrough), two arms collapse into onebehavior, and no abstraction, dependency, or module boundary is added.
Concerns
the only place that says why, and it lands in Claude's debug log — which an
operator has to know to look at. That is the deliberate trade (a blank row beats a
JSON wall), but it is a real discoverability cost and the reason the diagnostic is
required rather than optional.
dev3needs its dotfiles generation switched to actually get a rendered lineback. This PR stops the JSON; it does not supply the missing renderer. See
dotfiles #2185.
$ST_CLAUDE_STATUSLINE_RENDERERpath stays broken for service-launched seatsby construction —
home.sessionVariablesis a login-shell mechanism. The filepath is the one that has to work for managed seats.
Friction & bottlenecks
cargo fmt --allreformats ~36 unrelated files, becausemainis notfmt-clean. Every one had to be reverted by hand to keep the diff honest. A
fmt-clean
mainplus a CI gate would remove this trap for anyone runningfmt.clippy --all-targetsis red onmain— twonon_octal_unix_permissionsdenials atsrc/resync.rs:2614and:2650(
Permissions::from_mode(0)→0o0). Untouched here; noted as a pre-flipdeviation, reproduced on a pristine tree rather than assumed.
--all-targetsparallelism, so a failing-test-name diff alone cannot establish a delta. Each new
failure needed isolated and pristine-tree re-runs to classify.
Follow-ups
dev3dotfiles generation so a renderer actually resolves — dotfiles#2185. Until then those seats render a blank line rather than JSON, which is the
intended degraded state.
$ST_CLAUDE_STATUSLINE_RENDERERreach service-launched seats.main's red clippy lanes and non-fmt-clean tree (both logged above).References
docs/vrs/.decisions/0014-harness-context-is-a-sibling-numeric-record.md§Amendment 1docs/vrs/08-harness-context/requirements.md; the tee section ofspec.mdINVARIANTS.md→ Status-line slot chainingPosted on behalf of @schickling
agent_identitysessionagent_personaagent_supervisoragent_toolagent_tool_versionagent_runtimetooling_profile