Skip to content

Commit 5395ce4

Browse files
test(pa-tui): the headless settle bound names its stuck member in 60s - defense-in-depth inside the wedge wall (#2949)
The interactive_daemon_e2e exit-gate wedge family is root-killed upstream (#2945: the turn-end watermark restarts with the mounted stream) and the harness carries the 300s wall (#2942). This closes the remaining gap: the headless settle was the run loop's only unbounded wait on the product side — a settle member that never drains parked the run forever with no failure name (TS exits the process at shutdown and lets in-flight work dangle, so the settle has no TS counterpart). The gate's twelve settle members are now snapshotted (HeadlessSettle: settled() is the old gate exactly; blockers() names them), and after the plan completes a stuck member fails the run within HEADLESS_SETTLE_TIMEOUT_MS (60s) with the member named — e.g. 'a turn still active' — instead of waiting out the harness's 300s wall with a generic timeout. Green settles are milliseconds after HeadlessDone (the suite's green wall is ~15s; the last submit's own ack bound is 10s), and terminal runs never arm the bound (HeadlessDone exists only on the headless harness; a live terminal ends the run on exit_requested + the exit guard). Proven end-to-end by the lane's matrix: the wedge converted to a 98.4s attributable red naming the latched member (the bound-only diagnostic arm), and the full candidate stayed clean at 30/30 interleaved trials vs 3/30 wedges at the lane parent (record 20260927-085200-interaction-exit-gate-hang-remediation, bench hillclimb).
1 parent fdb05d9 commit 5395ce4

1 file changed

Lines changed: 267 additions & 21 deletions

File tree

‎crates/pa-tui/src/interactive.rs‎

Lines changed: 267 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,21 @@ type ReconnectConnect = tokio::sync::oneshot::Receiver<
4545
/// exit-within-1s contract.
4646
const TELEMETRY_EXIT_TIMEOUT_MS: u64 = 500;
4747

48+
/// The headless exit gate's settle bound: after the plan completes
49+
/// ([`UiInput::HeadlessDone`]), the run must end within this much wall
50+
/// clock. The gate has no other bound — a settle member that never drains
51+
/// (the `interactive_daemon_e2e` exit-gate wedge family: a submit/switch
52+
/// round-trip race latching `turn_active` with the whole daemon trio
53+
/// idle) parks the run in `Runtime::block_on` forever and eats a whole
54+
/// CI job budget with no failure name. The bound converts that into an
55+
/// attributable error naming the stuck member(s). Terminal runs never
56+
/// arm it: `HeadlessDone` exists only on the headless harness, and the
57+
/// gate is unreachable there (a live terminal ends the run on
58+
/// `exit_requested`). The margin: green settles are milliseconds (the
59+
/// suite's 32-test green wall is ~15s; the last submit's own ack bound
60+
/// is 10s), so 60s is a settle that went wrong, never a slow green.
61+
const HEADLESS_SETTLE_TIMEOUT_MS: u64 = 60_000;
62+
4863
/// Which session the interactive run opens.
4964
#[derive(Debug, Clone, PartialEq, Eq)]
5065
pub enum SessionSelection {
@@ -470,6 +485,122 @@ pub enum HeadlessStep {
470485
Key(crossterm::event::KeyEvent),
471486
}
472487

488+
/// The headless exit gate's settle, snapshotted: the members the gate
489+
/// requires before the run may end (every one is work the harness must
490+
/// not cut short — a live terminal never ends the run on its own; TS
491+
/// exits the PROCESS at shutdown and lets in-flight work dangle, so the
492+
/// harness's settle has no TS counterpart). `settled()` is the gate;
493+
/// `blockers()` is the same members named, so the settle bound's failure
494+
/// names exactly what stuck (the wedge family's conversion from a
495+
/// job-budget hang with no failure name into an attributable error).
496+
#[derive(Debug, Default)]
497+
struct HeadlessSettle {
498+
/// Plan inputs still queued behind a barrier.
499+
pending_inputs: usize,
500+
/// A turn still streaming (or latched).
501+
turn_active: bool,
502+
/// Prompt round trips whose ack has not landed.
503+
submits_in_flight: usize,
504+
/// The queued-message strip's items.
505+
queued: usize,
506+
/// An armed idle barrier waiting out its deadline.
507+
idle_barrier: bool,
508+
/// A frame the loop has not painted yet.
509+
dirty: bool,
510+
/// A `/share` upload whose outcome has not landed.
511+
share_pending: bool,
512+
/// A `/reload` whose outcome has not landed.
513+
reload_pending: bool,
514+
/// A `/traces` upload whose outcome has not landed.
515+
traces_upload_pending: bool,
516+
/// The inline auth panel is mounted.
517+
auth_panel_open: bool,
518+
/// A `/traces login` flow is pending.
519+
traces_login_pending: bool,
520+
/// An MCP auth flow is pending.
521+
mcp_auth_pending: bool,
522+
}
523+
524+
impl HeadlessSettle {
525+
/// Read the gate's members off the loop state (the gate's exact
526+
/// conditions, in the same order the gate historically checked them).
527+
fn snapshot(
528+
session: &SessionUi,
529+
view: &AgentView,
530+
pending_inputs: usize,
531+
idle_barrier: bool,
532+
) -> Self {
533+
Self {
534+
pending_inputs,
535+
turn_active: session.turn_active,
536+
submits_in_flight: session.prompt_submits_in_flight(),
537+
queued: view.queued.steering.len() + view.queued.follow_ups.len(),
538+
idle_barrier,
539+
dirty: session.dirty,
540+
share_pending: session.share_pending(),
541+
reload_pending: session.reload_pending(),
542+
traces_upload_pending: session.traces_upload_pending(),
543+
auth_panel_open: view.auth_panel.is_some(),
544+
traces_login_pending: session.pending_traces_login(),
545+
mcp_auth_pending: session.pending_mcp_auth(),
546+
}
547+
}
548+
549+
/// Whether every settle member drained (the exit gate).
550+
fn settled(&self) -> bool {
551+
self.blockers().is_empty()
552+
}
553+
554+
/// The members that are holding the run open, named for the settle
555+
/// bound's failure (empty when settled).
556+
fn blockers(&self) -> Vec<String> {
557+
let mut blockers = Vec::new();
558+
if self.pending_inputs > 0 {
559+
blockers.push(format!(
560+
"{} queued plan input(s) behind a barrier",
561+
self.pending_inputs
562+
));
563+
}
564+
if self.turn_active {
565+
blockers.push("a turn still active".to_string());
566+
}
567+
if self.submits_in_flight > 0 {
568+
blockers.push(format!(
569+
"{} prompt submit(s) without an ack",
570+
self.submits_in_flight
571+
));
572+
}
573+
if self.queued > 0 {
574+
blockers.push(format!("{} queued message(s) undelivered", self.queued));
575+
}
576+
if self.idle_barrier {
577+
blockers.push("an idle barrier waiting out its deadline".to_string());
578+
}
579+
if self.dirty {
580+
blockers.push("an unpainted frame".to_string());
581+
}
582+
if self.share_pending {
583+
blockers.push("a share upload in flight".to_string());
584+
}
585+
if self.reload_pending {
586+
blockers.push("a reload in flight".to_string());
587+
}
588+
if self.traces_upload_pending {
589+
blockers.push("a traces upload in flight".to_string());
590+
}
591+
if self.auth_panel_open {
592+
blockers.push("the inline auth panel open".to_string());
593+
}
594+
if self.traces_login_pending {
595+
blockers.push("a pending traces login".to_string());
596+
}
597+
if self.mcp_auth_pending {
598+
blockers.push("a pending MCP auth flow".to_string());
599+
}
600+
blockers
601+
}
602+
}
603+
473604
/// One typed string as key events: characters become `Char` presses, `\n`
474605
/// becomes Enter, and `\t` becomes Tab (the keys autocomplete reacts to).
475606
fn typed_keys(text: &str) -> Vec<KeyEvent> {
@@ -1922,6 +2053,11 @@ async fn run_interactive_surface(
19222053
// declared above the onboarding phase because the pane's drive marks
19232054
// it when the plan completes while the pane owns the input channel.
19242055
let mut headless_done = false;
2056+
// The settle bound's deadline, armed once the plan completes (the
2057+
// gate below ends the run on a full settle; the bound ends it with a
2058+
// named error when a member never drains — see
2059+
// [`HEADLESS_SETTLE_TIMEOUT_MS`]).
2060+
let mut headless_settle_deadline: Option<Instant> = None;
19252061
// First-run onboarding owns the pane before the session screen (TS
19262062
// `runStartupOnboarding`): a home whose startup model is ready sees
19272063
// the trace question alone, and a not-ready home runs the full
@@ -2430,27 +2566,35 @@ async fn run_interactive_surface(
24302566
inputs_pending = false;
24312567
}
24322568
}
2433-
// A `/share` upload in flight holds the run open like an active
2434-
// turn: the headless harness must not finish before its outcome
2435-
// rows land (a live terminal never ends the run on its own).
2436-
if headless_done
2437-
&& pending.is_empty()
2438-
&& !session.turn_active
2439-
&& session.prompt_submits_in_flight() == 0
2440-
&& view.queued.is_empty()
2441-
&& wait_idle_deadline.is_none()
2442-
&& !session.dirty
2443-
&& !session.share_pending()
2444-
&& !session.reload_pending()
2445-
&& !session.traces_upload_pending()
2446-
// An inline auth flow is work like an upload: the harness
2447-
// must not finish before its settled outcome lands (a live
2448-
// terminal never ends the run on its own).
2449-
&& view.auth_panel.is_none()
2450-
&& !session.pending_traces_login()
2451-
&& !session.pending_mcp_auth()
2452-
{
2453-
break;
2569+
// The headless exit gate: the plan completed, and the run ends
2570+
// once every settle member drains (a `/share` upload in flight
2571+
// holds the run open like an active turn — the headless harness
2572+
// must not finish before its outcome rows land, and an inline
2573+
// auth flow is work like an upload; a live terminal never ends
2574+
// the run on its own). The members are snapshotted so the bound
2575+
// below can name exactly what stuck.
2576+
let settle = headless_done.then(|| {
2577+
HeadlessSettle::snapshot(&session, &view, pending.len(), wait_idle_deadline.is_some())
2578+
});
2579+
if let Some(settle) = settle {
2580+
if settle.settled() {
2581+
break;
2582+
}
2583+
// The settle bound: the gate's wait is the harness's only
2584+
// unbounded one (TS exits the process at shutdown and lets
2585+
// in-flight work dangle), so a member that never drains
2586+
// fails the run with its name instead of wedging the test
2587+
// binary forever (the CI wedge family: a 30-45min job
2588+
// budget with no failure row).
2589+
let deadline = headless_settle_deadline
2590+
.get_or_insert(Instant::now() + Duration::from_millis(HEADLESS_SETTLE_TIMEOUT_MS));
2591+
if Instant::now() >= *deadline {
2592+
anyhow::bail!(
2593+
"the headless run's settle did not complete within {}ms of the plan's completion: {}",
2594+
HEADLESS_SETTLE_TIMEOUT_MS,
2595+
settle.blockers().join("; ")
2596+
);
2597+
}
24542598
}
24552599

24562600
let was_active = session.turn_active;
@@ -4085,4 +4229,106 @@ mod tests {
40854229
None
40864230
);
40874231
}
4232+
4233+
/// The headless settle snapshot: `settled()` is exactly the old exit
4234+
/// gate (every member clear), and each member that sticks is named in
4235+
/// the bound's failure — the diagnostic IS the wedge family's
4236+
/// failure name.
4237+
#[test]
4238+
fn the_headless_settle_names_every_stuck_member() {
4239+
// Everything clear: settled, no blockers.
4240+
let settled = HeadlessSettle::default();
4241+
assert!(settled.settled(), "the default snapshot is the open gate");
4242+
assert!(settled.blockers().is_empty());
4243+
// One member at a time: each blocker names exactly its member.
4244+
for stuck in [
4245+
HeadlessSettle {
4246+
pending_inputs: 2,
4247+
..Default::default()
4248+
},
4249+
HeadlessSettle {
4250+
turn_active: true,
4251+
..Default::default()
4252+
},
4253+
HeadlessSettle {
4254+
submits_in_flight: 1,
4255+
..Default::default()
4256+
},
4257+
HeadlessSettle {
4258+
queued: 3,
4259+
..Default::default()
4260+
},
4261+
HeadlessSettle {
4262+
idle_barrier: true,
4263+
..Default::default()
4264+
},
4265+
HeadlessSettle {
4266+
dirty: true,
4267+
..Default::default()
4268+
},
4269+
HeadlessSettle {
4270+
share_pending: true,
4271+
..Default::default()
4272+
},
4273+
HeadlessSettle {
4274+
reload_pending: true,
4275+
..Default::default()
4276+
},
4277+
HeadlessSettle {
4278+
traces_upload_pending: true,
4279+
..Default::default()
4280+
},
4281+
HeadlessSettle {
4282+
auth_panel_open: true,
4283+
..Default::default()
4284+
},
4285+
HeadlessSettle {
4286+
traces_login_pending: true,
4287+
..Default::default()
4288+
},
4289+
HeadlessSettle {
4290+
mcp_auth_pending: true,
4291+
..Default::default()
4292+
},
4293+
] {
4294+
assert!(!stuck.settled(), "one stuck member holds the gate shut");
4295+
assert_eq!(
4296+
stuck.blockers().len(),
4297+
1,
4298+
"each stuck member names exactly one blocker"
4299+
);
4300+
}
4301+
// The wedge family's member: a latched turn names the turn.
4302+
let wedge = HeadlessSettle {
4303+
turn_active: true,
4304+
..Default::default()
4305+
};
4306+
assert_eq!(
4307+
wedge.blockers(),
4308+
vec!["a turn still active".to_string()],
4309+
"the exit-gate wedge's failure names its stuck member"
4310+
);
4311+
// Everything stuck at once: every member is reported.
4312+
let all = HeadlessSettle {
4313+
pending_inputs: 1,
4314+
turn_active: true,
4315+
submits_in_flight: 1,
4316+
queued: 1,
4317+
idle_barrier: true,
4318+
dirty: true,
4319+
share_pending: true,
4320+
reload_pending: true,
4321+
traces_upload_pending: true,
4322+
auth_panel_open: true,
4323+
traces_login_pending: true,
4324+
mcp_auth_pending: true,
4325+
};
4326+
assert_eq!(all.blockers().len(), 12);
4327+
assert!(
4328+
all.blockers()
4329+
.iter()
4330+
.any(|blocker| blocker.contains("a turn still active")),
4331+
"the joined failure keeps the member names readable"
4332+
);
4333+
}
40884334
}

0 commit comments

Comments
 (0)