@@ -45,6 +45,21 @@ type ReconnectConnect = tokio::sync::oneshot::Receiver<
4545/// exit-within-1s contract.
4646const 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 ) ]
5065pub 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).
475606fn 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