Skip to content

fix(pane): retire a blocked turn-end gate only on sustained output [no-ticket] - #54

Merged
matthias-scale merged 3 commits into
fork/pr-basefrom
fix/gate-retirement-sustained-output
Aug 11, 2026
Merged

fix(pane): retire a blocked turn-end gate only on sustained output [no-ticket]#54
matthias-scale merged 3 commits into
fork/pr-basefrom
fix/gate-retirement-sustained-output

Conversation

@matthias-scale

Copy link
Copy Markdown
Owner

Follow-up to #53 (688143a). Three commits, one concern each.

1. fix(pane): a single stray late write no longer retires a gate

#53 measured first content change to latest content change, which is the rule
"any second change at least a grace period after the first" -- not "output is
still arriving". Confirmed failure: gate raised, turn-end prompt repaint arms
the window, pane quiet for 30s, then one content-seq bump retires the gate
instantly. Sources of that bump include a background job printing a line, an
OSC/synchronised-update sequence, and a herdr window resize -- so resizing the
herdr window silently cleared a blocked badge the human was still reading.

Retirement now measures a run of content changes: a silence longer than
FULL_LIFECYCLE_HOOK_OUTPUT_QUIET_RESET (2s) starts a new run, and a run has
to span the existing 5s grace before the gate goes. The detect loop ticks every
300ms and a real turn writes on essentially every tick, so 2s is six ticks of
headroom while the multi-second silences around isolated writes are rejected.

Resizes are excluded at the callsite rather than by a time heuristic: resize
still bumps detection_content_seq (the idle scan skip must be invalidated)
but now also moves the retirement baseline, because it is herdr's own write.
That keeps a "not agent output" fact out of the timing window entirely, which
is the only place that distinction is actually known.

Properties held, each with a test:

  • a lone turn-end repaint never retires (a_turn_end_repaint_alone_does_not_retire_a_hook_gate)
  • a repaint, a long quiet gap, then one isolated mark never retires (an_isolated_late_write_does_not_retire_a_hook_gate)
  • isolated writes never accumulate, however many arrive (isolated_writes_never_accumulate_into_a_retirement)
  • an agent that really resumes retires within one grace period plus one tick (continuing_output_retires_a_hook_gate_after_the_grace_period, output_resuming_after_a_pause_restarts_the_grace_period)
  • a resize does not arm retirement (a_resize_does_not_arm_retirement_of_a_blocked_gate)

Blocked stays exitable: forwarded input, a contradicting detected agent, a
process exit and the next hook report all still end it, unchanged.

2. test(state): AC4 gap from the #53 review

A fresh non-blocked closing-block report must end a Blocked state when screen
detection is unavailable (detected_agent == None) -- the exact panes the gate
is raised on, since a claude prompt box matches no detection rule. The path
(route_full_lifecycle_hook_report -> process_not_contradicted ->
set_hook_authority_at -> recompute_effective_state) was believed correct by
code reading and untested.

It is correct: both new tests passed first run, no defect found. Covered for
the idle report and the working report.

3. refactor(pane): one retirement poll, not two

hook_output_retirement was wired verbatim in both detect loops (spawn path
and restored/attached path) with no test over either, so a future fix could
land on one loop only. Both now call poll_full_lifecycle_hook_retirement;
behaviour is identical, and the helper has its own tests.

Verification

  • cargo test --bin herdr: 3567 passed. 12 failures are parallel-isolation
    flakes in unrelated modules (detect::manifest* mutate XDG_CONFIG_HOME
    process-globally, plus server::headless keybindings and
    workspace::generated_workspace_ids); all 12 pass under --test-threads=1.
  • cargo fmt --check clean, cargo clippy --bin herdr --all-features clean
    (also clean with --tests).

A blocked closing-block gate was retired by any second content change at
least a grace period after the first, so one isolated late write cleared a
badge the human was still reading: a background job printing a line, an
OSC synchronised-update sequence, or a herdr window resize.

Measure a run of content changes instead. A silence longer than the quiet
reset starts a new run, so retirement now needs output that keeps arriving
the way a resumed turn does. Resizes are excluded at their own callsite by
moving the retirement baseline, since they are herdr's write and not the
agent's, and still invalidate the idle scan skip.
The closing-block gate is raised on panes whose prompt box matches no
detection rule, so the pane has no detected agent. Ending that gate with
the next turn's report went through process_not_contradicted untested.
Both the idle and the working report end it.
…t loops

The spawn path and the restored/attached path carried the same retirement
wiring verbatim, so a fix could land on one loop only. Both now call one
helper, covered by tests over the helper itself.
@matthias-scale

Copy link
Copy Markdown
Owner Author

No description provided.

@matthias-scale
matthias-scale merged commit a54fe79 into fork/pr-base Aug 11, 2026
6 checks passed
@matthias-scale

Copy link
Copy Markdown
Owner Author

Correction to the flake-triage claim in this PR's description: it was disproved during review. --test-threads=1 does not make the suite green — 14 failures in parallel, 15 serial, a different set each run, and each failing test passes under its own filter. That is test-order/global-state contamination, not runner parallelism. None of the failures touch the files changed here. Tracked separately as its own cleanup task.

Post-merge verification (herdr a54fe79, protocol 19), on all four hosts — Mac, ub1, ub2, mbair: a pane that receives a closing-block report with 1 blocking gate, immediately after a burst of turn-end repaint output, holds agent_status=blocked for the whole observation window (100s on the Mac, 64-96s elsewhere) instead of retiring after ~5s. Negative control on the Mac: sustained output written straight to the pane's tty at 0.5s cadence (no forwarded input) retires the gate between +3s and +6s, as designed.

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