Skip to content

Commit c4d6f7a

Browse files
fix(harness-context): the status-line tee degrades to silence, not raw JSON (#391)
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. 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 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 7b14815 commit c4d6f7a

8 files changed

Lines changed: 254 additions & 71 deletions

File tree

INVARIANTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ materialization, messaging, DING, or presence must preserve them.
2525
| **Observed harness state discipline** | The `harness-state` record is written only by the owning session's driver processes — wrapper, channel, or hooks — serialized by a cross-process lock and coalesced against the on-disk record, atomically and byte-distinct on every write that lands, with freshness from its embedded timestamp and never file mtime; restating an unchanged state touches the record only when the refresh cadence is due. `unknown` is derived, never written: staleness, future skew, malformation, an unsupported schema, and a provably dead pty session each read as `unknown` with a distinct reason; an indeterminate liveness probe downgrades nothing; a missing record is no observation rather than `unknown`; no absence derives a definite state. A writer that loses sight of its harness stops heartbeating instead of refreshing a state it cannot see, and a predecessor session's record is never re-stamped. A reaped provider yields a terminal `ended` record carrying its real exit — written before the stop path's SIGKILL escalation and rewritten from the escalation cover when a grace-window reap observes the real status, proven against the real wrapper binaries of both stop implementations — and never a live state. | `src/harness_state.rs::unknown_state_is_derived_and_cannot_be_written`; `src/harness_state.rs::malformed_record_is_unknown_without_mtime_fallback`; `src/harness_state.rs::staleness_and_future_skew_derive_unknown_with_distinct_reasons`; `src/harness_state.rs::a_dead_session_reads_unknown_even_while_fresh_but_ended_survives`; `src/harness_state.rs::every_landed_write_is_byte_distinct_and_fresh_restatements_do_not_write`; `src/harness_state.rs::a_chatty_producer_restating_its_state_causes_zero_writes`; `src/harness_state.rs::concurrent_writers_defer_to_the_on_disk_record_not_their_cache`; `src/harness_state.rs::a_predecessor_sessions_record_is_never_heartbeat_eligible`; `src/harness_state.rs::missing_record_reads_as_none_not_unknown`; `src/codex_app_server.rs::pump_publishes_observations_and_stops_heartbeating_on_evidence_loss`; `src/claude_session.rs::a_provider_killed_mid_turn_reads_ended_rather_than_active`; `src/claude_session.rs::a_clean_provider_exit_writes_the_terminal_record`; `tests/harness_state_teardown.rs::stop_escalation_writes_the_terminal_record_before_sigkill`; `tests/harness_state_teardown.rs::opencode_stop_escalation_writes_the_cover_record_before_sigkill`; `tests/harness_state_teardown.rs::opencode_graceful_stop_records_the_real_reaped_exit` |
2626
| **Harness context discipline** | The numeric `harness-context` record is a sibling of `harness-state` and shares none of its ownership machinery: `incarnation` is provenance and never a fence, and a straggler's write lands rather than being refused. A reading is written only when it enters a different bucket of `usedPercent`, when a compaction edge arrives, or when the record is older than the heartbeat, so a chatty producer cannot inflate the write rate; every landed write is byte-distinct, and freshness comes from the record's own `observedAtMs`, never file mtime. A withheld value is carried as `null` and never fabricated from zero, the previous reading, or a division st2 could have done itself, and a reading above the window is carried unclamped. There is no `unknown` on this axis: past the horizon the reading is returned marked `stale` with its age, surviving every derivation the categorical record makes, while an unparseable record, a foreign schema, an uninterpretable harness, and an untrustworthy clock each read as absent. The relaunch claim removes the record, and Doctor's high-reading and stale-record lines are advisory in both directions and never change its exit status. Where a producer ships, its numerator is the harness's own and is pinned to the build it was measured on: pi's `tokens` is the last assistant message's `totalTokens` and omp's is its prompt figure, so neither harness's arithmetic can be published under the other's tag, and a harness bump that changes a number's meaning without changing its shape fails a fixture rather than shipping. A harness that positively reports it does not know its own occupancy is forwarded as withheld in the same write as the compaction edge that emptied it, a compaction edge whose harness names no reason yields `unknown` rather than an invented word, and a durable count the producer cannot read narrows to st2 counting edges rather than losing them. | `src/harness_context.rs::a_reading_inside_the_written_bucket_does_not_write_and_a_crossing_does`; `src/harness_context.rs::a_withheld_percent_has_no_bucket_and_only_a_compaction_or_heartbeat_writes`; `src/harness_context.rs::a_record_older_than_the_heartbeat_is_rewritten_and_every_landed_write_is_byte_distinct`; `src/harness_context.rs::a_compaction_always_lands_with_its_trigger_and_may_carry_a_durable_count`; `src/harness_context.rs::withheld_values_are_null_and_are_never_fabricated`; `src/harness_context.rs::a_reading_above_the_window_is_carried_unclamped`; `src/harness_context.rs::a_stale_reading_is_returned_with_its_age_rather_than_derived_away`; `src/harness_context.rs::freshness_comes_from_the_record_bytes_and_never_from_file_mtime`; `src/harness_context.rs::additive_fields_decode_but_foreign_schema_and_harness_read_as_nothing`; `src/harness_context.rs::a_record_beyond_the_future_skew_bound_reads_as_nothing`; `src/harness_context.rs::a_straggler_lands_and_is_visible_as_provenance_rather_than_being_refused`; `src/harness_state.rs::the_relaunch_claim_removes_the_harness_context_record`; `src/agents.rs::context_is_a_fourth_axis_that_survives_an_indeterminate_observed_state`; `tests/doctor.rs::harness_context_doctor_lines_are_advisory_and_never_change_the_exit_status`; `src/pi_channel.rs::the_pi_0_84_2_fixture_pins_total_tokens_as_the_numerator`; `src/pi_channel.rs::the_omp_18_0_9_fixture_pins_prompt_input_as_the_numerator`; `src/pi_channel.rs::a_pi_compaction_withholds_the_reading_it_emptied_in_the_same_write`; `src/pi_channel.rs::an_omp_compaction_yields_unknown_because_the_event_names_no_reason`; `src/pi_channel.rs::an_unreadable_durable_count_degrades_to_counting_edges_not_to_losing_them`; `src/pi_channel.rs::context_frames_decode_conservatively_or_not_at_all`; `src/pi_channel.rs::the_measured_pi_release_is_the_one_the_extension_gate_pins`; `src/omp_session.rs::the_measured_context_builds_are_admitted_by_this_gate` |
2727
| **Replicated-path discipline** | st2 pins the exact driver-record names it expects the replication transport's include list to carry — `harness-state` and `harness-context` — and both readers derive their paths from that list, so a rename cannot silently stop replication in a repository that does not own the list. A harness-context write never creates a staging file inside the agent directory: it stages outside the agent subtree, on the same filesystem, and leaves the record plus its own permanent lock and nothing else behind. `harness-state` still stages beside itself, pending its own change; the shared write helper takes the staging directory as an argument precisely because the two answer this differently. | `src/harness_context.rs::the_replicated_driver_record_names_are_pinned`; `src/harness_context.rs::writes_stage_outside_the_agent_subtree_and_leave_nothing_behind` |
28-
| **Status-line slot chaining** | Claude's `statusLine` is a single slot whose winning declaration replaces the others outright, and `.claude/settings.local.json` — the file st2 materializes — wins over every other settings file. So st2's entry there is a tee: it records the reading and then invokes the operator's own renderer, resolved from `$ST_CLAUDE_STATUSLINE_RENDERER` and then `~/.claude/statusline-renderer.json`, first hit wins and never both. Where no renderer resolves the payload is passed through byte-for-byte, and a recording that fails still renders — the degraded case is always a status line, never a blank one. | `tests/claude_statusline.rs::the_tee_records_the_reading_and_hands_the_same_payload_to_the_env_renderer`; `tests/claude_statusline.rs::the_operator_file_supplies_the_renderer_when_no_variable_does`; `tests/claude_statusline.rs::the_variable_wins_over_the_file_and_the_file_is_never_also_run`; `tests/claude_statusline.rs::with_no_renderer_the_tee_passes_its_stdin_through_byte_for_byte`; `tests/claude_statusline.rs::a_recording_failure_still_renders_the_status_line`; `tests/claude_statusline.rs::without_st2_on_path_the_script_itself_falls_back_to_the_payload`; `tests/claude_statusline.rs::the_rendered_registration_names_the_chaining_tee_and_carries_the_whole_slot` |
28+
| **Status-line slot chaining** | Claude's `statusLine` is a single slot whose winning declaration replaces the others outright, and `.claude/settings.local.json` — the file st2 materializes — wins over every other settings file. So st2's entry there is a tee: it records the reading and then invokes the operator's own renderer, resolved from `$ST_CLAUDE_STATUSLINE_RENDERER` and then `~/.claude/statusline-renderer.json`, first hit wins and never both. Where no renderer resolves, and wherever a resolved renderer fails, the tee writes NOTHING to stdout and puts its diagnostic on stderr: the payload is machine JSON, so echoing it into the slot is strictly worse for the operator than a blank row. Recording is unaffected by which arm runs. | `tests/claude_statusline.rs::the_tee_records_the_reading_and_hands_the_same_payload_to_the_env_renderer`; `tests/claude_statusline.rs::the_operator_file_supplies_the_renderer_when_no_variable_does`; `tests/claude_statusline.rs::the_variable_wins_over_the_file_and_the_file_is_never_also_run`; `tests/claude_statusline.rs::with_no_renderer_the_tee_renders_nothing_rather_than_the_raw_payload`; `tests/claude_statusline.rs::a_recording_failure_still_renders_the_status_line`; `tests/claude_statusline.rs::a_renderer_that_exits_non_zero_leaves_stdout_empty`; `tests/claude_statusline.rs::a_renderer_file_that_is_not_executable_leaves_stdout_empty`; `tests/claude_statusline.rs::without_st2_on_path_the_script_drains_stdin_and_renders_nothing`; `tests/claude_statusline.rs::the_rendered_registration_names_the_chaining_tee_and_carries_the_whole_slot` |
2929
| **Version-pinned producer arithmetic** | Every harness-context producer publishes the number its own harness means by that number, and each one is pinned to the exact build the meaning was measured on: Claude's integer percent over `total_input_tokens` and `context_window_size` (2.1.250), Codex's window with the 12,000 baseline subtracted from both sides (codex-cli 0.150.1), pi's last assistant `totalTokens` and omp's prompt-only `input` (0.84.2 and 18.0.9), and OpenCode's last non-summary assistant total over the providers window (1.18.25). The failure this bounds is a harness bump that changes what a key means without changing its shape, which no type gate and no round-trip assertion can see, so each fixture decodes a verbatim capture, asserts the version literally, and asserts the wrong numerator the same capture would yield. | `src/claude_session.rs::a_mid_session_statusline_payload_yields_claudes_own_triple`; `src/claude_session.rs::a_pre_turn_statusline_payload_withholds_rather_than_reporting_zero`; `src/codex_app_server.rs::codex_context_recomputes_the_captured_reading_and_pins_its_verified_version`; `src/pi_channel.rs::the_pi_0_84_2_fixture_pins_total_tokens_as_the_numerator`; `src/pi_channel.rs::the_omp_18_0_9_fixture_pins_prompt_input_as_the_numerator`; `src/opencode_session.rs::captured_opencode_turns_publish_the_assistant_total_over_the_providers_window` |
3030
| **Native-driver diagnostics are bounded and recoverable** | One additive-tolerant `st2.driver-diagnostic.v1` snapshot exposes the earliest failing native-driver boundary through closed stage/reason/source/support vocabulary and origin-timed evidence age. Absent, malformed, foreign, unknown, and wrongly paired records never read healthy. Stage recovery clears only its own failure and removes the record after final recovery without changing prompt submission, retry, read-back, or archive semantics. Roster and Doctor share the typed reader/repair policy; telemetry labels contain only bounded stage/reason/source/support/outcome values, never versions, identities, prompts, messages, or paths. | `src/driver_diagnostic.rs::every_stage_reason_and_source_has_bounded_wire_vocabulary`; `src/driver_diagnostic.rs::additive_fields_decode_but_malformed_foreign_and_unknown_records_are_indeterminate`; `src/driver_diagnostic.rs::recovery_clears_only_its_stage_and_reveals_the_next_failure`; `src/opencode_session.rs::delivery_and_read_back_boundaries_publish_and_clear_diagnostics_without_changing_retry`; `src/metrics.rs::driver_diagnostic_metric_attributes_are_exactly_the_bounded_axes`; `tests/doctor.rs::native_driver_diagnostic_roster_and_doctor_agree_and_recovery_clears` |
3131
| **Retirement health** | A retired declaration is healthy only after every declared task ID is absent. Any live or dead declared task record reports incomplete retirement; retired declarations do not require presence. Live declarations retain their existing task and presence checks. | `tests/doctor.rs::retired_declaration_is_healthy_when_tasks_and_presence_are_absent`; `tests/doctor.rs::retired_declaration_is_unhealthy_while_a_declared_task_is_alive`; `tests/doctor.rs::retired_declaration_is_unhealthy_while_a_dead_task_record_remains` |

docs/vrs/.decisions/0014-harness-context-is-a-sibling-numeric-record.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,41 @@ enumerates siblings by name, moves.
210210
- Bare *context* is already taken in st2's language for R09's working state. The
211211
wire key is `context` while the canonical term is *harness context record*;
212212
the ontology carries the collision rule.
213+
214+
## Amendment 1 — 2026-08-30: the degraded status-line arm is silent
215+
216+
The chaining obligation (q5, HC-R18) stands unchanged. What is withdrawn is the
217+
*fallback* the original decision paired with it: that where no downstream
218+
renderer resolves, the tee passes the payload through unchanged, "transparency
219+
rather than silence", so the degraded case is still a status line.
220+
221+
That reasoning was wrong for this surface, and observably so. The status-line
222+
payload is machine JSON, not prose — session id, transcript path, model and
223+
usage blocks — and the slot repaints every five seconds. A seat that resolves no
224+
renderer therefore renders a wall of
225+
`{"session_id":…,"transcript_path":…}` in place of its status line: worse for
226+
the operator than a blank row, and carrying nothing they can act on.
227+
Transparency is the right default for a channel a human reads, and the original
228+
argument applied it to a channel that carries a machine's serialization.
229+
230+
The failure was live rather than theoretical, and was observed on `dev3` on
231+
2026-08-30. Neither resolution path was present there — the renderer file is managed by the dotfiles
232+
generation and had not been switched, and the environment variable comes from
233+
the login-shell session variables while seats launch from a user service — so
234+
every managed Claude seat on that host displayed raw JSON as its status line.
235+
236+
Amended: where no renderer resolves, and wherever a resolved renderer fails, the
237+
tee writes nothing to stdout and puts its diagnostic on stderr, which the
238+
harness routes to its debug log and never to the rendered row. The diagnostic
239+
names both resolution paths, because absent both is precisely the diagnosis. The
240+
hook script's own outermost fallback follows the same rule, draining stdin so
241+
the harness does not take an EPIPE at the refresh cadence.
242+
243+
Recording is untouched by any of this: the reading lands whether or not anything
244+
is drawn, so the amendment trades no telemetry for the quieter line. Two
245+
consequences follow. The *Status-line slot chaining* invariant row changes
246+
wording, and its degraded proofs now assert an empty stdout with the stderr
247+
diagnostic as their positive evidence — an empty stdout alone is also what a tee
248+
that crashed instantly would leave. And a blank status line becomes a state an
249+
operator can reach without an error anywhere; stderr is the only place that says
250+
why, which is why the diagnostic is required rather than optional.

docs/vrs/08-harness-context/open-questions.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ Each entry links a spec `DQ-C*`. Questions leave this file when resolved — int
2525
`$ST_CLAUDE_STATUSLINE_RENDERER` first, then
2626
`~/.claude/statusline-renderer.json` (schema
2727
`dotfiles.claude-statusline-renderer.v1`, carrying `{"command": …}`), then —
28-
if neither resolves — passing stdin through unchanged. A user-owned file was
28+
if neither resolves — writing nothing to stdout and naming both paths in a
29+
stderr diagnostic. A user-owned file was
2930
chosen over a settings key because the settings file st2 wins in is the one
3031
st2 rewrites, so a renderer declared there is exactly what the merge does not
3132
preserve (HC-R18's inverse). Specified in [spec.md](./spec.md) under the

docs/vrs/08-harness-context/requirements.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,16 @@ record.
245245
the highest-precedence declaration replaces the others outright — nothing
246246
merges — so occupying it without chaining silently removes whatever the
247247
operator had, on every managed agent, with no warning. Where no downstream
248-
renderer resolves, the tee passes its input through unchanged rather than
249-
discarding it, so the degraded case is still a status line. The inverse also holds
250-
and is not solved by chaining: a renderer a human sets in a file st2
251-
materializes is not preserved by st2's merge, which owns only its own hook
252-
entries.
248+
renderer resolves, and wherever a resolved renderer fails, the tee writes
249+
nothing to stdout: the degraded case is an EMPTY status line, and its
250+
diagnostic goes to stderr, which the harness does not render. Silence is
251+
required rather than merely permitted, because the payload on that channel is
252+
machine-readable JSON — session id, transcript path, model and usage blocks —
253+
and a slot that echoes it paints that JSON across the operator's terminal at
254+
the refresh cadence. That is strictly worse for them than a blank row and
255+
carries nothing they can act on, so "transparency over silence" is the wrong
256+
trade for this surface even though it is the right one for a channel a human
257+
reads. Which arm runs never affects recording; only the human-facing line is
258+
at stake. The inverse also holds and is not solved by chaining: a renderer a
259+
human sets in a file st2 materializes is not preserved by st2's merge, which
260+
owns only its own hook entries.

0 commit comments

Comments
 (0)