Skip to content

fix(state): keep a blocked turn-end gate until the pane really works again [no-ticket] - #53

Merged
matthias-scale merged 1 commit into
fork/pr-basefrom
fix/closing-block-blocked-state
Aug 11, 2026
Merged

fix(state): keep a blocked turn-end gate until the pane really works again [no-ticket]#53
matthias-scale merged 1 commit into
fork/pr-basefrom
fix/closing-block-blocked-state

Conversation

@matthias-scale

Copy link
Copy Markdown
Owner

Symptom

On three hosts, every pane whose last turn ended with a blocking gate showed agent_status: working (or idle) in herdr agent list — never blocked — so the sidebar gate badge never appeared. The gate data itself was correct on the same record (tokens.closing_blocking = "1", state_labels.blocked = "gate", gates: [1 item]), which proves the report was accepted: handle_pane_report_agent only applies the gates array when the state report was accepted.

Confirmed cause

Live evidence on w1C:pK while blocked: screen_detection_skipped was absent (i.e. full_lifecycle_hook_authority_active() == false) while sibling non-gate panes had it true, and herdr agent explain w1C:pK returned state: working, rule: osc_title_working, evidence: "⠐ Prepare coaching kickoff notes with Davud" — a stale spinner OSC title. So the pane still detected claude (the authority was therefore effective), yet the full-lifecycle authority was no longer live, and the screen fallback won.

herdr:claude was ruled out as a competing authority: the installed integration hook only ever sends pane.report_agent_session, never a state report.

That leaves AppEvent::HookAuthorityRetiredretire_blocked_full_lifecycle_hook_authority_at. FullLifecycleHookOutputRetirement::observe armed on the first content change after the blocked report and then expired on wall clock alone, five seconds later, whether or not anything else was written. Ending a turn is itself a write: the agent repaints its prompt box as soon as the Stop hook returns, which bumps detection_content_seq (any non-empty PTY chunk does). So every gate was retired ~5s after it was raised, and the pane fell back to fallback_state.

Fix

  • src/pane/agent_detection.rs — retirement now measures the window between the first and the latest content change. Continuing output (a resumed turn: tokens, spinner, counters) still retires the gate after the same grace; a single turn-end repaint followed by silence never does.
  • src/terminal/state.rshook_authority_is_effective and the closing-block admission arm of route_full_lifecycle_hook_report no longer require the agent to be detected on screen. Absence of detection is not evidence of absence — at turn end the prompt box matches no rule, and the scan is skipped outright while the authority is live. Only a different detected agent or an observed process exit contradicts the hook; suppressed sources keep the stricter presence guard.

No protection is weakened: a stale frame still cannot resurrect a finished gate (visible_working_overrides_idle_hook / detected_working_overrides_idle_hook are unchanged and still guarded by authority.state == Idle), process exit and agent conflict still release the authority immediately.

Tests

New:

  • a_turn_end_repaint_alone_does_not_retire_a_hook_gate — one repaint burst then silence never retires.
  • a_blocked_closing_block_report_survives_unavailable_screen_detection — blocked report with detected_agent = None and a working fallback stays Blocked (failed before the fix: left: Working, right: Blocked).
  • a_blocked_closing_block_report_ends_when_a_new_turn_retires_it — genuine retirement still returns the pane to Working.
  • a_blocked_closing_block_report_yields_to_a_different_detected_agent, ..._yields_to_the_agent_process_exiting — the two real contradictions still win.

Updated: continuing_output_retires_a_hook_gate_after_the_grace_period and output_before_the_first_detection_tick_starts_the_grace_period now advance the content seq across the window, matching the new contract.

cargo test --bin herdr: 3456 passed, 3 failed — all pre-existing environment flakes unrelated to this diff (app::api::plugins::tests::manifest_action_invoke_injects_plugin_paths, server::headless::tests::render_and_stream_sends_terminal_frame_for_terminal_ansi_client, workspace::tests::generated_workspace_ids_are_short_base32_handles). cargo fmt --check and cargo clippy --bin herdr --all-features clean.

…again

A closing-block report that says "blocked" was reaching the pane record --
gates, tokens and state labels all applied -- and then losing its state
within seconds, so the sidebar never showed a gate badge on any host.

Two paths dropped it, both by treating the absence of screen evidence, or
the pane's own turn-end repaint, as evidence against the hook:

- Retirement armed on the first content change after the blocked report
  and expired on wall clock alone. Every agent repaints its prompt box the
  moment the hook returns, so a gate was retired five seconds after it was
  raised, handing the pane back to the screen fallback (a stale spinner
  OSC title, read as working). Retirement now needs output that is still
  arriving a grace period after it started: a resumed turn always is, a
  turn-end repaint never is.
- Both the admission route and the effectiveness check required the agent
  to be detected on screen. At turn end the prompt box matches no rule --
  and the scan is skipped outright while the authority is live -- so the
  hook was demoted exactly when it knew something the screen could not.
  Only a *different* detected agent or an observed process exit now
  contradicts it.
@matthias-scale

Copy link
Copy Markdown
Owner Author

No description provided.

@matthias-scale
matthias-scale merged commit 688143a into fork/pr-base Aug 11, 2026
6 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