Skip to content

fix(harness-context): the status-line tee degrades to silence, not raw JSON - #391

Merged
schickling-assistant merged 1 commit into
mainfrom
schickling-assistant/2026-08-30-2026-08-30-statusline-degraded-arm
Aug 30, 2026
Merged

fix(harness-context): the status-line tee degrades to silence, not raw JSON#391
schickling-assistant merged 1 commit into
mainfrom
schickling-assistant/2026-08-30-2026-08-30-statusline-degraded-arm

Conversation

@schickling-assistant

@schickling-assistant schickling-assistant commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Problem

Live regression, observed on dev3 on 2026-08-30. Every managed Claude seat
on the host rendered its own status-line payload as its status line — a wall of
{"session_id":"…","transcript_path":"…",…} repainting every five seconds in
place of the operator's line.

The tee (st2 driver claude-statusline, hooks/claude-statusline.sh) resolves the
operator's renderer from two paths in order. Both were absent on dev3:

  1. $ST_CLAUDE_STATUSLINE_RENDERER — supplied through home.sessionVariables,
    which reaches a login shell. st2 launches seats from a systemd user service, so
    it never reached them.
  2. ~/.claude/statusline-renderer.json — a home.file-managed file on a
    generation 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 st2 vanished mid-upgrade all render an empty status line and put
their reason on stderr. Recording is untouched in every one of them, and a
resolved renderer still receives the payload byte-for-byte.

Decisions

  • Silence over transparency, for this surface only. The reasoning is not that
    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.
  • The diagnostic goes to stderr, not nowhere. Claude routes a status-line
    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 rides tracing::warn!,
    matching the existing recording-failure warn; the tee's Telemetry::local_only()
    installs the stderr layer without building an OTel pipeline, so DQ-C13 still
    holds. (Verified empirically in the tests, not assumed.)
  • A renderer that cannot spawn now matches one that exits non-zero. Previously
    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.
  • The hook script's own outermost fallback changed too — flagging this as the one
    scope call beyond the literal ask.
    Leaving exec cat there would contradict
    the amended HC-R18 in shipped code. It is now exec cat >/dev/null: it drains
    rather 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 jq for the file path, and half-solving it asymmetrically
    is worse than emitting nothing.
  • VRS amended, not silently rewritten. Decision 0014 gains ## Amendment 1
    recording what is withdrawn and the live failure that forced it. requirements.md
    and spec.md are timeless by convention, so they state the corrected behavior and
    why the earlier rule was wrong rather than carrying an amendment header.

Verification

Baseline measured on pristine main (cd45d60) in this sandbox before any
edit, so no failure is assumed pre-existing:

passed failed
baseline (main) 1284 13
after 1284 14

The claude_statusline target 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) and
3 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 an
    isolated 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 a
    pristine 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_names fails on
both 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.

$ nix develop -c cargo test --test claude_statusline
running 13 tests
test with_no_renderer_the_tee_renders_nothing_rather_than_the_raw_payload ... ok
test a_renderer_that_exits_non_zero_leaves_stdout_empty ... ok
test a_renderer_file_that_is_not_executable_leaves_stdout_empty ... ok
test a_recording_failure_with_no_renderer_still_renders_nothing ... ok
test without_st2_on_path_the_script_drains_stdin_and_renders_nothing ... ok
test the_tee_records_the_reading_and_hands_the_same_payload_to_the_env_renderer ... ok
... (13 total)
test result: ok. 13 passed; 0 failed

Clippy: --all-targets aborts on main's two src/resync.rs denials before the
test targets are analyzed, so an empty grep there would prove nothing. Run against
the targets that do build — cargo clippy --lib --all-features and
cargo clippy --test claude_statusline --all-features — both are clean.

Complexity

Negative. A function is deleted (passthrough), two arms collapse into one
behavior, and no abstraction, dependency, or module boundary is added.

Concerns

  • A blank status line is now a reachable state with no error anywhere. stderr is
    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.
  • dev3 needs its dotfiles generation switched to actually get a rendered line
    back.
    This PR stops the JSON; it does not supply the missing renderer. See
    dotfiles #2185.
  • The $ST_CLAUDE_STATUSLINE_RENDERER path stays broken for service-launched seats
    by construction — home.sessionVariables is a login-shell mechanism. The file
    path is the one that has to work for managed seats.

Friction & bottlenecks

  • Friction: cargo fmt --all reformats ~36 unrelated files, because main is not
    fmt-clean. Every one had to be reverted by hand to keep the diff honest. A
    fmt-clean main plus a CI gate would remove this trap for anyone running fmt.
  • Friction: clippy --all-targets is red on main — two
    non_octal_unix_permissions denials at src/resync.rs:2614 and :2650
    (Permissions::from_mode(0)0o0). Untouched here; noted as a pre-flip
    deviation, reproduced on a pristine tree rather than assumed.
  • Friction: the sandbox suite is flaky in both directions under --all-targets
    parallelism, 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

  • Switch the dev3 dotfiles 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.
  • Not in scope: making $ST_CLAUDE_STATUSLINE_RENDERER reach service-launched seats.
  • Not in scope: main's red clippy lanes and non-fmt-clean tree (both logged above).

References

Posted on behalf of @schickling
field value
agent_identity dev3.direct.claude.5a5uzzdj
session dev3.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

…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
schickling-assistant marked this pull request as ready for review August 30, 2026 15:06
@schickling-assistant
schickling-assistant merged commit c4d6f7a into main Aug 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant