diff --git a/crates/freshell-activity/src/codex.rs b/crates/freshell-activity/src/codex.rs index a597ef8ed..591ed1ac6 100644 --- a/crates/freshell-activity/src/codex.rs +++ b/crates/freshell-activity/src/codex.rs @@ -88,6 +88,9 @@ pub struct CodexTaskEvents { pub latest_task_started_at: Option, pub latest_task_completed_at: Option, pub latest_turn_aborted_at: Option, + /// Reason string paired with `latest_turn_aborted_at` (e.g. "interrupted"). + /// None on legacy rollout lines that carry no reason. + pub latest_turn_aborted_reason: Option, } impl CodexTaskEvents { @@ -140,6 +143,18 @@ struct TerminalActivity { /// S5.a third lane: server-clock receipt time of the newest proxy /// TurnStarted -- the proxy lane's turn key for Busy/Unknown clears. last_proxy_started_at: Option, + /// Proxy lane (kata codex-turn-thread-scope): the turn id of the bound + /// thread's in-flight proxy turn, set on TurnStarted, cleared on rebind + /// AND at every accepted terminal-status completion. A TurnCompleted + /// carrying a DIFFERENT turn id is a stale echo of an already-closed + /// turn and is a no-op by construction. `None` falls back to phase + /// semantics (older protocols omit turnId). + current_proxy_turn_id: Option, + /// Outstanding server→client approval request ids (managed proxy lane). + pending_approvals: std::collections::HashSet, + /// True when the approval pause demoted a working phase; the resolve + /// restores Busy. False when the approval arrived while already idle. + resume_busy_after_approval: bool, last_observed_at: i64, last_emitted_turn_key: Option, parser_state: ParserState, @@ -221,6 +236,13 @@ impl CodexActivityTracker { self.ledger.list_latest_completions() } + #[cfg(test)] + fn current_proxy_turn_id_for(&self, terminal_id: &str) -> Option { + self.states + .get(terminal_id) + .and_then(|s| s.current_proxy_turn_id.clone()) + } + /// Track a codex terminal from create time (deviation 1 above — /// `bindTerminal` with the session identity the create carried, if any). pub fn track_terminal( @@ -234,6 +256,15 @@ impl CodexActivityTracker { if existing.session_id.as_deref() != Some(session_id) { let previous = existing.to_record(); existing.session_id = Some(session_id.to_string()); + // Design decision #7 (kata codex-turn-thread-scope): a + // rebind moves the pane to a DIFFERENT thread -- the old + // thread's in-flight turn id and start anchor must not + // survive (see bind_session). + existing.current_proxy_turn_id = None; + existing.last_proxy_started_at = None; + // Task 7: nor may the old thread's approval pause state. + existing.pending_approvals.clear(); + existing.resume_busy_after_approval = false; let next = existing.to_record(); return changed(Some(&previous), next); } @@ -257,6 +288,9 @@ impl CodexActivityTracker { swallow_next_proxy_complete: false, swallow_next_reconcile_clear: false, last_proxy_started_at: None, + current_proxy_turn_id: None, + pending_approvals: std::collections::HashSet::new(), + resume_busy_after_approval: false, last_observed_at: at, last_emitted_turn_key: None, parser_state: ParserState::new(), @@ -284,6 +318,17 @@ impl CodexActivityTracker { } let previous = state.to_record(); state.session_id = Some(session_id.to_string()); + // Design decision #7 (kata codex-turn-thread-scope): a rebind moves + // the pane to a DIFFERENT thread (fork/resume, delivered by the async + // disk fork-watch lane -- codex_proxy_route.rs:88-91). The old + // thread's in-flight turn id and start anchor must not survive, or + // the new thread's first turn/completed is misclassified as a stale + // echo / collides on last_emitted_turn_key. + state.current_proxy_turn_id = None; + state.last_proxy_started_at = None; + // Task 7: nor may the old thread's approval pause state. + state.pending_approvals.clear(); + state.resume_busy_after_approval = false; let next = state.to_record(); changed(Some(&previous), next) } @@ -312,6 +357,23 @@ impl CodexActivityTracker { events.latest_task_completed_at, events.latest_turn_aborted_at, ); + // The newest terminating event decides the clear's shape: an abort + // (Esc-interrupt / `turn_aborted`) still ends the turn, but only a + // HUMAN-attributed abort (reason `interrupted`/`replaced`, or a + // reason-less legacy line) stays silent -- the human is present. + // Any OTHER present reason is codex stopping on its own, which DOES + // record (rings terminal.idle). Ties go to task_complete: a real + // completion at the same instant still rings. + let clear_is_abort = match ( + events.latest_task_completed_at, + events.latest_turn_aborted_at, + ) { + (Some(completed), Some(aborted)) => aborted > completed, + (None, Some(_)) => true, + _ => false, + }; + let record = + !clear_is_abort || !abort_reason_is_human(events.latest_turn_aborted_reason.as_deref()); // Promote on a NEW unresolved start. if let Some(started_at) = events.latest_task_started_at { @@ -331,12 +393,22 @@ impl CodexActivityTracker { .map(|cleared| started_at > cleared) .unwrap_or(true) { - state.phase = CodexPhase::Busy; - state.force_read_logged = false; - state.next_force_read_at = None; - state.accepted_start_at = Some(started_at); - state.updated_at = at; - state.last_observed_at = at; + if state.pending_approvals.is_empty() { + state.phase = CodexPhase::Busy; + state.force_read_logged = false; + state.next_force_read_at = None; + state.accepted_start_at = Some(started_at); + state.updated_at = at; + state.last_observed_at = at; + } else { + // Lane-interference guard (decision 8 / audit A9): the + // turn's own task_started folding in MID-PAUSE would flip + // the phase Busy, feed the gate, and silently cancel the + // armed approval bell. Fold the anchors as usual but + // defer the Busy promotion to the approval resolve. + state.accepted_start_at = Some(started_at); + state.resume_busy_after_approval = true; + } } } @@ -363,6 +435,7 @@ impl CodexActivityTracker { at, &mut self.ledger, &mut completions, + record, ); // CE1: swallow the PTY BEL echo of this reconciled turn end // (armed regardless of whether the fold arrived as one @@ -376,7 +449,13 @@ impl CodexActivityTracker { .map(|accepted| cleared_at >= accepted) .unwrap_or(false) { - transition_after_turn_clear(state, at, &mut self.ledger, &mut completions); + transition_after_turn_clear( + state, + at, + &mut self.ledger, + &mut completions, + record, + ); state.swallow_next_bel = true; // S5.a: and the proxy echo of the same physical turn. state.swallow_next_proxy_complete = true; @@ -520,14 +599,31 @@ impl CodexActivityTracker { /// S5.a: proxy lane TurnStarted (third clock domain -- server-clock `at`). /// Promotes Idle/Unknown/Pending to Busy, edge-triggered; never completes. - pub fn note_proxy_turn_started(&mut self, terminal_id: &str, at: i64) -> Vec { + /// Thread-scoped (kata codex-turn-thread-scope): the shared app-server + /// connection relays turn events for EVERY thread on it (sub-agent, + /// review, fork threads -- spike scenario D). Only the bound thread's + /// turns may drive this terminal; before a thread binds we stay + /// conservative and ignore the proxy lane entirely (the Rust identity + /// gate holds turn/start until adoption binds, so the window is + /// structurally empty on the managed path -- design decision #2). + pub fn note_proxy_turn_started( + &mut self, + terminal_id: &str, + thread_id: &str, + turn_id: Option<&str>, + at: i64, + ) -> Vec { let Some(state) = self.states.get_mut(terminal_id) else { return Vec::new(); }; + if state.session_id.as_deref() != Some(thread_id) { + return Vec::new(); + } let previous = state.to_record(); // Design invariant: a NEW proxy turn is beginning -- a stale directed // swallow must not eat THIS turn's completion. state.swallow_next_proxy_complete = false; + state.current_proxy_turn_id = turn_id.map(str::to_string); state.last_proxy_started_at = Some(at); state.last_observed_at = at; if matches!( @@ -543,19 +639,74 @@ impl CodexActivityTracker { /// S5.a: proxy lane TurnCompleted. Real turn ends transition to Idle and /// record exactly one completion; echoes of turns another lane already /// ended are swallowed one-shot (CE1 generalized). - pub fn note_proxy_turn_completed(&mut self, terminal_id: &str, at: i64) -> Vec { + /// + /// Guard order (kata codex-turn-thread-scope): + /// 1. thread scope -- foreign threads (sub-agents etc.) are ignored + /// BEFORE any state is touched (they must not consume swallows); + /// 2. `inProgress` -- not a turn end at all (protocol.rs:111); + /// 3. turn-id -- a completion for a different turn than the in-flight + /// one is a stale echo, no-op by construction; + /// 4. directed proxy swallow (cross-lane dedupe, unchanged); + /// 5. status -- `completed | failed | absent` record a bell-worthy completion; `interrupted` clears silently. + pub fn note_proxy_turn_completed( + &mut self, + terminal_id: &str, + thread_id: &str, + turn_id: Option<&str>, + status: Option<&str>, + at: i64, + ) -> Vec { let Some(state) = self.states.get_mut(terminal_id) else { return Vec::new(); }; + if state.session_id.as_deref() != Some(thread_id) { + return Vec::new(); + } + if status == Some("inProgress") { + return Vec::new(); + } + if let (Some(current), Some(completed)) = (state.current_proxy_turn_id.as_deref(), turn_id) + { + if current != completed { + return Vec::new(); + } + } + // Node parity (codex-activity-tracker.ts onTurnCompleted): once the + // stale-id guard passes, this completion IS the in-flight turn's + // terminal event -- retire the id unconditionally, even when the + // effect below is swallowed or lands in the Idle arm. A surviving id + // could wrongly drop a later real completion whose turn/started was + // missed (proxy reconnect / fork windows). + state.current_proxy_turn_id = None; if state.swallow_next_proxy_complete { state.swallow_next_proxy_complete = false; return Vec::new(); } + // Task 7: an accepted terminal-status completion retires the turn's + // approval pause state ONCE, BEFORE the phase match -- a turn that + // completes during an approval pause routes through the Idle arm + // (the request itself demoted the phase, and the Idle arm never + // records or resumes), so a late resolve of the stale approval must + // not flip the pane Busy again. + state.pending_approvals.clear(); + state.resume_busy_after_approval = false; + // Attention-bell policy: completed AND failed are non-human stopping causes + // and record a completion (=> gate arms => terminal.idle). `interrupted` + // (and only it) is human-requested and stays a silent claim. If a queued + // submit exists the shared transition machinery re-arms instead of ringing — + // the queued message auto-submits and work continues. + let record = matches!(status, None | Some("completed") | Some("failed")); let previous = state.to_record(); let mut completions: Vec<(Option, i64, i64)> = Vec::new(); match state.phase { CodexPhase::Pending => { - transition_pending_after_turn_clear(state, at, &mut self.ledger, &mut completions); + transition_pending_after_turn_clear( + state, + at, + &mut self.ledger, + &mut completions, + record, + ); state.swallow_next_bel = true; state.swallow_next_reconcile_clear = true; } @@ -563,21 +714,134 @@ impl CodexActivityTracker { let turn_key = state.last_proxy_started_at.or(state.pending_submit_at); state.phase = CodexPhase::Idle; state.updated_at = at; - record_completion_if_idle( - state, - turn_key.or(Some(at)), - at, - &mut self.ledger, - &mut completions, - ); + if record { + record_completion_if_idle( + state, + turn_key.or(Some(at)), + at, + &mut self.ledger, + &mut completions, + ); + } else { + claim_turn_key_if_idle(state, turn_key.or(Some(at))); + } + state.swallow_next_bel = true; + state.swallow_next_reconcile_clear = true; + } + CodexPhase::Idle => { + // Mid-pause turn end / stale echo (silent claim): an approval + // pause demoted the phase, so the pause's turn/completed lands + // here -- no completion, no boundary (the approval bell + // already covers this attention event). But the anchors this + // turn planted (accepted via the mid-pause reconcile fold, + // pending via a pause keystroke) would otherwise survive and + // let the codex TUI's turn-complete BEL -- or the rollout's + // clear echo -- re-mint the same physical turn as a spurious + // TurnComplete (a second terminal.idle for one episode). + // Claim the turn key exactly like the Busy arm, retire the + // anchors, and arm both cross-lane swallows. Clearing + // accepted_start_at is safe for reconcile_rollout: its clear + // guard requires Busy|Unknown and `.map(..).unwrap_or(false)` + // on the anchor, and its promotion guard falls back to the + // is_new edge-trigger. + let turn_key = state.last_proxy_started_at.or(state.pending_submit_at); + state.accepted_start_at = None; + state.pending_submit_at = None; + claim_turn_key_if_idle(state, turn_key.or(Some(at))); state.swallow_next_bel = true; state.swallow_next_reconcile_clear = true; } - CodexPhase::Idle => {} } self.effects_after_transition(terminal_id, previous, completions) } + /// Approval-request pause (managed lane). Thread-scoped like turn events; + /// requests without a threadId are accepted (the proxy is per-terminal). + /// Public phase maps to the EXISTING not-busy value — no new wire phase. + /// Queued input never suppresses approval bells: still blocked on a human. + pub fn note_approval_requested( + &mut self, + terminal_id: &str, + thread_id: Option<&str>, + request_id: &str, + at: i64, + ) -> Vec { + let Some(state) = self.states.get_mut(terminal_id) else { + return Vec::new(); + }; + if let (Some(thread), Some(bound)) = (thread_id, state.session_id.as_deref()) { + if thread != bound { + return Vec::new(); + } + } + // Hardening: only a NEWLY inserted request id arms the gate. A + // duplicate request frame (proxy retry / reconnect replay) for an id + // already pending must not re-arm -- one boundary per approval pause. + let newly_inserted = state.pending_approvals.insert(request_id.to_string()); + let previous = state.to_record(); + if matches!( + state.phase, + CodexPhase::Busy | CodexPhase::Pending | CodexPhase::Unknown + ) { + state.resume_busy_after_approval = true; + state.phase = CodexPhase::Idle; + } + state.updated_at = at; + let next = state.to_record(); + let mut effects = changed(Some(&previous), next); + if newly_inserted { + effects.push(TrackerEffect::AttentionBoundary { + terminal_id: terminal_id.to_string(), + at, + }); + } + effects + } + + /// The approval response passed back through the proxy: the turn resumes. + /// Cancels a pending bell within the grace (gate sees Busy); un-greens the + /// pane. Stale/unknown request ids are no-ops. + pub fn note_approval_resolved( + &mut self, + terminal_id: &str, + request_id: &str, + at: i64, + ) -> Vec { + let Some(state) = self.states.get_mut(terminal_id) else { + return Vec::new(); + }; + if !state.pending_approvals.remove(request_id) { + return Vec::new(); + } + if !state.pending_approvals.is_empty() || !state.resume_busy_after_approval { + return Vec::new(); + } + state.resume_busy_after_approval = false; + let previous = state.to_record(); + state.phase = CodexPhase::Busy; + state.updated_at = at; + state.last_observed_at = at; + // Audit A9 hazard 2: a mid-pause Enter (the human answering the + // approval prompt in the TUI) planted PTY pending-submit state -- + // normalize it so the NEXT turn clear is not misread as a queued + // re-arm of the pause keystroke. + state.pending_submit_at = None; + state.pending_freshness_at = None; + state.pending_until = None; + let next = state.to_record(); + changed(Some(&previous), next) + } + + /// Death-bell engagement extension (decision 3): a pane blocked on an + /// approval whose process dies spontaneously must ring. Read by the hub's + /// Exit arm alongside IdleGate::is_engaged, BEFORE any teardown. + pub fn has_pending_approvals(&self, terminal_id: &str) -> bool { + self.states + .get(terminal_id) + .map(|s| !s.pending_approvals.is_empty()) + .unwrap_or(false) + } + /// Shared effect-assembly tail (extracted, S5.a): convert a transition's /// (previous record, completions) into the emitted effect vector -- a /// `Changed` upsert when the record publicly changed, plus one @@ -721,6 +985,13 @@ fn has_pending_output_liveness(state: &TerminalActivity, at: i64) -> bool { } } +/// Human-attributed abort reasons stay silent. A MISSING reason is treated +/// as human/uncertain (legacy rollouts omit it; the real-world corpus shows +/// 'interrupted' is the only observed value; uncertainty never rings). +fn abort_reason_is_human(reason: Option<&str>) -> bool { + matches!(reason, None | Some("interrupted") | Some("replaced")) +} + fn has_queued_submit(state: &TerminalActivity) -> bool { match state.queued_submit_at { Some(queued) => state @@ -741,13 +1012,13 @@ fn consume_turn_complete_signal( ) -> bool { if state.phase == CodexPhase::Pending { if state.pending_submit_at.is_some() { - transition_pending_after_turn_clear(state, at, ledger, completions); + transition_pending_after_turn_clear(state, at, ledger, completions, true); return true; } return false; } if state.accepted_start_at.is_some() { - transition_after_turn_clear(state, at, ledger, completions); + transition_after_turn_clear(state, at, ledger, completions, true); return true; } false @@ -758,6 +1029,7 @@ fn transition_pending_after_turn_clear( at: i64, ledger: &mut TurnCompletionLedger, completions: &mut Vec<(Option, i64, i64)>, + record: bool, ) { let turn_key = state.pending_submit_at; let queued = has_queued_submit(state); @@ -780,7 +1052,11 @@ fn transition_pending_after_turn_clear( state.pending_until = None; state.queued_submit_at = None; } - record_completion_if_idle(state, turn_key, at, ledger, completions); + if record { + record_completion_if_idle(state, turn_key, at, ledger, completions); + } else { + claim_turn_key_if_idle(state, turn_key); + } } fn transition_after_turn_clear( @@ -788,6 +1064,7 @@ fn transition_after_turn_clear( at: i64, ledger: &mut TurnCompletionLedger, completions: &mut Vec<(Option, i64, i64)>, + record: bool, ) { let turn_key = state.accepted_start_at; let queued = has_queued_submit(state); @@ -807,7 +1084,11 @@ fn transition_after_turn_clear( state.queued_submit_at = None; state.pending_until = None; } - record_completion_if_idle(state, turn_key, at, ledger, completions); + if record { + record_completion_if_idle(state, turn_key, at, ledger, completions); + } else { + claim_turn_key_if_idle(state, turn_key); + } } /// `recordCompletionIfIdle`: record only when a real turn-end transition @@ -832,6 +1113,23 @@ fn record_completion_if_idle( completions.push((state.session_id.clone(), at, seq)); } +/// Abort-shaped clears (`turn_aborted` in the rollout lane; status +/// `interrupted`/`failed` on the proxy lane, Task 2): claim the turn key +/// exactly like `record_completion_if_idle` does, but WITHOUT recording a +/// ledger completion -- the pane returns to idle silently (terminal.idle is +/// never emitted after a HUMAN-REQUESTED stop; it IS emitted for failed turns, +/// non-human abort reasons (forward-compatible — none emitted at codex <= +/// 0.147), spontaneous death while engaged, and approval pauses; +/// shared/ws-protocol.ts terminal.idle doc) and a later echo of the same +/// physical turn cannot mint a completion. +fn claim_turn_key_if_idle(state: &mut TerminalActivity, turn_key: Option) { + let Some(turn_key) = turn_key else { return }; + if state.phase != CodexPhase::Idle { + return; + } + state.last_emitted_turn_key = Some(turn_key); +} + #[cfg(test)] mod tests { use super::*; @@ -1069,6 +1367,14 @@ mod tests { ..Default::default() } } + fn aborted(at: i64, reason: Option<&str>) -> CodexTaskEvents { + CodexTaskEvents { + latest_task_started_at: Some(at - 1_000), + latest_task_completed_at: None, + latest_turn_aborted_at: Some(at), + latest_turn_aborted_reason: reason.map(str::to_string), + } + } #[test] fn reconcile_seeds_busy_for_an_unresolved_rollout() { @@ -1089,6 +1395,7 @@ mod tests { latest_task_started_at: Some(100), latest_task_completed_at: Some(150), latest_turn_aborted_at: None, + latest_turn_aborted_reason: None, }; let effects = tracker.reconcile_rollout("t1", &events, 200); assert!(effects.is_empty()); @@ -1111,7 +1418,20 @@ mod tests { } #[test] - fn reconcile_turn_aborted_also_clears_and_completes() { + fn reconcile_turn_aborted_clears_without_completing() { + // SEMANTIC CHANGE (kata: codex-turn-thread-scope). This test replaces + // `reconcile_turn_aborted_also_clears_and_completes`, which pinned the + // old buggy behavior. terminal.idle is never emitted after a + // HUMAN-REQUESTED stop; it IS emitted for failed turns, non-human + // abort reasons (forward-compatible — none emitted at codex <= 0.147), + // spontaneous death while engaged, and approval pauses + // (shared/ws-protocol.ts terminal.idle doc) -- an Esc-interrupt + // (`turn_aborted`) must return the pane to idle WITHOUT recording a + // bell-worthy completion. + // REFINED (attention-bell plan, Task 3): this fixture carries NO + // abort reason, which stays silent (uncertainty never rings). Aborts + // with a non-human reason DO record -- see the `reconcile_abort_*` + // tests below. let mut tracker = CodexActivityTracker::new(); tracker.track_terminal("t1", None, 0); tracker.reconcile_rollout("t1", &started(100), 200); @@ -1121,6 +1441,68 @@ mod tests { }; let effects = tracker.reconcile_rollout("t1", &events, 400); assert_eq!(phases(&effects), vec![CodexPhase::Idle]); + assert!( + completions(&effects).is_empty(), + "turn_aborted must not ring the bell" + ); + } + + /// Human-requested abort (Esc) — silent, unchanged behavior. + #[test] + fn reconcile_abort_with_interrupted_reason_clears_without_completing() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("sess-1"), 1_000); + tracker.reconcile_rollout("t1", &started(2_000), 2_000); + let effects = tracker.reconcile_rollout("t1", &aborted(5_000, Some("interrupted")), 5_000); + assert_eq!(completions(&effects).len(), 0); + } + + /// 'replaced' = human submitted new input — silent. + #[test] + fn reconcile_abort_with_replaced_reason_clears_without_completing() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("sess-1"), 1_000); + tracker.reconcile_rollout("t1", &started(2_000), 2_000); + let effects = tracker.reconcile_rollout("t1", &aborted(5_000, Some("replaced")), 5_000); + assert_eq!(completions(&effects).len(), 0); + } + + /// Missing reason = legacy rollout line / uncertainty — no heuristic bells. + #[test] + fn reconcile_abort_without_reason_clears_without_completing() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("sess-1"), 1_000); + tracker.reconcile_rollout("t1", &started(2_000), 2_000); + let effects = tracker.reconcile_rollout("t1", &aborted(5_000, None), 5_000); + assert_eq!(completions(&effects).len(), 0); + } + + /// Any OTHER present reason is not human-attributed — it records (rings). + #[test] + fn reconcile_abort_with_unknown_reason_records_a_completion() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("sess-1"), 1_000); + tracker.reconcile_rollout("t1", &started(2_000), 2_000); + let effects = + tracker.reconcile_rollout("t1", &aborted(5_000, Some("token_budget_exceeded")), 5_000); + assert_eq!(completions(&effects).len(), 1); + } + + #[test] + fn reconcile_task_complete_at_or_after_an_abort_still_completes() { + // Tie-break rule: abort suppresses the chime only when it is STRICTLY + // the newest terminating event. A real task_complete at the same + // instant (or newer) still rings. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", None, 0); + tracker.reconcile_rollout("t1", &started(100), 200); + let events = CodexTaskEvents { + latest_task_completed_at: Some(300), + latest_turn_aborted_at: Some(300), + ..Default::default() + }; + let effects = tracker.reconcile_rollout("t1", &events, 400); + assert_eq!(phases(&effects), vec![CodexPhase::Idle]); assert_eq!(completions(&effects), vec![1]); } @@ -1394,7 +1776,7 @@ mod tests { fn proxy_turn_started_promotes_idle_to_busy() { let mut tracker = CodexActivityTracker::new(); tracker.track_terminal("t", Some("sess"), 1_000); - let effects = tracker.note_proxy_turn_started("t", 2_000); + let effects = tracker.note_proxy_turn_started("t", "sess", Some("turn-1"), 2_000); assert!(effects .iter() .any(|e| matches!(e, TrackerEffect::Changed { .. }))); @@ -1408,8 +1790,14 @@ mod tests { fn proxy_turn_completes_exactly_once_per_turn() { let mut tracker = CodexActivityTracker::new(); tracker.track_terminal("t", Some("sess"), 1_000); - tracker.note_proxy_turn_started("t", 2_000); - let first = tracker.note_proxy_turn_completed("t", 3_000); + tracker.note_proxy_turn_started("t", "sess", Some("turn-1"), 2_000); + let first = tracker.note_proxy_turn_completed( + "t", + "sess", + Some("turn-1"), + Some("completed"), + 3_000, + ); assert_eq!( first .iter() @@ -1418,7 +1806,13 @@ mod tests { 1 ); // Same physical turn reported again (proxy echo / duplicate) -> no double. - let again = tracker.note_proxy_turn_completed("t", 3_001); + let again = tracker.note_proxy_turn_completed( + "t", + "sess", + Some("turn-1"), + Some("completed"), + 3_001, + ); assert!(!again .iter() .any(|e| matches!(e, TrackerEffect::TurnComplete { .. }))); @@ -1431,7 +1825,8 @@ mod tests { // A pending PTY turn… tracker.note_input("t", "\r", 2_000); // …cleared by the PROXY lane (the authoritative turn end)… - let cleared = tracker.note_proxy_turn_completed("t", 3_000); + let cleared = + tracker.note_proxy_turn_completed("t", "sess", None, Some("completed"), 3_000); assert!(cleared .iter() .any(|e| matches!(e, TrackerEffect::TurnComplete { .. }))); @@ -1457,7 +1852,7 @@ mod tests { .iter() .any(|e| matches!(e, TrackerEffect::TurnComplete { .. }))); // …then the proxy echo of the same physical turn is swallowed one-shot. - let echo = tracker.note_proxy_turn_completed("t", 3_050); + let echo = tracker.note_proxy_turn_completed("t", "sess", None, Some("completed"), 3_050); assert!(!echo .iter() .any(|e| matches!(e, TrackerEffect::TurnComplete { .. }))); @@ -1468,10 +1863,10 @@ mod tests { let mut tracker = CodexActivityTracker::new(); tracker.track_terminal("t", Some("sess"), 1_000); tracker.note_input("t", "\r", 2_000); - tracker.note_proxy_turn_completed("t", 3_000); // arms bel + reconcile swallows + tracker.note_proxy_turn_completed("t", "sess", None, Some("completed"), 3_000); // arms bel + reconcile swallows tracker.note_input("t", "\r", 4_000); // fresh pending turn: disarm // A REAL turn end for the NEW turn must complete, not be swallowed. - let done = tracker.note_proxy_turn_completed("t", 5_000); + let done = tracker.note_proxy_turn_completed("t", "sess", None, Some("completed"), 5_000); assert!(done .iter() .any(|e| matches!(e, TrackerEffect::TurnComplete { .. }))); @@ -1490,8 +1885,14 @@ mod tests { tracker.reconcile_rollout("t", &events, 3_000); // …but turn 2 STARTS on the proxy lane before any proxy echo of turn 1 // arrived: the stale swallow must be disarmed, not eat turn 2's end. - tracker.note_proxy_turn_started("t", 4_000); - let done = tracker.note_proxy_turn_completed("t", 5_000); + tracker.note_proxy_turn_started("t", "sess", Some("turn-2"), 4_000); + let done = tracker.note_proxy_turn_completed( + "t", + "sess", + Some("turn-2"), + Some("completed"), + 5_000, + ); assert!(done .iter() .any(|e| matches!(e, TrackerEffect::TurnComplete { .. }))); @@ -1524,9 +1925,533 @@ mod tests { // The proxy echo of the SAME physical turn lands next. Without the // BEL-clear arming it hits phase == Pending and PREMATURELY completes // queued turn 2 (ledger A11) — it must be swallowed instead. - let echo = tracker.note_proxy_turn_completed("t", 3_050); + let echo = tracker.note_proxy_turn_completed("t", "sess", None, Some("completed"), 3_050); assert!(!echo .iter() .any(|e| matches!(e, TrackerEffect::TurnComplete { .. }))); } + + // ---- Thread scoping (kata: codex-turn-thread-scope) ---- + + #[test] + fn subagent_thread_turn_completed_mid_parent_turn_is_ignored() { + // Spike scenario D (/tmp/codex-spike/spike-d.log): on a shared + // app-server connection a sub-agent child thread emits turn/completed + // (turn.status=completed) while the parent turn is still in progress. + // That event must not flip Busy->Idle, must not record a completion, + // and must not arm swallow flags that would eat the parent's real + // completion. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("thread-parent"), 0); + tracker.note_proxy_turn_started("t", "thread-parent", Some("turn-parent"), 1_000); + assert_eq!(tracker.list()[0].phase, CodexPhase::Busy); + + let child = tracker.note_proxy_turn_completed( + "t", + "thread-child", + Some("turn-child"), + Some("completed"), + 2_000, + ); + assert!( + child.is_empty(), + "foreign-thread completion must be a no-op" + ); + assert_eq!(tracker.list()[0].phase, CodexPhase::Busy); + + // The parent's REAL completion still rings exactly once. + let parent = tracker.note_proxy_turn_completed( + "t", + "thread-parent", + Some("turn-parent"), + Some("completed"), + 3_000, + ); + assert_eq!(phases(&parent), vec![CodexPhase::Idle]); + assert_eq!(completions(&parent), vec![1]); + } + + #[test] + fn foreign_thread_turn_started_does_not_promote_busy() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("thread-parent"), 0); + let effects = tracker.note_proxy_turn_started("t", "thread-child", Some("turn-c"), 1_000); + assert!(effects.is_empty()); + assert_eq!(tracker.list()[0].phase, CodexPhase::Idle); + } + + #[test] + fn unbound_terminal_ignores_proxy_turn_events() { + // Unbound window policy (documented in the plan): before a thread + // binds, the proxy lane is silent -- no busy promotion, no completion. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", None, 0); + assert!(tracker + .note_proxy_turn_started("t", "thread-x", Some("turn-1"), 1_000) + .is_empty()); + assert!(tracker + .note_proxy_turn_completed("t", "thread-x", Some("turn-1"), Some("completed"), 2_000) + .is_empty()); + assert_eq!(tracker.list()[0].phase, CodexPhase::Idle); + } + + #[test] + fn rebind_clears_stale_in_flight_proxy_turn_state() { + // Design decision #7 (load-bearing ledger A9, falsified without this): + // fork/resume rebinds arrive from the async disk fork-watch lane with + // NO ordering guarantee vs proxy turn events. The child thread's first + // turn/started can land BEFORE the rebind (the thread guard rightly + // drops it); if the parent's stale current_proxy_turn_id survived the + // rebind, the child's first turn/completed would be misclassified as + // a stale echo -- stuck busy until reconcile. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("thread-a"), 0); + tracker.note_proxy_turn_started("t", "thread-a", Some("turn-a1"), 1_000); + // Child turn starts pre-rebind: dropped by the thread guard. + tracker.note_proxy_turn_started("t", "thread-b", Some("turn-b1"), 1_200); + // Disk fork-watch lane rebinds the pane to the child thread. + tracker.bind_session("t", "thread-b"); + let effects = tracker.note_proxy_turn_completed( + "t", + "thread-b", + Some("turn-b1"), + Some("completed"), + 2_000, + ); + assert_eq!(phases(&effects), vec![CodexPhase::Idle]); + assert_eq!(completions(&effects), vec![1]); + } + + // ---- Status guard ---- + + #[test] + fn interrupted_status_clears_busy_without_completion() { + // Spike scenario B: turn/interrupt yields turn/completed with + // turn.status=interrupted. The pane returns to non-busy, no bell. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("sess"), 0); + tracker.note_proxy_turn_started("t", "sess", Some("turn-1"), 1_000); + let effects = tracker.note_proxy_turn_completed( + "t", + "sess", + Some("turn-1"), + Some("interrupted"), + 2_000, + ); + assert_eq!(phases(&effects), vec![CodexPhase::Idle]); + assert!(completions(&effects).is_empty()); + } + + /// SEMANTIC CHANGE (attention-bell plan 2026-08-01): a failed turn is a + /// non-human stopping cause — it records a completion so the IdleGate rings. + /// Failed takes EXACTLY the completed path, so queue suppression + grace + /// apply naturally. (Previously pinned as clears-without-completion.) + #[test] + fn failed_status_records_a_completion() { + // Mirror the setup of `absent_status_still_completes_for_the_bound_thread` + // (codex.rs:1843): track, bind thread, proxy turn started, then complete. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("sess"), 0); + tracker.note_proxy_turn_started("t", "sess", Some("turn-1"), 1_000); + let effects = + tracker.note_proxy_turn_completed("t", "sess", Some("turn-1"), Some("failed"), 5_000); + assert_eq!(phases(&effects), vec![CodexPhase::Idle]); + assert_eq!( + completions(&effects).len(), + 1, + "failed must mint a completion" + ); + } + + /// Failed must be indistinguishable from completed in effect shape — that is + /// what makes queued-submit suppression and the 2s grace apply for free. + #[test] + fn failed_with_queued_submit_behaves_exactly_like_completed_with_queued_submit() { + let run = |status: &str| { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("sess"), 0); + tracker.note_proxy_turn_started("t", "sess", Some("turn-1"), 1_000); + // Queue a submit while busy (mirror the input used by + // `queued_submit_rearms_pending_after_the_bel_and_completes_each_turn`, codex.rs:1039). + tracker.note_input("t", "do the next thing\r", 3_000); + let effects = + tracker.note_proxy_turn_completed("t", "sess", Some("turn-1"), Some(status), 5_000); + (phases(&effects), completions(&effects).len()) + }; + assert_eq!(run("failed"), run("completed")); + } + + #[test] + fn in_progress_status_is_a_no_op() { + // protocol.rs:111 -- turn/completed fires for ALL statuses; + // `inProgress` is not a turn end and must not clear busy. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("sess"), 0); + tracker.note_proxy_turn_started("t", "sess", Some("turn-1"), 1_000); + let effects = tracker.note_proxy_turn_completed( + "t", + "sess", + Some("turn-1"), + Some("inProgress"), + 2_000, + ); + assert!(effects.is_empty()); + assert_eq!(tracker.list()[0].phase, CodexPhase::Busy); + } + + #[test] + fn absent_status_still_completes_for_the_bound_thread() { + // Compatibility: older protocol forms omit status. Treat as a + // positive completion so panes never hang busy. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("sess"), 0); + tracker.note_proxy_turn_started("t", "sess", Some("turn-1"), 1_000); + let effects = tracker.note_proxy_turn_completed("t", "sess", Some("turn-1"), None, 2_000); + assert_eq!(phases(&effects), vec![CodexPhase::Idle]); + assert_eq!(completions(&effects), vec![1]); + } + + #[test] + fn bel_echo_after_an_interrupted_clear_does_not_ring() { + // The interrupt-shaped clear must arm the BEL swallow like a normal + // proxy clear does -- the aborted turn's PTY BEL echo stays silent. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("sess"), 0); + tracker.note_proxy_turn_started("t", "sess", Some("turn-1"), 1_000); + tracker.note_proxy_turn_completed("t", "sess", Some("turn-1"), Some("interrupted"), 2_000); + let echo = tracker.note_output("t", "\u{7}", 2_100); + assert!(completions(&echo).is_empty()); + } + + // ---- Turn-id dedupe ---- + + #[test] + fn stale_completion_for_a_previous_turn_id_is_ignored() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("sess"), 0); + tracker.note_proxy_turn_started("t", "sess", Some("turn-2"), 2_000); + // A late completion echo for an OLDER turn id arrives while turn-2 + // is running: no-op by construction. + let stale = tracker.note_proxy_turn_completed( + "t", + "sess", + Some("turn-1"), + Some("completed"), + 2_100, + ); + assert!(stale.is_empty()); + assert_eq!(tracker.list()[0].phase, CodexPhase::Busy); + // turn-2's real completion still rings. + let real = tracker.note_proxy_turn_completed( + "t", + "sess", + Some("turn-2"), + Some("completed"), + 3_000, + ); + assert_eq!(completions(&real), vec![1]); + } + + #[test] + fn completion_without_turn_ids_falls_back_to_phase_semantics() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("sess"), 0); + tracker.note_proxy_turn_started("t", "sess", None, 1_000); + let effects = + tracker.note_proxy_turn_completed("t", "sess", None, Some("completed"), 2_000); + assert_eq!(completions(&effects), vec![1]); + } + + /// Deferred minor from the thread-scope plan: the in-flight proxy turn id + /// must not survive the turn it belongs to. A NEW turn id arriving after a + /// completed one must not be rejected by the stale-turn-id guard. + #[test] + fn accepted_completion_clears_the_in_flight_proxy_turn_id() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + tracker.note_proxy_turn_completed( + "t1", + "thread-1", + Some("turn-1"), + Some("completed"), + 3_000, + ); + // The in-flight proxy turn id must be cleared after the accepted completion. + assert_eq!(tracker.current_proxy_turn_id_for("t1"), None); + // With the id cleared, a follow-up turn with a new id starts cleanly and + // its completion is NOT swallowed by the turn-id-mismatch guard. + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-2"), 4_000); + let effects = tracker.note_proxy_turn_completed( + "t1", + "thread-1", + Some("turn-2"), + Some("completed"), + 6_000, + ); + assert_eq!(completions(&effects).len(), 1); + } + + // ---- Approval pauses (attention bell, Task 7) ---- + + /// Approval pause: internal waiting state, public phase flips to the + /// EXISTING not-busy value, and the gate boundary arms (no completion). + #[test] + fn approval_request_pauses_busy_to_idle_and_arms_a_boundary() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + let effects = tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); + assert_eq!(phases(&effects), vec![CodexPhase::Idle]); + assert_eq!( + completions(&effects).len(), + 0, + "an approval pause is not a turn end" + ); + assert!( + effects + .iter() + .any(|e| matches!(e, TrackerEffect::AttentionBoundary { at: 3_000, .. })), + "the gate boundary must arm" + ); + } + + #[test] + fn approval_resolved_returns_to_busy() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); + let effects = tracker.note_approval_resolved("t1", "41", 4_000); + assert_eq!(phases(&effects), vec![CodexPhase::Busy], "the turn resumes"); + } + + #[test] + fn approval_resolved_with_no_prior_busy_stays_idle() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); // pane was idle + let effects = tracker.note_approval_resolved("t1", "41", 4_000); + assert_eq!( + phases(&effects), + Vec::::new(), + "nothing to resume" + ); + } + + #[test] + fn foreign_thread_approval_request_is_ignored() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + let effects = tracker.note_approval_requested("t1", Some("subagent-thread"), "41", 3_000); + assert!( + effects.is_empty(), + "a sub-agent approval must not ring the parent pane" + ); + } + + #[test] + fn approval_request_without_thread_id_is_accepted() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + let effects = tracker.note_approval_requested("t1", None, "41", 3_000); + assert_eq!(phases(&effects), vec![CodexPhase::Idle]); + } + + #[test] + fn queued_submit_does_not_block_the_approval_boundary() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + tracker.note_input("t1", "queued message\r", 2_500); // still blocked on the human + let effects = tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); + assert!(effects + .iter() + .any(|e| matches!(e, TrackerEffect::AttentionBoundary { .. }))); + } + + #[test] + fn turn_completion_clears_pending_approvals() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); + tracker.note_proxy_turn_completed( + "t1", + "thread-1", + Some("turn-1"), + Some("completed"), + 5_000, + ); + // A late response to the stale approval must not flip the pane busy. + let effects = tracker.note_approval_resolved("t1", "41", 6_000); + assert!(effects.is_empty()); + } + + /// A turn that ends WHILE the approval pause holds the phase at Idle must + /// end silently (the approval bell already covers the attention event) -- + /// AND its surviving anchors must not let the codex TUI's turn-complete + /// BEL echo re-mint the same physical turn as a spurious TurnComplete + /// (which would ring a second terminal.idle for one episode). + #[test] + fn mid_pause_turn_end_silences_the_bel_echo_and_clears_anchors() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); + // The turn's own task_started folds mid-pause (audit A9 branch): the + // accepted anchor lands without flipping Busy. + tracker.reconcile_rollout("t1", &started(3_500), 3_500); + // The turn completes while the approval is still pending: the Idle + // arm claims silently -- no completion. + let done = tracker.note_proxy_turn_completed( + "t1", + "thread-1", + Some("turn-1"), + Some("completed"), + 5_000, + ); + assert!( + completions(&done).is_empty(), + "a mid-pause turn end must not record a completion" + ); + // The TUI's turn-complete BEL echo of that same physical turn. + let echo = tracker.note_output("t1", "\u{7}", 5_100); + assert!( + completions(&echo).is_empty(), + "the BEL echo of a mid-pause turn end must not re-mint the turn" + ); + let state = tracker.states.get("t1").expect("state"); + assert_eq!(state.accepted_start_at, None, "accepted anchor retired"); + assert_eq!(state.pending_submit_at, None, "pending anchor retired"); + } + + /// Node parity: the in-flight proxy turn id is retired unconditionally + /// once the stale-id guard passes -- including swallowed echoes and the + /// Idle arm. A surviving id could wrongly drop a later real completion + /// whose turn/started was missed (proxy reconnect / fork windows). + #[test] + fn swallowed_and_idle_arm_proxy_echoes_retire_the_in_flight_turn_id() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + // The rollout lane ends the turn (fold the start, then its clear). + tracker.reconcile_rollout("t1", &started(2_500), 2_600); + let events = CodexTaskEvents { + latest_task_started_at: Some(2_500), + latest_task_completed_at: Some(3_000), + latest_turn_aborted_at: None, + latest_turn_aborted_reason: None, + }; + tracker.reconcile_rollout("t1", &events, 3_100); + // First proxy echo of the same physical turn: swallowed one-shot. + let first = tracker.note_proxy_turn_completed( + "t1", + "thread-1", + Some("turn-1"), + Some("completed"), + 3_200, + ); + assert!(completions(&first).is_empty(), "swallowed echo is silent"); + // Second echo lands in the Idle arm: still silent. + let second = tracker.note_proxy_turn_completed( + "t1", + "thread-1", + Some("turn-1"), + Some("completed"), + 3_300, + ); + assert!(completions(&second).is_empty(), "idle-arm echo is silent"); + // The id of the closed turn must not survive either path. + assert_eq!(tracker.current_proxy_turn_id_for("t1"), None); + } + + /// Hardening: a duplicate request frame for an id ALREADY pending must + /// not push a second AttentionBoundary (re-arming the gate would re-ring + /// the same approval pause). + #[test] + fn duplicate_approval_request_does_not_rearm_the_boundary() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + let first = tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); + assert!(first + .iter() + .any(|e| matches!(e, TrackerEffect::AttentionBoundary { .. }))); + let dup = tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_500); + assert!( + !dup.iter() + .any(|e| matches!(e, TrackerEffect::AttentionBoundary { .. })), + "a duplicate approval request frame must not re-arm the gate" + ); + } + + /// Audit A9: the FIRST rollout fold of the turn's own task_started passes + /// the reconcile edge-trigger (codex.rs:352-368) — landing mid-pause it + /// would flip phase Busy, feed the gate, and silently cancel the armed + /// approval bell. Mid-pause promotions must fold anchors but defer the + /// phase flip to the resolve. + #[test] + fn reconcile_task_started_during_pending_approval_does_not_flip_busy() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); + // Reuse Task 3's `started(at)` CodexTaskEvents helper. + let effects = tracker.reconcile_rollout("t1", &started(3_500), 3_500); + assert_eq!( + phases(&effects), + Vec::::new(), + "no Busy upsert mid-pause" + ); + // The deferred promotion resumes at resolve. + let effects = tracker.note_approval_resolved("t1", "41", 4_000); + assert_eq!(phases(&effects), vec![CodexPhase::Busy]); + } + + /// Audit A9 hazard 2: a mid-pause Enter (the human answering the approval + /// in the TUI) plants PTY pending-submit state; resolve must normalize it + /// so the NEXT turn clear is not misclassified as a queued re-arm (which + /// would suppress a legitimate later bell). + #[test] + fn approval_resolve_normalizes_pending_submit_input_state() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); + tracker.note_input("t1", "\r", 3_500); // answering the approval prompt + tracker.note_approval_resolved("t1", "41", 4_000); + let effects = tracker.note_proxy_turn_completed( + "t1", + "thread-1", + Some("turn-1"), + Some("completed"), + 6_000, + ); + assert_eq!( + phases(&effects), + vec![CodexPhase::Idle], + "no Pending re-arm from the pause keystroke" + ); + assert_eq!( + completions(&effects).len(), + 1, + "the completion bell must not be swallowed" + ); + } + + /// Decision 3 / audit A10: a pane blocked on an approval counts as engaged + /// for the death bell. + #[test] + fn has_pending_approvals_tracks_the_pending_set() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + assert!(!tracker.has_pending_approvals("t1")); + tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); + assert!(tracker.has_pending_approvals("t1")); + tracker.note_approval_resolved("t1", "41", 4_000); + assert!(!tracker.has_pending_approvals("t1")); + } } diff --git a/crates/freshell-activity/src/idle.rs b/crates/freshell-activity/src/idle.rs index fc5eae607..792470f31 100644 --- a/crates/freshell-activity/src/idle.rs +++ b/crates/freshell-activity/src/idle.rs @@ -3,7 +3,10 @@ //! Pinned wire contract: `{ terminalId, at (server epoch ms), reason: //! 'grace' | 'queue-empty' }`, emitted ONCE per busy→truly-idle transition. //! -//! Semantics: +//! Semantics (terminal.idle is never emitted after a HUMAN-REQUESTED stop; +//! it IS emitted for failed turns, non-human abort reasons (forward-compatible +//! — none emitted at codex <= 0.147), spontaneous death while engaged, and +//! approval pauses; see shared/ws-protocol.ts terminal.idle doc): //! * a turn boundary (the provider's positive turn end) ARMS a grace window //! (default [`IDLE_GRACE_MS`] = 2000ms); //! * new activity within the window EXTENDS it (amplifier: any events.jsonl @@ -18,10 +21,37 @@ //! * a turn boundary while the tracker still reports busy/pending is a //! QUEUED turn: it records queue evidence and never arms mid-turn; //! * subagent/tool completions inside a running turn never reach this gate -//! (the trackers only report REAL turn boundaries). +//! (the trackers only report REAL turn boundaries); +//! * spontaneous death (exit removal while `is_engaged`): the gate itself +//! never emits for a removed terminal. The hub reads `is_engaged` BEFORE +//! removal and emits the exit-death bell directly. `is_engaged` deliberately +//! excludes the input-only Pending state because a human `/quit`/`/exit` +//! Enter from an idle pane is indistinguishable from a prompt submit +//! (ringing there would bell the canonical human quit). //! //! Zero-polling: pure deadlines + `next_deadline()`; the hub arms a single //! one-shot timer. No pending windows ⇒ no timers. +//! +//! # Accepted Residuals +//! +//! The following edge cases are accepted design trade-offs (not deferrals): +//! 1. Mid-turn `/quit`/Ctrl+D: codex sends NO `Op::Interrupt` on Ctrl+D, and +//! the TUI's ~2s shutdown budget can exit before the abort evidence lands +//! — may ring on a human force-quit of a visibly-working pane. No in-band +//! discriminator exists; accepted. +//! 2. Out-of-band `kill -9`/SIGTERM of the CLI by the user: observationally +//! identical to a crash — rings; accepted. +//! 3. Claude/amplifier Enter-executed quits (`/exit`): input-driven Busy is +//! those trackers' ONLY turn evidence, so it stays death-bell engagement; +//! same residual family as (1); accepted. +//! 4. Node 120s busy-deadman swallow (audit A17): a recovery window longer +//! than `BUSY_DEADMAN_MS` demotes busy→unknown and `unknown` never arms the +//! death bell — a MISSED bell (never a false ring); accepted. +//! 5. A SENT approval request auto-resolved server-side slower than ~2s rings +//! once (decision 5); accepted. +//! 6. Node opencode death bells: deliberately excluded (noisy busy proxy) — +//! follow-up. Rust opencode: no hub tracker exists — N/A. +//! 7. Unmanaged/PTY-only codex has no approval signal — documented limitation. use std::collections::HashMap; @@ -143,6 +173,20 @@ impl IdleGate { self.states.remove(terminal_id); } + /// Engagement for the DEATH BELL (decision 3): true only for a CONFIRMED + /// busy phase or an armed grace window. The codex input-only Pending + /// submit gate is excluded — the Enter that executes a human /quit//exit + /// is indistinguishable from a prompt submit in the input lane + /// (signal.rs:36-38), so ringing on pending would bell the canonical + /// human quit. Read by the hub's exit arm BEFORE `note_exit` drops the + /// state: a spontaneous process death while engaged rings the bell. + pub fn is_engaged(&self, terminal_id: &str) -> bool { + self.states + .get(terminal_id) + .map(|s| (s.busy && !s.pending) || s.deadline.is_some()) + .unwrap_or(false) + } + /// Emit every window whose deadline has lapsed (once each). A terminal /// that re-entered busy never emits (defensive second gate). pub fn expire(&mut self, at: i64) -> Vec { @@ -400,6 +444,33 @@ mod tests { ); } + #[test] + fn is_engaged_reflects_confirmed_busy_and_armed_deadlines_but_never_input_pending() { + let mut gate = IdleGate::with_grace_ms(2_000); + assert!(!gate.is_engaged("t1"), "unknown terminal is not engaged"); + gate.note_phase("t1", IdleGatePhase::Pending); + assert!( + !gate.is_engaged("t1"), + "input-only pending is NOT death-bell engagement: the Enter that \ + executes /quit looks like a prompt submit (signal.rs:36-38) and \ + must not ring when the pty then exits (decision 3, audit A6)" + ); + gate.note_phase("t1", IdleGatePhase::Busy); + assert!(gate.is_engaged("t1"), "confirmed busy is engaged"); + gate.note_phase("t1", IdleGatePhase::Idle); + assert!( + !gate.is_engaged("t1"), + "idle with no pending window is not engaged" + ); + gate.note_turn_boundary("t1", 10_000); // arms deadline + assert!( + gate.is_engaged("t1"), + "an armed grace window is engaged (a pending bell must survive death)" + ); + gate.expire(20_000); + assert!(!gate.is_engaged("t1"), "after emission nothing is engaged"); + } + #[test] fn default_gate_uses_the_production_grace_window() { // HubInner is #[derive(Default)] (freshell-ws activity.rs), so diff --git a/crates/freshell-activity/src/lib.rs b/crates/freshell-activity/src/lib.rs index dbfcaf191..92bfe5620 100644 --- a/crates/freshell-activity/src/lib.rs +++ b/crates/freshell-activity/src/lib.rs @@ -49,4 +49,8 @@ pub enum TrackerEffect { }, /// Amplifier only: force-read the events tail (missed-signal failsafe). ForceRead { terminal_id: String, at: i64 }, + /// Arms the truly-idle gate WITHOUT minting a turn completion or a + /// terminal.turn.complete frame. Used for attention causes that are not + /// turn ends (approval-request pauses). + AttentionBoundary { terminal_id: String, at: i64 }, } diff --git a/crates/freshell-codex/src/remote_proxy.rs b/crates/freshell-codex/src/remote_proxy.rs index e394a2ace..da0dfc0c0 100644 --- a/crates/freshell-codex/src/remote_proxy.rs +++ b/crates/freshell-codex/src/remote_proxy.rs @@ -80,6 +80,36 @@ const STATEFUL_NOTIFICATION_METHODS: &[&str] = &[ "thread/status/changed", ]; +/// Server→client JSON-RPC REQUEST methods that block on a human. Sourced +/// from the codex 0.129.0 schema inventory +/// (test/fixtures/coding-cli/codex-app-server/schema-inventory.ts:84-94) +/// and verified EXACT against the codex `ServerRequest` enum at both +/// 0.129.0 and the deployed 0.146.0. +const APPROVAL_REQUEST_METHODS: &[&str] = &[ + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "item/permissions/requestApproval", + "item/tool/requestUserInput", + "mcpServer/elicitation/request", + "applyPatchApproval", + "execCommandApproval", +]; + +/// Machine-serviced server→client requests — never human-attention. +/// (`attestation/generate` and `currentTime/read` are new at 0.146.0.) +/// Anything outside BOTH lists is debug-logged to catch future drift +/// (decision 6) — no bell, just logging. +const AUTOMATED_SERVER_REQUEST_METHODS: &[&str] = &[ + "item/tool/call", + "account/chatgptAuthTokens/refresh", + "attestation/generate", + "currentTime/read", +]; + +/// Legacy approval methods carry `params.conversationId` instead of +/// `params.threadId` (codex-rs v1.rs:126-158). +const LEGACY_APPROVAL_REQUEST_METHODS: &[&str] = &["applyPatchApproval", "execCommandApproval"]; + /// `MAX_COMPLETED_TURN_KEYS` (`remote-proxy.ts:95`). const MAX_COMPLETED_TURN_KEYS: usize = 256; @@ -161,6 +191,18 @@ pub struct TurnEventParams { pub params: Map, } +/// One sniffed server→client approval REQUEST (a frame carrying BOTH `id` and `method`, +/// with the method in [`APPROVAL_REQUEST_METHODS`]) — the codex app-server is blocked on +/// a human until it resolves. Task 7 routes this into the hub's attention tracking. +#[derive(Clone, Debug, PartialEq)] +pub struct ApprovalRequestParams { + /// Canonicalized request id (string form of the JSON-RPC id). + pub request_id: String, + pub method: String, + /// Best-effort params.threadId — None for oversized/opaque frames. + pub thread_id: Option, +} + /// The lifecycle-LOSS subset of thread lifecycle notifications (`CodexThreadLifecycleLossEvent`, /// `client.ts`, consumed at `remote-proxy.ts:669,677-681,745,751-756`): `thread/closed` /// always; `thread/status/changed` only for the two loss-worthy statuses. @@ -204,6 +246,15 @@ pub enum RemoteProxyEvent { TurnStarted(TurnEventParams), TurnCompleted(TurnEventParams), RepairTrigger(RemoteProxyRepairTrigger), + /// A server→client approval request was sniffed (decision 5) — the app-server is + /// blocked on a human. The frame itself is relayed verbatim regardless. + ApprovalRequested(ApprovalRequestParams), + /// A previously-sniffed approval resolved: a client `{id, result}` OR `{id, error}` + /// response (decision 5a), an upstream `serverRequest/resolved` notification + /// (decision 5c), or connection teardown draining the pending set (decision 5b). + ApprovalResolved { + request_id: String, + }, } // ── the proxy handle ───────────────────────────────────────────────────────────────── @@ -575,6 +626,10 @@ struct ConnState { pending_to_upstream: VecDeque, pending_methods: HashMap, pending_fork_requests: HashMap>, + /// Sniffed server→client approval requests still awaiting a resolution (decision 5). + /// Keyed on the SERVER's id space (never consulted for our own client requests); + /// drained with `ApprovalResolved` emissions on connection teardown (decision 5b). + pending_server_approvals: HashSet, } impl ConnState { @@ -585,6 +640,7 @@ impl ConnState { pending_to_upstream: VecDeque::new(), pending_methods: HashMap::new(), pending_fork_requests: HashMap::new(), + pending_server_approvals: HashSet::new(), } } } @@ -770,7 +826,14 @@ async fn run_hub( None, ); } - for (_, conn) in hub.connections.drain() { + let drained: Vec = hub.connections.drain().map(|(_, c)| c).collect(); + for conn in drained { + // Decision 5b: shutdown is a teardown too — drain pending approvals. + for req_id in conn.pending_server_approvals { + hub.emit(RemoteProxyEvent::ApprovalResolved { + request_id: request_id_to_string(&req_id), + }); + } if let Some(tx) = conn.client_tx { let _ = tx.send(WriterMsg::Close); } @@ -792,6 +855,15 @@ impl Hub { fn close_connection(&mut self, conn_id: u64) { if let Some(conn) = self.connections.remove(&conn_id) { + // Decision 5b: teardown/restart drains ALL pending approvals. A restarted + // app-server's per-process id counter starts at 0 again, so stale pending + // ids would collide with the next incarnation's fresh requests — resolve + // them now rather than letting a tracker stay paused forever. + for req_id in conn.pending_server_approvals { + self.emit(RemoteProxyEvent::ApprovalResolved { + request_id: request_id_to_string(&req_id), + }); + } if let Some(tx) = conn.client_tx { let _ = tx.send(WriterMsg::Close); } @@ -1022,10 +1094,30 @@ impl Hub { id: Option, method: Option, ) { - if let (Some(id), Some(method)) = (id.as_ref().and_then(envelope_id_to_request_id), method) - { - if let Some(conn) = self.connections.get_mut(&conn_id) { - conn.pending_methods.insert(id, method); + if let Some(req_id) = id.as_ref().and_then(envelope_id_to_request_id) { + match method { + Some(method) => { + if let Some(conn) = self.connections.get_mut(&conn_id) { + conn.pending_methods.insert(req_id, method); + } + } + None => { + // A response frame: {id, result} OR {id, error} — BOTH resolve a + // pending server approval (decision 5a; codex handles errors via + // process_error). The `method`-absence check is MANDATORY + // (decision 5d): a client REQUEST whose id numerically collides + // with a pending server approval must not resolve it, so this arm + // only ever sees genuine responses. + let resolved = self + .connections + .get_mut(&conn_id) + .is_some_and(|conn| conn.pending_server_approvals.remove(&req_id)); + if resolved { + self.emit(RemoteProxyEvent::ApprovalResolved { + request_id: request_id_to_string(&req_id), + }); + } + } } } self.send_to_upstream(conn_id, data, binary); @@ -1097,6 +1189,49 @@ impl Hub { }; if let Some(id) = envelope.id.clone() { + if let Some(method) = envelope.method.as_deref() { + // id + method ⇒ a server→client REQUEST (our own responses never + // reach this path). Never consult pending_methods for these — the + // server's id space is not ours. + if APPROVAL_REQUEST_METHODS.contains(&method) { + if let Some(req_id) = envelope_id_to_request_id(&id) { + // v2 methods carry params.threadId; legacy methods carry + // params.conversationId (decision 7, codex-rs v1.rs:126-158). + let thread_pointer = if LEGACY_APPROVAL_REQUEST_METHODS.contains(&method) { + "/params/conversationId" + } else { + "/params/threadId" + }; + let thread_id = (data.len() <= MAX_FULL_PARSE_BYTES) + .then(|| serde_json::from_slice::(&data).ok()) + .flatten() + .and_then(|v| { + v.pointer(thread_pointer) + .and_then(|t| t.as_str()) + .map(str::to_string) + }); + if let Some(conn) = self.connections.get_mut(&conn_id) { + conn.pending_server_approvals.insert(req_id); + } + self.emit(RemoteProxyEvent::ApprovalRequested(ApprovalRequestParams { + request_id: envelope_id_to_string(&id), + method: method.to_string(), + thread_id, + })); + } + } else if !AUTOMATED_SERVER_REQUEST_METHODS.contains(&method) { + // Decision 6: the method set is version-fluid — surface drift. + tracing::debug!( + method, + "unrecognized codex server->client request method (not treated as an approval)" + ); + } + // The proxy observes, never consumes: every server→client request + // relays verbatim, approval or not. + self.send_to_client(conn_id, data, binary); + return; + } + let req_id = envelope_id_to_request_id(&id); let (method, fork_request) = match self.connections.get_mut(&conn_id) { Some(conn) => { @@ -1130,6 +1265,15 @@ impl Hub { } if let Some(method) = envelope.method.as_deref() { + if method == "serverRequest/resolved" { + // Decision 5c: the app-server resolved its own request (fields + // {thread_id, request_id} under camelCase serde rename — codex + // v2/notification.rs:53-56 @0.146.0). Resolve the pending approval; + // relay the notification verbatim regardless. + self.handle_server_request_resolved_notification(&data); + self.send_to_client(conn_id, data, binary); + return; + } if STATEFUL_NOTIFICATION_METHODS.contains(&method) { self.handle_stateful_upstream_notification(conn_id, data, binary, method); return; @@ -1138,6 +1282,50 @@ impl Hub { self.send_to_client(conn_id, data, binary); } + /// Matches an upstream `serverRequest/resolved` notification's `params.requestId` + /// against every connection's pending approval set (the request went out on this + /// proxy's single upstream) and emits [`RemoteProxyEvent::ApprovalResolved`] when it + /// was pending. Best-effort: oversized/opaque frames resolve nothing (the teardown + /// drain, decision 5b, remains the backstop). + fn handle_server_request_resolved_notification(&mut self, data: &[u8]) { + if data.len() > MAX_FULL_PARSE_BYTES { + return; + } + let Ok(parsed) = serde_json::from_slice::(data) else { + return; + }; + let Some(request_id_value) = parsed.pointer("/params/requestId") else { + return; + }; + // The wire may carry the id as a string ("41") or a number (41); a pending + // RequestId::Int(41) must resolve either way. + let (candidates, request_id) = match request_id_value { + Value::String(s) => { + let mut candidates = vec![RequestId::Str(s.clone())]; + if let Ok(n) = s.parse::() { + candidates.push(RequestId::Int(n)); + } + (candidates, s.clone()) + } + Value::Number(n) => match n.as_i64() { + Some(n) => (vec![RequestId::Int(n)], n.to_string()), + None => return, + }, + _ => return, + }; + let mut resolved = false; + for conn in self.connections.values_mut() { + for candidate in &candidates { + if conn.pending_server_approvals.remove(candidate) { + resolved = true; + } + } + } + if resolved { + self.emit(RemoteProxyEvent::ApprovalResolved { request_id }); + } + } + fn handle_thread_start_response( &mut self, conn_id: u64, @@ -1544,6 +1732,31 @@ fn envelope_id_to_request_id(id: &JsonRpcEnvelopeId) -> Option { } } +/// Canonicalizes a JSON-RPC id for the [`ApprovalRequestParams::request_id`] payload: +/// string ids verbatim, numeric ids via their canonical integer formatting (matching +/// [`envelope_id_to_json`]'s integer-literal preference). +fn envelope_id_to_string(id: &JsonRpcEnvelopeId) -> String { + match id { + JsonRpcEnvelopeId::Str(s) => s.clone(), + JsonRpcEnvelopeId::Num(n) => { + if n.fract() == 0.0 && n.is_finite() && *n >= i64::MIN as f64 && *n <= i64::MAX as f64 { + (*n as i64).to_string() + } else { + n.to_string() + } + } + } +} + +/// The [`RequestId`] counterpart of [`envelope_id_to_string`] — used where only the +/// bridged pending-set key is at hand (response matching, teardown drains). +fn request_id_to_string(id: &RequestId) -> String { + match id { + RequestId::Int(n) => n.to_string(), + RequestId::Str(s) => s.clone(), + } +} + fn envelope_id_to_json(id: &JsonRpcEnvelopeId) -> Value { match id { JsonRpcEnvelopeId::Str(s) => Value::String(s.clone()), diff --git a/crates/freshell-codex/tests/remote_proxy_relay.rs b/crates/freshell-codex/tests/remote_proxy_relay.rs index 0d1aca10a..e33011242 100644 --- a/crates/freshell-codex/tests/remote_proxy_relay.rs +++ b/crates/freshell-codex/tests/remote_proxy_relay.rs @@ -761,7 +761,405 @@ async fn a_slow_tui_consumer_does_not_lose_messages_from_upstream() { proxy.close().await; } -// ── 10. close() tears down active connections and stops accepting new ones ───────── +// ── 10. approval-request sniffing (decisions 5/6/7) ───────────────────────────────── + +/// Polls briefly and asserts NO proxy event arrives — used to prove non-approval +/// frames and unknown-id responses stay event-silent. +async fn assert_no_event(rx: &mut mpsc::UnboundedReceiver) { + let res = timeout(Duration::from_millis(300), rx.recv()).await; + assert!(res.is_err(), "expected no proxy event, got {res:?}"); +} + +#[tokio::test] +async fn approval_request_frame_emits_approval_requested_and_relays_verbatim() { + let mut upstream = start_fake_upstream().await; + let (proxy, mut events) = + CodexRemoteProxy::start(CodexRemoteProxyOptions::new(&upstream.ws_url, true)) + .await + .unwrap(); + let mut tui = connect_tui(proxy.ws_url()).await; + let conn = upstream.accept().await; + + let frame = json!({ + "jsonrpc": "2.0", + "id": 41, + "method": "item/commandExecution/requestApproval", + "params": {"threadId": "thread-1", "command": "rm -rf /tmp/x"}, + }) + .to_string(); + conn.send_text(frame.clone()); + + let received_events = recv_events(&mut events, 1).await; + match &received_events[0] { + RemoteProxyEvent::ApprovalRequested(params) => { + assert_eq!(params.request_id, "41"); + assert_eq!(params.method, "item/commandExecution/requestApproval"); + assert_eq!(params.thread_id.as_deref(), Some("thread-1")); + } + other => panic!("expected ApprovalRequested, got {other:?}"), + } + + let relayed = recv_text(&mut tui).await; + assert_eq!( + relayed, frame, + "the approval request must relay to the client byte-identical" + ); + + proxy.close().await; +} + +#[tokio::test] +async fn non_approval_server_request_is_relayed_without_events() { + let mut upstream = start_fake_upstream().await; + let (proxy, mut events) = + CodexRemoteProxy::start(CodexRemoteProxyOptions::new(&upstream.ws_url, true)) + .await + .unwrap(); + let mut tui = connect_tui(proxy.ws_url()).await; + let conn = upstream.accept().await; + + let frame = json!({ + "jsonrpc": "2.0", + "id": 41, + "method": "item/tool/call", + "params": {"threadId": "thread-1"}, + }) + .to_string(); + conn.send_text(frame.clone()); + + let relayed = recv_text(&mut tui).await; + assert_eq!( + relayed, frame, + "automated server request must relay verbatim" + ); + assert_no_event(&mut events).await; + + proxy.close().await; +} + +#[tokio::test] +async fn approval_response_emits_approval_resolved_and_forwards_upstream() { + let mut upstream = start_fake_upstream().await; + let (proxy, mut events) = + CodexRemoteProxy::start(CodexRemoteProxyOptions::new(&upstream.ws_url, true)) + .await + .unwrap(); + let mut tui = connect_tui(proxy.ws_url()).await; + let mut conn = upstream.accept().await; + + conn.send_text( + json!({ + "jsonrpc": "2.0", + "id": 41, + "method": "item/commandExecution/requestApproval", + "params": {"threadId": "thread-1", "command": "rm -rf /tmp/x"}, + }) + .to_string(), + ); + let _ = recv_events(&mut events, 1).await; // ApprovalRequested + let _ = recv_text(&mut tui).await; // the relayed approval request + + let response = + json!({"jsonrpc": "2.0", "id": 41, "result": {"decision": "approved"}}).to_string(); + tui.send(Message::Text(response.clone())).await.unwrap(); + + let received_events = recv_events(&mut events, 1).await; + assert_eq!( + received_events[0], + RemoteProxyEvent::ApprovalResolved { + request_id: "41".to_string() + } + ); + + let forwarded = conn.recv_text().await; + assert_eq!( + forwarded, response, + "the approval response must forward upstream unchanged" + ); + + proxy.close().await; +} + +#[tokio::test] +async fn client_response_with_unknown_id_emits_nothing() { + let mut upstream = start_fake_upstream().await; + let (proxy, mut events) = + CodexRemoteProxy::start(CodexRemoteProxyOptions::new(&upstream.ws_url, true)) + .await + .unwrap(); + let mut tui = connect_tui(proxy.ws_url()).await; + let mut conn = upstream.accept().await; + + let response = json!({"id": 999, "result": {}}).to_string(); + tui.send(Message::Text(response.clone())).await.unwrap(); + + let forwarded = conn.recv_text().await; + assert_eq!(forwarded, response); + assert_no_event(&mut events).await; + + proxy.close().await; +} + +#[tokio::test] +async fn approval_request_without_thread_id_yields_none() { + let mut upstream = start_fake_upstream().await; + let (proxy, mut events) = + CodexRemoteProxy::start(CodexRemoteProxyOptions::new(&upstream.ws_url, true)) + .await + .unwrap(); + let _tui = connect_tui(proxy.ws_url()).await; + let conn = upstream.accept().await; + + conn.send_text( + json!({ + "jsonrpc": "2.0", + "id": 41, + "method": "item/commandExecution/requestApproval", + "params": {"command": "rm -rf /tmp/x"}, + }) + .to_string(), + ); + + let received_events = recv_events(&mut events, 1).await; + match &received_events[0] { + RemoteProxyEvent::ApprovalRequested(params) => { + assert_eq!(params.request_id, "41"); + assert_eq!(params.thread_id, None); + } + other => panic!("expected ApprovalRequested, got {other:?}"), + } + + proxy.close().await; +} + +#[tokio::test] +async fn legacy_approval_reads_conversation_id() { + // Decision 7 / audit A3: legacy methods carry params.conversationId, not + // params.threadId (codex-rs v1.rs:126-158). + let mut upstream = start_fake_upstream().await; + let (proxy, mut events) = + CodexRemoteProxy::start(CodexRemoteProxyOptions::new(&upstream.ws_url, true)) + .await + .unwrap(); + let _tui = connect_tui(proxy.ws_url()).await; + let conn = upstream.accept().await; + + conn.send_text( + json!({ + "jsonrpc": "2.0", + "id": 42, + "method": "execCommandApproval", + "params": {"conversationId": "thread-1", "command": "rm -rf /tmp/x"}, + }) + .to_string(), + ); + + let received_events = recv_events(&mut events, 1).await; + match &received_events[0] { + RemoteProxyEvent::ApprovalRequested(params) => { + assert_eq!(params.request_id, "42"); + assert_eq!(params.method, "execCommandApproval"); + assert_eq!(params.thread_id.as_deref(), Some("thread-1")); + } + other => panic!("expected ApprovalRequested, got {other:?}"), + } + + proxy.close().await; +} + +#[tokio::test] +async fn error_response_also_resolves() { + // Decision 5a / audit A5: a client {id, error} response resolves identically to + // {id, result} — codex handles errors via process_error. + let mut upstream = start_fake_upstream().await; + let (proxy, mut events) = + CodexRemoteProxy::start(CodexRemoteProxyOptions::new(&upstream.ws_url, true)) + .await + .unwrap(); + let mut tui = connect_tui(proxy.ws_url()).await; + let mut conn = upstream.accept().await; + + conn.send_text( + json!({ + "jsonrpc": "2.0", + "id": 41, + "method": "item/commandExecution/requestApproval", + "params": {"threadId": "thread-1", "command": "rm -rf /tmp/x"}, + }) + .to_string(), + ); + let _ = recv_events(&mut events, 1).await; // ApprovalRequested + let _ = recv_text(&mut tui).await; // the relayed approval request + + let response = + json!({"jsonrpc": "2.0", "id": 41, "error": {"code": -1, "message": "denied"}}).to_string(); + tui.send(Message::Text(response.clone())).await.unwrap(); + + let received_events = recv_events(&mut events, 1).await; + assert_eq!( + received_events[0], + RemoteProxyEvent::ApprovalResolved { + request_id: "41".to_string() + } + ); + + let forwarded = conn.recv_text().await; + assert_eq!(forwarded, response); + + proxy.close().await; +} + +#[tokio::test] +async fn client_frame_with_id_and_method_never_resolves() { + // Decision 5d: response matching REQUIRES method-absence — a client REQUEST whose + // id numerically collides with a pending server approval must not resolve it. + let mut upstream = start_fake_upstream().await; + let (proxy, mut events) = + CodexRemoteProxy::start(CodexRemoteProxyOptions::new(&upstream.ws_url, true)) + .await + .unwrap(); + let mut tui = connect_tui(proxy.ws_url()).await; + let mut conn = upstream.accept().await; + + conn.send_text( + json!({ + "jsonrpc": "2.0", + "id": 41, + "method": "item/commandExecution/requestApproval", + "params": {"threadId": "thread-1", "command": "rm -rf /tmp/x"}, + }) + .to_string(), + ); + let _ = recv_events(&mut events, 1).await; // ApprovalRequested + let _ = recv_text(&mut tui).await; // the relayed approval request + + let colliding_request = + json!({"jsonrpc": "2.0", "id": 41, "method": "thread/start", "params": {}}).to_string(); + tui.send(Message::Text(colliding_request.clone())) + .await + .unwrap(); + + let forwarded = conn.recv_text().await; + assert_eq!( + forwarded, colliding_request, + "the colliding client REQUEST must forward upstream unchanged" + ); + assert_no_event(&mut events).await; + + proxy.close().await; +} + +#[tokio::test] +async fn server_request_resolved_notification_resolves() { + // Decision 5c: the upstream `serverRequest/resolved` notification (fields + // {thread_id, request_id} under camelCase serde rename — codex + // v2/notification.rs:53-56 @0.146.0) resolves the pending approval. + let mut upstream = start_fake_upstream().await; + let (proxy, mut events) = + CodexRemoteProxy::start(CodexRemoteProxyOptions::new(&upstream.ws_url, true)) + .await + .unwrap(); + let mut tui = connect_tui(proxy.ws_url()).await; + let conn = upstream.accept().await; + + conn.send_text( + json!({ + "jsonrpc": "2.0", + "id": 41, + "method": "item/commandExecution/requestApproval", + "params": {"threadId": "thread-1", "command": "rm -rf /tmp/x"}, + }) + .to_string(), + ); + let _ = recv_events(&mut events, 1).await; // ApprovalRequested + let _ = recv_text(&mut tui).await; // the relayed approval request + + let notification = json!({ + "method": "serverRequest/resolved", + "params": {"threadId": "thread-1", "requestId": "41"}, + }) + .to_string(); + conn.send_text(notification.clone()); + + let received_events = recv_events(&mut events, 1).await; + assert_eq!( + received_events[0], + RemoteProxyEvent::ApprovalResolved { + request_id: "41".to_string() + } + ); + + let relayed = recv_text(&mut tui).await; + assert_eq!( + relayed, notification, + "the resolved notification must relay to the client verbatim" + ); + + proxy.close().await; +} + +#[tokio::test] +async fn upstream_reconnect_clears_pending_approvals() { + // Decision 5b: upstream teardown drains ALL pending approvals — the restarted + // app-server's per-process id counter starts at 0 again and stale ids would collide. + let mut upstream = start_fake_upstream().await; + let (proxy, mut events) = + CodexRemoteProxy::start(CodexRemoteProxyOptions::new(&upstream.ws_url, true)) + .await + .unwrap(); + let mut tui = connect_tui(proxy.ws_url()).await; + let conn = upstream.accept().await; + + conn.send_text( + json!({ + "jsonrpc": "2.0", + "id": 41, + "method": "item/commandExecution/requestApproval", + "params": {"threadId": "thread-1", "command": "rm -rf /tmp/x"}, + }) + .to_string(), + ); + let _ = recv_events(&mut events, 1).await; // ApprovalRequested + let _ = recv_text(&mut tui).await; // the relayed approval request + + drop(conn); // upstream goes away (the harness's disconnect simulation) + + let received_events = recv_events(&mut events, 2).await; + assert!( + received_events.iter().any(|e| matches!( + e, + RemoteProxyEvent::ApprovalResolved { request_id } if request_id == "41" + )), + "expected the drained pending approval to resolve, got {received_events:?}" + ); + + proxy.close().await; +} + +#[tokio::test] +async fn unknown_server_request_method_is_logged_not_belled() { + // Decision 6: an unrecognized server->client request method is debug-logged and + // relayed — never treated as an approval. + let mut upstream = start_fake_upstream().await; + let (proxy, mut events) = + CodexRemoteProxy::start(CodexRemoteProxyOptions::new(&upstream.ws_url, true)) + .await + .unwrap(); + let mut tui = connect_tui(proxy.ws_url()).await; + let conn = upstream.accept().await; + + let frame = json!({"jsonrpc": "2.0", "id": 43, "method": "some/future/method", "params": {}}) + .to_string(); + conn.send_text(frame.clone()); + + let relayed = recv_text(&mut tui).await; + assert_eq!(relayed, frame, "unknown server request must relay verbatim"); + assert_no_event(&mut events).await; + + proxy.close().await; +} + +// ── 11. close() tears down active connections and stops accepting new ones ───────── #[tokio::test] async fn close_tears_down_active_connections_and_stops_accepting_new_ones() { diff --git a/crates/freshell-sessions/src/meta.rs b/crates/freshell-sessions/src/meta.rs index d2cb98876..89bea3bfb 100644 --- a/crates/freshell-sessions/src/meta.rs +++ b/crates/freshell-sessions/src/meta.rs @@ -28,6 +28,10 @@ pub struct CodexTaskEventSnapshot { pub latest_task_started_at: Option, pub latest_task_completed_at: Option, pub latest_turn_aborted_at: Option, + /// Reason string paired with `latest_turn_aborted_at` (e.g. "interrupted"). + /// None on legacy rollout lines that carry no reason. Node mirror: + /// `latestTurnAbortedReason` (Task 10 of the attention-bell plan). + pub latest_turn_aborted_reason: Option, } impl CodexTaskEventSnapshot { diff --git a/crates/freshell-sessions/src/parse/codex.rs b/crates/freshell-sessions/src/parse/codex.rs index 655117144..d085b0b0e 100644 --- a/crates/freshell-sessions/src/parse/codex.rs +++ b/crates/freshell-sessions/src/parse/codex.rs @@ -292,6 +292,7 @@ pub fn parse_codex_session_content(content: &str) -> ParsedSessionMeta { let mut latest_task_started_at: Option = None; let mut latest_task_completed_at: Option = None; let mut latest_turn_aborted_at: Option = None; + let mut latest_turn_aborted_reason: Option = None; for line in &lines { let obj: Value = match serde_json::from_str(line) { @@ -447,7 +448,22 @@ pub fn parse_codex_session_content(content: &str) -> ParsedSessionMeta { latest_task_completed_at = max_timestamp(latest_task_completed_at, timestamp_ms) } Some("turn_aborted") => { - latest_turn_aborted_at = max_timestamp(latest_turn_aborted_at, timestamp_ms) + // Newest-wins PAIRING: the reason always corresponds to + // the winning `latest_turn_aborted_at`, and is None when + // that abort carried no reason (legacy lines). + let beats = match (latest_turn_aborted_at, timestamp_ms) { + (_, None) => false, + (None, Some(_)) => true, + (Some(seen), Some(at)) => at > seen, + }; + if beats { + latest_turn_aborted_at = timestamp_ms; + latest_turn_aborted_reason = obj + .get("payload") + .and_then(|p| p.get("reason")) + .and_then(Value::as_str) + .map(str::to_string); + } } _ => {} } @@ -474,6 +490,7 @@ pub fn parse_codex_session_content(content: &str) -> ParsedSessionMeta { latest_task_started_at, latest_task_completed_at, latest_turn_aborted_at, + latest_turn_aborted_reason, }) } else { None diff --git a/crates/freshell-sessions/tests/codex_fixture_parity.rs b/crates/freshell-sessions/tests/codex_fixture_parity.rs index ea4ac385f..c652b236f 100644 --- a/crates/freshell-sessions/tests/codex_fixture_parity.rs +++ b/crates/freshell-sessions/tests/codex_fixture_parity.rs @@ -40,12 +40,44 @@ fn task_events_stream_matches_reference() { latest_task_started_at: Some(1_772_323_205_000), latest_task_completed_at: Some(1_772_323_204_000), latest_turn_aborted_at: Some(1_772_323_206_000), + latest_turn_aborted_reason: Some("Sanitized abort".to_string()), }), ..Default::default() }; assert_eq!(meta, expected); } +#[test] +fn turn_aborted_reason_pairs_with_the_newest_abort() { + // Newest-wins PAIRING: the emitted reason belongs to the winning + // `latest_turn_aborted_at`; a reason-less legacy line yields None. + let reasoned = concat!( + r#"{"timestamp":"2026-03-01T00:00:01.000Z","type":"event_msg","payload":{"type":"turn_aborted","turn_id":"a","reason":"replaced"}}"#, + "\n", + r#"{"timestamp":"2026-03-01T00:00:02.000Z","type":"event_msg","payload":{"type":"turn_aborted","turn_id":"b","reason":"interrupted"}}"#, + ); + let events = parse_codex_session_content(reasoned) + .codex_task_events + .expect("task events present"); + assert_eq!(events.latest_turn_aborted_at, Some(1_772_323_202_000)); + assert_eq!( + events.latest_turn_aborted_reason, + Some("interrupted".to_string()) + ); + + // A NEWER reason-less abort must not inherit the older abort's reason. + let legacy_newest = concat!( + r#"{"timestamp":"2026-03-01T00:00:01.000Z","type":"event_msg","payload":{"type":"turn_aborted","turn_id":"a","reason":"interrupted"}}"#, + "\n", + r#"{"timestamp":"2026-03-01T00:00:02.000Z","type":"event_msg","payload":{"type":"turn_aborted","turn_id":"b"}}"#, + ); + let events = parse_codex_session_content(legacy_newest) + .codex_task_events + .expect("task events present"); + assert_eq!(events.latest_turn_aborted_at, Some(1_772_323_202_000)); + assert_eq!(events.latest_turn_aborted_reason, None); +} + #[test] fn corrupt_and_empty_codex_streams_never_panic() { // Corruption tolerance: garbage lines are skipped, empty input yields empty meta. diff --git a/crates/freshell-terminal/src/registry.rs b/crates/freshell-terminal/src/registry.rs index 83d4c4be5..63617802c 100644 --- a/crates/freshell-terminal/src/registry.rs +++ b/crates/freshell-terminal/src/registry.rs @@ -407,6 +407,10 @@ pub enum ActivityEvent { Exit { terminal_id: String, at: i64, + /// true = the process died on its own (finish_pty_exit); false = a + /// freshell-initiated kill (api / idle reaper / shutdown). Human-requested + /// closes must never ring the attention bell. + spontaneous: bool, }, } @@ -1507,6 +1511,7 @@ impl TerminalRegistry { self.notify_activity(ActivityEvent::Exit { terminal_id: terminal_id.to_string(), at: now_ms(), + spontaneous: false, }); true } @@ -1625,6 +1630,7 @@ impl TerminalRegistry { self.notify_activity(ActivityEvent::Exit { terminal_id: terminal_id.to_string(), at: now_ms(), + spontaneous: true, }); true } @@ -4345,6 +4351,46 @@ mod tests { ); } + #[test] + fn kill_emits_a_non_spontaneous_exit_event() { + let reg = TerminalRegistry::new(); + let seen: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink_seen = Arc::clone(&seen); + reg.set_activity_observer(Arc::new(move |event| { + sink_seen.lock().unwrap().push(event); + })); + reg.insert_headless("T-kill", "S-kill"); + assert!(reg.kill("T-kill")); + assert!( + seen.lock().unwrap().iter().any(|e| matches!( + e, + ActivityEvent::Exit { terminal_id, spontaneous, .. } + if terminal_id == "T-kill" && !spontaneous + )), + "a freshell-initiated kill must emit Exit with spontaneous == false" + ); + } + + #[test] + fn natural_pty_exit_emits_a_spontaneous_exit_event() { + let reg = TerminalRegistry::new(); + let seen: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink_seen = Arc::clone(&seen); + reg.set_activity_observer(Arc::new(move |event| { + sink_seen.lock().unwrap().push(event); + })); + reg.insert_headless("T-spont", "S-spont"); + assert!(reg.finish_pty_exit("T-spont", 0)); + assert!( + seen.lock().unwrap().iter().any(|e| matches!( + e, + ActivityEvent::Exit { terminal_id, spontaneous, .. } + if terminal_id == "T-spont" && *spontaneous + )), + "a natural PTY exit must emit Exit with spontaneous == true" + ); + } + #[test] fn eviction_on_box_drawing_content_never_panics_and_stays_within_char_cap() { // Many small multi-byte frames driving continuous eviction: proves the diff --git a/crates/freshell-ws/src/activity.rs b/crates/freshell-ws/src/activity.rs index 679a4d88a..9833a54d0 100644 --- a/crates/freshell-ws/src/activity.rs +++ b/crates/freshell-ws/src/activity.rs @@ -46,7 +46,7 @@ use freshell_activity::TrackerEffect; use freshell_protocol::{ AgentProvider, AmplifierActivityRecord, AmplifierActivityUpdated, ClaudeActivityRecord, ClaudeActivityUpdated, CodexActivityRecord, CodexActivityUpdated, ServerMessage, TerminalIdle, - TerminalTurnComplete, TurnCompletionSnapshot, + TerminalIdleReason, TerminalTurnComplete, TurnCompletionSnapshot, }; use freshell_terminal::ActivityEvent; @@ -133,11 +133,27 @@ enum HubEvent { CodexFsChange { terminal_id: String, }, - /// S5.a: a proxy TurnStarted/TurnCompleted for a managed codex terminal. + /// S5.a + kata codex-turn-thread-scope: a proxy TurnStarted/TurnCompleted + /// for a managed codex terminal, carrying the EMITTING thread's identity + /// (which may be a sub-agent/review/fork thread, not the bound one) and, + /// for completions, the raw turn status. The tracker owns the guards. CodexProxyTurn { terminal_id: String, + thread_id: String, + turn_id: Option, + status: Option, completed: bool, }, + /// Task 7: a sniffed server→client approval request (`requested: true`) + /// or its resolution (`requested: false`) for a managed codex terminal. + /// Requests may carry the emitting thread's id (the tracker's thread + /// guard drops sub-agent approvals); resolves never do. + CodexApproval { + terminal_id: String, + thread_id: Option, + request_id: String, + requested: bool, + }, } struct AmplifierLane { @@ -266,15 +282,48 @@ impl ActivityHub { }); } - /// S5.a: proxy (managed-launch) turn lane — channel-deferred like + /// S5.a: proxy (managed-launch) turn lane -- channel-deferred like /// `bind_codex_session` so all frame emission stays on the hub task. - pub fn note_codex_proxy_turn(&self, terminal_id: &str, completed: bool) { + /// `status` is only meaningful for completions (`turn/completed` carries + /// 'completed' | 'interrupted' | 'failed' | 'inProgress'); pass `None` + /// for starts. + pub fn note_codex_proxy_turn( + &self, + terminal_id: &str, + thread_id: &str, + turn_id: Option<&str>, + status: Option<&str>, + completed: bool, + ) { let _ = self.tx.send(HubEvent::CodexProxyTurn { terminal_id: terminal_id.to_string(), + thread_id: thread_id.to_string(), + turn_id: turn_id.map(str::to_string), + status: status.map(str::to_string), completed, }); } + /// Task 7: proxy (managed-launch) approval lane -- channel-deferred like + /// `note_codex_proxy_turn` so all frame emission stays on the hub task. + /// `requested: true` is a sniffed server→client approval request; + /// `false` is its resolution. `thread_id` is best-effort and only + /// present on requests. + pub fn note_codex_approval( + &self, + terminal_id: &str, + thread_id: Option<&str>, + request_id: &str, + requested: bool, + ) { + let _ = self.tx.send(HubEvent::CodexApproval { + terminal_id: terminal_id.to_string(), + thread_id: thread_id.map(str::to_string), + request_id: request_id.to_string(), + requested, + }); + } + /// Install the resume-time rollout locator (called once from /// `freshell-server` at boot; tests inject tempdir-backed closures). pub fn set_codex_rollout_locator(&self, locator: CodexRolloutLocator) { @@ -508,15 +557,55 @@ impl ActivityHub { } HubEvent::CodexProxyTurn { terminal_id, + thread_id, + turn_id, + status, completed, } => { let at = now_ms(); let frames = { let mut inner = self.inner.lock().expect("activity hub lock"); let effects = if completed { - inner.codex.note_proxy_turn_completed(&terminal_id, at) + inner.codex.note_proxy_turn_completed( + &terminal_id, + &thread_id, + turn_id.as_deref(), + status.as_deref(), + at, + ) + } else { + inner.codex.note_proxy_turn_started( + &terminal_id, + &thread_id, + turn_id.as_deref(), + at, + ) + }; + let (frames, _force_reads) = codex_frames(&mut inner.idle, effects); + frames + }; + self.emit(frames); + } + HubEvent::CodexApproval { + terminal_id, + thread_id, + request_id, + requested, + } => { + let at = now_ms(); + let frames = { + let mut inner = self.inner.lock().expect("activity hub lock"); + let effects = if requested { + inner.codex.note_approval_requested( + &terminal_id, + thread_id.as_deref(), + &request_id, + at, + ) } else { - inner.codex.note_proxy_turn_started(&terminal_id, at) + inner + .codex + .note_approval_resolved(&terminal_id, &request_id, at) }; let (frames, _force_reads) = codex_frames(&mut inner.idle, effects); frames @@ -688,33 +777,66 @@ impl ActivityHub { }; self.emit(frames); } - ActivityEvent::Exit { terminal_id, .. } => { + ActivityEvent::Exit { + terminal_id, + at, + spontaneous, + } => { let frames = { let mut inner = self.inner.lock().expect("activity hub lock"); - let Some(mode) = inner.modes.remove(&terminal_id) else { - return; - }; - inner.idle.note_exit(&terminal_id); - inner.lanes.remove(&terminal_id); - inner.lane_retries.remove(&terminal_id); - inner.codex_lanes.remove(&terminal_id); - match mode.as_str() { - "claude" => { - let effects = inner.claude.note_exit(&terminal_id); - claude_frames(&mut inner.idle, effects) - } - "codex" => { - let effects = inner.codex.note_exit(&terminal_id); - let (frames, _force_reads) = codex_frames(&mut inner.idle, effects); - frames - } - "amplifier" => { - let effects = inner.amplifier.note_exit(&terminal_id); - let (frames, _force) = amplifier_frames(&mut inner.idle, effects); - frames - } - _ => Vec::new(), + // Read engagement BEFORE any teardown: `idle.note_exit` deletes the + // per-terminal gate state and `modes.remove` would early-return. + // Task 7: a pane blocked on an approval whose process dies must + // ring even after its 2s boundary already rang, so pending + // approvals count as engagement too. + let ring_death_bell = spontaneous + && (inner.idle.is_engaged(&terminal_id) + || inner.codex.has_pending_approvals(&terminal_id)); + let mut frames = Vec::new(); + if ring_death_bell { + // Spontaneous death while engaged: same frame, same reason — + // no wire change. reason MUST be Grace: the client zod enum + // (shared/ws-protocol.ts:210-215) and the Rust enum + // (freshell-protocol server_messages.rs:397-402) allow ONLY + // grace|queue-empty — a novel reason is silently dropped by + // the Node schema and unrepresentable here. `at` is the fresh + // exit timestamp (client dedupe is per-terminal monotonic + // `at`). Immediate (no grace): a dead process emits nothing + // further, so nothing could ever cancel it. Exactly once per + // terminal: the modes.remove below guarantees the teardown + // runs once, and a later shutdown sweep of a retained exited + // row arrives with spontaneous=false. + frames.push(ServerMessage::TerminalIdle(TerminalIdle { + terminal_id: terminal_id.clone(), + at, + reason: TerminalIdleReason::Grace, + })); + } + if let Some(mode) = inner.modes.remove(&terminal_id) { + inner.idle.note_exit(&terminal_id); + inner.lanes.remove(&terminal_id); + inner.lane_retries.remove(&terminal_id); + inner.codex_lanes.remove(&terminal_id); + let tracker_frames = match mode.as_str() { + "claude" => { + let effects = inner.claude.note_exit(&terminal_id); + claude_frames(&mut inner.idle, effects) + } + "codex" => { + let effects = inner.codex.note_exit(&terminal_id); + let (frames, _force_reads) = codex_frames(&mut inner.idle, effects); + frames + } + "amplifier" => { + let effects = inner.amplifier.note_exit(&terminal_id); + let (frames, _force) = amplifier_frames(&mut inner.idle, effects); + frames + } + _ => Vec::new(), + }; + frames.extend(tracker_frames); } + frames }; self.emit(frames); } @@ -1152,6 +1274,8 @@ fn claude_frames( )); } TrackerEffect::ForceRead { .. } => {} + // Codex-only (approval pauses); never emitted by the claude tracker. + TrackerEffect::AttentionBoundary { .. } => {} } } frames @@ -1203,6 +1327,12 @@ fn codex_frames( )); } TrackerEffect::ForceRead { terminal_id, .. } => force_reads.push(terminal_id), + TrackerEffect::AttentionBoundary { terminal_id, at } => { + // Arm the gate WITHOUT a terminal.turn.complete frame — an approval + // pause is not a turn end. Effect order guarantees the Idle phase + // Changed was processed first, so the boundary arms. + idle.note_turn_boundary(&terminal_id, at); + } } } (frames, force_reads) @@ -1253,6 +1383,8 @@ fn amplifier_frames( )); } TrackerEffect::ForceRead { terminal_id, .. } => force_reads.push(terminal_id), + // Codex-only (approval pauses); never emitted by the amplifier tracker. + TrackerEffect::AttentionBoundary { .. } => {} } } (frames, force_reads) @@ -1559,6 +1691,7 @@ mod tests { ActivityEvent::Exit { terminal_id: "t1".into(), at: now_ms(), + spontaneous: false, }, ); let removed = next_frame_matching(&mut rx, "codex.activity.updated", 2_000, |v| { @@ -1571,222 +1704,507 @@ mod tests { assert!(records.is_empty()); } - /// Gemini/Kimi terminals stay status-inert (TERM-16): no activity frames. - #[tokio::test(flavor = "multi_thread")] - async fn gemini_and_kimi_are_status_inert() { - let (hub, mut rx) = hub(); - for (i, mode) in ["gemini", "kimi"].iter().enumerate() { - observer_send( - &hub, - ActivityEvent::Created { - terminal_id: format!("t{i}"), - mode: mode.to_string(), - resume_session_id: None, - at: now_ms(), - }, - ); - observer_send( - &hub, - ActivityEvent::Input { - terminal_id: format!("t{i}"), - data: "\r".into(), - at: now_ms(), - }, - ); - observer_send( - &hub, - ActivityEvent::Output { - terminal_id: format!("t{i}"), - data: "\u{07}".into(), - at: now_ms(), - }, - ); - } - // Nothing may be broadcast for status-inert modes. - let frame = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await; - assert!(frame.is_err(), "status-inert modes must broadcast nothing"); - let (claude, _) = hub.claude_list(); - let (codex, _) = hub.codex_list(); - let (amplifier, _) = hub.amplifier_list(); - assert!(claude.is_empty() && codex.is_empty() && amplifier.is_empty()); - } - - /// The amplifier events lane: association attach replays the young file - /// (prompt:submit confirms busy), a later prompt:complete broadcasts - /// idle + turn.complete + terminal.idle — all driven by inotify, with - /// tail reads ONLY on attach/writes (zero polling). + /// Decision 3 death bell: a spontaneous exit (the process died on its + /// own) while ENGAGED (confirmed busy) rings exactly one terminal.idle. + /// This test doubles as the audit-A17 ordering pin: if the hub read + /// engagement AFTER `idle.note_exit` (which deletes the per-terminal + /// state), the read would always be false and no frame would arrive. #[tokio::test(flavor = "multi_thread")] - async fn amplifier_events_lane_drives_busy_complete_and_idle_via_inotify() { - let dir = tempfile::tempdir().unwrap(); - let events_path = dir.path().join("events.jsonl"); - std::fs::write( - &events_path, - [ - amplifier_line("session:start"), - amplifier_line("prompt:submit"), - ] - .concat(), - ) - .unwrap(); - + async fn spontaneous_exit_while_busy_rings_terminal_idle_once() { let (hub, mut rx) = hub(); observer_send( &hub, ActivityEvent::Created { terminal_id: "t1".into(), - mode: "amplifier".into(), - resume_session_id: None, - at: now_ms(), - }, - ); - // PTY Enter: provisional busy. - observer_send( - &hub, - ActivityEvent::Input { - terminal_id: "t1".into(), - data: "\r".into(), + mode: "codex".into(), + resume_session_id: Some("thread-1".into()), at: now_ms(), }, ); - let busy = next_frame_matching(&mut rx, "amplifier.activity.updated", 2_000, |v| { - v["upsert"][0]["phase"] == "busy" + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["terminalId"] == "t1" }) .await - .expect("provisional busy upsert"); - assert_eq!(busy["upsert"][0]["terminalId"], "t1"); - - // Association resolves: lane attaches at Start and replays the - // recorded prompt:submit (confirms busy — no public flap). - hub.attach_amplifier_association("t1", "sess-1", &events_path); + .expect("initial idle upsert"); - // Wait for the attach + initial drain to land (sessionId binds). - let bound = next_frame_matching(&mut rx, "amplifier.activity.updated", 3_000, |v| { - v["upsert"][0]["sessionId"] == "sess-1" + // Drive to CONFIRMED busy via the proxy turn lane. + hub.note_codex_proxy_turn("t1", "thread-1", Some("turn-1"), None, false); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["phase"] == "busy" }) .await - .expect("bind upsert"); - assert_eq!(bound["upsert"][0]["terminalId"], "t1"); - - // DEFLAKE (f3wp refresh): the bind upsert can broadcast BEFORE the - // attach's initial drain has incremented `tail_reads` (observed once - // under workspace load, /tmp/f3wp-refresh/cargo-run5.log: - // `reads_after_attach >= 1` failed on a one-shot read taken right - // after the bind frame). Poll to the attach-read edge instead of - // racing it -- the attach performs exactly one drain read with no - // writes pending, so the settled counter is what the zero-polling - // stability assertion below then holds against, unchanged. - let attach_read_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); - while hub.stats().tail_reads.load(Ordering::SeqCst) < 1 { - assert!( - tokio::time::Instant::now() < attach_read_deadline, - "attach never performed its initial tail read" - ); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - let reads_after_attach = hub.stats().tail_reads.load(Ordering::SeqCst); - assert!(reads_after_attach >= 1); + .expect("busy upsert"); - // Zero-polling: with no writes, NO further tail reads happen. - tokio::time::sleep(std::time::Duration::from_millis(400)).await; - assert_eq!( - hub.stats().tail_reads.load(Ordering::SeqCst), - reads_after_attach, - "no writes ⇒ no tail reads (inotify-driven, never polled)" + // The process dies mid-turn. + observer_send( + &hub, + ActivityEvent::Exit { + terminal_id: "t1".into(), + at: now_ms(), + spontaneous: true, + }, ); - - // The turn completes: append prompt:complete — inotify drives the read. - let mut f = std::fs::OpenOptions::new() - .append(true) - .open(&events_path) - .unwrap(); - f.write_all(amplifier_line("prompt:complete").as_bytes()) - .unwrap(); - f.flush().unwrap(); - drop(f); - - let complete = next_frame_of_type(&mut rx, "terminal.turn.complete", 5_000) - .await - .expect("amplifier turn.complete"); - assert_eq!(complete["provider"], "amplifier"); - assert_eq!(complete["sessionId"], "sess-1"); - - // Truly idle after the grace window (no further file activity). - let idle = next_frame_of_type(&mut rx, "terminal.idle", 5_000) + let idle = next_frame_of_type(&mut rx, "terminal.idle", 3_000) .await - .expect("terminal.idle"); + .expect("terminal.idle death bell for a spontaneous exit while busy"); assert_eq!(idle["terminalId"], "t1"); assert_eq!(idle["reason"], "grace"); - assert!( - hub.stats().tail_reads.load(Ordering::SeqCst) > reads_after_attach, - "the write must have driven a tail read" - ); - } - - /// Steady-state zero-wake proof: idle tracked terminals arm NO timers and - /// read NO files. (The 20-agents-idle scenario in miniature.) - #[tokio::test(flavor = "multi_thread")] - async fn idle_terminals_arm_no_timers_and_read_no_files() { - let (hub, _rx) = hub(); - for i in 0..20 { - observer_send( - &hub, - ActivityEvent::Created { - terminal_id: format!("t{i}"), - mode: if i % 2 == 0 { "claude" } else { "codex" }.into(), - resume_session_id: None, - at: now_ms(), - }, - ); - } - // Let the hub settle, then observe a quiet window. - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let wakes_before = hub.stats().timer_wakes.load(Ordering::SeqCst); - let reads_before = hub.stats().tail_reads.load(Ordering::SeqCst); - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - assert_eq!( - hub.stats().timer_wakes.load(Ordering::SeqCst), - wakes_before, - "20 idle tracked terminals must cause zero timer wakes" - ); - assert_eq!( - hub.stats().tail_reads.load(Ordering::SeqCst), - reads_before, - "20 idle tracked terminals must cause zero file reads" + next_frame_of_type(&mut rx, "terminal.idle", 1_000) + .await + .is_none(), + "exactly one terminal.idle for the death, never a duplicate" ); - { - let inner = hub.inner.lock().unwrap(); - assert_eq!(hub_next_deadline(&inner), None, "no deadline while idle"); - } } + /// Decision 3: a freshell-initiated kill (api / idle reaper / shutdown — + /// spontaneous=false) stays silent even mid-turn. #[tokio::test(flavor = "multi_thread")] - async fn catchup_cap_attaches_at_eof_for_oversized_backlog() { - let dir = tempfile::tempdir().unwrap(); - let events_path = dir.path().join("events.jsonl"); - // > 4 MiB of pre-filter noise (skipped without parsing — no lifecycle - // event prefix) followed by a lifecycle record that must NOT be - // replayed once the cap downgrades the attach to Eof. - let noise = format!("{{\"noise\":\"{}\"}}\n", "x".repeat(5 * 1024 * 1024)); - std::fs::write( - &events_path, - [noise, amplifier_line("prompt:submit")].concat(), - ) - .unwrap(); - + async fn freshell_initiated_kill_while_busy_stays_silent() { let (hub, mut rx) = hub(); observer_send( &hub, ActivityEvent::Created { terminal_id: "t1".into(), - mode: "amplifier".into(), - resume_session_id: None, + mode: "codex".into(), + resume_session_id: Some("thread-1".into()), at: now_ms(), }, ); - hub.attach_amplifier_association("t1", "sess-1", &events_path); - - // The oversized backlog must NOT be replayed: no busy upsert appears. + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["terminalId"] == "t1" + }) + .await + .expect("initial idle upsert"); + hub.note_codex_proxy_turn("t1", "thread-1", Some("turn-1"), None, false); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["phase"] == "busy" + }) + .await + .expect("busy upsert"); + + observer_send( + &hub, + ActivityEvent::Exit { + terminal_id: "t1".into(), + at: now_ms(), + spontaneous: false, + }, + ); + assert!( + next_frame_of_type(&mut rx, "terminal.idle", 1_500) + .await + .is_none(), + "a requested exit must never ring the death bell" + ); + } + + /// Decision 3: exit while idle (no engagement) is silent — a human + /// closing an idle pane is not an attention event. + #[tokio::test(flavor = "multi_thread")] + async fn spontaneous_exit_while_idle_stays_silent() { + let (hub, mut rx) = hub(); + observer_send( + &hub, + ActivityEvent::Created { + terminal_id: "t1".into(), + mode: "codex".into(), + resume_session_id: None, + at: now_ms(), + }, + ); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["terminalId"] == "t1" + }) + .await + .expect("initial idle upsert"); + + observer_send( + &hub, + ActivityEvent::Exit { + terminal_id: "t1".into(), + at: now_ms(), + spontaneous: true, + }, + ); + assert!( + next_frame_of_type(&mut rx, "terminal.idle", 1_500) + .await + .is_none(), + "a spontaneous exit while idle must stay silent" + ); + } + + /// Decision 3 (audit A8): queue evidence does NOT suppress the death + /// bell — a dead process never runs its queued submit. + #[tokio::test(flavor = "multi_thread")] + async fn queued_submit_does_not_suppress_the_death_bell() { + let (hub, mut rx) = hub(); + observer_send( + &hub, + ActivityEvent::Created { + terminal_id: "t1".into(), + mode: "codex".into(), + resume_session_id: Some("thread-1".into()), + at: now_ms(), + }, + ); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["terminalId"] == "t1" + }) + .await + .expect("initial idle upsert"); + hub.note_codex_proxy_turn("t1", "thread-1", Some("turn-1"), None, false); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["phase"] == "busy" + }) + .await + .expect("busy upsert"); + + // A submit queued while busy (would auto-run at the turn clear — + // but the process dies first, so it never will). + observer_send( + &hub, + ActivityEvent::Input { + terminal_id: "t1".into(), + data: "\r".into(), + at: now_ms(), + }, + ); + observer_send( + &hub, + ActivityEvent::Exit { + terminal_id: "t1".into(), + at: now_ms(), + spontaneous: true, + }, + ); + let idle = next_frame_of_type(&mut rx, "terminal.idle", 3_000) + .await + .expect("queue evidence must not suppress the death bell"); + assert_eq!(idle["terminalId"], "t1"); + } + + /// Decision 3, claude tracker: same death bell for a claude-mode + /// terminal driven busy via the claude input lane. + #[tokio::test(flavor = "multi_thread")] + async fn claude_spontaneous_exit_while_busy_rings() { + let (hub, mut rx) = hub(); + observer_send( + &hub, + ActivityEvent::Created { + terminal_id: "t1".into(), + mode: "claude".into(), + resume_session_id: None, + at: now_ms(), + }, + ); + observer_send( + &hub, + ActivityEvent::Input { + terminal_id: "t1".into(), + data: "\r".into(), + at: now_ms(), + }, + ); + next_frame_matching(&mut rx, "claude.activity.updated", 2_000, |v| { + v["upsert"][0]["phase"] == "busy" + }) + .await + .expect("busy upsert"); + + observer_send( + &hub, + ActivityEvent::Exit { + terminal_id: "t1".into(), + at: now_ms(), + spontaneous: true, + }, + ); + let idle = next_frame_of_type(&mut rx, "terminal.idle", 3_000) + .await + .expect("terminal.idle death bell for a claude spontaneous exit while busy"); + assert_eq!(idle["terminalId"], "t1"); + assert_eq!(idle["reason"], "grace"); + assert!( + next_frame_of_type(&mut rx, "terminal.idle", 1_000) + .await + .is_none(), + "exactly one terminal.idle for the death" + ); + } + + /// Audit A6 red test: `/quit` typed into an IDLE codex pane. The Enter + /// that executes the slash command is indistinguishable from a prompt + /// submit in the input lane, so the tracker goes Idle→Pending — and the + /// pty then exits. Input-only pending must NOT count as engagement: + /// ringing here would bell the canonical human quit. + #[tokio::test(flavor = "multi_thread")] + async fn slash_command_quit_from_an_idle_pane_does_not_ring() { + let (hub, mut rx) = hub(); + observer_send( + &hub, + ActivityEvent::Created { + terminal_id: "t1".into(), + mode: "codex".into(), + resume_session_id: None, + at: now_ms(), + }, + ); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["terminalId"] == "t1" + }) + .await + .expect("initial idle upsert"); + + // The lone-CR "/quit" Enter: the input lane promotes Idle→Pending. + observer_send( + &hub, + ActivityEvent::Input { + terminal_id: "t1".into(), + data: "\r".into(), + at: now_ms(), + }, + ); + next_frame_matching(&mut rx, "codex.activity.updated", 2_000, |v| { + v["upsert"][0]["phase"] == "pending" + }) + .await + .expect("pending upsert"); + + // The process exits on its own — exactly what /quit looks like. + observer_send( + &hub, + ActivityEvent::Exit { + terminal_id: "t1".into(), + at: now_ms(), + spontaneous: true, + }, + ); + assert!( + next_frame_of_type(&mut rx, "terminal.idle", 1_500) + .await + .is_none(), + "a human /quit from an idle pane must never ring the death bell" + ); + } + + /// Gemini/Kimi terminals stay status-inert (TERM-16): no activity frames. + #[tokio::test(flavor = "multi_thread")] + async fn gemini_and_kimi_are_status_inert() { + let (hub, mut rx) = hub(); + for (i, mode) in ["gemini", "kimi"].iter().enumerate() { + observer_send( + &hub, + ActivityEvent::Created { + terminal_id: format!("t{i}"), + mode: mode.to_string(), + resume_session_id: None, + at: now_ms(), + }, + ); + observer_send( + &hub, + ActivityEvent::Input { + terminal_id: format!("t{i}"), + data: "\r".into(), + at: now_ms(), + }, + ); + observer_send( + &hub, + ActivityEvent::Output { + terminal_id: format!("t{i}"), + data: "\u{07}".into(), + at: now_ms(), + }, + ); + } + // Nothing may be broadcast for status-inert modes. + let frame = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await; + assert!(frame.is_err(), "status-inert modes must broadcast nothing"); + let (claude, _) = hub.claude_list(); + let (codex, _) = hub.codex_list(); + let (amplifier, _) = hub.amplifier_list(); + assert!(claude.is_empty() && codex.is_empty() && amplifier.is_empty()); + } + + /// The amplifier events lane: association attach replays the young file + /// (prompt:submit confirms busy), a later prompt:complete broadcasts + /// idle + turn.complete + terminal.idle — all driven by inotify, with + /// tail reads ONLY on attach/writes (zero polling). + #[tokio::test(flavor = "multi_thread")] + async fn amplifier_events_lane_drives_busy_complete_and_idle_via_inotify() { + let dir = tempfile::tempdir().unwrap(); + let events_path = dir.path().join("events.jsonl"); + std::fs::write( + &events_path, + [ + amplifier_line("session:start"), + amplifier_line("prompt:submit"), + ] + .concat(), + ) + .unwrap(); + + let (hub, mut rx) = hub(); + observer_send( + &hub, + ActivityEvent::Created { + terminal_id: "t1".into(), + mode: "amplifier".into(), + resume_session_id: None, + at: now_ms(), + }, + ); + // PTY Enter: provisional busy. + observer_send( + &hub, + ActivityEvent::Input { + terminal_id: "t1".into(), + data: "\r".into(), + at: now_ms(), + }, + ); + let busy = next_frame_matching(&mut rx, "amplifier.activity.updated", 2_000, |v| { + v["upsert"][0]["phase"] == "busy" + }) + .await + .expect("provisional busy upsert"); + assert_eq!(busy["upsert"][0]["terminalId"], "t1"); + + // Association resolves: lane attaches at Start and replays the + // recorded prompt:submit (confirms busy — no public flap). + hub.attach_amplifier_association("t1", "sess-1", &events_path); + + // Wait for the attach + initial drain to land (sessionId binds). + let bound = next_frame_matching(&mut rx, "amplifier.activity.updated", 3_000, |v| { + v["upsert"][0]["sessionId"] == "sess-1" + }) + .await + .expect("bind upsert"); + assert_eq!(bound["upsert"][0]["terminalId"], "t1"); + + // DEFLAKE (f3wp refresh): the bind upsert can broadcast BEFORE the + // attach's initial drain has incremented `tail_reads` (observed once + // under workspace load, /tmp/f3wp-refresh/cargo-run5.log: + // `reads_after_attach >= 1` failed on a one-shot read taken right + // after the bind frame). Poll to the attach-read edge instead of + // racing it -- the attach performs exactly one drain read with no + // writes pending, so the settled counter is what the zero-polling + // stability assertion below then holds against, unchanged. + let attach_read_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + while hub.stats().tail_reads.load(Ordering::SeqCst) < 1 { + assert!( + tokio::time::Instant::now() < attach_read_deadline, + "attach never performed its initial tail read" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + let reads_after_attach = hub.stats().tail_reads.load(Ordering::SeqCst); + assert!(reads_after_attach >= 1); + + // Zero-polling: with no writes, NO further tail reads happen. + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + assert_eq!( + hub.stats().tail_reads.load(Ordering::SeqCst), + reads_after_attach, + "no writes ⇒ no tail reads (inotify-driven, never polled)" + ); + + // The turn completes: append prompt:complete — inotify drives the read. + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&events_path) + .unwrap(); + f.write_all(amplifier_line("prompt:complete").as_bytes()) + .unwrap(); + f.flush().unwrap(); + drop(f); + + let complete = next_frame_of_type(&mut rx, "terminal.turn.complete", 5_000) + .await + .expect("amplifier turn.complete"); + assert_eq!(complete["provider"], "amplifier"); + assert_eq!(complete["sessionId"], "sess-1"); + + // Truly idle after the grace window (no further file activity). + let idle = next_frame_of_type(&mut rx, "terminal.idle", 5_000) + .await + .expect("terminal.idle"); + assert_eq!(idle["terminalId"], "t1"); + assert_eq!(idle["reason"], "grace"); + + assert!( + hub.stats().tail_reads.load(Ordering::SeqCst) > reads_after_attach, + "the write must have driven a tail read" + ); + } + + /// Steady-state zero-wake proof: idle tracked terminals arm NO timers and + /// read NO files. (The 20-agents-idle scenario in miniature.) + #[tokio::test(flavor = "multi_thread")] + async fn idle_terminals_arm_no_timers_and_read_no_files() { + let (hub, _rx) = hub(); + for i in 0..20 { + observer_send( + &hub, + ActivityEvent::Created { + terminal_id: format!("t{i}"), + mode: if i % 2 == 0 { "claude" } else { "codex" }.into(), + resume_session_id: None, + at: now_ms(), + }, + ); + } + // Let the hub settle, then observe a quiet window. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let wakes_before = hub.stats().timer_wakes.load(Ordering::SeqCst); + let reads_before = hub.stats().tail_reads.load(Ordering::SeqCst); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + assert_eq!( + hub.stats().timer_wakes.load(Ordering::SeqCst), + wakes_before, + "20 idle tracked terminals must cause zero timer wakes" + ); + assert_eq!( + hub.stats().tail_reads.load(Ordering::SeqCst), + reads_before, + "20 idle tracked terminals must cause zero file reads" + ); + { + let inner = hub.inner.lock().unwrap(); + assert_eq!(hub_next_deadline(&inner), None, "no deadline while idle"); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn catchup_cap_attaches_at_eof_for_oversized_backlog() { + let dir = tempfile::tempdir().unwrap(); + let events_path = dir.path().join("events.jsonl"); + // > 4 MiB of pre-filter noise (skipped without parsing — no lifecycle + // event prefix) followed by a lifecycle record that must NOT be + // replayed once the cap downgrades the attach to Eof. + let noise = format!("{{\"noise\":\"{}\"}}\n", "x".repeat(5 * 1024 * 1024)); + std::fs::write( + &events_path, + [noise, amplifier_line("prompt:submit")].concat(), + ) + .unwrap(); + + let (hub, mut rx) = hub(); + observer_send( + &hub, + ActivityEvent::Created { + terminal_id: "t1".into(), + mode: "amplifier".into(), + resume_session_id: None, + at: now_ms(), + }, + ); + hub.attach_amplifier_association("t1", "sess-1", &events_path); + + // The oversized backlog must NOT be replayed: no busy upsert appears. let busy = next_frame_matching(&mut rx, "amplifier.activity.updated", 1_500, |v| { v["upsert"] .as_array() @@ -2128,6 +2546,7 @@ mod tests { ActivityEvent::Exit { terminal_id: "t1".into(), at: now_ms(), + spontaneous: false, }, ); tokio::time::sleep(std::time::Duration::from_millis(300)).await; @@ -2317,16 +2736,178 @@ mod tests { ); let idle = next_frame_of_type(&mut rx, "terminal.idle", 5_000) .await - .expect("terminal.idle after the codex queue drains"); - assert_eq!( - idle["reason"], "grace", - "codex queue evidence is unreachable from the PTY lane alone (deviation note 3)" - ); + .expect("terminal.idle after the codex queue drains"); + assert_eq!( + idle["reason"], "grace", + "codex queue evidence is unreachable from the PTY lane alone (deviation note 3)" + ); + assert!( + next_frame_of_type(&mut rx, "terminal.idle", 1_000) + .await + .is_none(), + "exactly one emission for the codex drain" + ); + } + + /// SEMANTIC CHANGE (attention-bell plan 2026-08-01): failed turns now ring. + /// PROXY-lane queued-then-failed -- the hub-level mirror of the tracker + /// test `failed_with_queued_submit_behaves_exactly_like_completed_with_queued_submit` + /// (freshell-activity codex.rs): a submit queued while turn 1 is busy + /// auto-submits as turn 2 when turn 1 FAILS; turn 2's start lands inside + /// turn 1's grace window and cancels the pending emission (the queued + /// submit suppresses the immediate ring), so only the final drain rings: + /// exactly ONE terminal.idle with reason 'grace' (the proxy lane never + /// re-arms busy->pending, so no queue evidence accrues). BOTH completions + /// are 'failed': with the old record predicate (failed = silent claim) no + /// completion is ever minted and NO terminal.idle arrives at all. + #[tokio::test(flavor = "multi_thread")] + async fn codex_failed_turn_rings_and_queued_failed_drains_to_a_single_idle() { + let (hub, mut rx) = hub(); + observer_send( + &hub, + ActivityEvent::Created { + terminal_id: "t1".into(), + mode: "codex".into(), + resume_session_id: Some("thread-1".into()), + at: now_ms(), + }, + ); + // Initial idle upsert (session bound at create). + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["terminalId"] == "t1" + }) + .await + .expect("initial idle upsert"); + + // Turn 1 starts on the proxy lane -> Busy. + hub.note_codex_proxy_turn("t1", "thread-1", Some("turn-1"), None, false); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["phase"] == "busy" + }) + .await + .expect("turn-1 busy upsert"); + + // Queue a submit while busy (goes into the tracker's submit queue). + observer_send( + &hub, + ActivityEvent::Input { + terminal_id: "t1".into(), + data: "do the next thing\r".into(), + at: now_ms(), + }, + ); + + // Turn 1 FAILS. The flipped predicate records a completion and arms + // the grace window (the old predicate claimed silently: nothing in + // this test would ever ring). + hub.note_codex_proxy_turn("t1", "thread-1", Some("turn-1"), Some("failed"), true); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["phase"] == "idle" + }) + .await + .expect("turn-1 failed clear upsert"); + + // Distinct-ms guard: proxy turn keys are last_proxy_started_at + // stamped with now_ms() on the hub task; a same-millisecond second + // start would collide with the per-turn dedupe + // (last_emitted_turn_key) and swallow turn 2's completion. + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + // The queued message auto-submits as turn 2 INSIDE turn 1's grace + // window: the busy re-entry cancels the pending emission -- the + // queued submit suppresses turn 1's immediate ring. + hub.note_codex_proxy_turn("t1", "thread-1", Some("turn-2"), None, false); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["phase"] == "busy" + }) + .await + .expect("turn-2 busy upsert"); + + // Turn 2 also FAILS -> the queue has drained: one completion, the + // gate re-arms, and the lapsed grace window emits exactly one idle. + hub.note_codex_proxy_turn("t1", "thread-1", Some("turn-2"), Some("failed"), true); + let idle = next_frame_of_type(&mut rx, "terminal.idle", 5_000) + .await + .expect("terminal.idle after the queued-failed sequence drains"); + assert_eq!( + idle["reason"], "grace", + "no busy->pending re-arm on the proxy lane => no queue evidence => grace" + ); + assert!( + next_frame_of_type(&mut rx, "terminal.idle", 1_000) + .await + .is_none(), + "turn-1's suppressed window must not produce a second idle" + ); + } + + /// Plain failed turn (no queue): failed status now records a completion + /// and the gate arms, emitting exactly one terminal.idle. + #[tokio::test(flavor = "multi_thread")] + async fn codex_failed_turn_emits_terminal_idle() { + let (hub, mut rx) = hub(); + observer_send( + &hub, + ActivityEvent::Created { + terminal_id: "t".into(), + mode: "codex".into(), + resume_session_id: Some("thread-1".into()), + at: crate::terminal::now_ms(), + }, + ); + // Initial idle upsert (session bound at create). + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["terminalId"] == "t" + }) + .await + .expect("initial idle upsert"); + + // Exercise: proxy turn lane with failed status. + hub.note_codex_proxy_turn("t", "thread-1", Some("turn-1"), None, false); // started + hub.note_codex_proxy_turn("t", "thread-1", Some("turn-1"), Some("failed"), true); // failed + + // Assert: busy→idle transition via activity update. + let busy = next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"] + .as_array() + .map(|u| { + u.iter() + .any(|r| r["terminalId"] == "t" && r["phase"] == "busy") + }) + .unwrap_or(false) + }) + .await + .expect("busy upsert"); + assert_eq!(busy["upsert"][0]["terminalId"], "t"); + + // Assert: at least one codex.activity.updated showing idle phase (from failed). + let idle_upsert = next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"] + .as_array() + .map(|u| { + u.iter() + .any(|r| r["terminalId"] == "t" && r["phase"] == "idle") + }) + .unwrap_or(false) + }) + .await + .expect("idle upsert"); + assert_eq!(idle_upsert["upsert"][0]["terminalId"], "t"); + + // Assert: exactly ONE terminal.idle frame (failed now records a completion). + let _idle = next_frame_of_type(&mut rx, "terminal.idle", 3_000) + .await + .expect("terminal.idle on failed turn"); + + // Assert: no second terminal.idle frame. + let no_second = tokio::time::timeout( + std::time::Duration::from_millis(500), + next_frame_of_type(&mut rx, "terminal.idle", 3_000), + ) + .await; assert!( - next_frame_of_type(&mut rx, "terminal.idle", 1_000) - .await - .is_none(), - "exactly one emission for the codex drain" + no_second.is_err(), + "must emit exactly one terminal.idle, not a duplicate" ); } @@ -2504,11 +3085,14 @@ mod tests { ActivityEvent::Created { terminal_id: "t".into(), mode: "codex".into(), - resume_session_id: None, + // kata codex-turn-thread-scope: the proxy lane is thread- + // scoped, so this test binds the thread at create (the + // resume path); unbound terminals now ignore proxy turns. + resume_session_id: Some("thread-1".into()), at: crate::terminal::now_ms(), }, ); - // Initial idle upsert (no sessionId -- the G3 gap state). + // Initial idle upsert (session bound at create). next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { v["upsert"][0]["terminalId"] == "t" }) @@ -2516,9 +3100,9 @@ mod tests { .expect("initial idle upsert"); // Exercise: proxy turn lane. - hub.note_codex_proxy_turn("t", false); // started - hub.note_codex_proxy_turn("t", true); // completed - hub.note_codex_proxy_turn("t", true); // duplicate echo — must not double + hub.note_codex_proxy_turn("t", "thread-1", Some("turn-1"), None, false); // started + hub.note_codex_proxy_turn("t", "thread-1", Some("turn-1"), Some("completed"), true); // completed + hub.note_codex_proxy_turn("t", "thread-1", Some("turn-1"), Some("completed"), true); // duplicate echo — must not double // Assert: busy→idle transition via activity update. let busy = next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { @@ -2779,6 +3363,7 @@ mod tests { ActivityEvent::Exit { terminal_id: "t1".into(), at: crate::terminal::now_ms(), + spontaneous: false, }, ); next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { @@ -2812,6 +3397,7 @@ mod tests { ActivityEvent::Exit { terminal_id: "t1".into(), at: now, + spontaneous: false, }, ); next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { @@ -2876,4 +3462,369 @@ mod tests { .expect("resume-busy seeding via the locator-attached lane"); assert_eq!(busy["upsert"][0]["sessionId"], "sess-1"); } + + #[tokio::test(flavor = "multi_thread")] + async fn foreign_thread_proxy_completion_does_not_ring() { + // Regression pin for spike scenario D at the hub seam: a sub-agent + // child thread's turn/completed mid-parent-turn must not emit + // terminal.turn.complete (and therefore can never arm the IdleGate). + let (hub, mut rx) = hub(); + observer_send( + &hub, + ActivityEvent::Created { + terminal_id: "t".into(), + mode: "codex".into(), + resume_session_id: Some("thread-parent".into()), + at: crate::terminal::now_ms(), + }, + ); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["terminalId"] == "t" + }) + .await + .expect("initial upsert"); + + hub.note_codex_proxy_turn("t", "thread-parent", Some("turn-parent"), None, false); + // Sub-agent child thread completes while the parent turn runs. + hub.note_codex_proxy_turn( + "t", + "thread-child", + Some("turn-child"), + Some("completed"), + true, + ); + + let premature = tokio::time::timeout( + std::time::Duration::from_millis(500), + next_frame_matching(&mut rx, "terminal.turn.complete", 3_000, |v| { + v["terminalId"] == "t" + }), + ) + .await; + assert!( + premature.is_err(), + "a sub-agent thread completion must not ring" + ); + + // The parent's real completion still rings. + hub.note_codex_proxy_turn( + "t", + "thread-parent", + Some("turn-parent"), + Some("completed"), + true, + ); + let complete = next_frame_matching(&mut rx, "terminal.turn.complete", 3_000, |v| { + v["terminalId"] == "t" + }) + .await + .expect("parent turn complete"); + assert_eq!(complete["provider"], "codex"); + assert_eq!(complete["sessionId"], "thread-parent"); + } + + // ---- Approval pauses (attention bell, Task 7) ---- + + /// Shared setup: a codex terminal bound to thread-1, driven to CONFIRMED + /// busy via the proxy turn lane. + async fn busy_codex_terminal( + hub: &ActivityHub, + rx: &mut tokio::sync::broadcast::Receiver, + ) { + observer_send( + hub, + ActivityEvent::Created { + terminal_id: "t1".into(), + mode: "codex".into(), + resume_session_id: Some("thread-1".into()), + at: now_ms(), + }, + ); + next_frame_matching(rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["terminalId"] == "t1" + }) + .await + .expect("initial idle upsert"); + hub.note_codex_proxy_turn("t1", "thread-1", Some("turn-1"), None, false); + next_frame_matching(rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["phase"] == "busy" + }) + .await + .expect("busy upsert"); + } + + /// An approval request pauses the turn: the pane flips to the EXISTING + /// not-busy phase, the gate arms, and exactly ONE terminal.idle rings + /// after the 2s grace — never a second. + #[tokio::test(flavor = "multi_thread")] + async fn approval_request_rings_once_after_grace() { + let (hub, mut rx) = hub(); + busy_codex_terminal(&hub, &mut rx).await; + + hub.note_codex_approval("t1", Some("thread-1"), "41", true); + let paused = next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["phase"] == "idle" + }) + .await + .expect("approval pause maps to the existing not-busy phase"); + assert_eq!(paused["upsert"][0]["terminalId"], "t1"); + + let idle = next_frame_of_type(&mut rx, "terminal.idle", 5_000) + .await + .expect("terminal.idle for the approval pause"); + assert_eq!(idle["terminalId"], "t1"); + assert!( + next_frame_of_type(&mut rx, "terminal.idle", 1_000) + .await + .is_none(), + "exactly one terminal.idle per approval pause" + ); + } + + /// A SENT request answered quickly stays silent: the resolve restores + /// Busy within the grace, cancelling the pending bell. + #[tokio::test(flavor = "multi_thread")] + async fn approval_answered_within_grace_stays_silent() { + let (hub, mut rx) = hub(); + busy_codex_terminal(&hub, &mut rx).await; + + hub.note_codex_approval("t1", Some("thread-1"), "41", true); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["phase"] == "idle" + }) + .await + .expect("pause upsert"); + // Answered immediately (resolves carry no threadId on the wire). + hub.note_codex_approval("t1", None, "41", false); + let resumed = next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["phase"] == "busy" + }) + .await + .expect("resolve restores busy"); + assert_eq!(resumed["upsert"][0]["terminalId"], "t1"); + assert!( + next_frame_of_type(&mut rx, "terminal.idle", 3_500) + .await + .is_none(), + "an approval answered within the grace must stay silent" + ); + } + + /// Queued input does NOT suppress approval bells — the pane is still + /// blocked on the human. + #[tokio::test(flavor = "multi_thread")] + async fn queued_input_does_not_suppress_the_approval_bell() { + let (hub, mut rx) = hub(); + busy_codex_terminal(&hub, &mut rx).await; + + // Submit-shaped input while Busy: queued behind the running turn. + observer_send( + &hub, + ActivityEvent::Input { + terminal_id: "t1".into(), + data: "queued message\r".into(), + at: now_ms(), + }, + ); + hub.note_codex_approval("t1", Some("thread-1"), "41", true); + let idle = next_frame_of_type(&mut rx, "terminal.idle", 5_000) + .await + .expect("queued input must not suppress the approval bell"); + assert_eq!(idle["terminalId"], "t1"); + assert!( + next_frame_of_type(&mut rx, "terminal.idle", 1_000) + .await + .is_none(), + "still exactly one terminal.idle" + ); + } + + /// Audit A9: a rollout reconcile whose newest event is the turn's own + /// task_started lands MID-PAUSE — it must not flip the pane Busy (which + /// would cancel the armed approval bell at the gate). + #[tokio::test(flavor = "multi_thread")] + async fn reconcile_tick_during_a_pending_approval_does_not_cancel_the_armed_bell() { + let (hub, mut rx) = hub(); + let now = crate::terminal::now_ms(); + observer_send( + &hub, + ActivityEvent::Created { + terminal_id: "t1".into(), + mode: "codex".into(), + resume_session_id: Some("sess-1".into()), + at: now, + }, + ); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["terminalId"] == "t1" + }) + .await + .expect("initial upsert"); + + // Rollout lane attached with no unresolved turn yet. + let (_guard, rollout) = codex_rollout_fixture(&[]); + hub.attach_codex_rollout("t1", "sess-1", &rollout); + // Proxy lane drives the confirmed busy. + hub.note_codex_proxy_turn("t1", "sess-1", Some("turn-1"), None, false); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["phase"] == "busy" + }) + .await + .expect("busy upsert"); + + hub.note_codex_approval("t1", Some("sess-1"), "41", true); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["phase"] == "idle" + }) + .await + .expect("pause upsert"); + + // BEFORE the 2s grace elapses: the turn's own task_started reaches + // the rollout fold via inotify. + { + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&rollout) + .expect("append"); + writeln!(f, "{}", codex_event_line("task_started", now_ms())).expect("write"); + } + // No Busy-phase upsert may be emitted mid-pause. + assert!( + next_frame_matching(&mut rx, "codex.activity.updated", 1_000, |v| { + v["upsert"][0]["phase"] == "busy" + }) + .await + .is_none(), + "a mid-pause reconcile promotion must not flip the pane busy" + ); + // The armed approval bell still rings after the grace. + let idle = next_frame_of_type(&mut rx, "terminal.idle", 5_000) + .await + .expect("the reconcile tick must not cancel the armed approval bell"); + assert_eq!(idle["terminalId"], "t1"); + + // The resolve restores Busy (deferred promotion). + hub.note_codex_approval("t1", None, "41", false); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["phase"] == "busy" + }) + .await + .expect("resolve restores busy after the deferred promotion"); + } + + /// One bell per episode: an approval pause rings once; the turn then + /// completes MID-PAUSE (the approval is never resolved) and the codex + /// TUI's turn-complete BEL echoes on the PTY. Neither the mid-pause + /// turn/completed (Idle-arm silent claim) nor the BEL echo (armed + /// swallow) may mint a second terminal.idle. + #[tokio::test(flavor = "multi_thread")] + async fn mid_pause_turn_end_and_bel_echo_ring_exactly_once_per_episode() { + let (hub, mut rx) = hub(); + let now = crate::terminal::now_ms(); + observer_send( + &hub, + ActivityEvent::Created { + terminal_id: "t1".into(), + mode: "codex".into(), + resume_session_id: Some("sess-1".into()), + at: now, + }, + ); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["terminalId"] == "t1" + }) + .await + .expect("initial upsert"); + + // Rollout lane attached; proxy lane drives the confirmed busy. + let (_guard, rollout) = codex_rollout_fixture(&[]); + hub.attach_codex_rollout("t1", "sess-1", &rollout); + hub.note_codex_proxy_turn("t1", "sess-1", Some("turn-1"), None, false); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["phase"] == "busy" + }) + .await + .expect("busy upsert"); + + hub.note_codex_approval("t1", Some("sess-1"), "41", true); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["phase"] == "idle" + }) + .await + .expect("pause upsert"); + + // The turn's own task_started folds MID-PAUSE (audit A9): the + // accepted anchor lands without flipping busy. + { + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&rollout) + .expect("append"); + writeln!(f, "{}", codex_event_line("task_started", now_ms())).expect("write"); + } + assert!( + next_frame_matching(&mut rx, "codex.activity.updated", 1_000, |v| { + v["upsert"][0]["phase"] == "busy" + }) + .await + .is_none(), + "the mid-pause fold must not flip the pane busy" + ); + + // The ONE bell of the episode: the armed approval boundary. + let idle = next_frame_of_type(&mut rx, "terminal.idle", 5_000) + .await + .expect("the approval bell rings once"); + assert_eq!(idle["terminalId"], "t1"); + + // The turn ends while the approval is still pending, then the TUI's + // turn-complete BEL echoes on the PTY. + hub.note_codex_proxy_turn("t1", "sess-1", Some("turn-1"), Some("completed"), true); + observer_send( + &hub, + ActivityEvent::Output { + terminal_id: "t1".into(), + data: "\u{07}".into(), + at: now_ms(), + }, + ); + assert!( + next_frame_of_type(&mut rx, "terminal.idle", 3_500) + .await + .is_none(), + "exactly ONE terminal.idle for the whole episode -- the mid-pause \ + turn end and its BEL echo must not re-ring" + ); + } + + /// Decision 3 / audit A10: a pane blocked on an approval whose process + /// dies spontaneously rings — even AFTER the armed deadline already rang + /// (pending_approvals counts as death-bell engagement). + #[tokio::test(flavor = "multi_thread")] + async fn spontaneous_exit_during_a_pending_approval_rings() { + let (hub, mut rx) = hub(); + busy_codex_terminal(&hub, &mut rx).await; + + hub.note_codex_approval("t1", Some("thread-1"), "41", true); + // Let the grace elapse: the approval bell rings (deadline now spent, + // phase not busy). + let first = next_frame_of_type(&mut rx, "terminal.idle", 5_000) + .await + .expect("approval bell"); + assert_eq!(first["terminalId"], "t1"); + + // The process dies while still blocked on the approval. + observer_send( + &hub, + ActivityEvent::Exit { + terminal_id: "t1".into(), + at: now_ms(), + spontaneous: true, + }, + ); + let second = next_frame_of_type(&mut rx, "terminal.idle", 3_000) + .await + .expect("death bell: pending approvals count as engagement"); + assert_eq!(second["terminalId"], "t1"); + } } diff --git a/crates/freshell-ws/src/codex_proxy_route.rs b/crates/freshell-ws/src/codex_proxy_route.rs index a8bb7a7ef..915d28aa3 100644 --- a/crates/freshell-ws/src/codex_proxy_route.rs +++ b/crates/freshell-ws/src/codex_proxy_route.rs @@ -54,14 +54,31 @@ async fn route_proxy_event(state: &WsState, tagged: TerminalProxyEvent) { RemoteProxyEvent::Candidate(candidate) => { route_candidate(state, &terminal_id, cwd.as_deref(), candidate).await; } - RemoteProxyEvent::TurnStarted(_) => { + RemoteProxyEvent::TurnStarted(params) => { if let Some(hub) = &state.activity { - hub.note_codex_proxy_turn(&terminal_id, false); + hub.note_codex_proxy_turn( + &terminal_id, + ¶ms.thread_id, + params.turn_id.as_deref(), + None, + false, + ); } } - RemoteProxyEvent::TurnCompleted(_) => { + RemoteProxyEvent::TurnCompleted(params) => { if let Some(hub) = &state.activity { - hub.note_codex_proxy_turn(&terminal_id, true); + // `status` lives inside params -- nested `params.turn.status` + // on the small-frame path, flat `params.status` on the + // oversized byte-scan path. `turn_status` handles both + // (protocol.rs:316-333). + let status = freshell_codex::turn_status(¶ms.params); + hub.note_codex_proxy_turn( + &terminal_id, + ¶ms.thread_id, + params.turn_id.as_deref(), + status.as_deref(), + true, + ); } } RemoteProxyEvent::ThreadStarted(_) | RemoteProxyEvent::ThreadLifecycle(_) => { @@ -76,6 +93,23 @@ async fn route_proxy_event(state: &WsState, tagged: TerminalProxyEvent) { // S5.a + D-GATE-SOFT: log only (includes CandidateCaptureTimeout). tracing::warn!(terminal_id = %terminal_id, ?trigger, "codex_proxy_repair_trigger"); } + RemoteProxyEvent::ApprovalRequested(params) => { + // Task 7: the app-server is blocked on a human -- the hub's + // attention tracking pauses the pane and arms the idle gate. + if let Some(hub) = &state.activity { + hub.note_codex_approval( + &terminal_id, + params.thread_id.as_deref(), + ¶ms.request_id, + true, + ); + } + } + RemoteProxyEvent::ApprovalResolved { request_id } => { + if let Some(hub) = &state.activity { + hub.note_codex_approval(&terminal_id, None, &request_id, false); + } + } } } @@ -178,6 +212,7 @@ mod tests { use freshell_codex::remote_proxy_side_effects::{ CandidateSource, CandidateThread, RemoteProxyCandidate, }; + use freshell_terminal::ActivityEvent; use std::sync::Arc as StdArc; /// WsState test-construction, copied from `codex_association.rs`'s @@ -294,6 +329,185 @@ mod tests { } } + /// kata codex-turn-thread-scope: a hub-bearing state for observing turn + /// routing (test_state() deliberately sets `activity: None`), plus the + /// hub's broadcast receiver. + fn test_state_with_hub() -> (WsState, tokio::sync::broadcast::Receiver) { + let mut state = test_state(); + let (tx, rx) = tokio::sync::broadcast::channel::(256); + state.activity = Some(crate::activity::ActivityHub::new(StdArc::new(tx), None)); + (state, rx) + } + + /// A TurnEventParams whose status sits NESTED at `params.turn.status` + /// exactly like the real app-server's small-frame form -- proves the + /// router reads it via `freshell_codex::turn_status`, not a naive + /// `params.get("status")`. + fn turn_params( + thread_id: &str, + turn_id: &str, + nested_status: Option<&str>, + ) -> freshell_codex::remote_proxy::TurnEventParams { + let mut params = serde_json::Map::new(); + params.insert( + "threadId".to_string(), + serde_json::Value::String(thread_id.to_string()), + ); + params.insert( + "turnId".to_string(), + serde_json::Value::String(turn_id.to_string()), + ); + if let Some(status) = nested_status { + params.insert("turn".to_string(), serde_json::json!({ "status": status })); + } + freshell_codex::remote_proxy::TurnEventParams { + thread_id: thread_id.to_string(), + turn_id: Some(turn_id.to_string()), + params, + } + } + + /// Local copy of the activity.rs test harness's frame matcher (that one + /// is `#[cfg(test)]`-private to its module). + async fn next_frame_matching( + rx: &mut tokio::sync::broadcast::Receiver, + wanted: &str, + timeout_ms: u64, + pred: impl Fn(&serde_json::Value) -> bool, + ) -> Option { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return None; + } + match tokio::time::timeout(remaining, rx.recv()).await { + Ok(Ok(frame)) => { + let value: serde_json::Value = serde_json::from_str(&frame).ok()?; + if value["type"] == wanted && pred(&value) { + return Some(value); + } + } + _ => return None, + } + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn turn_events_forward_thread_turn_and_nested_status_to_the_hub() { + let (state, mut rx) = test_state_with_hub(); + let hub = state.activity.clone().expect("hub"); + // Track + bind the terminal the way a resume-create does. + (hub.registry_observer())(ActivityEvent::Created { + terminal_id: "term-t".into(), + mode: "codex".into(), + resume_session_id: Some("thread-parent".into()), + at: 1, + }); + + route_proxy_event( + &state, + tagged( + "term-t", + RemoteProxyEvent::TurnStarted(turn_params("thread-parent", "turn-1", None)), + ), + ) + .await; + + // Foreign sub-agent completion: must not ring. The bounded no-ring + // check sits BETWEEN the foreign and bound completions -- without it, + // a regressed thread guard would ring HERE and the trailing + // "exactly one" tail could still pass (the bound completion would + // then hit the Idle arm and no-op, leaving one frame total). + route_proxy_event( + &state, + tagged( + "term-t", + RemoteProxyEvent::TurnCompleted(turn_params( + "thread-child", + "turn-c", + Some("completed"), + )), + ), + ) + .await; + let premature = tokio::time::timeout( + std::time::Duration::from_millis(500), + next_frame_matching(&mut rx, "terminal.turn.complete", 3_000, |v| { + v["terminalId"] == "term-t" + }), + ) + .await; + assert!( + premature.is_err(), + "a foreign thread completion must not ring" + ); + + // NESTED-status pin: an `inProgress` completion for the BOUND thread + // and the in-flight turn id must not ring. THIS event is what proves + // the router extracts `params.turn.status` via + // `freshell_codex::turn_status`: a router that forgets the extraction + // (or reads a naive flat `params.get("status")`) forwards `None`, + // which records a completion (design decision #3: absent status + // records) and rings here. The tracker's `inProgress` guard returns + // before touching state, so the pane stays Busy for the real + // completion below. + route_proxy_event( + &state, + tagged( + "term-t", + RemoteProxyEvent::TurnCompleted(turn_params( + "thread-parent", + "turn-1", + Some("inProgress"), + )), + ), + ) + .await; + let premature = tokio::time::timeout( + std::time::Duration::from_millis(500), + next_frame_matching(&mut rx, "terminal.turn.complete", 3_000, |v| { + v["terminalId"] == "term-t" + }), + ) + .await; + assert!( + premature.is_err(), + "a nested inProgress status must be extracted and must not ring" + ); + + // Bound thread's real completion with NESTED turn.status: rings once. + route_proxy_event( + &state, + tagged( + "term-t", + RemoteProxyEvent::TurnCompleted(turn_params( + "thread-parent", + "turn-1", + Some("completed"), + )), + ), + ) + .await; + + let complete = next_frame_matching(&mut rx, "terminal.turn.complete", 3_000, |v| { + v["terminalId"] == "term-t" + }) + .await + .expect("bound thread's completion rings"); + assert_eq!(complete["sessionId"], "thread-parent"); + + // Exactly one -- the foreign and inProgress completions produced nothing. + let second = tokio::time::timeout( + std::time::Duration::from_millis(500), + next_frame_matching(&mut rx, "terminal.turn.complete", 3_000, |v| { + v["terminalId"] == "term-t" + }), + ) + .await; + assert!(second.is_err(), "exactly one turn.complete expected"); + } + #[tokio::test] async fn candidate_adopts_identity_through_the_single_writer_tail() { let state = test_state(); diff --git a/crates/freshell-ws/src/codex_reconcile.rs b/crates/freshell-ws/src/codex_reconcile.rs index 997500c58..5f971cd31 100644 --- a/crates/freshell-ws/src/codex_reconcile.rs +++ b/crates/freshell-ws/src/codex_reconcile.rs @@ -125,14 +125,23 @@ pub(crate) fn fold_task_events(lines: &[String]) -> CodexTaskEvents { continue; } let ts = value.get("timestamp").and_then(parse_timestamp_ms); - let slot = match value - .get("payload") - .and_then(|p| p.get("type")) - .and_then(|t| t.as_str()) - { + let payload = value.get("payload"); + let slot = match payload.and_then(|p| p.get("type")).and_then(|t| t.as_str()) { Some("task_started") => &mut events.latest_task_started_at, Some("task_complete") => &mut events.latest_task_completed_at, - Some("turn_aborted") => &mut events.latest_turn_aborted_at, + Some("turn_aborted") => { + // Newest-wins PAIRING: the reason always corresponds to the + // winning `latest_turn_aborted_at`, and is None when that + // abort carried no reason (legacy lines). + if timestamp_beats(events.latest_turn_aborted_at, ts) { + events.latest_turn_aborted_at = ts; + events.latest_turn_aborted_reason = payload + .and_then(|p| p.get("reason")) + .and_then(|v| v.as_str()) + .map(str::to_string); + } + continue; + } _ => continue, }; *slot = match (*slot, ts) { @@ -144,6 +153,17 @@ pub(crate) fn fold_task_events(lines: &[String]) -> CodexTaskEvents { events } +/// True when `candidate` becomes the new max over `current` (the fold's +/// max-assign idiom, expressed as a predicate so paired fields can move +/// together). A timestamp-less candidate never wins; ties keep the current. +fn timestamp_beats(current: Option, candidate: Option) -> bool { + match (current, candidate) { + (_, None) => false, + (None, Some(_)) => true, + (Some(current), Some(candidate)) => candidate > current, + } +} + /// Resume-time rollout locator: find the rollout owned by `session_id` under /// the codex sessions root. Filename containment is only a cheap PREFILTER; /// ownership is proven by the first line being a `session_meta` whose @@ -240,6 +260,41 @@ mod tests { ]; let events = fold_task_events(&lines); assert_eq!(events.latest_turn_aborted_at, Some(1_753_430_400_000)); + assert_eq!( + events.latest_turn_aborted_reason, None, + "a reason-less legacy line yields None" + ); + } + + #[test] + fn fold_pairs_the_abort_reason_with_the_newest_abort_timestamp() { + // Newest-wins pairing: the reason belongs to the WINNING abort, even + // when an older reasoned abort arrives later in the batch. + let lines = vec![ + r#"{"timestamp":"2026-07-25T08:00:00.000Z","type":"event_msg","payload":{"type":"turn_aborted","turn_id":"x","reason":"interrupted"}}"# + .to_string(), + r#"{"timestamp":"2026-07-25T07:00:00.000Z","type":"event_msg","payload":{"type":"turn_aborted","turn_id":"w","reason":"replaced"}}"# + .to_string(), + ]; + let events = fold_task_events(&lines); + assert_eq!( + events.latest_turn_aborted_reason, + Some("interrupted".to_string()) + ); + } + + #[test] + fn fold_newer_reasonless_abort_clears_a_stale_reason() { + // The pairing invariant also holds in reverse: a NEWER reason-less + // abort must not inherit the older abort's reason. + let lines = vec![ + r#"{"timestamp":"2026-07-25T07:00:00.000Z","type":"event_msg","payload":{"type":"turn_aborted","turn_id":"w","reason":"interrupted"}}"# + .to_string(), + r#"{"timestamp":"2026-07-25T08:00:00.000Z","type":"event_msg","payload":{"type":"turn_aborted","turn_id":"x"}}"# + .to_string(), + ]; + let events = fold_task_events(&lines); + assert_eq!(events.latest_turn_aborted_reason, None); } #[test] diff --git a/docs/plans/2026-08-01-codex-attention-bell.md b/docs/plans/2026-08-01-codex-attention-bell.md new file mode 100644 index 000000000..11d51598d --- /dev/null +++ b/docs/plans/2026-08-01-codex-attention-bell.md @@ -0,0 +1,1735 @@ +# Codex Attention-Bell Cause Semantics Implementation Plan + +> **For agentic workers:** This plan is executed task-by-task by the +> workflow's execute stage: a fresh implementer per task, with a spec + +> quality review after each task. Steps use checkbox (`- [ ]`) syntax +> for tracking. + +**Goal:** Ring the `terminal.idle` bell for every non-human-requested stop of a codex terminal pane — failed turns, spontaneous process death while engaged, and approval-request pauses, plus forward-compatible policy plumbing for non-human `turn_aborted` reasons (NO live producer emits a ring-worthy abort reason at codex 0.129.0–0.147.0-alpha; today's real failures surface as `turn/completed status='failed'`, covered by Tasks 1/8) — while keeping human-requested stops (Esc/interrupt, `/quit`/`/exit`, tab close, `terminal.close`, server shutdown) silent, with zero wire-shape changes. Deployed codex is pinned at **0.146.0** (`codex --version`; the fixture inventory is 0.129.0 — both were source-audited by the load-bearing-assumption audit). + +**Architecture:** All new causes are internal representations inside the existing server-side tracker/gate machinery (`crates/freshell-activity` + `crates/freshell-ws` on Rust; `server/coding-cli/*` on Node). Every cause emits the SAME `terminal.idle` frame and maps to the SAME not-busy public phase. The codex app-server proxy gains server→client request sniffing (approvals); the rollout parser gains `turn_aborted.reason` plumbing (forward-compatible policy — see decision 2); the registry exit event gains a spontaneous-vs-requested discriminator. + +**Tech Stack:** Rust (freshell-activity, freshell-ws, freshell-codex, freshell-terminal, freshell-sessions crates), TypeScript Node server (server/coding-cli, server/terminal-registry.ts, shared/ws-protocol.ts), cargo test, vitest. + +## Global Constraints + +- Base branch: this worktree (`/home/dan/code/freshell/.worktrees/codex-attention-bell`, branch `feat/codex-attention-bell`) is branched FROM `fix/codex-turn-thread-scope` (head 911fa4cdc). Do NOT rebase onto or branch from `origin/main`. +- ZERO wire-shape changes: the `terminal.idle` frame stays exactly `{ terminalId, at, reason: 'grace' | 'queue-empty' }`. All new causes reuse `reason: 'grace'`. The contract freeze (`npm run test:port`, `port/contract/*.json`) must stay green with NO regenerated contract files. +- The bell (`terminal.idle`) is the ONLY client bell/attention trigger; the not-busy icon is the only indication. NO new user-facing signals, NO new public phase enum values. +- Never emit `terminal.idle` for a HUMAN-REQUESTED stop: Esc/interrupt (`turn.status='interrupted'`, abort reason `interrupted`/`replaced`), slash-command quits (`/quit`/`/exit` from an idle pane — the executing Enter looks like a prompt submit to the input lane; see decision 3), tab close / `terminal.close` API / server shutdown kills (including `shutdownGracefully()`'s direct SIGTERMs — Task 11). +- Baseline repair first: the branch base ships a red `cargo test` gate (freshell-ws `tests/auto_resume_e2e.rs`) — Task 0 fixes it before ANY feature work. `npm install` inside the worktree is allowed during execution (node_modules is missing). +- Busy-deadman/unknown (120s silence) stays silent — uncertainty is not a stop signal; no heuristic bells. +- Strict Red-Green-Refactor TDD: write the failing test, see it fail, implement minimally, see it pass, commit. +- Test coordination (AGENTS.md): run vitest ONLY via `npm run test:vitest -- ` (never raw `npx vitest`). `test:unit` covers `test/unit`, `test:integration` covers `test/server`. +- Rust gates: `cargo fmt --all --check` and `cargo clippy --workspace --all-targets -- -D warnings` must pass. +- Do NOT create/open a PR (needs explicit user approval). Never restart the self-hosted server (build ok, deploy not). Do not touch the running production server (port 3002) or live codex sidecars. +- Commit `.kata.toml` if modified (we do not expect to modify it). +- README.md is the only end-user markdown doc; this plan under `docs/plans/` is a working doc. + +## Locked design decisions (read before any task) + +1. **`turn/completed status='failed'` rings** by flipping the record predicate — failed takes EXACTLY the same code path as completed, so queued-submit suppression and the 2s IdleGate grace apply naturally. `interrupted` stays a silent clear. +2. **`turn_aborted` reason policy:** reason `interrupted` or `replaced` → silent (human-requested). Reason MISSING → silent (legacy rollout lines carry no reason; an absent reason is uncertainty, and per constraint above uncertainty is not a stop signal). Any OTHER present reason string → ring. **Corrected rationale (audit A11/A12):** this is FORWARD-COMPATIBLE POLICY, not a live bell cause — at codex 0.129.0–0.147.0-alpha the `TurnAbortReason` enum is exactly `{interrupted, replaced, review_ended, budget_limited}` and only `interrupted`/`replaced` have construction sites (codex-rs protocol.rs:4207-4214 @0.146.0); a 5,114-file rollout corpus (2,527 `turn_aborted` lines) is 100% `"interrupted"`. NO ring-worthy abort writes a reasoned `turn_aborted` line today; today's real non-human failures surface as `turn/completed status='failed'` / rollout `turn_complete{error}` — covered by Tasks 1/8. Known false negative (accepted): codex's guardian automation aborts write `"interrupted"` and stay silent. +3. **Spontaneous exit while engaged rings immediately** (no grace — a dead process produces no more events; nothing can cancel). **"Engaged" for the death bell (corrected by audit A6/A10):** busy from a CONFIRMED turn (codex: accepted proxy turn started / accepted rollout `task_started`) OR an armed grace deadline (a completion bell that was pending when the process died still rings) OR a NON-EMPTY pending-approval set (a pane blocked on an approval whose process dies must ring — it is not busy, and its 2s boundary may already have rung; Tasks 5/7/11/12). **Input-only pending state NEVER counts as engagement:** the canonical human quit — `/quit`/`/exit` typed into an IDLE pane — is read by the input lane as a prompt submit (the CR: Rust `signal.rs:36-38` `is_submit_input` → `codex.rs:443-490` `note_input` → Pending; Node `codex-activity-tracker.ts:181-209` `noteInput`), drives the gate busy, and the pty exits <2s later; ringing there would bell the canonical human quit and violate the hard silence constraint. Queue evidence does NOT suppress death bells. Freshell-initiated kills (`kill`/`kill_all`, by `api`/`idle`/`shutdown` — including `shutdownGracefully()`'s direct SIGTERMs, which flow through the normal pty-exit finalizer; Task 11) are silent. Exit while idle is silent. Rust covers claude/codex/amplifier uniformly via the shared hub Exit arm + gate (opencode has no Rust tracker — N/A). Node covers codex/claude/amplifier via the shared `TrulyIdleEmitter`; Node opencode is a documented follow-up (its "record exists ⇔ busy" signal is a noisy busy proxy and would produce heuristic bells). **Accepted residuals (documented in Task 13):** mid-turn `/quit`/Ctrl+D (codex sends NO `Op::Interrupt` on Ctrl+D; the TUI's ~2s shutdown budget may skip the abort write) and out-of-band `kill -9` by the user may still ring — no in-band discriminator exists; claude/amplifier Enter-executed quits are the same family (input-driven Busy is those trackers' ONLY turn evidence, so it stays engagement); Node's 120s busy-deadman can demote busy→unknown during long recovery windows and swallow the bell (missed bell, never a false ring). +4. **Auto-resume interaction (rationale corrected by audit A15):** successful durable recovery swallows the pty exit entirely — `finishTerminalPtyExit` never runs, no internal exit event is emitted, hence no bell — and the resumed backend turn may CONTINUE where it left off: codex `thread/resume` re-attaches to a still-running backend thread via `resume_running_thread` (codex-rs thread_processor.rs:3426/:3528 @0.146.0 — "rejoin semantics"); freshell's clean-exit recovery gate recovers precisely when the backend turn is `inProgress` (`terminal-registry.ts:3362`). The death bell therefore fires only when recovery FAILS or is ABANDONED and the exit event is actually emitted — which is exactly when attention is needed. (When the app-server itself died, resume is a pure history restore, the interrupted turn is dead, and the bell is equally justified.) +5. **Approval-request pause:** managed (`--remote`) codex only. The proxy sniffs server→client JSON-RPC REQUESTS (frames with BOTH `id` and `method`) whose method is in the approval set below; a resolution clears it. Internal waiting state maps to the EXISTING not-busy public phase; the same IdleGate boundary arms the bell (2s grace suppresses a SENT request answered quickly). Queued input does NOT suppress approval bells. Unmanaged/PTY-only codex has no approval signal — acceptable, documented. **Resolution signals (audit A4/A5 — ALL of these must resolve, with tests, Tasks 6/12):** (a) a client `{id, result}` response frame; (b) a client `{id, error}` response frame — errors resolve identically (codex handles them via `process_error`, message_processor.rs:756-758 @0.146.0); (c) the server-side notification **`serverRequest/resolved`** with params `{threadId, requestId}` (common.rs:1701, v2/notification.rs:53-56 @0.146.0) — codex 0.146.0 can resolve a SENT request server-side with NO client response frame (`auto_resolution_ms` on `item/tool/requestUserInput`; turn cancel via `cancel_requests_for_thread`); (d) sidecar/upstream restart or reconnect clears ALL pending approvals (emitting resolutions) — the app-server's request-id allocator is a per-process monotonic `AtomicI64` starting at 0 (outgoing_message.rs:283 @0.146.0), so stale ids from a previous incarnation would collide. Response matching REQUIRES `method` ABSENT on the frame — an id alone is not enough (client requests always carry `method`; server and client request ids are independent integer spaces both starting near 0). Note: policy-auto-approvals (allowlisted commands; `auto_review`, served by a server-side subagent — v2/shared.rs:224-247 @0.146.0) never emit wire frames at all and cannot ring by construction; a SENT request auto-resolved slower than ~2s rings once (accepted residual, low severity). +6. **Approval method set** (from `test/fixtures/coding-cli/codex-app-server/schema-inventory.ts:84-94`, codex 0.129.0 inventory; verified EXACT against the codex `ServerRequest` enum at BOTH 0.129.0 and the deployed 0.146.0 — audit A2): + `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/permissions/requestApproval`, `item/tool/requestUserInput`, `mcpServer/elicitation/request`, `applyPatchApproval`, `execCommandApproval`. + Machine-serviced server→client requests are deliberately EXCLUDED: `item/tool/call`, `account/chatgptAuthTokens/refresh` (both tags), plus `attestation/generate` and `currentTime/read` (new at 0.146.0 — both machine-serviced, correctly excluded). The set is version-fluid: both proxies DEBUG-LOG any unrecognized server→client request method (no bell, just logging — catches future drift; Tasks 6/12). +7. **Approval thread scoping (corrected by audit A3):** the five v2 methods carry `params.threadId`; the two LEGACY methods (`applyPatchApproval`, `execCommandApproval`) carry `params.conversationId` instead (codex-rs v1.rs:126-158 — reading only `threadId` would misread every legacy approval as thread-less). Sniff by method name with opaque params; best-effort extract `params.threadId` OR, for the legacy methods, `params.conversationId`, when the frame is small enough to fully parse (`<= MAX_FULL_PARSE_BYTES`); when present and the tracker has a bound thread that differs → ignore (BEST-EFFORT heuristic: codex gives no guarantee about how child-thread approvals present on the wire, so mismatch⇒sub-agent is a safe-direction bet, not a verified fact); when absent → accept (the proxy is per-terminal). +8. **Gate arming for approvals uses a NEW internal tracker effect** (`AttentionBoundary` / Node tracker event `attention.boundary`) that arms the IdleGate WITHOUT emitting a `terminal.turn.complete` frame (an approval pause is not a turn end). **Lane interference guard (audit A9):** while `pending_approvals` is non-empty, OTHER lanes' busy promotions — the rollout reconcile's first fold of the turn's own `task_started` (codex.rs:352-368 passes the edge-trigger and would call `idle.note_phase(Busy)`, clearing the armed deadline and silently cancelling the bell), and Node's `reconcileProjects`/`refreshExistingBinding` promotions — must fold their anchors as usual but set the resume-busy flag (`resume_busy_after_approval` / `resumeBusyAfterApproval`) INSTEAD of flipping the public phase or feeding Busy to the gate (both servers, Tasks 7/12). Approval resolve restores Busy and normalizes any pending-submit input state planted during the pause. +9. **Deferred minors:** (a) clear the in-flight proxy turn id at accepted completion on BOTH servers; (b) Node `lastSeenTaskCompletedAt` only advances for genuine completed status (`undefined` or `'completed'`) — failed/interrupted turns do not bump it (the field name means "task COMPLETED"). + +## File structure (what each touched file is responsible for) + +| File | Responsibility in this plan | +|---|---| +| `crates/freshell-activity/src/codex.rs` | Codex tracker state machine: record predicate, abort-reason policy, approval state, turn-id clear | +| `crates/freshell-activity/src/idle.rs` | IdleGate: new `is_engaged` read accessor (confirmed busy or armed deadline; input-only pending excluded) | +| `crates/freshell-activity/src/ledger.rs` (or wherever `TrackerEffect` lives) | New `TrackerEffect::AttentionBoundary` variant | +| `crates/freshell-ws/src/activity.rs` | Hub: exit-bell emission, approval HubEvent routing, effect→gate mapping | +| `crates/freshell-ws/src/codex_reconcile.rs` | Rollout fold: capture `turn_aborted.reason` | +| `crates/freshell-ws/src/codex_proxy_route.rs` | Route new proxy approval events into the hub | +| `crates/freshell-codex/src/remote_proxy.rs` | Proxy: approval request sniff + response matching, new event variants | +| `crates/freshell-terminal/src/registry.rs` | `ActivityEvent::Exit` gains `spontaneous: bool` | +| `crates/freshell-sessions/src/parse/codex.rs`, `src/meta.rs` | Rollout parser + snapshot: `latest_turn_aborted_reason` | +| `server/coding-cli/codex-activity-tracker.ts` | Node codex tracker: record predicate, abort policy, approval state, turn-id clear, timestamp fix | +| `server/coding-cli/truly-idle-emitter.ts` | Node gate: spontaneous-exit bell, attention-boundary arming | +| `server/coding-cli/codex-activity-wiring.ts` (+ claude/amplifier wirings) | Thread exit discriminator + approval events into trackers | +| `server/coding-cli/codex-app-server/remote-proxy.ts` | Node proxy approval sniff + response matching | +| `server/coding-cli/providers/codex.ts`, `server/coding-cli/types.ts` | Node rollout parser + snapshot: aborted reason | +| `server/terminal-registry.ts` | Internal `terminal.exit` emissions carry `spontaneous`; sidecar approval subscriptions | +| `server/terminal-stream/registry-events.ts` | New approval event types | +| `shared/ws-protocol.ts` | Doc-comment-only update of `terminal.idle` semantics (schema untouched) | + +Run all commands from the worktree root `/home/dan/code/freshell/.worktrees/codex-attention-bell` unless stated otherwise. + +--- + +### Task 0: Baseline repair — inherited `cargo test` regression + worktree npm install (audit A20) + +**Files:** +- Possibly modify: base-branch production code under `crates/freshell-ws/src/` (`activity.rs`, `codex_proxy_route.rs`) and/or `crates/freshell-activity/src/codex.rs` — OR, only with evidence of an intentional behavior change, `crates/freshell-ws/tests/auto_resume_e2e.rs` +- No plan-feature code in this task. + +**Interfaces:** +- Consumes: the inherited branch head (911fa4cdc + the plan-doc commit). The audit (V8 report) established: `cargo test -p freshell-activity -p freshell-ws` FAILS (exit 101) — `crates/freshell-ws/tests/auto_resume_e2e.rs` tests `reconcile_after_replacement_attaches_to_the_new_terminal` and `crashing_agent_is_resumed_twice_then_settles_exited` both time out at `tests/common/mod.rs:959` ("timed out waiting for a terminal.created frame"); deterministic (2/2 reruns); the SAME test binary passes on main (c7badcbef); the test files are byte-identical between merge-base 35fbf1357 and 911fa4cdc ⇒ the regression lives in the base-branch production changes (941ad584e..911fa4cdc: `freshell-ws/src/activity.rs` +116, `freshell-ws/src/codex_proxy_route.rs` +205, `freshell-activity/src/codex.rs` +426). +- Produces: ALL five gates green before Task 1 begins. Every later task assumes a green baseline. + +- [ ] **Step 1: Reproduce** + +Run: `cargo test -p freshell-ws --test auto_resume_e2e` +Expected: both tests FAIL with the terminal.created timeout. If they pass, record the environment difference and re-run twice before proceeding (the audit observed determinism). + +- [ ] **Step 2: Diagnose the regression** + +Diff the base-branch production changes: `git diff 35fbf1357..911fa4cdc -- crates/freshell-ws/src/activity.rs crates/freshell-ws/src/codex_proxy_route.rs crates/freshell-activity/src/codex.rs`. Bisect within the five branch commits (941ad584e, e039320b8, f740b1722, d2341c999, 911fa4cdc) if the diff read is not conclusive: `git bisect start 911fa4cdc 35fbf1357` running the two tests as the predicate. + +- [ ] **Step 3: Fix** + +Fix the production regression so the tests pass unmodified. ONLY if the diff proves the base branch intentionally changed the behavior these e2e tests pin (decide from the commit messages + code evidence, not convenience) may the tests be updated instead — cite the intentional change in the test doc comment. + +- [ ] **Step 4: Worktree npm install** + +Run: `npm install` (from the worktree root — allowed during execution). Do NOT rely on the parent repo's `/home/dan/code/freshell/node_modules`: running `npm run` against it silently resolves main-branch dependency versions and yields misleading signal. + +- [ ] **Step 5: Verify ALL five gates green** + +```bash +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test -p freshell-activity -p freshell-ws +npm run test:vitest -- test/unit/server/coding-cli +npm run test:port +``` + +Expected: all PASS. Do not start Task 1 until they do. + +- [ ] **Step 6: Commit separately** + +```bash +git add -A ':!docs/plans' +git commit -m "fix(ws): repair auto_resume_e2e regression inherited from the base branch" +``` + +--- + +### Task 1: Rust — failed `turn/completed` records a completion (rings, with queue suppression) + +**Files:** +- Modify: `crates/freshell-activity/src/codex.rs:641` (record predicate), `:601-613` (guard-order doc), tests `:1794-1866` +- Modify: `crates/freshell-ws/src/activity.rs` tests (new hub-level test mirroring `:2291`) + +**Interfaces:** +- Consumes: `note_proxy_turn_completed(&mut self, terminal_id: &str, thread_id: &str, turn_id: Option<&str>, status: Option<&str>, at: i64) -> Vec` (`codex.rs:614-677`); test helpers `phases(&[CodexEffect]) -> Vec` (`:962`), `completions(&[CodexEffect]) -> Vec` (`:975`). +- Produces: `status == Some("failed")` now yields `record = true` (a `TrackerEffect::TurnComplete`, gate armed). `interrupted` unchanged (silent claim). Later tasks rely on failed being routed through the same `record` machinery. + +- [ ] **Step 1: Rewrite the pinned test that freezes old behavior (deliberate semantic change) + add the queued-parity test** + +In `crates/freshell-activity/src/codex.rs` tests, find `failed_status_clears_busy_without_completion` (`:1814`). Replace it (keep the sibling `interrupted_status_clears_busy_without_completion` at `:1796` untouched): + +```rust +/// SEMANTIC CHANGE (attention-bell plan 2026-08-01): a failed turn is a +/// non-human stopping cause — it records a completion so the IdleGate rings. +/// Failed takes EXACTLY the completed path, so queue suppression + grace +/// apply naturally. (Previously pinned as clears-without-completion.) +#[test] +fn failed_status_records_a_completion() { + // Mirror the setup of `absent_status_still_completes_for_the_bound_thread` + // (codex.rs:1843): track, bind thread, proxy turn started, then complete. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + let effects = + tracker.note_proxy_turn_completed("t1", "thread-1", Some("turn-1"), Some("failed"), 5_000); + assert_eq!(phases(&effects), vec![CodexPhase::Idle]); + assert_eq!(completions(&effects).len(), 1, "failed must mint a completion"); +} + +/// Failed must be indistinguishable from completed in effect shape — that is +/// what makes queued-submit suppression and the 2s grace apply for free. +#[test] +fn failed_with_queued_submit_behaves_exactly_like_completed_with_queued_submit() { + let run = |status: &str| { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + // Queue a submit while busy (mirror the input used by + // `queued_submit_rearms_pending_after_the_bel_and_completes_each_turn`, codex.rs:1039). + tracker.note_input("t1", "do the next thing\r", 3_000); + let effects = tracker.note_proxy_turn_completed( + "t1", "thread-1", Some("turn-1"), Some(status), 5_000, + ); + (phases(&effects), completions(&effects).len()) + }; + assert_eq!(run("failed"), run("completed")); +} +``` + +Adapt constructor/helper calls to the exact signatures used by the neighboring tests in the same `mod tests` (e.g. if `track_terminal` takes different args there, copy that file's local idiom — the assertions above are the contract). + +- [ ] **Step 2: Run to verify the new tests fail** + +Run: `cargo test -p freshell-activity failed_status -- --nocapture` and `cargo test -p freshell-activity failed_with_queued_submit` +Expected: FAIL — `completions(&effects).len()` is 0 for failed (old predicate). + +- [ ] **Step 3: Flip the record predicate** + +At `crates/freshell-activity/src/codex.rs:641` change: + +```rust +let record = matches!(status, None | Some("completed")); +``` + +to: + +```rust +// Attention-bell policy: completed AND failed are non-human stopping causes +// and record a completion (=> gate arms => terminal.idle). `interrupted` +// (and only it) is human-requested and stays a silent claim. If a queued +// submit exists the shared transition machinery re-arms instead of ringing — +// the queued message auto-submits and work continues. +let record = matches!(status, None | Some("completed") | Some("failed")); +``` + +Update the guard-order doc block at `codex.rs:601-613` (item 6, the status decision) to say `completed | failed | absent ⇒ record; interrupted ⇒ silent claim`. + +- [ ] **Step 4: Run the crate suite** + +Run: `cargo test -p freshell-activity` +Expected: PASS (including the two new tests). If any other test pinned failed-is-silent, update it with the same SEMANTIC CHANGE comment. + +- [ ] **Step 5: Add the hub-level bell test (failed rings; failed+queued drains to a single idle)** + +In `crates/freshell-ws/src/activity.rs` `mod tests`, copy the body of `codex_queued_rearm_drains_to_a_single_grace_idle` (`:2291`) into a new test `codex_failed_turn_rings_and_queued_failed_drains_to_a_single_idle`, changing the final proxy completion's status argument from `"completed"` to `"failed"` (the hub entry is `hub.note_codex_proxy_turn(terminal_id, thread_id, turn_id, Some("failed"), true)` — see `activity.rs:280-287`). Assert the identical frame outcome the original asserts (exactly one `terminal.idle` via `next_frame_matching(rx, "terminal.idle", ..)`, `:1345`). Also add a plain (no queue) variant mirroring `proxy_turn_events_reach_the_codex_tracker_and_emit_turn_complete` (`:2533`) with status `"failed"`, asserting one `terminal.idle` arrives. + +- [ ] **Step 6: Run and verify** + +Run: `cargo test -p freshell-ws codex_failed` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add crates/freshell-activity/src/codex.rs crates/freshell-ws/src/activity.rs +git commit -m "feat(activity): failed codex turns record a completion and ring terminal.idle" +``` + +--- + +### Task 2: Rust — clear the in-flight proxy turn id at accepted completion (deferred minor a) + +**Files:** +- Modify: `crates/freshell-activity/src/codex.rs` (`note_proxy_turn_completed` `:614-677`; field doc `:143-148`) + +**Interfaces:** +- Consumes: `current_proxy_turn_id: Option` — set at `codex.rs:588`, today cleared only on rebind (`:247`, `:306`). +- Produces: `current_proxy_turn_id == None` after ANY accepted terminal-status completion (completed/failed/interrupted). `inProgress` and guard-rejected events leave it untouched. + +- [ ] **Step 1: Write the failing test** + +```rust +/// Deferred minor from the thread-scope plan: the in-flight proxy turn id +/// must not survive the turn it belongs to. A NEW turn id arriving after a +/// completed one must not be rejected by the stale-turn-id guard. +#[test] +fn accepted_completion_clears_the_in_flight_proxy_turn_id() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + tracker.note_proxy_turn_completed("t1", "thread-1", Some("turn-1"), Some("completed"), 3_000); + // With the id cleared, a follow-up turn with a new id starts cleanly and + // its completion is NOT swallowed by the turn-id-mismatch guard. + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-2"), 4_000); + let effects = tracker.note_proxy_turn_completed( + "t1", "thread-1", Some("turn-2"), Some("completed"), 6_000, + ); + assert_eq!(completions(&effects).len(), 1); +} +``` + +Additionally, if the tracker exposes no direct state read, prove the clear via the mismatch guard: after the first completion, send a completion for `Some("turn-1")` again — with the id cleared AND `swallow_next_proxy_complete` consumed, dedupe must come from `last_emitted_turn_key`, not guard 4. Keep the test above as the primary contract. + +- [ ] **Step 2: Run to verify it fails (or passes only by accident)** + +Run: `cargo test -p freshell-activity accepted_completion_clears` +Expected: compile+run. If it passes already (turn-2 path may survive via other guards), strengthen: assert via a `#[cfg(test)]` accessor `fn current_proxy_turn_id_for(&self, terminal_id: &str) -> Option` added to the tracker, asserting `None` after the first completion. Expected: FAIL. + +- [ ] **Step 3: Implement the clear** + +In `note_proxy_turn_completed`, in BOTH accepted arms — the `Pending` arm (after `codex.rs:652-654`) and the `Busy | Unknown` arm (after `:670-672`) — add: + +```rust +state.current_proxy_turn_id = None; +``` + +Update the field doc at `codex.rs:143-148` to say: set on TurnStarted, cleared on rebind AND at every accepted terminal-status completion. + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p freshell-activity` +Expected: PASS (existing turn-id-dedupe tests `:1867-1903` must still pass — the stale-id guard still works for a completion arriving BEFORE the in-flight turn ends). + +- [ ] **Step 5: Commit** + +```bash +git add crates/freshell-activity/src/codex.rs +git commit -m "fix(activity): clear the in-flight codex proxy turn id at accepted completion" +``` + +--- + +### Task 3: Rust — plumb `turn_aborted.reason` and ring on non-human abort reasons + +**Files:** +- Modify: `crates/freshell-sessions/src/parse/codex.rs` (`:294` area, output struct fill `:449-450`) +- Modify: `crates/freshell-sessions/src/meta.rs:30` (snapshot struct) +- Modify: `crates/freshell-activity/src/codex.rs:86-99` (`CodexTaskEvents`), `:341-348` + `:400`/`:419` (abort policy), tests `:1237`, `:1260` +- Modify: `crates/freshell-ws/src/codex_reconcile.rs:117-140` (fold), fixture tests near `:238` + +**Interfaces:** +- Consumes: `CodexTaskEvents { latest_task_started_at, latest_task_completed_at, latest_turn_aborted_at: Option }`; `reconcile_rollout(&mut self, terminal_id: &str, events: &CodexTaskEvents, at: i64) -> Vec` (`codex.rs:320-429`). +- Produces: `CodexTaskEvents` and the freshell-sessions snapshot gain `pub latest_turn_aborted_reason: Option` (the reason paired with the winning `latest_turn_aborted_at`). Policy helper `fn abort_reason_is_human(reason: Option<&str>) -> bool` in `codex.rs`. Task 10 mirrors the same field/policy names on Node (`latestTurnAbortedReason`, `abortReasonIsHuman`). + +**Rationale (corrected by audit A12 — read before implementing):** this task is forward-compatible policy plumbing, NOT a live bell cause. At codex 0.129.0–0.147.0-alpha, `TurnAbortReason` = `{interrupted, replaced, review_ended, budget_limited}`; only `interrupted`/`replaced` have construction sites, and a 5,114-file rollout corpus (2,527 `turn_aborted` lines) is 100% `"interrupted"` — NO ring-worthy abort writes a reasoned `turn_aborted` line today. Today's real failures surface as `turn/completed status='failed'` (Tasks 1/8). Keep the tasks and tests exactly as specified: they pin the default-ring policy for reasons a future codex may emit. + +- [ ] **Step 1: Write failing tracker tests (policy)** + +In `crates/freshell-activity/src/codex.rs` tests, next to `reconcile_turn_aborted_clears_without_completing` (`:1237`) — which stays valid for reason-less aborts but should be renamed/documented — add. Use the local `CodexTaskEvents` construction idiom (helpers `started(at)`/`completed(at)` at `:1183`/`:1189`; extend or inline with the new field): + +```rust +fn aborted(at: i64, reason: Option<&str>) -> CodexTaskEvents { + CodexTaskEvents { + latest_task_started_at: Some(at - 1_000), + latest_task_completed_at: None, + latest_turn_aborted_at: Some(at), + latest_turn_aborted_reason: reason.map(str::to_string), + } +} + +/// Human-requested abort (Esc) — silent, unchanged behavior. +#[test] +fn reconcile_abort_with_interrupted_reason_clears_without_completing() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("sess-1"), 1_000); + tracker.reconcile_rollout("t1", &started(2_000), 2_000); + let effects = tracker.reconcile_rollout("t1", &aborted(5_000, Some("interrupted")), 5_000); + assert_eq!(completions(&effects).len(), 0); +} + +/// 'replaced' = human submitted new input — silent. +#[test] +fn reconcile_abort_with_replaced_reason_clears_without_completing() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("sess-1"), 1_000); + tracker.reconcile_rollout("t1", &started(2_000), 2_000); + let effects = tracker.reconcile_rollout("t1", &aborted(5_000, Some("replaced")), 5_000); + assert_eq!(completions(&effects).len(), 0); +} + +/// Missing reason = legacy rollout line / uncertainty — no heuristic bells. +#[test] +fn reconcile_abort_without_reason_clears_without_completing() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("sess-1"), 1_000); + tracker.reconcile_rollout("t1", &started(2_000), 2_000); + let effects = tracker.reconcile_rollout("t1", &aborted(5_000, None), 5_000); + assert_eq!(completions(&effects).len(), 0); +} + +/// Any OTHER present reason is not human-attributed — it records (rings). +#[test] +fn reconcile_abort_with_unknown_reason_records_a_completion() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("sess-1"), 1_000); + tracker.reconcile_rollout("t1", &started(2_000), 2_000); + let effects = tracker.reconcile_rollout("t1", &aborted(5_000, Some("token_budget_exceeded")), 5_000); + assert_eq!(completions(&effects).len(), 1); +} +``` + +- [ ] **Step 2: Verify they fail to compile (no `latest_turn_aborted_reason` field yet)** + +Run: `cargo test -p freshell-activity reconcile_abort` +Expected: COMPILE ERROR — missing field. That is the red state. + +- [ ] **Step 3: Widen the carriers and implement the policy** + +1. `crates/freshell-activity/src/codex.rs:86-99` — add to `CodexTaskEvents`: + +```rust +/// Reason string paired with `latest_turn_aborted_at` (e.g. "interrupted"). +/// None on legacy rollout lines that carry no reason. +pub latest_turn_aborted_reason: Option, +``` + +2. Same file, next to `has_queued_submit` (`:822-830`), add: + +```rust +/// Human-attributed abort reasons stay silent. A MISSING reason is treated +/// as human/uncertain (legacy rollouts omit it; the real-world corpus shows +/// 'interrupted' is the only observed value; uncertainty never rings). +fn abort_reason_is_human(reason: Option<&str>) -> bool { + matches!(reason, None | Some("interrupted") | Some("replaced")) +} +``` + +3. In `reconcile_rollout`, the tie-break at `:341-348` stays; where `!clear_is_abort` is passed as `record` (`:400`, `:419`), change both to: + +```rust +let record = !clear_is_abort + || !abort_reason_is_human(events.latest_turn_aborted_reason.as_deref()); +``` + +(bind once above the two call sites and pass `record`). Update the rationale comment at `codex.rs:336-340` — see Task 13 for the exact new doc language; here just make it not lie (aborts with a non-human reason DO record). + +4. `crates/freshell-ws/src/codex_reconcile.rs:117-140` — in `fold_task_events`, the `Some("turn_aborted")` arm currently only maxes the timestamp. Capture the reason WITH the winning timestamp (only overwrite the reason when this event's timestamp becomes the new max): + +```rust +Some("turn_aborted") => { + if timestamp_beats(events.latest_turn_aborted_at, at) { + events.latest_turn_aborted_at = Some(at); + events.latest_turn_aborted_reason = payload + .get("reason") + .and_then(|v| v.as_str()) + .map(str::to_string); + } +} +``` + +Adapt to the fold's existing max-assign idiom (it may use a helper; the invariant is: reason always corresponds to the newest abort, and is `None` when that abort had no reason). + +5. `crates/freshell-sessions/src/meta.rs:30` — add the same `pub latest_turn_aborted_reason: Option` to the snapshot struct; `crates/freshell-sessions/src/parse/codex.rs` (`:294` and struct fill around `:449-450`) — track and emit the reason alongside the timestamp with the same newest-wins pairing. Fix all struct-literal construction sites the compiler flags (tests included) with `latest_turn_aborted_reason: None` where behavior is not under test. + +- [ ] **Step 4: Add fold + parser tests** + +In `codex_reconcile.rs` tests (fixture literal near `:238`), add a line with a reason and assert extraction: + +```rust +// payload: {"type":"turn_aborted","turn_id":"x","reason":"interrupted"} +// assert events.latest_turn_aborted_reason == Some("interrupted".into()) +// and a reason-less legacy line yields None. +``` + +Mirror the existing fold test structure exactly. Add the equivalent parser test in `freshell-sessions` next to its existing `turn_aborted` coverage. + +- [ ] **Step 5: Run all three crates** + +Run: `cargo test -p freshell-activity -p freshell-ws -p freshell-sessions` +Expected: PASS. The pre-existing `reconcile_turn_aborted_clears_without_completing` (`:1237`) must still pass (its fixture has no reason → silent); update its doc comment to note the refinement. + +- [ ] **Step 6: Commit** + +```bash +git add crates/freshell-activity/src/codex.rs crates/freshell-ws/src/codex_reconcile.rs crates/freshell-sessions/src/meta.rs crates/freshell-sessions/src/parse/codex.rs +git commit -m "feat(activity): ring on non-human turn_aborted reasons via end-to-end reason plumbing" +``` + +--- + +### Task 4: Rust — exit-cause discriminator on `ActivityEvent::Exit` + +**Files:** +- Modify: `crates/freshell-terminal/src/registry.rs` (`:407-410` enum, `:1507-1510` kill emit, `:1625-1628` natural emit; tests `:4260`, `:4342`) +- Modify: every `ActivityEvent::Exit` construction/match site the compiler flags (notably `crates/freshell-ws/src/activity.rs` tests, which construct Exit via `observer_send`) + +**Interfaces:** +- Consumes: `ActivityEvent::Exit { terminal_id: String, at: i64 }`; `kill_internal(&self, terminal_id, by: &'static str)` (`:1444`); `finish_pty_exit(&self, terminal_id, exit_code)` (`:1567`). +- Produces: `ActivityEvent::Exit { terminal_id: String, at: i64, spontaneous: bool }` — `false` from `kill_internal` (freshell-initiated: api/idle/shutdown), `true` from `finish_pty_exit` (spontaneous PTY/process death). Task 5 consumes `spontaneous` and MUST read gate engagement BEFORE any hub state teardown (`modes.remove` / `idle.note_exit` both destroy the evidence — audit A17; the ordering is spelled out and test-pinned in Task 5 Step 5). + +- [ ] **Step 1: Write the failing registry tests** + +Next to the existing exit-event tests (`registry.rs:4260`, `:4342`), add assertions on the new field (mirror their observer-capture harness): + +```rust +// kill() path: captured ActivityEvent::Exit must have spontaneous == false. +// finish_pty_exit() path: captured ActivityEvent::Exit must have spontaneous == true. +``` + +Write them as two tests, `kill_emits_a_non_spontaneous_exit_event` and `natural_pty_exit_emits_a_spontaneous_exit_event`, copying the setup of the nearest existing test that captures activity events. + +- [ ] **Step 2: Verify compile failure (red)** + +Run: `cargo test -p freshell-terminal kill_emits_a_non_spontaneous` +Expected: COMPILE ERROR — no such field. + +- [ ] **Step 3: Implement** + +`registry.rs:407-410`: + +```rust +Exit { + terminal_id: String, + at: i64, + /// true = the process died on its own (finish_pty_exit); false = a + /// freshell-initiated kill (api / idle reaper / shutdown). Human-requested + /// closes must never ring the attention bell. + spontaneous: bool, +}, +``` + +`kill_internal` emit (`:1507-1510`): add `spontaneous: false,`. `finish_pty_exit` emit (`:1625-1628`): add `spontaneous: true,`. Fix every other construction site the compiler flags: in `crates/freshell-ws/src/activity.rs` tests and anywhere else, use `spontaneous: false` (preserves prior silent expectations) unless the test is specifically about death bells (Task 5 adds those). + +- [ ] **Step 4: Run** + +Run: `cargo test -p freshell-terminal && cargo test -p freshell-ws` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/freshell-terminal/src/registry.rs crates/freshell-ws/src/activity.rs +git commit -m "feat(terminal): carry a spontaneous-vs-requested discriminator on exit activity events" +``` + +--- + +### Task 5: Rust — spontaneous exit while engaged rings `terminal.idle` (claude/codex/amplifier) + +**Files:** +- Modify: `crates/freshell-activity/src/idle.rs` (new accessor; tests `:182-416`) +- Modify: `crates/freshell-ws/src/activity.rs` Exit arm (`:724-753`) + tests + +**Interfaces:** +- Consumes: `ActivityEvent::Exit { terminal_id, at, spontaneous }` (Task 4); `IdleGate` internals `TerminalIdleState { busy, pending, saw_queue_evidence, deadline }` (`idle.rs:49-60`); frame shape `ServerMessage::TerminalIdle(TerminalIdle { terminal_id, at, reason })` as built at `activity.rs:1039-1045`. +- Produces: `IdleGate::is_engaged(&self, terminal_id: &str) -> bool` — CONFIRMED busy (`busy && !pending`) OR an armed deadline. The codex input-only submit gate (`pending`, set by `note_phase(Pending)`) is deliberately NOT engagement: the Enter that executes a human `/quit`/`/exit` from an idle pane is indistinguishable from a prompt submit in the input lane (decision 3, audit A6). Hub emits exactly one `terminal.idle` (reason `Grace`) for a spontaneous exit while engaged, for all three hub trackers. Task 7 extends the hub's engagement read with `codex.has_pending_approvals(..)` once approval state exists. + +- [ ] **Step 1: Write the failing IdleGate accessor test** + +In `idle.rs` `mod tests`: + +```rust +#[test] +fn is_engaged_reflects_confirmed_busy_and_armed_deadlines_but_never_input_pending() { + let mut gate = IdleGate::with_grace_ms(2_000); + assert!(!gate.is_engaged("t1"), "unknown terminal is not engaged"); + gate.note_phase("t1", IdleGatePhase::Pending); + assert!( + !gate.is_engaged("t1"), + "input-only pending is NOT death-bell engagement: the Enter that \ + executes /quit looks like a prompt submit (signal.rs:36-38) and \ + must not ring when the pty then exits (decision 3, audit A6)" + ); + gate.note_phase("t1", IdleGatePhase::Busy); + assert!(gate.is_engaged("t1"), "confirmed busy is engaged"); + gate.note_phase("t1", IdleGatePhase::Idle); + assert!(!gate.is_engaged("t1"), "idle with no pending window is not engaged"); + gate.note_turn_boundary("t1", 10_000); // arms deadline + assert!(gate.is_engaged("t1"), "an armed grace window is engaged (a pending bell must survive death)"); + gate.expire(20_000); + assert!(!gate.is_engaged("t1"), "after emission nothing is engaged"); +} +``` + +- [ ] **Step 2: Red, then implement the accessor** + +Run: `cargo test -p freshell-activity is_engaged` → COMPILE ERROR. Then add to `impl IdleGate` next to `note_exit` (`idle.rs:142-144`): + +```rust +/// Engagement for the DEATH BELL (decision 3): true only for a CONFIRMED +/// busy phase or an armed grace window. The codex input-only Pending +/// submit gate is excluded — the Enter that executes a human /quit//exit +/// is indistinguishable from a prompt submit in the input lane +/// (signal.rs:36-38), so ringing on pending would bell the canonical +/// human quit. Read by the hub's exit arm BEFORE `note_exit` drops the +/// state: a spontaneous process death while engaged rings the bell. +pub fn is_engaged(&self, terminal_id: &str) -> bool { + self.states + .get(terminal_id) + .map(|s| (s.busy && !s.pending) || s.deadline.is_some()) + .unwrap_or(false) +} +``` + +Run: `cargo test -p freshell-activity is_engaged` → PASS. + +- [ ] **Step 3: Write the failing hub tests** + +In `activity.rs` `mod tests` (use `hub()` `:1331`, `observer_send` `:1337`, `next_frame_matching`/`next_frame_of_type` `:1345`/`:1369`; mirror `exit_broadcasts_remove_and_clears_state` `:1564` for setup — codex mode terminal driven to Busy via the proxy lane or rollout fixture): + +```rust +// 1. spontaneous_exit_while_busy_rings_terminal_idle_once +// Drive t1 (codex) to Busy; observer_send(Exit { spontaneous: true, at }); +// assert exactly one terminal.idle frame for t1 (and then no second one — +// mirror the no-second-idle assertion style of :2778-2789). +// 2. freshell_initiated_kill_while_busy_stays_silent +// Same setup; Exit { spontaneous: false }; assert NO terminal.idle +// (bounded wait, mirror existing negative-assertion helpers). +// 3. spontaneous_exit_while_idle_stays_silent +// Track t1 but leave it Idle; Exit { spontaneous: true }; no frame. +// 4. queued_submit_does_not_suppress_the_death_bell +// Busy + queued submit input, then Exit { spontaneous: true }; +// terminal.idle STILL emitted (a dead process never runs the queue). +// 5. claude_spontaneous_exit_while_busy_rings — same as (1) with a claude-mode +// terminal driven Busy via the claude lane (mirror +// claude_submit_bel_turn_complete_and_terminal_idle_flow :1450 setup). +// 6. slash_command_quit_from_an_idle_pane_does_not_ring (audit A6 red test) +// Track t1 (codex) and leave it Idle; feed a lone-CR PTY input ("\r") the +// way existing hub tests drive the input lane (locate a test that sends +// ActivityEvent::Input / the hub's input entry and mirror it) — the input +// lane promotes Idle→Pending (codex.rs:443-490) and the gate goes +// busy+pending; then observer_send(Exit { spontaneous: true, at }); +// assert NO terminal.idle (bounded negative wait). This is exactly what +// `/quit`/`/exit` typed into an idle pane looks like to the tracker: the +// slash-command Enter is indistinguishable from a prompt submit. +``` + +Write all six as real tests with the file's local harness idioms. Test 1 doubles as the audit-A17 ordering pin: if the implementation reads `is_engaged` AFTER `idle.note_exit` (which deletes the per-terminal state), the read is always false and test 1 fails. + +- [ ] **Step 4: Red** + +Run: `cargo test -p freshell-ws spontaneous_exit` +Expected: FAIL — no frame emitted (exit is currently silent). + +- [ ] **Step 5: Implement the hub exit-bell** + +Rewrite the Exit arm (`activity.rs:724-753`) — bind `at` and `spontaneous`. ORDERING IS LOAD-BEARING (audits A17/A8): the engagement read AND the bell decision happen FIRST, before the `modes.remove` early-return and before `idle.note_exit` — both destroy the evidence. + +```rust +ActivityEvent::Exit { terminal_id, at, spontaneous } => { + let frames = { + let mut inner = self.inner.lock().expect("activity hub lock"); + // Read engagement BEFORE any teardown: `idle.note_exit` deletes the + // per-terminal gate state and `modes.remove` would early-return. + // Task 7 extends this read with + // `|| inner.codex.has_pending_approvals(&terminal_id)` (a pane + // blocked on an approval whose process dies must ring even after + // its 2s boundary already rang). + let ring_death_bell = spontaneous && inner.idle.is_engaged(&terminal_id); + let mut frames = Vec::new(); + if ring_death_bell { + // Spontaneous death while engaged: same frame, same reason — + // no wire change. reason MUST be Grace: the client zod enum + // (shared/ws-protocol.ts:210-215) and the Rust enum + // (freshell-protocol server_messages.rs:397-402) allow ONLY + // grace|queue-empty — a novel reason is silently dropped by + // the Node schema and unrepresentable here. `at` is the fresh + // exit timestamp (client dedupe is per-terminal monotonic + // `at`). Immediate (no grace): a dead process emits nothing + // further, so nothing could ever cancel it. Exactly once per + // terminal: the modes.remove below guarantees the teardown + // runs once, and a later shutdown sweep of a retained exited + // row arrives with spontaneous=false. + frames.push(ServerMessage::TerminalIdle(TerminalIdle { + terminal_id: terminal_id.clone(), + at, + reason: TerminalIdleReason::Grace, + })); + } + if let Some(mode) = inner.modes.remove(&terminal_id) { + inner.idle.note_exit(&terminal_id); + inner.lanes.remove(&terminal_id); + inner.lane_retries.remove(&terminal_id); + inner.codex_lanes.remove(&terminal_id); + let tracker_frames = match mode.as_str() { + "claude" => { + let effects = inner.claude.note_exit(&terminal_id); + claude_frames(&mut inner.idle, effects) + } + "codex" => { + let effects = inner.codex.note_exit(&terminal_id); + let (frames, _force_reads) = codex_frames(&mut inner.idle, effects); + frames + } + "amplifier" => { + let effects = inner.amplifier.note_exit(&terminal_id); + let (frames, _force) = amplifier_frames(&mut inner.idle, effects); + frames + } + _ => Vec::new(), + }; + frames.extend(tracker_frames); + } + frames + }; + self.emit(frames); +} +``` + +- [ ] **Step 6: Run** + +Run: `cargo test -p freshell-ws && cargo test -p freshell-activity` +Expected: PASS, including pre-existing exit tests (`exit_broadcasts_remove_and_clears_state`, `codex_lane_is_torn_down_on_exit`) which now pass `spontaneous: false` (or are idle at exit). + +- [ ] **Step 7: Commit** + +```bash +git add crates/freshell-activity/src/idle.rs crates/freshell-ws/src/activity.rs +git commit -m "feat(ws): ring terminal.idle on spontaneous process death while engaged" +``` + +--- + +### Task 6: Rust — proxy sniffs approval requests and matches their responses + +**Files:** +- Modify: `crates/freshell-codex/src/remote_proxy.rs` (const set near `:74-81`; `handle_upstream_frame` `:1085-1139`; `forward_client_frame` `:1017-1032`; `RemoteProxyEvent` `:199-207`; per-connection state alongside `pending_methods`) + +**Interfaces:** +- Consumes: `scan_json_rpc_envelope` → `JsonRpcEnvelope { id: Option, method: Option, .. }` (`remote_proxy_envelope.rs:55-59`); existing `MAX_FULL_PARSE_BYTES` full-parse gating pattern (`turn_notification_effects` `:1326-1399`). +- Produces (Task 7 consumes): + +```rust +pub struct ApprovalRequestParams { + /// Canonicalized request id (string form of the JSON-RPC id). + pub request_id: String, + pub method: String, + /// Best-effort params.threadId — None for oversized/opaque frames. + pub thread_id: Option, +} +// New RemoteProxyEvent variants: +ApprovalRequested(ApprovalRequestParams), +ApprovalResolved { request_id: String }, +``` + +- [ ] **Step 1: Write the failing proxy tests** + +Locate the existing `mod tests` in `remote_proxy.rs` (the branch added upstream-notification tests — find the test that feeds a synthetic upstream `turn/completed` frame through the hub/connection harness and mirror its setup exactly). Add: + +```rust +// 1. approval_request_frame_emits_approval_requested_and_relays_verbatim +// Feed upstream frame: {"jsonrpc":"2.0","id":41,"method":"item/commandExecution/requestApproval", +// "params":{"threadId":"thread-1","command":"rm -rf /tmp/x"}} +// Assert: RemoteProxyEvent::ApprovalRequested { request_id: "41", method: ".../requestApproval", +// thread_id: Some("thread-1") } is emitted AND the exact bytes reach the client side. +// 2. non_approval_server_request_is_relayed_without_events +// Same with method "item/tool/call" — no event, bytes relayed. +// 3. approval_response_emits_approval_resolved_and_forwards_upstream +// After (1), feed CLIENT frame {"jsonrpc":"2.0","id":41,"result":{"decision":"approved"}}. +// Assert ApprovalResolved { request_id: "41" } emitted AND frame forwarded upstream. +// 4. client_response_with_unknown_id_emits_nothing +// Client frame {"id":999,"result":{}} — no ApprovalResolved (it may be a +// response to OUR own pending client request; untouched behavior). +// 5. approval_request_without_thread_id_yields_none +// Frame like (1) but params lacks threadId — thread_id == None. +// 6. legacy_approval_reads_conversation_id (decision 7 / audit A3) +// Upstream frame {"id":42,"method":"execCommandApproval", +// "params":{"conversationId":"thread-1","command":"..."}} → +// ApprovalRequested { thread_id: Some("thread-1"), .. } (legacy methods +// carry params.conversationId, codex-rs v1.rs:126-158). +// 7. error_response_also_resolves (decision 5a / audit A5) +// After (1), CLIENT frame {"id":41,"error":{"code":-1,"message":"denied"}} +// → ApprovalResolved { request_id: "41" } AND forwarded upstream. +// 8. client_frame_with_id_and_method_never_resolves (decision 5d) +// After (1), CLIENT frame {"id":41,"method":"thread/start","params":{}} +// (a REQUEST whose id happens to collide) → NO ApprovalResolved; the +// frame forwards upstream unchanged. +// 9. server_request_resolved_notification_resolves (decision 5c) +// After (1), UPSTREAM notification (no id) +// {"method":"serverRequest/resolved","params":{"threadId":"thread-1","requestId":"41"}} +// → ApprovalResolved { request_id: "41" } AND the notification relays to +// the client verbatim. (Verify the exact wire field casing against the +// cached codex source v2/notification.rs:53-56 @0.146.0 — the struct is +// {thread_id, request_id} under camelCase serde rename.) +// 10. upstream_reconnect_clears_pending_approvals (decision 5b) +// After (1), tear down / re-dial the upstream connection the way the +// harness simulates disconnects → ApprovalResolved { request_id: "41" } +// is emitted for every drained pending id (the app-server id counter is +// per-process and restarts at 0 — stale ids would collide). +// 11. unknown_server_request_method_is_logged_not_belled (decision 6) +// Upstream frame {"id":43,"method":"some/future/method","params":{}} → +// no ApprovalRequested, bytes relayed verbatim (assert relay; the debug +// log itself needs no assertion — keep it a tracing::debug!). +``` + +- [ ] **Step 2: Red** + +Run: `cargo test -p freshell-codex approval` +Expected: COMPILE ERROR (no variants) — red. + +- [ ] **Step 3: Implement** + +1. Const next to `STATEFUL_NOTIFICATION_METHODS` (`:74-81`): + +```rust +/// Server→client JSON-RPC REQUEST methods that block on a human. Sourced +/// from the codex 0.129.0 schema inventory +/// (test/fixtures/coding-cli/codex-app-server/schema-inventory.ts:84-94) +/// and verified EXACT against the codex `ServerRequest` enum at both +/// 0.129.0 and the deployed 0.146.0. +const APPROVAL_REQUEST_METHODS: &[&str] = &[ + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "item/permissions/requestApproval", + "item/tool/requestUserInput", + "mcpServer/elicitation/request", + "applyPatchApproval", + "execCommandApproval", +]; + +/// Machine-serviced server→client requests — never human-attention. +/// (`attestation/generate` and `currentTime/read` are new at 0.146.0.) +/// Anything outside BOTH lists is debug-logged to catch future drift +/// (decision 6) — no bell, just logging. +const AUTOMATED_SERVER_REQUEST_METHODS: &[&str] = &[ + "item/tool/call", + "account/chatgptAuthTokens/refresh", + "attestation/generate", + "currentTime/read", +]; + +/// Legacy approval methods carry `params.conversationId` instead of +/// `params.threadId` (codex-rs v1.rs:126-158). +const LEGACY_APPROVAL_REQUEST_METHODS: &[&str] = &["applyPatchApproval", "execCommandApproval"]; +``` + +2. Per-connection state: alongside `pending_methods` / `pending_fork_requests` add `pending_server_approvals: HashSet` (use the same `RequestId` type `pending_methods` keys on; add a small `fn envelope_id_to_string(id: &JsonRpcEnvelopeId) -> String` for the event payload — string ids verbatim, numeric ids via their canonical integer formatting). + +3. In `handle_upstream_frame`, at the TOP of the with-id branch (`:1099`), BEFORE the `pending_methods.remove` lookup: + +```rust +if let Some(method) = envelope.method.as_deref() { + // id + method ⇒ a server→client REQUEST (our own responses never + // reach this path). Never consult pending_methods for these — the + // server's id space is not ours. + if APPROVAL_REQUEST_METHODS.contains(&method) { + if let Some(req_id) = envelope_id_to_request_id(&id) { + // v2 methods carry params.threadId; legacy methods carry + // params.conversationId (decision 7, codex-rs v1.rs:126-158). + let thread_pointer = if LEGACY_APPROVAL_REQUEST_METHODS.contains(&method) { + "/params/conversationId" + } else { + "/params/threadId" + }; + let thread_id = (data.len() <= MAX_FULL_PARSE_BYTES) + .then(|| serde_json::from_slice::(&data).ok()) + .flatten() + .and_then(|v| v.pointer(thread_pointer).and_then(|t| t.as_str()).map(str::to_string)); + if let Some(conn) = self.connections.get_mut(&conn_id) { + conn.pending_server_approvals.insert(req_id); + } + self.emit(RemoteProxyEvent::ApprovalRequested(ApprovalRequestParams { + request_id: envelope_id_to_string(&id), + method: method.to_string(), + thread_id, + })); + } + } else if !AUTOMATED_SERVER_REQUEST_METHODS.contains(&method) { + // Decision 6: the method set is version-fluid — surface drift. + tracing::debug!(method, "unrecognized codex server->client request method (not treated as an approval)"); + } + self.send_to_client(conn_id, data, binary); + return; +} +``` + +(Reuse the existing `envelope_id_to_request_id` helper visible at `:1017`; keep relay-verbatim semantics for ALL server requests, approval or not.) + +4. In `forward_client_frame` (`:1017-1032`), when `id` is Some and `method` is None (a response — the `method.is_none()` check is MANDATORY, decision 5d: a client REQUEST whose id numerically collides with a pending server approval must not resolve it): + +```rust +if method.is_none() { + // A response frame: {id, result} OR {id, error} — BOTH resolve + // (decision 5a; codex handles errors via process_error). No need to + // inspect the payload beyond id+method-absence. + if let Some(req_id) = id.as_ref().and_then(envelope_id_to_request_id) { + if let Some(conn) = self.connections.get_mut(&conn_id) { + if conn.pending_server_approvals.remove(&req_id) { + self.emit(RemoteProxyEvent::ApprovalResolved { + request_id: envelope_id_to_string(id.as_ref().unwrap()), + }); + } + } + } +} +``` + +Restructure to fit the existing `if let (Some(id), Some(method))` shape without double-borrowing; forward the frame upstream unchanged in all cases. + +5. Add the two variants + struct to `RemoteProxyEvent` (`:199-207`). + +6. **Server-side resolution (decision 5c):** in the upstream NOTIFICATION path (frames with `method` and NO `id` — the same branch that handles `STATEFUL_NOTIFICATION_METHODS`), when `envelope.method.as_deref() == Some("serverRequest/resolved")` and `data.len() <= MAX_FULL_PARSE_BYTES`, parse `params.requestId` (verify the exact wire casing against the cached codex source `v2/notification.rs:53-56` @0.146.0 — struct fields `{thread_id, request_id}` under camelCase serde rename), remove it from `pending_server_approvals` (search ALL connections' pending sets — the request went out on this proxy's single upstream), and emit `ApprovalResolved` when it was pending. Relay the notification to the client verbatim regardless. + +7. **Restart hygiene (decision 5b):** wherever the proxy tears down or re-dials the upstream connection (mirror how `pending_methods`/`pending_fork_requests` are handled on connection teardown — locate with `grep -n "pending_methods" crates/freshell-codex/src/remote_proxy.rs`), drain `pending_server_approvals` and emit `ApprovalResolved` for EVERY drained id, so trackers never stay paused across an incarnation whose fresh id counter (per-process `AtomicI64` from 0) would collide with stale ids. + +- [ ] **Step 4: Run** + +Run: `cargo test -p freshell-codex` +Expected: PASS (all five new tests + no regression in the notification tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/freshell-codex/src/remote_proxy.rs +git commit -m "feat(codex): sniff app-server approval requests and match their responses in the proxy" +``` + +--- + +### Task 7: Rust — approval pause rings once; response returns to busy + +**Files:** +- Modify: `crates/freshell-activity/src/ledger.rs` (or the file defining `TrackerEffect` — locate with `grep -rn "enum TrackerEffect" crates/freshell-activity/src/`): new variant +- Modify: `crates/freshell-activity/src/codex.rs` (new state fields + `note_approval_requested`/`note_approval_resolved` + `has_pending_approvals`; clears in completion/rebind paths; reconcile promotion guard at `:352-368`) +- Modify: `crates/freshell-ws/src/activity.rs` (`codex_frames` `:1195-1242`; `claude_frames` `:1146-1191`; `amplifier_frames` `:1246-1292`; new `HubEvent::CodexApproval` + public entry; Exit-arm engagement extension from Task 5; hub tests) +- Modify: `crates/freshell-ws/src/codex_proxy_route.rs` (`route_proxy_event` `:46-95`) + +**Interfaces:** +- Consumes: `RemoteProxyEvent::ApprovalRequested(ApprovalRequestParams)` / `ApprovalResolved { request_id }` (Task 6); `IdleGate::note_turn_boundary` (`idle.rs:109-119`), `note_phase` (`:91-104`). +- Produces: + +```rust +// freshell-activity (TrackerEffect definition): +/// Arms the truly-idle gate WITHOUT minting a turn completion or a +/// terminal.turn.complete frame. Used for attention causes that are not +/// turn ends (approval-request pauses). +AttentionBoundary { terminal_id: String, at: i64 }, + +// CodexActivityTracker: +pub fn note_approval_requested(&mut self, terminal_id: &str, thread_id: Option<&str>, request_id: &str, at: i64) -> Vec; +pub fn note_approval_resolved(&mut self, terminal_id: &str, request_id: &str, at: i64) -> Vec; +/// Death-bell engagement extension (decision 3): a pane blocked on an +/// approval whose process dies spontaneously must ring. Read by the hub's +/// Exit arm alongside IdleGate::is_engaged, BEFORE any teardown. +pub fn has_pending_approvals(&self, terminal_id: &str) -> bool; + +// ActivityHub: +pub fn note_codex_approval(&self, terminal_id: &str, thread_id: Option<&str>, request_id: &str, requested: bool); +``` + +- [ ] **Step 1: Write the failing tracker tests** + +```rust +/// Approval pause: internal waiting state, public phase flips to the +/// EXISTING not-busy value, and the gate boundary arms (no completion). +#[test] +fn approval_request_pauses_busy_to_idle_and_arms_a_boundary() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + let effects = tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); + assert_eq!(phases(&effects), vec![CodexPhase::Idle]); + assert_eq!(completions(&effects).len(), 0, "an approval pause is not a turn end"); + assert!( + effects.iter().any(|e| matches!(e, TrackerEffect::AttentionBoundary { at: 3_000, .. })), + "the gate boundary must arm" + ); +} + +#[test] +fn approval_resolved_returns_to_busy() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); + let effects = tracker.note_approval_resolved("t1", "41", 4_000); + assert_eq!(phases(&effects), vec![CodexPhase::Busy], "the turn resumes"); +} + +#[test] +fn approval_resolved_with_no_prior_busy_stays_idle() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); // pane was idle + let effects = tracker.note_approval_resolved("t1", "41", 4_000); + assert_eq!(phases(&effects), Vec::::new(), "nothing to resume"); +} + +#[test] +fn foreign_thread_approval_request_is_ignored() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + let effects = tracker.note_approval_requested("t1", Some("subagent-thread"), "41", 3_000); + assert!(effects.is_empty(), "a sub-agent approval must not ring the parent pane"); +} + +#[test] +fn approval_request_without_thread_id_is_accepted() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + let effects = tracker.note_approval_requested("t1", None, "41", 3_000); + assert_eq!(phases(&effects), vec![CodexPhase::Idle]); +} + +#[test] +fn queued_submit_does_not_block_the_approval_boundary() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + tracker.note_input("t1", "queued message\r", 2_500); // still blocked on the human + let effects = tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); + assert!(effects.iter().any(|e| matches!(e, TrackerEffect::AttentionBoundary { .. }))); +} + +#[test] +fn turn_completion_clears_pending_approvals() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); + tracker.note_proxy_turn_completed("t1", "thread-1", Some("turn-1"), Some("completed"), 5_000); + // A late response to the stale approval must not flip the pane busy. + let effects = tracker.note_approval_resolved("t1", "41", 6_000); + assert!(effects.is_empty()); +} + +/// Audit A9: the FIRST rollout fold of the turn's own task_started passes +/// the reconcile edge-trigger (codex.rs:352-368) — landing mid-pause it +/// would flip phase Busy, feed the gate, and silently cancel the armed +/// approval bell. Mid-pause promotions must fold anchors but defer the +/// phase flip to the resolve. +#[test] +fn reconcile_task_started_during_pending_approval_does_not_flip_busy() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); + // Reuse Task 3's `started(at)` CodexTaskEvents helper (codex.rs:1183). + let effects = tracker.reconcile_rollout("t1", &started(3_500), 3_500); + assert_eq!(phases(&effects), Vec::::new(), "no Busy upsert mid-pause"); + // The deferred promotion resumes at resolve. + let effects = tracker.note_approval_resolved("t1", "41", 4_000); + assert_eq!(phases(&effects), vec![CodexPhase::Busy]); +} + +/// Audit A9 hazard 2: a mid-pause Enter (the human answering the approval +/// in the TUI) plants PTY pending-submit state; resolve must normalize it +/// so the NEXT turn clear is not misclassified as a queued re-arm (which +/// would suppress a legitimate later bell). +#[test] +fn approval_resolve_normalizes_pending_submit_input_state() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + tracker.note_proxy_turn_started("t1", "thread-1", Some("turn-1"), 2_000); + tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); + tracker.note_input("t1", "\r", 3_500); // answering the approval prompt + tracker.note_approval_resolved("t1", "41", 4_000); + let effects = + tracker.note_proxy_turn_completed("t1", "thread-1", Some("turn-1"), Some("completed"), 6_000); + assert_eq!(phases(&effects), vec![CodexPhase::Idle], "no Pending re-arm from the pause keystroke"); + assert_eq!(completions(&effects).len(), 1, "the completion bell must not be swallowed"); +} + +/// Decision 3 / audit A10: a pane blocked on an approval counts as engaged +/// for the death bell. +#[test] +fn has_pending_approvals_tracks_the_pending_set() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", Some("thread-1"), 1_000); + assert!(!tracker.has_pending_approvals("t1")); + tracker.note_approval_requested("t1", Some("thread-1"), "41", 3_000); + assert!(tracker.has_pending_approvals("t1")); + tracker.note_approval_resolved("t1", "41", 4_000); + assert!(!tracker.has_pending_approvals("t1")); +} +``` + +- [ ] **Step 2: Red** + +Run: `cargo test -p freshell-activity approval` +Expected: COMPILE ERROR — red. + +- [ ] **Step 3: Implement tracker + effect variant** + +1. Add to the `TrackerEffect` enum (generic, shared): the `AttentionBoundary { terminal_id: String, at: i64 }` variant with the doc comment from Interfaces above. +2. `TerminalActivity` gains: + +```rust +/// Outstanding server→client approval request ids (managed proxy lane). +pending_approvals: std::collections::HashSet, +/// True when the approval pause demoted a working phase; the resolve +/// restores Busy. False when the approval arrived while already idle. +resume_busy_after_approval: bool, +``` + +(init empty/false everywhere `TerminalActivity` is constructed). +3. Methods (place near the other proxy-lane methods): + +```rust +/// Approval-request pause (managed lane). Thread-scoped like turn events; +/// requests without a threadId are accepted (the proxy is per-terminal). +/// Public phase maps to the EXISTING not-busy value — no new wire phase. +/// Queued input never suppresses approval bells: still blocked on a human. +pub fn note_approval_requested( + &mut self, + terminal_id: &str, + thread_id: Option<&str>, + request_id: &str, + at: i64, +) -> Vec { + let Some(state) = self.states.get_mut(terminal_id) else { + return Vec::new(); + }; + if let (Some(thread), Some(bound)) = (thread_id, state.session_id.as_deref()) { + if thread != bound { + return Vec::new(); + } + } + state.pending_approvals.insert(request_id.to_string()); + let previous = state.to_record(); + if matches!(state.phase, CodexPhase::Busy | CodexPhase::Pending | CodexPhase::Unknown) { + state.resume_busy_after_approval = true; + state.phase = CodexPhase::Idle; + } + state.updated_at = at; + let mut effects = Vec::new(); + if state.has_public_change(&previous) { + effects.push(TrackerEffect::Changed { + upsert: vec![state.to_record()], + remove: Vec::new(), + }); + } + effects.push(TrackerEffect::AttentionBoundary { + terminal_id: terminal_id.to_string(), + at, + }); + effects +} + +/// The approval response passed back through the proxy: the turn resumes. +/// Cancels a pending bell within the grace (gate sees Busy); un-greens the +/// pane. Stale/unknown request ids are no-ops. +pub fn note_approval_resolved( + &mut self, + terminal_id: &str, + request_id: &str, + at: i64, +) -> Vec { + let Some(state) = self.states.get_mut(terminal_id) else { + return Vec::new(); + }; + if !state.pending_approvals.remove(request_id) { + return Vec::new(); + } + if !state.pending_approvals.is_empty() || !state.resume_busy_after_approval { + return Vec::new(); + } + state.resume_busy_after_approval = false; + let previous = state.to_record(); + state.phase = CodexPhase::Busy; + state.updated_at = at; + state.last_observed_at = at; + if state.has_public_change(&previous) { + vec![TrackerEffect::Changed { + upsert: vec![state.to_record()], + remove: Vec::new(), + }] + } else { + Vec::new() + } +} +``` + +Adapt the `to_record`/`has_public_change` usage to the file's existing effect-emission idiom (see `note_proxy_turn_started` `:571-599` for the canonical pattern). +4. Clear approval state at turn end and rebind: in `note_proxy_turn_completed`, add `state.pending_approvals.clear(); state.resume_busy_after_approval = false;` ONCE on the accepted completion path (same acceptance/terminal-status gating as Task 2's clear) but placed BEFORE the phase match — NOT inside individual match arms. Placement rationale: a turn that completes during an approval pause routes through the `CodexPhase::Idle => {}` arm (the approval request itself demoted the phase to Idle), so arm-local clears in the Pending and Busy|Unknown arms would never run there and the `turn_completion_clears_pending_approvals` test above could not pass — the stale approval's later resolve would find `resume_busy_after_approval == true`, flip the pane Busy, and emit a `Changed` effect. Clearing before the match covers every accepted arm, including Idle. Likewise clear both fields in the rebind branches (`track_terminal` `:247` area, `bind_session` `:306` area). +5. Match-arm fallout: add `TrackerEffect::AttentionBoundary { .. } => {}` arms wherever the compiler demands (claude/amplifier frame mappers, ledger, etc.). +6. Accessor: + +```rust +pub fn has_pending_approvals(&self, terminal_id: &str) -> bool { + self.states + .get(terminal_id) + .map(|s| !s.pending_approvals.is_empty()) + .unwrap_or(false) +} +``` + +7. **Reconcile promotion guard (decision 8 / audit A9):** in `reconcile_rollout`'s promotion branch (`codex.rs:352-368` — the arm that sets `state.phase = CodexPhase::Busy` at `:368`), when `!state.pending_approvals.is_empty()`: still fold the anchors exactly as the branch does today (`last_seen_task_started_at`, `accepted_start_at`) but set `state.resume_busy_after_approval = true` INSTEAD of assigning the phase (and emit no Busy `Changed` upsert). The resolve path already restores Busy via the flag. +8. **Resolve normalization (audit A9 hazard 2):** in `note_approval_resolved`, when the pending set empties, also clear the PTY pending-submit state a mid-pause Enter may have planted (`pending_submit_at` and the disarmed-swallow flags set at `codex.rs:480-489` — mirror how the rebind path resets them) so the next turn clear is not misread as a queued re-arm. + +Run: `cargo test -p freshell-activity approval` → PASS. + +- [ ] **Step 4: Wire hub + router (failing hub tests first)** + +Hub tests in `activity.rs` `mod tests` (grace is 2s in production — mirror how existing idle tests wait; they already emit real `terminal.idle` frames, e.g. `:2291`): + +```rust +// 1. approval_request_rings_once_after_grace +// Codex terminal Busy via proxy lane; hub.note_codex_approval("t1", Some("thread-1"), "41", true); +// expect exactly one terminal.idle for t1 (and a codex.activity.updated frame +// showing the not-busy phase); assert no SECOND idle. +// 2. approval_answered_within_grace_stays_silent +// Same, then hub.note_codex_approval("t1", None, "41", false) immediately; +// assert NO terminal.idle within a bounded wait, and the activity frame is Busy again. +// 3. queued_input_does_not_suppress_the_approval_bell +// Busy + submit-shaped input first, then approval request; still one terminal.idle. +// 4. reconcile_tick_during_a_pending_approval_does_not_cancel_the_armed_bell (audit A9) +// Busy via proxy lane; approval requested (deadline armed); BEFORE the 2s +// grace elapses, drive a rollout reconcile whose newest event is the +// turn's task_started (mirror how existing tests feed rollout fixtures / +// CodexFsChange); assert the terminal.idle STILL arrives after the grace +// AND no Busy-phase codex.activity.updated frame was emitted mid-pause; +// then resolve → the activity frame shows Busy again. +// 5. spontaneous_exit_during_a_pending_approval_rings (decision 3 / audit A10) +// Busy via proxy lane; approval requested; let the 2s grace elapse and the +// approval bell ring (deadline now spent, phase not busy); then +// observer_send(Exit { spontaneous: true, at }); assert a SECOND +// terminal.idle arrives — pending_approvals counts as death-bell +// engagement even after the armed deadline has already rung. +``` + +Implementation: +1. `HubEvent` (`activity.rs:140-146` area): add + +```rust +CodexApproval { + terminal_id: String, + thread_id: Option, + request_id: String, + requested: bool, +}, +``` + +2. Public entry next to `note_codex_proxy_turn` (`:280-295`): + +```rust +pub fn note_codex_approval(&self, terminal_id: &str, thread_id: Option<&str>, request_id: &str, requested: bool) +``` + +(channel-defer exactly like `note_codex_proxy_turn`). +3. Hub-task arm (next to the `CodexProxyTurn` arm at `:528-558`): call `inner.codex.note_approval_requested(...)` or `note_approval_resolved(...)`, map through `codex_frames`, emit. +4. `codex_frames` (`:1195-1242`): add + +```rust +TrackerEffect::AttentionBoundary { terminal_id, at } => { + // Arm the gate WITHOUT a terminal.turn.complete frame — an approval + // pause is not a turn end. Effect order guarantees the Idle phase + // Changed was processed first, so the boundary arms. + idle.note_turn_boundary(&terminal_id, at); +} +``` + +Add ignore-arms in `claude_frames`/`amplifier_frames`. +5. `codex_proxy_route.rs` `route_proxy_event`: add arms + +```rust +RemoteProxyEvent::ApprovalRequested(params) => { + hub.note_codex_approval(&terminal_id, params.thread_id.as_deref(), ¶ms.request_id, true); +} +RemoteProxyEvent::ApprovalResolved { request_id } => { + hub.note_codex_approval(&terminal_id, None, &request_id, false); +} +``` + +- [ ] **Step 5: Run** + +Run: `cargo test -p freshell-ws && cargo test -p freshell-activity && cargo test -p freshell-codex` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/freshell-activity crates/freshell-ws/src/activity.rs crates/freshell-ws/src/codex_proxy_route.rs +git commit -m "feat(activity): approval-request pauses ring once and resolve back to busy" +``` + +--- + +### Task 8: Node — failed rings + `lastSeenTaskCompletedAt` gating (deferred minor b) + +**Files:** +- Modify: `server/coding-cli/codex-activity-tracker.ts` (`onTurnCompleted` `:263-291`; record predicate `:278`; timestamp bump `:281`) +- Test: `test/unit/server/coding-cli/codex-activity-tracker.test.ts` (existing pins at `:1197` interrupted, `:1214` failed, `:1231` inProgress; timestamp reads `:1040`, `:1074`) + +**Interfaces:** +- Consumes: `onTurnCompleted(event: CodexTurnCompletedEvent)` with `status?: string`; guard order at `:266-278`. +- Produces: `status === 'failed'` records (mirrors Rust Task 1); `lastSeenTaskCompletedAt` advances ONLY when `status === undefined || status === 'completed'`. + +- [ ] **Step 1: Update the pinned failed test + add queued-parity + timestamp tests** + +In `codex-activity-tracker.test.ts`, rewrite the failed pin at `:1214` (mirror the file's local setup helpers — the interrupted test at `:1197` shows the idiom): + +```ts +// SEMANTIC CHANGE (attention-bell plan 2026-08-01): failed is a non-human +// stopping cause — it records a completion (rings). Parity with Rust. +it('records a completion when the bound thread turn fails', () => { + // setup identical to the interrupted test, but status: 'failed' + // assert: one 'turn.complete' emission / recorded completion (whatever the + // sibling completed-status test asserts), and phase flips to idle. +}) + +it('failed with a queued submit behaves exactly like completed with a queued submit', () => { + // run the same sequence twice (queued input while busy, then completion), + // once status 'completed', once 'failed'; assert identical emissions. +}) + +it('does not advance lastSeenTaskCompletedAt on interrupted or failed turns', () => { + // drive an interrupted completion and a failed completion; read the state + // the way the existing tests at :1040/:1074 do; assert the diagnostics + // timestamp did NOT move. Then a completed turn DOES move it. +}) +``` + +Write full bodies using the file's existing factory/assertion helpers. + +- [ ] **Step 2: Red** + +Run: `npm run test:vitest -- test/unit/server/coding-cli/codex-activity-tracker.test.ts` +Expected: FAIL — failed currently claims silently, and the timestamp bumps unconditionally. + +- [ ] **Step 3: Implement** + +At `:278`, change the record predicate to: + +```ts +// Attention-bell policy: completed AND failed record (ring); interrupted is +// the human-requested silent clear. Mirrors Rust codex.rs record predicate. +const record = status === undefined || status === 'completed' || status === 'failed' +``` + +At `:281`, gate the diagnostics bump on GENUINE completion (deliberately narrower than `record` — the field means "task COMPLETED"; deferred minor from the delta review): + +```ts +if (status === undefined || status === 'completed') { + state.lastSeenTaskCompletedAt = maxDefined(state.lastSeenTaskCompletedAt, event.at) +} +``` + +(keep the exact `maxDefined(...)` expression currently on that line). + +- [ ] **Step 4: Green** + +Run: `npm run test:vitest -- test/unit/server/coding-cli/codex-activity-tracker.test.ts` +Expected: PASS. Update the reads at `:1040`/`:1074` if their expectations depended on the unconditional bump (comment why). + +- [ ] **Step 5: Commit** + +```bash +git add server/coding-cli/codex-activity-tracker.ts test/unit/server/coding-cli/codex-activity-tracker.test.ts +git commit -m "feat(server): failed codex turns ring; gate the completed-at diagnostics timestamp" +``` + +--- + +### Task 9: Node — clear `currentTurnId` at accepted completion (minor a parity) + +**Files:** +- Modify: `server/coding-cli/codex-activity-tracker.ts` (`onTurnCompleted`; field decl `:54`, set `:257`, guard `:272-275`) +- Test: `test/unit/server/coding-cli/codex-activity-tracker.test.ts` + +**Interfaces:** +- Consumes/Produces: mirrors Rust Task 2 — `state.currentTurnId = undefined` after any ACCEPTED terminal-status completion; untouched on `inProgress`/guard rejections. + +- [ ] **Step 1: Write the failing test** + +```ts +it('clears the in-flight turn id at accepted completion so the next turn is not swallowed', () => { + // start turn-1, complete it (status 'completed'); start turn-2, complete + // turn-2 — assert the second completion records (not rejected as stale). + // Mirror the stale-turn-id test's setup in this file and invert it. +}) +``` + +- [ ] **Step 2: Red → implement** + +Run the file's suite (expected FAIL or vacuous-pass; if vacuous, assert `state.currentTurnId === undefined` via the tracker's test-visible state access used elsewhere in this suite). Then in `onTurnCompleted`, in the accepted path (right where the phase transition happens, after the guards at `:266-278`), add: + +```ts +state.currentTurnId = undefined +``` + +- [ ] **Step 3: Green + commit** + +Run: `npm run test:vitest -- test/unit/server/coding-cli/codex-activity-tracker.test.ts` +Expected: PASS. + +```bash +git add server/coding-cli/codex-activity-tracker.ts test/unit/server/coding-cli/codex-activity-tracker.test.ts +git commit -m "fix(server): clear the in-flight codex turn id at accepted completion" +``` + +--- + +### Task 10: Node — `turn_aborted.reason` plumbing + abort bell policy + +**Files:** +- Modify: `server/coding-cli/providers/codex.ts` (`:362` case, snapshot build `:376-384`) +- Modify: `server/coding-cli/types.ts` (`CodexTaskEventSnapshot`) +- Modify: `server/coding-cli/codex-activity-tracker.ts` (`reconcileProjects` `:293`, `clearIsAbort` `:306-313`) +- Test: `test/unit/server/coding-cli/codex-activity-tracker.test.ts` (abort pins `:1288`, `:1318`), plus the providers/codex parser test file (locate with `grep -rl "turn_aborted" test/unit/server/coding-cli`) + +**Interfaces:** +- Consumes: rollout payload `{"type":"turn_aborted","turn_id":"...","reason":"interrupted"}` (reason may be absent on legacy lines). +- Produces: `CodexTaskEventSnapshot.latestTurnAbortedReason?: string` (paired newest-wins with `latestTurnAbortedAt`); tracker helper `abortReasonIsHuman(reason: string | undefined): boolean` mirroring Rust (`undefined | 'interrupted' | 'replaced'` → true). + +**Rationale (corrected by audit A12):** as in Task 3, this is forward-compatible policy plumbing — no live codex (0.129.0–0.147.0-alpha) writes a ring-worthy `turn_aborted` reason (a 5,114-file corpus is 100% `"interrupted"`); today's real failures ring via `status='failed'` (Tasks 1/8). Keep the tests as specified. + +- [ ] **Step 1: Write failing tests (parser + tracker policy)** + +Parser: a rollout line with `reason` populates `latestTurnAbortedReason`; a legacy line leaves it `undefined`; newest abort wins the pairing. +Tracker (mirror Rust Task 3's four policy tests): interrupted-reason silent, replaced-reason silent, missing-reason silent (update the existing pin at `:1288` with a comment noting the refinement), unknown-reason records/rings. + +- [ ] **Step 2: Red** + +Run: `npm run test:vitest -- test/unit/server/coding-cli/codex-activity-tracker.test.ts` (+ the parser test file) +Expected: FAIL / type errors. + +- [ ] **Step 3: Implement** + +1. `types.ts`: add `latestTurnAbortedReason?: string` to `CodexTaskEventSnapshot`. +2. `providers/codex.ts:362`: capture the reason with the winning timestamp: + +```ts +case 'turn_aborted': { + const at = extractTimestamp(entry) // keep the file's existing extraction + if (at !== undefined && (latestTurnAbortedAt === undefined || at > latestTurnAbortedAt)) { + latestTurnAbortedAt = at + latestTurnAbortedReason = + typeof payload.reason === 'string' ? payload.reason : undefined + } + break +} +``` + +(adapt to the file's existing `maxTimestamp(...)` idiom while preserving the pairing invariant), and emit it in the snapshot build at `:376-384`. +3. Tracker: add + +```ts +// Mirrors Rust abort_reason_is_human: missing reason = legacy/uncertainty → +// silent; 'interrupted'/'replaced' = human-requested → silent; anything else +// is not human-attributed and records (rings). +function abortReasonIsHuman(reason: string | undefined): boolean { + return reason === undefined || reason === 'interrupted' || reason === 'replaced' +} +``` + +and where `clearIsAbort` (`:306-313`) feeds the record decision, change it to `record = !clearIsAbort || !abortReasonIsHuman(nextTurnAbortedReason)` following the Rust shape. + +- [ ] **Step 4: Green + commit** + +Run: `npm run test:vitest -- test/unit/server/coding-cli/codex-activity-tracker.test.ts` (+ parser test file) +Expected: PASS. + +```bash +git add server/coding-cli/providers/codex.ts server/coding-cli/types.ts server/coding-cli/codex-activity-tracker.ts test/unit/server/coding-cli +git commit -m "feat(server): ring on non-human codex turn_aborted reasons (Node parity)" +``` + +--- + +### Task 11: Node — spontaneous-exit bell (codex/claude/amplifier) + +**Files:** +- Modify: `server/terminal-registry.ts` (`finishTerminalPtyExit` `:1504-1535`; internal emits at `:1527-1531` and `:4091-4095` — NOT the client `safeSend` wire frames at `:1520`/`:4084`) +- Modify: `server/coding-cli/codex-activity-wiring.ts:82-92` and the claude/amplifier equivalents (locate: `grep -rn "registry.on('terminal.exit'" server/coding-cli/`) +- Modify: `server/coding-cli/codex-activity-tracker.ts` (`noteExit` `:176-179`, `removeState` `:640-645`), `claude-activity-tracker.ts` (`noteExit` `:169`, `removeState` `:200`), `amplifier-activity-tracker.ts` (`noteExit` `:343`, `removeState` `:387`) +- Modify: `server/coding-cli/truly-idle-emitter.ts` (remove loop `:102-109`; change type; doc `:57-58`) +- Test: `test/unit/server/coding-cli/truly-idle-emitter.test.ts` (update the never-emit pin at `:110`), tracker test files + +**Interfaces:** +- Consumes: registry internal event `'terminal.exit'` payload; `TrulyIdleActivityChange` ('changed' event payload `{ upsert, remove }`). +- Produces: internal `'terminal.exit'` payload gains `spontaneous: boolean` — `finishTerminalPtyExit` computes it as `!requestedClose` where `requestedClose` is `record.codexRecoveryFinalClose === true` captured at function ENTRY (see Step 3; a blanket `true` would ring on server shutdown — audit A7); the `kill` path (`:4091-4095`) emits `false`. Tracker `noteExit(input: { terminalId: string; at: number; spontaneous?: boolean })`; `removeState(terminalId, opts?: { spontaneousExit?: boolean })`; `'changed'` payload gains optional `spontaneousExitRemovals?: string[]`. Emitter emits `{ terminalId, at: now(), reason: 'grace' }` immediately for a spontaneous removal while engaged: `(state.busy && !state.pending) || state.graceTimer !== undefined` — input-only pending is NOT engagement (decision 3: `/quit` from an idle pane arrives as phase `'pending'`). Task 12 extends engagement with approval waits (`approvalPendingRemovals`). `unbindTerminal` never passes the flag (requested). **Accepted residual (audit A17, document in Task 13):** the 120s busy-deadman (`BUSY_DEADMAN_MS`, `codex-activity-tracker.ts:20`, demotion `:624-628`) can flip busy→unknown during recovery windows longer than 120s, and `unknown` never arms the death bell — a missed bell, never a false ring; no death-time snapshot is threaded. + +- [ ] **Step 1: Write the failing emitter tests** + +In `truly-idle-emitter.test.ts` (fake timers; mirror existing patterns). Update the pin at `:110` to scope it to REQUESTED removals with a comment, and add: + +```ts +it('emits terminal.idle immediately when a busy terminal is removed by a spontaneous exit', () => { + // drive t1 busy via noteActivityChanged({ upsert: [busyRecord('t1')] }) + // then noteActivityChanged({ upsert: [], remove: ['t1'], spontaneousExitRemovals: ['t1'] }) + // assert exactly one 'idle' emission { terminalId: 't1', reason: 'grace' }, no timers pending. +}) + +it('stays silent when a busy terminal is removed by a requested close', () => { + // remove without spontaneousExitRemovals → no emission (old pin, scoped). +}) + +it('stays silent when an idle terminal exits spontaneously', () => {}) + +it('stays silent when an input-pending terminal exits spontaneously (slash-command quit)', () => { + // decision 3 / audit A6 red test: /quit typed into an idle pane arrives as + // phase 'pending' (the executing Enter looks like a prompt submit — + // codex-activity-tracker.ts:181-209). Drive: + // noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'pending' }] }) + // noteActivityChanged({ upsert: [], remove: ['t1'], spontaneousExitRemovals: ['t1'] }) + // assert ZERO 'idle' emissions — input-only pending is never engagement. +}) + +it('rings when a spontaneous exit lands during an armed grace window', () => { + // busy → turn complete (arms grace) → spontaneous removal before expiry + // → one immediate idle emission (the pending bell survives death). +}) + +it('queue evidence does not suppress the death bell', () => { + // busy + queue evidence, then spontaneous removal → one emission. +}) +``` + +- [ ] **Step 2: Red** + +Run: `npm run test:vitest -- test/unit/server/coding-cli/truly-idle-emitter.test.ts` +Expected: FAIL (type + behavior). + +- [ ] **Step 3: Implement the emitter + threading** + +1. `truly-idle-emitter.ts` remove loop (`:102-109`) becomes: + +```ts +const spontaneous = new Set(change.spontaneousExitRemovals ?? []) +for (const terminalId of change.remove ?? []) { + const state = this.states.get(terminalId) + if (!state) continue + // Engagement for the death bell (decision 3): CONFIRMED busy or an armed + // grace window. phase 'pending' is input-only (the Enter that executes a + // human /quit looks like a prompt submit) and NEVER counts. Task 12 ORs + // in approval waits via change.approvalPendingRemovals. + const engaged = (state.busy && !state.pending) || state.graceTimer !== undefined + this.cancelGrace(state) + this.states.delete(terminalId) + if (spontaneous.has(terminalId) && engaged) { + // Spontaneous process death while working: ring immediately — a dead + // process emits nothing further, and a queued prompt will never run. + // Requested closes (tab close / terminal.close / shutdown) never ring. + this.emit('idle', { terminalId, at: this.now(), reason: 'grace' } satisfies TrulyIdleEvent) + } +} +``` + +Extend the `TrulyIdleActivityChange` type with `spontaneousExitRemovals?: string[]` and update the doc comment at `:57-58` (see Task 13 language). +2. `terminal-registry.ts` (internal registry EventEmitter only — the client-facing `safeSend({ type: 'terminal.exit', ... })` payloads are wire frames and MUST NOT change). A blanket `spontaneous: true` at `:1527` is WRONG (audit A7): `shutdownGracefully()` (`:4908`) SIGTERMs ptys directly WITHOUT setting `status='exited'` (`:4955-4964`), so its exits flow through `finishTerminalPtyExit` normally and would ring death bells on server shutdown — a requested stop that must stay silent. Instead: + - In `finishTerminalPtyExit` (`:1504-1535`), capture `const requestedClose = record.codexRecoveryFinalClose === true` as the FIRST statement — it must be read BEFORE the function's own `this.markCodexRecoveryFinalClose(record)` call at `:1509`, which marks EVERY finishing record and would erase the signal. Then add `spontaneous: !requestedClose` to the internal emit at `:1527-1531`. + - Add `spontaneous: false` to the kill-path emit at `:4091-4095`. + - Why this flag works: `markCodexRecoveryFinalClose` (`:3574-3576`, field `codexRecoveryFinalClose?: boolean` on `TerminalRecord` at `:644`) is set by every REQUESTED close BEFORE exit dispatch — `kill()` at `:4069` (tab close, `terminal.close`, `remove()`, idle reaper, `killAndWait`) and `shutdownGracefully()` at `:4957` — and, despite the codex-prefixed name, both call it unconditionally on ANY record, so claude/amplifier terminals are covered too. + - VERIFY at implementation time: `grep -n markCodexRecoveryFinalClose server/terminal-registry.ts` must show only `:1509` (the finalizer itself), `:4069`, and `:4957` as callers — i.e. no genuinely-spontaneous path sets the flag before exit dispatch. If a new caller has appeared that breaks this invariant, introduce a dedicated `requestedClose` record flag set at `:4069`/`:4957` instead and emit `spontaneous: !record.requestedClose`. +3. Wirings: widen `onExit` to `(event: { terminalId: string; spontaneous?: boolean })` and pass through: `tracker.noteExit({ terminalId: event.terminalId, at: now(), spontaneous: event.spontaneous === true })` — codex wiring at `codex-activity-wiring.ts:82-84`, and the claude/amplifier wirings' identical handlers. +4. Trackers (codex `:176-179`/`:640`, claude `:169`/`:200`, amplifier `:343`/`:387`): `noteExit` forwards to `removeState(terminalId, { spontaneousExit: input.spontaneous === true })`; `removeState` includes `spontaneousExitRemovals: [terminalId]` on the emitted `'changed'` payload when the flag is set. `unbindTerminal` (and every other `removeState` caller) stays flag-less. Opencode tracker: DELIBERATELY unchanged (decision 3) — its record-exists⇔busy signal would over-ring; noted as follow-up in Task 13. + +- [ ] **Step 4: Add tracker-level tests + integration check** + +One test per tracker (codex/claude/amplifier): `noteExit({ spontaneous: true })` emits `'changed'` carrying `spontaneousExitRemovals`; `unbindTerminal` does not. + +Add the registry-level shutdown-silence red test (audit A7 — write it RED first, mirror the existing terminal-registry vitest harness; locate with `grep -rl "shutdownGracefully" test/`): create a fake-pty terminal, subscribe to the internal `'terminal.exit'` event, call `shutdownGracefully()`, and assert every emitted exit payload carries `spontaneous: false` (server shutdown is a requested stop — no death bell may ring for it). Then run the integration suite that exercises the real WsHandler idle path: + +Run: `npm run test:vitest -- test/unit/server/coding-cli test/server/ws-terminal-idle.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/terminal-registry.ts server/coding-cli test/unit/server/coding-cli test/server/ws-terminal-idle.test.ts +git commit -m "feat(server): ring terminal.idle on spontaneous process death while engaged (Node)" +``` + +--- + +### Task 12: Node — approval sniffing + approval pause semantics + +**Files:** +- Modify: `server/coding-cli/codex-app-server/remote-proxy.ts` (`handleUpstreamMessage` `:457-511`; `forwardClientFrame` `:831-840`; handler sets `:126-131` + `on*` subscribers `:263-273`; connection state) +- Modify: `server/terminal-registry.ts` (sidecar subscriptions next to `:1935`/`:1949`) +- Modify: `server/terminal-stream/registry-events.ts` (new event types next to `:38-62`) +- Modify: `server/coding-cli/codex-activity-wiring.ts` (subscribe next to `:90-91`) +- Modify: `server/coding-cli/codex-activity-tracker.ts` (approval state + methods, `attention.boundary` event) +- Modify: `server/coding-cli/truly-idle-emitter.ts` `wireTrulyIdleEmitter` (`:182-202`) +- Tests: remote-proxy test file (locate: `grep -rl "handleUpstreamMessage\|CodexRemoteProxy" test/unit/server/coding-cli`), tracker + emitter test files + +**Interfaces:** +- Consumes: Rust Task 6/7 designs (mirror them); `scanJsonRpcEnvelope` (`json-rpc-envelope.ts:6-23`). +- Produces: + +```ts +// remote-proxy.ts +export type CodexApprovalRequestEvent = { requestId: string; method: string; threadId?: string } +onApprovalRequested(handler: (event: CodexApprovalRequestEvent) => void): () => void +onApprovalResolved(handler: (event: { requestId: string }) => void): () => void + +// registry-events.ts +export type CodexApprovalRequestedEvent = { terminalId: string; threadId?: string; requestId: string; at: number } +export type CodexApprovalResolvedEvent = { terminalId: string; requestId: string; at: number } +// registry event names: 'codex.approval.requested' / 'codex.approval.resolved' + +// codex-activity-tracker.ts +onApprovalRequested(event: CodexApprovalRequestedEvent): void +onApprovalResolved(event: CodexApprovalResolvedEvent): void +// emits 'attention.boundary' { terminalId, at } (arms the gate, no turn.complete) + +// truly-idle-emitter.ts — death-bell engagement extension (decision 3, +// Node mirror of Rust has_pending_approvals): +// TrulyIdleActivityChange gains approvalPendingRemovals?: string[] — +// terminals removed while their pending-approval set was non-empty. The +// emitter's spontaneous-removal ring condition becomes: +// spontaneous && (engaged || approvalPending.has(terminalId)) +// so a pane blocked on an approval whose process dies rings even after its +// 2s boundary already rang (busy=false, no armed timer). +``` + +- [ ] **Step 1: Write failing proxy tests (mirror Task 6's ELEVEN cases)** + +In the existing remote-proxy vitest suite, using its synthetic-frame harness: (1) approval request (id+method in set) → `onApprovalRequested` fires with `{ requestId: '41', method, threadId: 'thread-1' }` AND the frame relays to the client verbatim; (2) `item/tool/call` → no event; (3) client response `{id: 41, result}` → `onApprovalResolved` + forwarded upstream; (4) unknown response id → nothing; (5) missing `params.threadId` → `threadId: undefined`; (6) legacy `execCommandApproval` with `params.conversationId` → `threadId` populated from it (decision 7); (7) client `{id: 41, error}` also resolves (decision 5a); (8) a client frame with BOTH id and method never resolves (decision 5d); (9) upstream notification `serverRequest/resolved` `{threadId, requestId}` resolves and relays (decision 5c); (10) upstream connection teardown/restart emits `onApprovalResolved` for every pending id (decision 5b); (11) an unrecognized server→client request method (e.g. `some/future/method`) emits no event and relays (decision 6 — debug log only). Const set `CODEX_APPROVAL_REQUEST_METHODS` = the same 7 methods as Rust, plus the same `AUTOMATED`/`LEGACY` companion sets (decision 6/7). + +- [ ] **Step 2: Red → implement the proxy** + +In `handleUpstreamMessage`'s `if (id !== undefined)` branch (`:471`), FIRST read `envelope.method`; if present → it is a server→client request: if in the approval set, record `connection.pendingServerApprovals.set(id, true)` (new `Map`/`Set` beside `pendingMethods`), best-effort parse `params.threadId` — or `params.conversationId` for the legacy methods — when `frame.data.length <= MAX_FULL_PARSE_BYTES`, emit via the new handler set (canonicalize `requestId = String(id)`), then relay verbatim and return; if the method is in NEITHER the approval set nor the automated set, `log.debug({ method }, 'unrecognized codex server->client request method')` and relay. In `forwardClientFrame` (`:831-840`): when `request.id !== undefined && request.method === undefined && connection.pendingServerApprovals.delete(request.id)` → emit resolved (`method === undefined` is MANDATORY — decision 5d; the frame may carry `result` OR `error`, both resolve). In the upstream NOTIFICATION path (method, no id): `serverRequest/resolved` → parse `params.requestId` (bounded by `MAX_FULL_PARSE_BYTES`), delete from `pendingServerApprovals`, emit resolved when it was pending, relay verbatim. On upstream connection close/teardown/re-dial (mirror how `pendingMethods` is drained there): drain `pendingServerApprovals` and emit resolved for every id — the restarted app-server's id counter begins at 0 again and stale ids would collide. Add the two handler sets + `on*` subscription methods mirroring `:263-273`. + +Run: `npm run test:vitest -- ` → PASS. + +- [ ] **Step 3: Write failing tracker tests (mirror Task 7's cases)** + +`onApprovalRequested` while busy → phase idle + one `'attention.boundary'` emission, zero `'turn.complete'`; resolved → busy again; resolved with no prior busy → stays idle; foreign-thread request ignored; missing threadId accepted; queued submit does not block the boundary; turn completion clears pending approvals (late resolve is a no-op). Plus the audit-A9 lane-interference cases (mirror Task 7's Rust tests): a `reconcileProjects` sweep whose newest snapshot event is the turn's `task_started`, landing during a pending approval, does NOT flip the public phase to `'busy'` (anchors still fold; `resumeBusyAfterApproval` set; resolve restores `'busy'`); a `refreshExistingBinding` `reason === 'resume'` re-announce during a pending approval likewise does not promote (`:549-556` promotes idle→busy with no edge-trigger today); resolve normalizes pending-submit input state planted by a mid-pause Enter (a following completed turn records normally, no `'pending'` re-arm). And the death-engagement case: `removeState` with a non-empty pending-approval set emits `'changed'` carrying `approvalPendingRemovals: [terminalId]` (read BEFORE the state is deleted). + +- [ ] **Step 4: Red → implement tracker + wiring + emitter arm** + +1. Tracker: add `pendingApprovals: Set` + `resumeBusyAfterApproval: boolean` to `CodexTerminalActivity` (`:32-56` area); implement the two methods mirroring the Rust bodies in Task 7 Step 3 (thread guard against `state.sessionId`; phase demotion to `'idle'`; `this.emit('changed', ...)` + `this.emit('attention.boundary', { terminalId, at })`; resolve restores `'busy'` when the set empties and the flag is set, and normalizes pending-submit input state planted mid-pause — mirror Rust Task 7 Step 3.8). Clear both in the accepted paths of `onTurnCompleted`, in `noteExit`, and on rebind — in `noteExit`/`removeState` read the pending set BEFORE deleting state and include `approvalPendingRemovals: [terminalId]` on the emitted `'changed'` payload when it was non-empty (death-bell engagement, decision 3). Lane-interference guards (decision 8 / audit A9): in `reconcileProjects`' busy promotion (the newest-`task_started` compare at `:317-325`) and in `refreshExistingBinding`'s idle→busy promote (`:549-556`), when `state.pendingApprovals.size > 0`, fold the anchors but set `resumeBusyAfterApproval = true` instead of flipping `state.phase` to `'busy'` (no busy upsert reaches the emitter mid-pause). +2. `terminal-registry.ts`: next to the turn subscriptions (`:1935`/`:1949`) add + +```ts +sidecar.onApprovalRequested?.((event) => { + this.emit('codex.approval.requested', { + terminalId: record.terminalId, + threadId: event.threadId, + requestId: event.requestId, + at: Date.now(), + } satisfies CodexApprovalRequestedEvent) +}) +sidecar.onApprovalResolved?.((event) => { + this.emit('codex.approval.resolved', { + terminalId: record.terminalId, + requestId: event.requestId, + at: Date.now(), + } satisfies CodexApprovalResolvedEvent) +}) +``` + +(adapt `record`/subscription-disposal to the local idiom of the turn subscriptions; widen the sidecar type where `codexAppServer.sidecar` is declared). +3. `codex-activity-wiring.ts`: subscribe `'codex.approval.requested'`/`'codex.approval.resolved'` → `tracker.onApprovalRequested`/`onApprovalResolved` (register + dispose like `:90-91`). +4. `wireTrulyIdleEmitter` (`truly-idle-emitter.ts:182-202`): also bridge the boundary — + +```ts +const onAttentionBoundary = (event: { terminalId: string; at: number }) => { + emitter.noteTurnComplete(event) // arms the same grace window; no turn.complete frame is involved +} +tracker.on('attention.boundary', onAttentionBoundary) +// + matching tracker.off in dispose() +``` + +(All four trackers pass through this wiring; only the codex tracker ever emits the event — a no-op for the rest.) +5. Emitter death-engagement extension: `TrulyIdleActivityChange` gains `approvalPendingRemovals?: string[]`; in the Task 11 remove loop, ring when `spontaneous.has(terminalId) && (engaged || approvalPending.has(terminalId))` (where `approvalPending = new Set(change.approvalPendingRemovals ?? [])`). +6. Emitter-level tests: approval boundary arms grace → after `TERMINAL_IDLE_GRACE_MS` one `'idle'`; a `'changed'` busy upsert within the grace (the resolve path) cancels it → silent; a spontaneous removal carrying `approvalPendingRemovals` rings once even when the terminal is not busy and no timer is armed (the approval bell already rang — the pane was still blocked on a human when it died). + +- [ ] **Step 5: Green + commit** + +Run: `npm run test:vitest -- test/unit/server/coding-cli` +Expected: PASS. + +```bash +git add server/coding-cli server/terminal-registry.ts server/terminal-stream/registry-events.ts test/unit/server/coding-cli +git commit -m "feat(server): codex approval-request pauses ring once and resolve back to busy (Node)" +``` + +--- + +### Task 13: Docs — deliberately update the `terminal.idle` semantics contract + +**Files:** +- Modify: `shared/ws-protocol.ts:199-209` (doc comment ONLY — `TerminalIdleSchema` at `:210-215` must be byte-identical) +- Modify: `crates/freshell-activity/src/codex.rs:336-340`, `:943-949` (and the test doc at `:1237-1244` if not already updated in Task 3), `crates/freshell-activity/src/idle.rs:1-24` (module doc), `server/coding-cli/truly-idle-emitter.ts:57-58` (if not already updated in Task 11) + +**Interfaces:** +- Consumes: the final behavior from Tasks 1–12. +- Produces: documentation that states the NEW policy: never after HUMAN-REQUESTED stops; emitted on all other stopping causes. + +- [ ] **Step 1: Replace the ws-protocol doc comment** + +Replace `shared/ws-protocol.ts:199-209` (keep the schema untouched): + +```ts +/** + * Attention edge for terminal-mode CLI panes (claude/codex/opencode/amplifier): + * "the agent stopped making progress and you don't already know". Emitted once + * per attention transition. Rings for: completed turns (after a grace window + * with no new activity and no detectable queued prompt), FAILED turns, + * non-human rollout abort reasons (forward-compatible policy — no live codex + * <= 0.147 emits one), spontaneous process death while ENGAGED (confirmed + * turn, armed grace window, or pending approval; immediate — no grace), and + * approval-request pauses (managed codex only; unmanaged/PTY-only codex has + * no approval signal). NEVER emitted after a HUMAN-REQUESTED stop: + * Esc/interrupt (turn.status 'interrupted', abort reason + * 'interrupted'/'replaced'), slash-command quits from an idle pane + * (input-only pending state never counts as death-bell engagement), tab + * close, terminal.close, or server shutdown (including graceful-shutdown + * SIGTERMs). Subagent completions inside a running turn never produce it. + * Queued input suppresses completion bells (work continues) but NOT death + * bells (a dead process never runs the queue) and NOT approval bells (still + * blocked on the human). This is the ONLY edge the client rings/shades on + * for terminal CLI panes ('terminal.turn.complete' stays informational). + * + * Pinned wire contract shared with the Rust server port - do not change + * unilaterally: { terminalId, at (server epoch ms), reason: 'grace' | 'queue-empty' }. + */ +``` + +- [ ] **Step 2: Update the Rust doc anchors** + +Rewrite the three `codex.rs` comments and the `idle.rs:1-24` module doc so no comment still claims "never emitted after crash/interrupt/exit". The canonical sentence to use: *"terminal.idle is never emitted after a HUMAN-REQUESTED stop; it IS emitted for failed turns, non-human abort reasons (forward-compatible — none emitted at codex <= 0.147), spontaneous death while engaged, and approval pauses (shared/ws-protocol.ts terminal.idle doc)."* In `idle.rs` note that the exit-death bell is emitted by the HUB directly (the gate itself still never emits for a removed terminal) and that `is_engaged` deliberately excludes the input-only Pending state. + +Also record the ACCEPTED RESIDUALS + scoped follow-ups where the docs discuss coverage (these are decisions, not deferrals — audit dispositions): +1. Mid-turn `/quit`/Ctrl+D: codex sends NO `Op::Interrupt` on Ctrl+D, and the TUI's ~2s shutdown budget can exit before the abort evidence lands — may ring on a human force-quit of a visibly-working pane. No in-band discriminator exists; accepted. +2. Out-of-band `kill -9`/SIGTERM of the CLI by the user: observationally identical to a crash — rings; accepted. +3. Claude/amplifier Enter-executed quits (`/exit`): input-driven Busy is those trackers' ONLY turn evidence, so it stays death-bell engagement; same residual family as (1); accepted. +4. Node 120s busy-deadman swallow (audit A17): a recovery window longer than `BUSY_DEADMAN_MS` demotes busy→unknown and `unknown` never arms the death bell — a MISSED bell (never a false ring); accepted. +5. A SENT approval request auto-resolved server-side slower than ~2s rings once (decision 5); accepted. +6. Node opencode death bells: deliberately excluded (noisy busy proxy) — follow-up. Rust opencode: no hub tracker exists — N/A. +7. Unmanaged/PTY-only codex has no approval signal — documented limitation. + +- [ ] **Step 3: Verify contract freeze is untouched and everything compiles** + +Run: `npm run test:port` +Expected: PASS with NO changes under `port/contract/` (`git status --short port/contract/` prints nothing). +Run: `cargo test -p freshell-activity --doc 2>/dev/null; cargo build -p freshell-activity` +Expected: builds clean. + +- [ ] **Step 4: Commit** + +```bash +git add shared/ws-protocol.ts crates/freshell-activity/src/codex.rs crates/freshell-activity/src/idle.rs server/coding-cli/truly-idle-emitter.ts +git commit -m "docs: terminal.idle rings on all non-human stops (contract comment update)" +``` + +--- + +### Task 14: Full verification sweep + +**Files:** +- No new files; fixes only if gates fail. + +**Interfaces:** +- Consumes: everything above. +- Produces: green gates proving the branch is review-ready. + +- [ ] **Step 1: Rust gates** + +```bash +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test -p freshell-activity -p freshell-ws -p freshell-codex -p freshell-terminal -p freshell-sessions +``` + +Expected: all PASS. Fix `cargo fmt --all` / clippy findings and re-run. + +- [ ] **Step 2: Node gates** + +```bash +npm run test:vitest -- test/unit/server/coding-cli test/server/ws-terminal-idle.test.ts +npm run test:port +npm run check +``` + +Expected: all PASS; `git status --short port/contract/` prints nothing (wire shape unchanged). + +- [ ] **Step 3: Behavior spot-audit (read-only)** + +Re-read the goal + locked decisions in this plan's header against the test names now in the tree — every row must map to at least one green test on each server: +1 completed rings / queued suppresses (pre-existing), 2 failed rings + failed+queued silent, 3 interrupted silent, 4 abort reasons (forward-compatible policy — unknown reason rings, interrupted/replaced/missing silent), 5 death bells (spontaneous CONFIRMED-busy rings once / input-pending slash-quit silent / requested kill silent / shutdownGracefully silent / idle silent / queue no-suppress / pending-approval death rings), 6 approvals (rings once / result AND error responses resolve / serverRequest-resolved resolves / restart clears pendings / method-present frames never resolve / resolve→busy / reconcile-mid-pause does not cancel / queue no-suppress / legacy conversationId scoping), 7 deadman untouched (pre-existing tests still green; the >120s recovery swallow is a documented residual, not a test target). + +- [ ] **Step 4: Final commit (only if fixes were needed)** + +```bash +git add -A ':!docs/plans' +git commit -m "test: verification sweep fixes for the attention-bell causes" +``` + +Do NOT open a PR — that requires explicit user approval. + +--- + +## Self-review record + +(Re-run 2026-08-01 after folding in the load-bearing-assumption audit — ledger + V1–V8 reports under `.worktrees/.the-usual-logs/codex-attention-bell/`.) + +- **Spec coverage:** cause rows → tasks: (0) baseline repair → Task 0 (inherited auto_resume_e2e regression — audit A20 — fixed before any feature work); (1) completed keep = untouched + re-verified in Task 14; (2) failed → Tasks 1, 8 (today's ONLY live failure cause — audit A12/A14); (3) interrupted keep = pinned tests retained; (4) abort reason → Tasks 3, 10 (forward-compatible policy plumbing, rationale corrected — no live producer); (5) death bells → Tasks 4, 5, 7, 11, 12 with the corrected engagement ontology (confirmed busy OR armed deadline OR pending approvals; input-only pending excluded — audit A6/A10/A17), requested-close discrimination via `codexRecoveryFinalClose` captured at finalizer entry (audit A7), and read-before-teardown ordering pinned by test (audit A17/A8); (6) approvals → Tasks 6, 7, 12 with the full resolution set (result|error responses, `serverRequest/resolved`, restart clears — audit A4/A5), legacy `conversationId` scoping (audit A3), lane-interference guards + resume-busy flag (audit A9), and unknown-method drift logging (audit A2); (7) deadman unchanged. Minors: (a) Tasks 2, 9; (b) Task 8. Docs incl. residuals: Task 13. Checks/freeze: Task 14. Every ringing cause AND every mandated silence (slash-quit, shutdown, requested kill, interrupted, mid-pause reconcile) maps to at least one named red test per server. +- **No silent deferrals:** every ringing cause is proven by hub/emitter-level tests emitting the REAL `terminal.idle` frame through production code paths (no stubs standing in for behavior). All exclusions are recorded DECISIONS with audit dispositions, listed in Task 13: mid-turn `/quit`/Ctrl+D and out-of-band `kill -9` (no in-band discriminator — accepted false-ring residual), claude/amplifier Enter-quits (same family), Node 120s busy-deadman swallow (missed bell only, never false — audit A17), slow (`>2s`) server-side auto-resolutions (one bell, low severity — audit A4), Node opencode death bell (noisy busy proxy — follow-up), unmanaged-codex approvals (no signal — documented). +- **Placeholder scan:** no TBDs; steps that modify unseen code bodies cite exact file:line anchors plus the sibling idiom to mirror, with the assertion/behavior contract spelled out in full; new audit-sourced anchors were re-verified against the worktree (`terminal-registry.ts:1504/:1509/:1527/:3574/:4069/:4908/:4955-4964/:4957/:644`; `idle.rs:49-60/:91-104/:124-128/:142-144`; `truly-idle-emitter.ts:20-42/:86-110`) or cited to the exact codex tag (v1.rs:126-158, common.rs:1701, v2/notification.rs:53-56, thread_processor.rs:3426/:3528, outgoing_message.rs:283, message_processor.rs:756-758 — all @0.146.0 unless noted). +- **Type consistency:** `spontaneous` (Rust field + Node event field), `latest_turn_aborted_reason`/`latestTurnAbortedReason`, `abort_reason_is_human`/`abortReasonIsHuman`, `AttentionBoundary`/`'attention.boundary'`, `is_engaged` (same exclusion semantics both sides: Rust `(busy && !pending) || deadline`, Node `(busy && !pending) || graceTimer`), `pending_approvals`/`pendingApprovals`, `resume_busy_after_approval`/`resumeBusyAfterApproval` (the lane-deferral flag), `has_pending_approvals` (Rust accessor) ↔ `approvalPendingRemovals` (Node changed-payload field — the emitter is event-fed, so the set membership travels on the removal payload instead of an accessor), `note_approval_requested/resolved` ↔ `onApprovalRequested/Resolved`, `APPROVAL_REQUEST_METHODS`/`CODEX_APPROVAL_REQUEST_METHODS` with matching `AUTOMATED_*`/`LEGACY_*` companion sets, `ApprovalResolved`/`'codex.approval.resolved'` (also emitted for error responses, `serverRequest/resolved`, and restart drains), `spontaneousExitRemovals` are used with the same names and shapes across all tasks. diff --git a/docs/plans/2026-08-01-codex-turn-thread-scope.md b/docs/plans/2026-08-01-codex-turn-thread-scope.md new file mode 100644 index 000000000..8acacc5c1 --- /dev/null +++ b/docs/plans/2026-08-01-codex-turn-thread-scope.md @@ -0,0 +1,1771 @@ +# Codex Turn Thread-Scope Implementation Plan + +> **For agentic workers:** This plan is executed task-by-task by the +> workflow's execute stage: a fresh implementer per task, with a spec + +> quality review after each task. Steps use checkbox (`- [ ]`) syntax +> for tracking. + +**Goal:** Stop freshell from showing Codex panes green and ringing the bell (`terminal.idle`) while Codex is still working, by making turn-completion detection thread-scoped, status-guarded, and turn-id-deduplicated on both servers (Rust production + Node parity). + +**Architecture:** The codex app-server relays `turn/started`/`turn/completed` JSON-RPC notifications for EVERY thread on a shared connection (sub-agents, review threads, forks). Both servers currently discard the notification's `threadId`/`turnId`/`status` at the routing layer, so a sub-agent's `turn/completed` flips the tracker Busy→Idle and rings the bell mid-parent-turn (verified by live spike scenario D, `/tmp/codex-spike/spike-d.log`). The fix plumbs identity through the event path and puts three guards in the trackers: (1) ignore turn events whose thread id doesn't match the terminal's bound codex thread, (2) only `status == 'completed'` records a bell-worthy completion (`interrupted`/`failed` clear the phase silently; `inProgress` is a no-op), (3) a completion for a different turn id than the in-flight one is a stale echo and a no-op. The rollout-reconcile lane gets the same status rule: `turn_aborted` clears phase without recording a completion. + +**Tech Stack:** Rust (crates `freshell-activity`, `freshell-ws`, `freshell-codex`; tokio, serde_json), TypeScript Node server (`server/`), vitest. + +## Global Constraints + +- Work in the existing worktree `/home/dan/code/freshell/.worktrees/codex-turn-thread-scope` on branch `fix/codex-turn-thread-scope` (branched from `origin/main` @ `35fbf1357`). All commands below run from that worktree root. +- Strict Red-Green-Refactor TDD: write the failing test first, watch it fail, make it pass, never skip the refactor or the test. +- Do NOT create a PR without explicit user approval. Never restart or deploy to the self-hosted server (building is fine). Do not touch the running production server on port 3002 or any live codex sidecars. +- Wire contract is FROZEN (`WS_PROTOCOL_VERSION=7`): this plan changes NO wire shapes. `shared/ws-protocol.ts` zod schemas must not change (the `terminal.idle` doc comment at `shared/ws-protocol.ts:199-208` already promises the post-fix semantics — "Never emitted after crash/interrupt/exit; subagent completions inside a running turn never produce it" — so no doc edit is needed either). `server/terminal-stream/registry-events.ts` is a server-INTERNAL event type, not wire-visible. If you believe a wire shape must change, STOP — that is out of scope; the contract-generation workflow in `port/contract/README.md` must not be triggered by this plan. +- Keep the IdleGate 2s grace untouched: `IDLE_GRACE_MS = 2_000` (`crates/freshell-activity/src/idle.rs:30`) and `TERMINAL_IDLE_GRACE_MS = 2_000` (`server/coding-cli/truly-idle-emitter.ts:1`) stay exactly as they are. +- No client changes: `terminal.idle` remains the only bell/green edge. +- Vitest ONLY via the coordinator: `npm run test:vitest -- --config config/vitest/vitest.server.config.ts --run`. Raw `npx vitest` is forbidden (AGENTS.md). Broad runs (`npm run check`) go through the shared coordinator gate — wait for the gate, never kill foreign holders. +- Worktree prerequisite (ledger A14): the worktree must have its own `node_modules` (`npm ci` — already performed during plan validation). The `freshell-ws`/`freshell-codex` Rust integration binaries spawn the server, which resolves the worktree-local `tsx`; without the install ~20 integration binaries fail with `Unable to resolve MCP dependency "tsx"`. Baseline at HEAD was verified green after install: 53/53 Rust test binaries (800 tests), 136/136 targeted vitest tests. +- Rust targeted tests: `cargo test -p `. +- Commit author email must be the verified `3732858+danshapiro@users.noreply.github.com`. +- `.kata.toml`: this plan does not modify it; if any step somehow does, commit it. +- `README.md` is the only end-user markdown doc — this plan file under `docs/plans/` is a working doc and is fine; create no other docs. + +## Design decisions locked by this plan + +1. **Guards live in the trackers** (`crates/freshell-activity/src/codex.rs`, `server/coding-cli/codex-activity-tracker.ts`), not in the routers — the trackers are pure, synchronous, and densely unit-tested; the routers/wiring stay dumb pass-throughs that merely stop discarding the payload. +2. **Unbound window = ignore.** Before a terminal has a bound codex thread id (`session_id`/`sessionId` is `None`/state absent), proxy/app-server turn events are ignored entirely (no phase change, no completion). Rationale (validated — load-bearing ledger A3): on the Rust managed path the proxy's identity gate HOLDS client `turn/start`/`thread/fork` frames until candidate adoption has bound the pane (`crates/freshell-codex/src/remote_proxy.rs:601-613`; release after adoption in `crates/freshell-ws/src/codex_proxy_route.rs:127-146`), so the unbound window is structurally empty there. On Node the fresh-create path binds only via rollout proof, which is skipped until the first turn completes (`server/terminal-registry.ts:2669-2672`, bind at `:2904`) — the ENTIRE first fresh turn runs pre-bind and the tracker has no state for it (state is created on bind), so "ignore" is byte-identical to today's behavior. The justification is therefore STATUS-QUO PARITY on Node, not fallback-lane coverage: the first fresh Node turn is dark today and stays dark (pre-existing, out of scope); the rollout lane reconciles from bind onward. This is the "simplest correct behavior" the spec asks to choose and document. +3. **Status guard:** only `Some("completed")` — or an ABSENT status (older protocol forms; avoids panes hanging busy) — records a bell-worthy completion. `interrupted`/`failed` clear the busy phase without recording. `inProgress` is a strict no-op (not a turn end at all). +4. **Turn-id dedupe:** the tracker remembers the in-flight proxy turn id (set on `turn/started`). A completion carrying a DIFFERENT turn id (both present) is a stale echo of an already-closed turn — a no-op by construction. When either id is absent, fall through to existing behavior. The existing cross-lane swallow flags (`swallow_next_bel`, `swallow_next_proxy_complete`, `swallow_next_reconcile_clear`) are KEPT unchanged — they dedupe across the disjoint clock domains (PTY BEL / rollout / proxy) that turn ids cannot reach. The five pinned swallow tests must stay green. +5. **Abort-shaped clears claim the turn key.** When a clear does not record a completion (abort/interrupt/failed), it still writes `last_emitted_turn_key`/`lastEmittedTurnKey` so a later echo of the same physical turn cannot mint a completion. +6. **Tie-break:** when `latest_task_completed_at == latest_turn_aborted_at`, the clear counts as a real completion (a genuine `task_complete` at the same instant still rings). Abort wins only when strictly newer. (Validated — ledger A8: rollout terminal events are one-per-turn and `task_complete` is never co-written for an interrupted turn, so ties are theoretical; the rule direction is safe.) +7. **Rebind clears in-flight proxy-turn state (Rust only).** Fork/resume rebinds arrive from the async disk fork-watch lane with NO ordering guarantee vs proxy turn events (`crates/freshell-ws/src/codex_proxy_route.rs:88-91` explicitly defers fork rebinds to it). `bind_session` and `track_terminal`'s rebind branch must clear `current_proxy_turn_id` AND `last_proxy_started_at` whenever the bound id changes (ledger A9, falsified without this) — otherwise the child thread's first `turn/completed` is misclassified as a stale echo (stuck busy until reconcile) or collides on `last_emitted_turn_key`. Node needs nothing: `bindTerminal` builds a fresh state literal on rebind (`server/coding-cli/codex-activity-tracker.ts:139-152`). A candidate-SET thread match (accepting any owned/forked thread) was considered to close the fork window and rejected: sub-agent threads ARE forks (spike D rollout: `forked_from_id` = parent thread), so set-matching would reintroduce the exact bug this plan fixes. Residual fork-window drops (child turn events landing pre-rebind are ignored by the thread guard) are covered by the rollout-reconcile lane after rebind. + +### Residual risks (validated and accepted — see load-bearing ledger) + +- **Hard kill / crash can leave a turn with NO `turn_aborted`** in the rollout (openai/codex#12843): pre-existing gap, unchanged by this plan; the busy-deadman force-read lane self-heals. Out of scope. +- **Id casing:** codex emits lowercase thread ids and every managed bind source takes the id verbatim from the wire or rollout `payload.id` (4/4 spike rollouts: filename UUID == `payload.id` == wire threadId). A hand-supplied UPPERCASE resume id would bind but never match the strict-equality guard — accepted; no normalization added. +- **Detached review threads** (separate thread, no parent turn) exist in the codex protocol but are not delivered by the 0.146 TUI (`/review` is hardcoded inline, running on the parent thread). If a future codex version flips that default, strict thread-equality would leave review work invisible — revisit on codex upgrades. +- **`turn/completed` status is a required field with vocabulary exactly `completed|interrupted|failed|inProgress`** at codex 0.146 (`thread_data.rs:246`); the absent-status fallback in decision #3 exists only for older/other protocol forms. + +## File Structure + +| File | Role in this plan | +|---|---| +| `crates/freshell-activity/src/codex.rs` | Tracker: thread guard, status guard, turn-id dedupe, unbound window, abort de-chime, `claim_turn_key_if_idle`, all pure unit tests (Tasks 1–2) | +| `crates/freshell-ws/src/activity.rs` | Hub: widened `HubEvent::CodexProxyTurn` + `note_codex_proxy_turn`, dispatch arm, hub-level tests (Tasks 2–3) | +| `crates/freshell-ws/src/codex_proxy_route.rs` | Router: stop discarding `TurnEventParams`, extract status via `freshell_codex::turn_status`, router-level test (Tasks 2–3) | +| `server/terminal-stream/registry-events.ts` | Node: widened `CodexTurnStartedEvent`/`CodexTurnCompletedEvent` (Task 4) | +| `server/terminal-registry.ts` | Node: emission site carries threadId/turnId/status; `codexTurnStatus` helper (Task 4) | +| `server/coding-cli/codex-activity-tracker.ts` | Node tracker: guards + `claimTurnKeyIfIdle` + reconcile abort de-chime (Tasks 5–6) | +| `test/unit/server/terminal-registry.codex-sidecar.test.ts` | Node: emission payload pin (Task 4) | +| `test/unit/server/coding-cli/codex-activity-tracker.test.ts` | Node: tracker behavior tests (Tasks 5–6) | +| `test/unit/server/coding-cli/codex-activity-wiring.test.ts` | Node: wiring fixtures updated to the new event shape (Task 5) | + +`server/coding-cli/codex-activity-wiring.ts` needs NO code change (it is a 1:1 pass-through; the widened event flows through `(event: CodexTurnStartedEvent) => tracker.onTurnStarted(event)` untouched). `crates/freshell-codex` needs NO code change (`TurnEventParams` already carries `thread_id`/`turn_id`, and `status` is readable via the exported `freshell_codex::turn_status(¶ms)`; proven by `crates/freshell-codex/tests/remote_proxy_relay.rs:464-503`). + +--- + +### Task 1: Rust rollout lane — `turn_aborted` clears without recording a completion + +**Files:** +- Modify: `crates/freshell-activity/src/codex.rs` (reconcile clear branch ~`:311-385`; helpers `transition_pending_after_turn_clear`/`transition_after_turn_clear` ~`:619-674`; `consume_turn_complete_signal` ~`:599-617`; test `reconcile_turn_aborted_also_clears_and_completes` ~`:1113-1125`) + +**Interfaces:** +- Consumes: existing `record_completion_if_idle(state, turn_key, at, ledger, completions)`, `max_ts(a, b)`, `CodexTaskEvents`. +- Produces (Task 2 depends on these): + - `fn claim_turn_key_if_idle(state: &mut TerminalActivity, turn_key: Option)` + - `fn transition_pending_after_turn_clear(state: &mut TerminalActivity, at: i64, ledger: &mut TurnCompletionLedger, completions: &mut Vec<(Option, i64, i64)>, record: bool)` + - `fn transition_after_turn_clear(state: &mut TerminalActivity, at: i64, ledger: &mut TurnCompletionLedger, completions: &mut Vec<(Option, i64, i64)>, record: bool)` + +- [ ] **Step 1: Rewrite the pinned abort test and add the tie-break test (RED)** + +In `crates/freshell-activity/src/codex.rs`, REPLACE the test `reconcile_turn_aborted_also_clears_and_completes` (currently at ~`:1113-1125`) with: + +```rust + #[test] + fn reconcile_turn_aborted_clears_without_completing() { + // SEMANTIC CHANGE (kata: codex-turn-thread-scope). This test replaces + // `reconcile_turn_aborted_also_clears_and_completes`, which pinned the + // old buggy behavior. shared/ws-protocol.ts:199-208 pins terminal.idle + // as "never emitted after crash/interrupt/exit" -- an Esc-interrupt + // (`turn_aborted`) must return the pane to idle WITHOUT recording a + // bell-worthy completion. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", None, 0); + tracker.reconcile_rollout("t1", &started(100), 200); + let events = CodexTaskEvents { + latest_turn_aborted_at: Some(300), + ..Default::default() + }; + let effects = tracker.reconcile_rollout("t1", &events, 400); + assert_eq!(phases(&effects), vec![CodexPhase::Idle]); + assert!( + completions(&effects).is_empty(), + "turn_aborted must not ring the bell" + ); + } + + #[test] + fn reconcile_task_complete_at_or_after_an_abort_still_completes() { + // Tie-break rule: abort suppresses the chime only when it is STRICTLY + // the newest terminating event. A real task_complete at the same + // instant (or newer) still rings. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t1", None, 0); + tracker.reconcile_rollout("t1", &started(100), 200); + let events = CodexTaskEvents { + latest_task_completed_at: Some(300), + latest_turn_aborted_at: Some(300), + ..Default::default() + }; + let effects = tracker.reconcile_rollout("t1", &events, 400); + assert_eq!(phases(&effects), vec![CodexPhase::Idle]); + assert_eq!(completions(&effects), vec![1]); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test -p freshell-activity reconcile_turn_aborted -- --nocapture` and `cargo test -p freshell-activity reconcile_task_complete_at_or_after` +Expected: `reconcile_turn_aborted_clears_without_completing` FAILS (`completions` is `[1]` today); the tie-break test PASSES already (that is fine — it pins the tie rule against regressions in Step 3). + +- [ ] **Step 3: Implement the abort de-chime** + +3a. Add the claim helper immediately after `record_completion_if_idle` (~`:696`): + +```rust +/// Abort-shaped clears (`turn_aborted` in the rollout lane; status +/// `interrupted`/`failed` on the proxy lane, Task 2): claim the turn key +/// exactly like `record_completion_if_idle` does, but WITHOUT recording a +/// ledger completion -- the pane returns to idle silently +/// (shared/ws-protocol.ts:199-208: `terminal.idle` is never emitted after +/// crash/interrupt/exit) and a later echo of the same physical turn cannot +/// mint a completion. +fn claim_turn_key_if_idle(state: &mut TerminalActivity, turn_key: Option) { + let Some(turn_key) = turn_key else { return }; + if state.phase != CodexPhase::Idle { + return; + } + state.last_emitted_turn_key = Some(turn_key); +} +``` + +3b. Add a `record: bool` final parameter to BOTH transition helpers. The bodies stay identical except the last line: + +```rust +fn transition_pending_after_turn_clear( + state: &mut TerminalActivity, + at: i64, + ledger: &mut TurnCompletionLedger, + completions: &mut Vec<(Option, i64, i64)>, + record: bool, +) { + let turn_key = state.pending_submit_at; + let queued = has_queued_submit(state); + // CE2: a pending-key turn end also retires any accepted anchor -- a + // stale one (e.g. left by a deadman-demoted seeded busy) would let the + // second BEL of a dup-BEL chunk fire a bogus extra completion. + state.accepted_start_at = None; + state.updated_at = at; + state.last_observed_at = at; + if queued { + state.phase = CodexPhase::Pending; + state.pending_submit_at = state.queued_submit_at; + state.pending_freshness_at = Some(at); + state.pending_until = Some(at + PENDING_SUBMIT_GATE_MS); + state.queued_submit_at = None; + } else { + state.phase = CodexPhase::Idle; + state.pending_submit_at = None; + state.pending_freshness_at = None; + state.pending_until = None; + state.queued_submit_at = None; + } + if record { + record_completion_if_idle(state, turn_key, at, ledger, completions); + } else { + claim_turn_key_if_idle(state, turn_key); + } +} +``` + +Apply the same `record: bool` + `if record { record_completion_if_idle(...) } else { claim_turn_key_if_idle(state, turn_key); }` tail to `transition_after_turn_clear` (whose `turn_key` is `state.accepted_start_at`), leaving the rest of its body byte-identical. + +3c. Update the two call sites in `consume_turn_complete_signal` (PTY BEL lane keeps recording) to pass `true`: + +```rust +fn consume_turn_complete_signal( + state: &mut TerminalActivity, + at: i64, + ledger: &mut TurnCompletionLedger, + completions: &mut Vec<(Option, i64, i64)>, +) -> bool { + if state.phase == CodexPhase::Pending { + if state.pending_submit_at.is_some() { + transition_pending_after_turn_clear(state, at, ledger, completions, true); + return true; + } + return false; + } + if state.accepted_start_at.is_some() { + transition_after_turn_clear(state, at, ledger, completions, true); + return true; + } + false +} +``` + +3d. In `reconcile_rollout`, immediately after the existing `observed_clear` computation (`:311-314`), add the abort classifier: + +```rust + let observed_clear = max_ts( + events.latest_task_completed_at, + events.latest_turn_aborted_at, + ); + // The newest terminating event decides the clear's shape: an abort + // (Esc-interrupt / `turn_aborted`) still ends the turn but must not + // ring (shared/ws-protocol.ts:199-208 -- terminal.idle is "never + // emitted after crash/interrupt/exit"). Ties go to task_complete: a + // real completion at the same instant still rings. + let clear_is_abort = match ( + events.latest_task_completed_at, + events.latest_turn_aborted_at, + ) { + (Some(completed), Some(aborted)) => aborted > completed, + (None, Some(_)) => true, + _ => false, + }; +``` + +3e. In the reconcile clear branch (`:341-371`), pass the flag to both transitions — `transition_pending_after_turn_clear(state, at, &mut self.ledger, &mut completions, !clear_is_abort);` and `transition_after_turn_clear(state, at, &mut self.ledger, &mut completions, !clear_is_abort);`. The swallow-flag arming lines (`state.swallow_next_bel = true; state.swallow_next_proxy_complete = true;`) stay in place for BOTH shapes — the BEL/proxy echoes of an aborted turn must be eaten too. + +3f. There is one more caller pair of the transition helpers in `note_proxy_turn_completed` (`:546-579`): pass `true` there for now (Task 2 replaces it with the status-derived flag): +`transition_pending_after_turn_clear(state, at, &mut self.ledger, &mut completions, true);` + +- [ ] **Step 4: Run the crate tests to verify green** + +Run: `cargo test -p freshell-activity` +Expected: ALL tests pass, including the two from Step 1 and the untouched swallow/proxy/BEL tests. + +- [ ] **Step 5: Format and commit** + +```bash +cargo fmt -p freshell-activity +git add crates/freshell-activity/src/codex.rs +git commit -m "fix(activity): rollout turn_aborted clears codex phase without recording a completion" +``` + +--- + +### Task 2: Rust proxy lane — plumb thread/turn/status end-to-end and guard in the tracker + +This is the core fix (spec items A, B, C-proxy, D). The tracker signature change and the hub/router plumbing MUST land in one commit or the workspace will not compile. + +**Files:** +- Modify: `crates/freshell-activity/src/codex.rs` (`TerminalActivity` ~`:109-163`, `track_terminal` initializer + rebind branch ~`:225-247`, `bind_session` ~`:278-289`, `note_proxy_turn_started` ~`:523-541`, `note_proxy_turn_completed` ~`:546-579`, tests ~`:1393-1531`) +- Modify: `crates/freshell-ws/src/activity.rs` (`HubEvent::CodexProxyTurn` ~`:136-140`, `note_codex_proxy_turn` ~`:269-276`, dispatch arm ~`:509-525`, test `proxy_turn_events_reach_the_codex_tracker_and_emit_turn_complete` ~`:2499-2571`) +- Modify: `crates/freshell-ws/src/codex_proxy_route.rs` (turn arms ~`:57-66`) + +**Interfaces:** +- Consumes (from Task 1): `transition_pending_after_turn_clear(..., record: bool)`, `claim_turn_key_if_idle(state, turn_key)`. +- Consumes (existing, unchanged): `freshell_codex::remote_proxy::TurnEventParams { thread_id: String, turn_id: Option, params: Map }`; `freshell_codex::turn_status(&Map) -> Option` (re-exported at `crates/freshell-codex/src/lib.rs:77`; implements `params.turn?.status ?? params.status`). +- Produces (Task 3 depends on these exact signatures): + - `CodexActivityTracker::note_proxy_turn_started(&mut self, terminal_id: &str, thread_id: &str, turn_id: Option<&str>, at: i64) -> Vec` + - `CodexActivityTracker::note_proxy_turn_completed(&mut self, terminal_id: &str, thread_id: &str, turn_id: Option<&str>, status: Option<&str>, at: i64) -> Vec` + - `ActivityHub::note_codex_proxy_turn(&self, terminal_id: &str, thread_id: &str, turn_id: Option<&str>, status: Option<&str>, completed: bool)` + +- [ ] **Step 1: Write the failing tracker tests** + +Append to the `mod tests` block in `crates/freshell-activity/src/codex.rs`: + +```rust + // ---- Thread scoping (kata: codex-turn-thread-scope) ---- + + #[test] + fn subagent_thread_turn_completed_mid_parent_turn_is_ignored() { + // Spike scenario D (/tmp/codex-spike/spike-d.log): on a shared + // app-server connection a sub-agent child thread emits turn/completed + // (turn.status=completed) while the parent turn is still in progress. + // That event must not flip Busy->Idle, must not record a completion, + // and must not arm swallow flags that would eat the parent's real + // completion. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("thread-parent"), 0); + tracker.note_proxy_turn_started("t", "thread-parent", Some("turn-parent"), 1_000); + assert_eq!(tracker.list()[0].phase, CodexPhase::Busy); + + let child = tracker.note_proxy_turn_completed( + "t", + "thread-child", + Some("turn-child"), + Some("completed"), + 2_000, + ); + assert!(child.is_empty(), "foreign-thread completion must be a no-op"); + assert_eq!(tracker.list()[0].phase, CodexPhase::Busy); + + // The parent's REAL completion still rings exactly once. + let parent = tracker.note_proxy_turn_completed( + "t", + "thread-parent", + Some("turn-parent"), + Some("completed"), + 3_000, + ); + assert_eq!(phases(&parent), vec![CodexPhase::Idle]); + assert_eq!(completions(&parent), vec![1]); + } + + #[test] + fn foreign_thread_turn_started_does_not_promote_busy() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("thread-parent"), 0); + let effects = tracker.note_proxy_turn_started("t", "thread-child", Some("turn-c"), 1_000); + assert!(effects.is_empty()); + assert_eq!(tracker.list()[0].phase, CodexPhase::Idle); + } + + #[test] + fn unbound_terminal_ignores_proxy_turn_events() { + // Unbound window policy (documented in the plan): before a thread + // binds, the proxy lane is silent -- no busy promotion, no completion. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", None, 0); + assert!(tracker + .note_proxy_turn_started("t", "thread-x", Some("turn-1"), 1_000) + .is_empty()); + assert!(tracker + .note_proxy_turn_completed("t", "thread-x", Some("turn-1"), Some("completed"), 2_000) + .is_empty()); + assert_eq!(tracker.list()[0].phase, CodexPhase::Idle); + } + + #[test] + fn rebind_clears_stale_in_flight_proxy_turn_state() { + // Design decision #7 (load-bearing ledger A9, falsified without this): + // fork/resume rebinds arrive from the async disk fork-watch lane with + // NO ordering guarantee vs proxy turn events. The child thread's first + // turn/started can land BEFORE the rebind (the thread guard rightly + // drops it); if the parent's stale current_proxy_turn_id survived the + // rebind, the child's first turn/completed would be misclassified as + // a stale echo -- stuck busy until reconcile. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("thread-a"), 0); + tracker.note_proxy_turn_started("t", "thread-a", Some("turn-a1"), 1_000); + // Child turn starts pre-rebind: dropped by the thread guard. + tracker.note_proxy_turn_started("t", "thread-b", Some("turn-b1"), 1_200); + // Disk fork-watch lane rebinds the pane to the child thread. + tracker.bind_session("t", "thread-b"); + let effects = tracker.note_proxy_turn_completed( + "t", + "thread-b", + Some("turn-b1"), + Some("completed"), + 2_000, + ); + assert_eq!(phases(&effects), vec![CodexPhase::Idle]); + assert_eq!(completions(&effects), vec![1]); + } + + // ---- Status guard ---- + + #[test] + fn interrupted_status_clears_busy_without_completion() { + // Spike scenario B: turn/interrupt yields turn/completed with + // turn.status=interrupted. The pane returns to non-busy, no bell. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("sess"), 0); + tracker.note_proxy_turn_started("t", "sess", Some("turn-1"), 1_000); + let effects = + tracker.note_proxy_turn_completed("t", "sess", Some("turn-1"), Some("interrupted"), 2_000); + assert_eq!(phases(&effects), vec![CodexPhase::Idle]); + assert!(completions(&effects).is_empty()); + } + + #[test] + fn failed_status_clears_busy_without_completion() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("sess"), 0); + tracker.note_proxy_turn_started("t", "sess", Some("turn-1"), 1_000); + let effects = + tracker.note_proxy_turn_completed("t", "sess", Some("turn-1"), Some("failed"), 2_000); + assert_eq!(phases(&effects), vec![CodexPhase::Idle]); + assert!(completions(&effects).is_empty()); + } + + #[test] + fn in_progress_status_is_a_no_op() { + // protocol.rs:111 -- turn/completed fires for ALL statuses; + // `inProgress` is not a turn end and must not clear busy. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("sess"), 0); + tracker.note_proxy_turn_started("t", "sess", Some("turn-1"), 1_000); + let effects = + tracker.note_proxy_turn_completed("t", "sess", Some("turn-1"), Some("inProgress"), 2_000); + assert!(effects.is_empty()); + assert_eq!(tracker.list()[0].phase, CodexPhase::Busy); + } + + #[test] + fn absent_status_still_completes_for_the_bound_thread() { + // Compatibility: older protocol forms omit status. Treat as a + // positive completion so panes never hang busy. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("sess"), 0); + tracker.note_proxy_turn_started("t", "sess", Some("turn-1"), 1_000); + let effects = tracker.note_proxy_turn_completed("t", "sess", Some("turn-1"), None, 2_000); + assert_eq!(phases(&effects), vec![CodexPhase::Idle]); + assert_eq!(completions(&effects), vec![1]); + } + + #[test] + fn bel_echo_after_an_interrupted_clear_does_not_ring() { + // The interrupt-shaped clear must arm the BEL swallow like a normal + // proxy clear does -- the aborted turn's PTY BEL echo stays silent. + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("sess"), 0); + tracker.note_proxy_turn_started("t", "sess", Some("turn-1"), 1_000); + tracker.note_proxy_turn_completed("t", "sess", Some("turn-1"), Some("interrupted"), 2_000); + let echo = tracker.note_output("t", "\u{7}", 2_100); + assert!(completions(&echo).is_empty()); + } + + // ---- Turn-id dedupe ---- + + #[test] + fn stale_completion_for_a_previous_turn_id_is_ignored() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("sess"), 0); + tracker.note_proxy_turn_started("t", "sess", Some("turn-2"), 2_000); + // A late completion echo for an OLDER turn id arrives while turn-2 + // is running: no-op by construction. + let stale = + tracker.note_proxy_turn_completed("t", "sess", Some("turn-1"), Some("completed"), 2_100); + assert!(stale.is_empty()); + assert_eq!(tracker.list()[0].phase, CodexPhase::Busy); + // turn-2's real completion still rings. + let real = + tracker.note_proxy_turn_completed("t", "sess", Some("turn-2"), Some("completed"), 3_000); + assert_eq!(completions(&real), vec![1]); + } + + #[test] + fn completion_without_turn_ids_falls_back_to_phase_semantics() { + let mut tracker = CodexActivityTracker::new(); + tracker.track_terminal("t", Some("sess"), 0); + tracker.note_proxy_turn_started("t", "sess", None, 1_000); + let effects = tracker.note_proxy_turn_completed("t", "sess", None, Some("completed"), 2_000); + assert_eq!(completions(&effects), vec![1]); + } +``` + +- [ ] **Step 2: Mechanically update the existing proxy-turn test call sites** + +The signature change breaks 13 in-crate test call sites. All existing tests bind `"sess"` at `track_terminal`, so pass `"sess"` as `thread_id`. Exact substitutions (old → new), preserving each test's semantics: + +| test (line) | old call | new call | +|---|---|---| +| `proxy_turn_started_promotes_idle_to_busy` (:1397) | `note_proxy_turn_started("t", 2_000)` | `note_proxy_turn_started("t", "sess", Some("turn-1"), 2_000)` | +| `proxy_turn_completes_exactly_once_per_turn` (:1411, :1412, :1421) | `note_proxy_turn_started("t", 2_000)` / `note_proxy_turn_completed("t", 3_000)` / `note_proxy_turn_completed("t", 3_001)` | `note_proxy_turn_started("t", "sess", Some("turn-1"), 2_000)` / `note_proxy_turn_completed("t", "sess", Some("turn-1"), Some("completed"), 3_000)` / `note_proxy_turn_completed("t", "sess", Some("turn-1"), Some("completed"), 3_001)` | +| `proxy_clear_swallows_the_late_pty_bel_echo` (:1434) | `note_proxy_turn_completed("t", 3_000)` | `note_proxy_turn_completed("t", "sess", None, Some("completed"), 3_000)` | +| `reconcile_clear_swallows_the_late_proxy_echo` (:1460) | `note_proxy_turn_completed("t", 3_050)` | `note_proxy_turn_completed("t", "sess", None, Some("completed"), 3_050)` | +| `fresh_submit_disarms_all_swallow_flags` (:1471, :1474) | `note_proxy_turn_completed("t", 3_000)` / `note_proxy_turn_completed("t", 5_000)` | `note_proxy_turn_completed("t", "sess", None, Some("completed"), 3_000)` / `note_proxy_turn_completed("t", "sess", None, Some("completed"), 5_000)` | +| `proxy_start_disarms_a_stale_proxy_swallow` (:1493, :1494) | `note_proxy_turn_started("t", 4_000)` / `note_proxy_turn_completed("t", 5_000)` | `note_proxy_turn_started("t", "sess", Some("turn-2"), 4_000)` / `note_proxy_turn_completed("t", "sess", Some("turn-2"), Some("completed"), 5_000)` | +| `bel_clear_swallows_the_late_proxy_echo` (:1527) | `note_proxy_turn_completed("t", 3_050)` | `note_proxy_turn_completed("t", "sess", None, Some("completed"), 3_050)` | + +Where the original scenario had no proxy `turn/started` (the swallow tests drive PTY/reconcile lanes), pass `turn_id = None` so the turn-id guard falls through — that preserves the exact cross-lane semantics each test pins. + +- [ ] **Step 3: Run to verify RED** + +Run: `cargo test -p freshell-activity` +Expected: FAILS to compile — `note_proxy_turn_started`/`note_proxy_turn_completed` take 2/2 extra arguments the implementation doesn't have yet. (A compile failure is this step's red.) + +- [ ] **Step 4: Implement the tracker changes** + +4a. Add the in-flight turn-id field to `TerminalActivity` (after `last_proxy_started_at: Option,` ~`:145`): + +```rust + /// Proxy lane (kata codex-turn-thread-scope): the turn id of the bound + /// thread's in-flight proxy turn, set on TurnStarted. A TurnCompleted + /// carrying a DIFFERENT turn id is a stale echo of an already-closed + /// turn and is a no-op by construction. `None` falls back to phase + /// semantics (older protocols omit turnId). + current_proxy_turn_id: Option, +``` + +and initialize it in `track_terminal`'s struct literal (next to `last_proxy_started_at: None,`): `current_proxy_turn_id: None,`. + +4b. Replace `note_proxy_turn_started` with: + +```rust + /// S5.a: proxy lane TurnStarted (third clock domain -- server-clock `at`). + /// Promotes Idle/Unknown/Pending to Busy, edge-triggered; never completes. + /// Thread-scoped (kata codex-turn-thread-scope): the shared app-server + /// connection relays turn events for EVERY thread on it (sub-agent, + /// review, fork threads -- spike scenario D). Only the bound thread's + /// turns may drive this terminal; before a thread binds we stay + /// conservative and ignore the proxy lane entirely (the Rust identity + /// gate holds turn/start until adoption binds, so the window is + /// structurally empty on the managed path -- design decision #2). + pub fn note_proxy_turn_started( + &mut self, + terminal_id: &str, + thread_id: &str, + turn_id: Option<&str>, + at: i64, + ) -> Vec { + let Some(state) = self.states.get_mut(terminal_id) else { + return Vec::new(); + }; + if state.session_id.as_deref() != Some(thread_id) { + return Vec::new(); + } + let previous = state.to_record(); + // Design invariant: a NEW proxy turn is beginning -- a stale directed + // swallow must not eat THIS turn's completion. + state.swallow_next_proxy_complete = false; + state.current_proxy_turn_id = turn_id.map(str::to_string); + state.last_proxy_started_at = Some(at); + state.last_observed_at = at; + if matches!( + state.phase, + CodexPhase::Idle | CodexPhase::Unknown | CodexPhase::Pending + ) { + state.phase = CodexPhase::Busy; + state.updated_at = at; + } + self.effects_after_transition(terminal_id, previous, Vec::new()) + } +``` + +4c. Replace `note_proxy_turn_completed` with: + +```rust + /// S5.a: proxy lane TurnCompleted. Real turn ends transition to Idle and + /// record exactly one completion; echoes of turns another lane already + /// ended are swallowed one-shot (CE1 generalized). + /// + /// Guard order (kata codex-turn-thread-scope): + /// 1. thread scope -- foreign threads (sub-agents etc.) are ignored + /// BEFORE any state is touched (they must not consume swallows); + /// 2. `inProgress` -- not a turn end at all (protocol.rs:111); + /// 3. turn-id -- a completion for a different turn than the in-flight + /// one is a stale echo, no-op by construction; + /// 4. directed proxy swallow (cross-lane dedupe, unchanged); + /// 5. status -- only `completed` (or absent: older protocols) records a + /// bell-worthy completion; `interrupted`/`failed` clear silently. + pub fn note_proxy_turn_completed( + &mut self, + terminal_id: &str, + thread_id: &str, + turn_id: Option<&str>, + status: Option<&str>, + at: i64, + ) -> Vec { + let Some(state) = self.states.get_mut(terminal_id) else { + return Vec::new(); + }; + if state.session_id.as_deref() != Some(thread_id) { + return Vec::new(); + } + if status == Some("inProgress") { + return Vec::new(); + } + if let (Some(current), Some(completed)) = + (state.current_proxy_turn_id.as_deref(), turn_id) + { + if current != completed { + return Vec::new(); + } + } + if state.swallow_next_proxy_complete { + state.swallow_next_proxy_complete = false; + return Vec::new(); + } + let record = matches!(status, None | Some("completed")); + let previous = state.to_record(); + let mut completions: Vec<(Option, i64, i64)> = Vec::new(); + match state.phase { + CodexPhase::Pending => { + transition_pending_after_turn_clear( + state, + at, + &mut self.ledger, + &mut completions, + record, + ); + state.swallow_next_bel = true; + state.swallow_next_reconcile_clear = true; + } + CodexPhase::Busy | CodexPhase::Unknown => { + let turn_key = state.last_proxy_started_at.or(state.pending_submit_at); + state.phase = CodexPhase::Idle; + state.updated_at = at; + if record { + record_completion_if_idle( + state, + turn_key.or(Some(at)), + at, + &mut self.ledger, + &mut completions, + ); + } else { + claim_turn_key_if_idle(state, turn_key.or(Some(at))); + } + state.swallow_next_bel = true; + state.swallow_next_reconcile_clear = true; + } + CodexPhase::Idle => {} + } + self.effects_after_transition(terminal_id, previous, completions) + } +``` + +(Note: this replaces Task 1 Step 3f's temporary `true` with the status-derived `record`.) + +4d. Clear the in-flight proxy-turn state on rebind (design decision #7 — ledger A9). In `bind_session` (~`:278-289`), after the same-id no-op check, alongside the `state.session_id = Some(...)` assignment, add: + +```rust + // Design decision #7 (kata codex-turn-thread-scope): a rebind moves + // the pane to a DIFFERENT thread (fork/resume, delivered by the async + // disk fork-watch lane -- codex_proxy_route.rs:88-91). The old + // thread's in-flight turn id and start anchor must not survive, or + // the new thread's first turn/completed is misclassified as a stale + // echo / collides on last_emitted_turn_key. + state.current_proxy_turn_id = None; + state.last_proxy_started_at = None; +``` + +and add the same two lines in `track_terminal`'s rebind branch (~`:232-241`), next to its `existing.session_id = Some(...)` assignment (guarded by the same "id actually changed" condition that branch already establishes). + +- [ ] **Step 5: Run freshell-activity, expect the crate green but the workspace still red** + +Run: `cargo test -p freshell-activity` +Expected: PASS (all new + all updated tests). +Run: `cargo test -p freshell-ws --no-run` +Expected: COMPILE FAILURE at `crates/freshell-ws/src/activity.rs:517-519` (hub calls the old tracker signature) — proceed to Step 6. + +- [ ] **Step 6: Widen the hub event and API (`crates/freshell-ws/src/activity.rs`)** + +6a. Replace the `HubEvent::CodexProxyTurn` variant (~`:136-140`): + +```rust + /// S5.a + kata codex-turn-thread-scope: a proxy TurnStarted/TurnCompleted + /// for a managed codex terminal, carrying the EMITTING thread's identity + /// (which may be a sub-agent/review/fork thread, not the bound one) and, + /// for completions, the raw turn status. The tracker owns the guards. + CodexProxyTurn { + terminal_id: String, + thread_id: String, + turn_id: Option, + status: Option, + completed: bool, + }, +``` + +6b. Replace `note_codex_proxy_turn` (~`:269-276`): + +```rust + /// S5.a: proxy (managed-launch) turn lane -- channel-deferred like + /// `bind_codex_session` so all frame emission stays on the hub task. + /// `status` is only meaningful for completions (`turn/completed` carries + /// 'completed' | 'interrupted' | 'failed' | 'inProgress'); pass `None` + /// for starts. + pub fn note_codex_proxy_turn( + &self, + terminal_id: &str, + thread_id: &str, + turn_id: Option<&str>, + status: Option<&str>, + completed: bool, + ) { + let _ = self.tx.send(HubEvent::CodexProxyTurn { + terminal_id: terminal_id.to_string(), + thread_id: thread_id.to_string(), + turn_id: turn_id.map(str::to_string), + status: status.map(str::to_string), + completed, + }); + } +``` + +6c. Replace the dispatch arm in `handle_event` (~`:509-525`): + +```rust + HubEvent::CodexProxyTurn { + terminal_id, + thread_id, + turn_id, + status, + completed, + } => { + let at = now_ms(); + let frames = { + let mut inner = self.inner.lock().expect("activity hub lock"); + let effects = if completed { + inner.codex.note_proxy_turn_completed( + &terminal_id, + &thread_id, + turn_id.as_deref(), + status.as_deref(), + at, + ) + } else { + inner.codex.note_proxy_turn_started( + &terminal_id, + &thread_id, + turn_id.as_deref(), + at, + ) + }; + let (frames, _force_reads) = codex_frames(&mut inner.idle, effects); + frames + }; + self.emit(frames); + } +``` + +6d. Update the existing hub test `proxy_turn_events_reach_the_codex_tracker_and_emit_turn_complete` (~`:2499-2571`). Two edits: bind the thread at create, and pass identity on the calls. Replace the Created event and the three call lines: + +```rust + observer_send( + &hub, + ActivityEvent::Created { + terminal_id: "t".into(), + mode: "codex".into(), + // kata codex-turn-thread-scope: the proxy lane is thread- + // scoped, so this test binds the thread at create (the + // resume path); unbound terminals now ignore proxy turns. + resume_session_id: Some("thread-1".into()), + at: crate::terminal::now_ms(), + }, + ); +``` + +```rust + // Exercise: proxy turn lane. + hub.note_codex_proxy_turn("t", "thread-1", Some("turn-1"), None, false); // started + hub.note_codex_proxy_turn("t", "thread-1", Some("turn-1"), Some("completed"), true); // completed + hub.note_codex_proxy_turn("t", "thread-1", Some("turn-1"), Some("completed"), true); // duplicate echo — must not double +``` + +(Leave the frame assertions untouched; also update the comment `// Initial idle upsert (no sessionId -- the G3 gap state).` to `// Initial idle upsert (session bound at create).`) + +- [ ] **Step 7: Update the router (`crates/freshell-ws/src/codex_proxy_route.rs`)** + +Replace the two turn arms (~`:57-66`): + +```rust + RemoteProxyEvent::TurnStarted(params) => { + if let Some(hub) = &state.activity { + hub.note_codex_proxy_turn( + &terminal_id, + ¶ms.thread_id, + params.turn_id.as_deref(), + None, + false, + ); + } + } + RemoteProxyEvent::TurnCompleted(params) => { + if let Some(hub) = &state.activity { + // `status` lives inside params -- nested `params.turn.status` + // on the small-frame path, flat `params.status` on the + // oversized byte-scan path. `turn_status` handles both + // (protocol.rs:316-333). + let status = freshell_codex::turn_status(¶ms.params); + hub.note_codex_proxy_turn( + &terminal_id, + ¶ms.thread_id, + params.turn_id.as_deref(), + status.as_deref(), + true, + ); + } + } +``` + +- [ ] **Step 8: Run both crates green** + +Run: `cargo test -p freshell-activity && cargo test -p freshell-ws` +Expected: PASS (includes the freshell-ws `tests/` integration suites — `codex_locator_activity.rs` and `codex_fork_rebind.rs` assert `terminal.turn.complete` via the rollout/BEL lanes, which are unchanged for bound threads). + +- [ ] **Step 9: Format and commit** + +```bash +cargo fmt -p freshell-activity -p freshell-ws +git add crates/freshell-activity/src/codex.rs crates/freshell-ws/src/activity.rs crates/freshell-ws/src/codex_proxy_route.rs +git commit -m "fix(activity): thread-scope, status-guard, and turn-id-dedupe the codex proxy turn lane" +``` + +--- + +### Task 3: Rust freshell-ws — behavioral seam tests (hub + router) + +Pin the fix at the two seams above the tracker: the hub must not ring for a foreign thread, and the router must forward thread/turn and extract the NESTED `turn.status`. + +**Files:** +- Modify: `crates/freshell-ws/src/activity.rs` (append one test to `mod tests`) +- Modify: `crates/freshell-ws/src/codex_proxy_route.rs` (append helpers + one test to `mod tests`) + +**Interfaces:** +- Consumes (from Task 2): `ActivityHub::note_codex_proxy_turn(&self, terminal_id, thread_id, turn_id, status, completed)`; router arms forwarding `TurnEventParams`. +- Consumes (existing test harness): `hub()`, `observer_send()`, `next_frame_matching()` in `activity.rs` tests (~`:1293-1334`); `test_state()`, `tagged()` in `codex_proxy_route.rs` tests (~`:183-295`). +- Produces: nothing new for later tasks (tests only). + +- [ ] **Step 1: Write the failing hub test** + +Append to `mod tests` in `crates/freshell-ws/src/activity.rs`: + +```rust + #[tokio::test(flavor = "multi_thread")] + async fn foreign_thread_proxy_completion_does_not_ring() { + // Regression pin for spike scenario D at the hub seam: a sub-agent + // child thread's turn/completed mid-parent-turn must not emit + // terminal.turn.complete (and therefore can never arm the IdleGate). + let (hub, mut rx) = hub(); + observer_send( + &hub, + ActivityEvent::Created { + terminal_id: "t".into(), + mode: "codex".into(), + resume_session_id: Some("thread-parent".into()), + at: crate::terminal::now_ms(), + }, + ); + next_frame_matching(&mut rx, "codex.activity.updated", 3_000, |v| { + v["upsert"][0]["terminalId"] == "t" + }) + .await + .expect("initial upsert"); + + hub.note_codex_proxy_turn("t", "thread-parent", Some("turn-parent"), None, false); + // Sub-agent child thread completes while the parent turn runs. + hub.note_codex_proxy_turn("t", "thread-child", Some("turn-child"), Some("completed"), true); + + let premature = tokio::time::timeout( + std::time::Duration::from_millis(500), + next_frame_matching(&mut rx, "terminal.turn.complete", 3_000, |v| { + v["terminalId"] == "t" + }), + ) + .await; + assert!( + premature.is_err(), + "a sub-agent thread completion must not ring" + ); + + // The parent's real completion still rings. + hub.note_codex_proxy_turn("t", "thread-parent", Some("turn-parent"), Some("completed"), true); + let complete = next_frame_matching(&mut rx, "terminal.turn.complete", 3_000, |v| { + v["terminalId"] == "t" + }) + .await + .expect("parent turn complete"); + assert_eq!(complete["provider"], "codex"); + assert_eq!(complete["sessionId"], "thread-parent"); + } +``` + +- [ ] **Step 2: Write the failing router test** + +Append to `mod tests` in `crates/freshell-ws/src/codex_proxy_route.rs` (after `tagged()`): + +```rust + /// kata codex-turn-thread-scope: a hub-bearing state for observing turn + /// routing (test_state() deliberately sets `activity: None`), plus the + /// hub's broadcast receiver. + fn test_state_with_hub() -> (WsState, tokio::sync::broadcast::Receiver) { + let mut state = test_state(); + let (tx, rx) = tokio::sync::broadcast::channel::(256); + state.activity = Some(crate::activity::ActivityHub::new(StdArc::new(tx), None)); + (state, rx) + } + + /// A TurnEventParams whose status sits NESTED at `params.turn.status` + /// exactly like the real app-server's small-frame form -- proves the + /// router reads it via `freshell_codex::turn_status`, not a naive + /// `params.get("status")`. + fn turn_params( + thread_id: &str, + turn_id: &str, + nested_status: Option<&str>, + ) -> freshell_codex::remote_proxy::TurnEventParams { + let mut params = serde_json::Map::new(); + params.insert( + "threadId".to_string(), + serde_json::Value::String(thread_id.to_string()), + ); + params.insert( + "turnId".to_string(), + serde_json::Value::String(turn_id.to_string()), + ); + if let Some(status) = nested_status { + params.insert("turn".to_string(), serde_json::json!({ "status": status })); + } + freshell_codex::remote_proxy::TurnEventParams { + thread_id: thread_id.to_string(), + turn_id: Some(turn_id.to_string()), + params, + } + } + + /// Local copy of the activity.rs test harness's frame matcher (that one + /// is `#[cfg(test)]`-private to its module). + async fn next_frame_matching( + rx: &mut tokio::sync::broadcast::Receiver, + wanted: &str, + timeout_ms: u64, + pred: impl Fn(&serde_json::Value) -> bool, + ) -> Option { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return None; + } + match tokio::time::timeout(remaining, rx.recv()).await { + Ok(Ok(frame)) => { + let value: serde_json::Value = serde_json::from_str(&frame).ok()?; + if value["type"] == wanted && pred(&value) { + return Some(value); + } + } + _ => return None, + } + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn turn_events_forward_thread_turn_and_nested_status_to_the_hub() { + let (state, mut rx) = test_state_with_hub(); + let hub = state.activity.clone().expect("hub"); + // Track + bind the terminal the way a resume-create does. + (hub.registry_observer())(ActivityEvent::Created { + terminal_id: "term-t".into(), + mode: "codex".into(), + resume_session_id: Some("thread-parent".into()), + at: 1, + }); + + route_proxy_event( + &state, + tagged( + "term-t", + RemoteProxyEvent::TurnStarted(turn_params("thread-parent", "turn-1", None)), + ), + ) + .await; + + // Foreign sub-agent completion: must not ring. The bounded no-ring + // check sits BETWEEN the foreign and bound completions -- without it, + // a regressed thread guard would ring HERE and the trailing + // "exactly one" tail could still pass (the bound completion would + // then hit the Idle arm and no-op, leaving one frame total). + route_proxy_event( + &state, + tagged( + "term-t", + RemoteProxyEvent::TurnCompleted(turn_params( + "thread-child", + "turn-c", + Some("completed"), + )), + ), + ) + .await; + let premature = tokio::time::timeout( + std::time::Duration::from_millis(500), + next_frame_matching(&mut rx, "terminal.turn.complete", 3_000, |v| { + v["terminalId"] == "term-t" + }), + ) + .await; + assert!( + premature.is_err(), + "a foreign thread completion must not ring" + ); + + // NESTED-status pin: an `inProgress` completion for the BOUND thread + // and the in-flight turn id must not ring. THIS event is what proves + // the router extracts `params.turn.status` via + // `freshell_codex::turn_status`: a router that forgets the extraction + // (or reads a naive flat `params.get("status")`) forwards `None`, + // which records a completion (design decision #3: absent status + // records) and rings here. The tracker's `inProgress` guard returns + // before touching state, so the pane stays Busy for the real + // completion below. + route_proxy_event( + &state, + tagged( + "term-t", + RemoteProxyEvent::TurnCompleted(turn_params( + "thread-parent", + "turn-1", + Some("inProgress"), + )), + ), + ) + .await; + let premature = tokio::time::timeout( + std::time::Duration::from_millis(500), + next_frame_matching(&mut rx, "terminal.turn.complete", 3_000, |v| { + v["terminalId"] == "term-t" + }), + ) + .await; + assert!( + premature.is_err(), + "a nested inProgress status must be extracted and must not ring" + ); + + // Bound thread's real completion with NESTED turn.status: rings once. + route_proxy_event( + &state, + tagged( + "term-t", + RemoteProxyEvent::TurnCompleted(turn_params( + "thread-parent", + "turn-1", + Some("completed"), + )), + ), + ) + .await; + + let complete = next_frame_matching(&mut rx, "terminal.turn.complete", 3_000, |v| { + v["terminalId"] == "term-t" + }) + .await + .expect("bound thread's completion rings"); + assert_eq!(complete["sessionId"], "thread-parent"); + + // Exactly one -- the foreign and inProgress completions produced nothing. + let second = tokio::time::timeout( + std::time::Duration::from_millis(500), + next_frame_matching(&mut rx, "terminal.turn.complete", 3_000, |v| { + v["terminalId"] == "term-t" + }), + ) + .await; + assert!(second.is_err(), "exactly one turn.complete expected"); + } +``` + +Import note: `ActivityEvent` must be imported in this test module — add `use freshell_terminal::registry::ActivityEvent;` to the `mod tests` imports (mirror the exact import path `crates/freshell-ws/src/activity.rs` uses for `ActivityEvent`; adjust if that file imports it via a re-export). `registry_observer()` is the same public hub method the activity.rs tests call. + +- [ ] **Step 3: Run to verify the tests fail meaningfully first** + +These tests are written AFTER the Task 2 implementation, so they should pass immediately — their red was Task 2's red. To honor red-green discipline, verify each asserts the fixed behavior by temporarily reverting the guards: run `git stash push crates/freshell-activity/src/codex.rs` ONLY if Task 2 is uncommitted — since Task 2 IS committed, instead verify by mutation. In `note_proxy_turn_completed`, temporarily comment out BOTH: + +1. the thread-scope guard: `if state.session_id.as_deref() != Some(thread_id) { return Vec::new(); }`, AND +2. the turn-id dedupe guard directly below the `inProgress` check: `if let (Some(current), Some(completed)) = (state.current_proxy_turn_id.as_deref(), turn_id) { if current != completed { return Vec::new(); } }` + +Both must be disabled because these tests drive the foreign completion with a DIFFERENT turn id (`turn-c` / `turn-child`) than the bound thread's in-flight one (`turn-1` / `turn-parent` from the preceding `turn/started`): with only the thread guard removed, the turn-id guard would still drop the foreign completion and mask the red (the tests would stay green for the wrong reason). Then run: + +```bash +cargo test -p freshell-ws foreign_thread_proxy_completion_does_not_ring +cargo test -p freshell-ws turn_events_forward_thread_turn_and_nested_status +``` + +(Two separate invocations — `cargo test` accepts only ONE positional test-name filter; passing a second positional errors with `unexpected argument` before anything compiles or runs.) + +Expected: both FAIL — each at its mid-test bounded no-ring check, because the unguarded foreign completion rings (the hub test at "a sub-agent thread completion must not ring", the router test at "a foreign thread completion must not ring"). Restore the guards (`git checkout -- crates/freshell-activity/src/codex.rs` restores the committed version if you edited in place). + +Then run a SECOND directed mutation to prove the router test pins the NESTED `turn.status` extraction (the router-seam property no other test covers — the hub tests inject status directly into `note_codex_proxy_turn`, bypassing the router). With `codex.rs` restored, in `crates/freshell-ws/src/codex_proxy_route.rs`'s `RemoteProxyEvent::TurnCompleted` arm temporarily replace the Task 2 Step 7 line + +```rust + let status = freshell_codex::turn_status(¶ms.params); +``` + +with + +```rust + let status: Option = None; +``` + +and run: + +```bash +cargo test -p freshell-ws turn_events_forward_thread_turn_and_nested_status +``` + +Expected: FAIL at "a nested inProgress status must be extracted and must not ring" — the mutated router forwards `None`, absent status records (design decision #3), and the bound-thread `inProgress` completion rings prematurely. Restore with `git checkout -- crates/freshell-ws/src/codex_proxy_route.rs`. + +- [ ] **Step 4: Run green** + +Run: `cargo test -p freshell-ws` +Expected: PASS, including all pre-existing router tests (they use `test_state()` with `activity: None` and are untouched). + +- [ ] **Step 5: Format and commit** + +```bash +cargo fmt -p freshell-ws +git add crates/freshell-ws/src/activity.rs crates/freshell-ws/src/codex_proxy_route.rs +git commit -m "test(ws): pin thread-scoped codex proxy routing at the hub and router seams" +``` + +--- + +### Task 4: Node — carry threadId/turnId/status on the codex turn registry events + +**Files:** +- Modify: `server/terminal-stream/registry-events.ts:38-46` +- Modify: `server/terminal-registry.ts:1920-1943` (+ one module-level helper) +- Test: `test/unit/server/terminal-registry.codex-sidecar.test.ts:865-905` + +**Interfaces:** +- Consumes (existing): the sidecar callback's `event: CodexTurnEvent = { threadId: string; turnId?: string; params: Record }` (`server/coding-cli/codex-app-server/client.ts:112-116`). `params` is path-dependent: small frames keep the ORIGINAL nested params (status may sit at `params.turn.status`), oversized frames are flattened to `{ threadId, turnId?, status? }` — so status must be read as `params.turn?.status ?? params.status`. +- Produces (Task 5 depends on these exact types): + +```ts +export type CodexTurnStartedEvent = { + terminalId: string + threadId: string + turnId?: string + at: number +} + +export type CodexTurnCompletedEvent = { + terminalId: string + threadId: string + turnId?: string + status?: string + at: number +} +``` + +- [ ] **Step 1: Update the emission-pin test (RED)** + +In `test/unit/server/terminal-registry.codex-sidecar.test.ts`, in the test `emits Codex turn activity events before durability early returns` (~`:865-905`), replace the two `sidecar.emit*` lines and the `expect(turnEvents)` block with: + +```ts + sidecar.emitTurnStarted({ threadId: 'thread-durable', turnId: 'turn-1', params: {} }) + sidecar.emitTurnCompleted({ + threadId: 'thread-durable', + turnId: 'turn-1', + // Nested like the real app-server's small-frame form -- pins that the + // registry reads params.turn?.status ?? params.status. + params: { turn: { status: 'completed' } }, + }) + + expect(turnEvents).toEqual([ + { + type: 'started', + event: { terminalId: term.terminalId, threadId: 'thread-durable', turnId: 'turn-1', at: 4_200 }, + }, + { + type: 'completed', + event: { + terminalId: term.terminalId, + threadId: 'thread-durable', + turnId: 'turn-1', + status: 'completed', + at: 4_200, + }, + }, + ]) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npm run test:vitest -- --config config/vitest/vitest.server.config.ts test/unit/server/terminal-registry.codex-sidecar.test.ts --run` +Expected: FAIL — actual events are still `{ terminalId, at }`. + +- [ ] **Step 3: Widen the event types** + +Replace `server/terminal-stream/registry-events.ts:38-46` with: + +```ts +export type CodexTurnStartedEvent = { + terminalId: string + /** + * The codex thread that emitted `turn/started` -- NOT necessarily the + * terminal's bound thread: sub-agent, review, and fork threads share the + * app-server connection (kata codex-turn-thread-scope, spike scenario D). + * Consumers MUST scope by the terminal's bound session id. + */ + threadId: string + turnId?: string + at: number +} + +export type CodexTurnCompletedEvent = { + terminalId: string + /** See CodexTurnStartedEvent.threadId -- may be a foreign thread. */ + threadId: string + turnId?: string + /** + * Raw turn status: 'completed' | 'interrupted' | 'failed' | 'inProgress' + * (absent on older protocol forms). Only 'completed' is a positive, + * bell-worthy completion -- see shared/ws-protocol.ts terminal.idle. + */ + status?: string + at: number +} +``` + +- [ ] **Step 4: Emit the payload** + +4a. Add a module-level helper in `server/terminal-registry.ts` (near the other module-level helpers/imports, outside the class): + +```ts +/** + * `params.turn?.status ?? params.status` -- the codex turn/completed status. + * Mirror of `freshell_codex::protocol::turn_status` and adapter.ts:922-923; + * handles both the small-frame nested form and the large-frame flattened form. + */ +function codexTurnStatus(params: Record): string | undefined { + const turn = params.turn + if (turn && typeof turn === 'object') { + const nested = (turn as Record).status + if (typeof nested === 'string') return nested + } + const status = params.status + return typeof status === 'string' ? status : undefined +} +``` + +4b. Replace the two emits in `registerCodexSidecarLifecycle` (`server/terminal-registry.ts:1920-1943`): + +```ts + const turnStartedUnsubscribe = sidecar.onTurnStarted?.((event) => { + if (!isCurrentSidecar()) return + this.emit('codex.turn.started', { + terminalId: record.terminalId, + threadId: event.threadId, + ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), + at: Date.now(), + } satisfies CodexTurnStartedEvent) + void this.handleCodexTurnStarted(record.terminalId, event).catch((err) => { + logger.error({ err, terminalId: record.terminalId }, 'Failed to update Codex turn-start durability state') + }) + }) + if (turnStartedUnsubscribe) unsubscribers.push(turnStartedUnsubscribe) + + const turnCompletedUnsubscribe = sidecar.onTurnCompleted?.((event) => { + if (!isCurrentSidecar()) return + const status = codexTurnStatus(event.params) + this.emit('codex.turn.completed', { + terminalId: record.terminalId, + threadId: event.threadId, + ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), + ...(status !== undefined ? { status } : {}), + at: Date.now(), + } satisfies CodexTurnCompletedEvent) + void this.handleCodexTurnCompleted(record.terminalId, event).catch((err) => { + logger.error({ err, terminalId: record.terminalId }, 'Failed to proof Codex rollout after turn completion') + }) + }) + if (turnCompletedUnsubscribe) unsubscribers.push(turnCompletedUnsubscribe) +``` + +- [ ] **Step 5: Run to verify green** + +Run: `npm run test:vitest -- --config config/vitest/vitest.server.config.ts test/unit/server/terminal-registry.codex-sidecar.test.ts test/unit/server/coding-cli/codex-activity-wiring.test.ts --run` +Expected: PASS (the wiring test still passes — its fake registry emits plain objects and the tracker does not read the new fields yet). + +- [ ] **Step 6: Commit** + +```bash +git add server/terminal-stream/registry-events.ts server/terminal-registry.ts test/unit/server/terminal-registry.codex-sidecar.test.ts +git commit -m "feat(server): carry threadId/turnId/status on codex turn registry events" +``` + +--- + +### Task 5: Node tracker — thread scope, status guard, turn-id dedupe + +**Files:** +- Modify: `server/coding-cli/codex-activity-tracker.ts` (`CodexTerminalActivity` type `:32-49`; `onTurnStarted`/`onTurnCompleted` `:238-263`; `transitionAfterTurnClear` `:372+`; `transitionPendingAfterTurnClear` `:412+`; new `claimTurnKeyIfIdle` next to `recordCompletionIfIdle` `:442+`) +- Test: `test/unit/server/coding-cli/codex-activity-tracker.test.ts` +- Test: `test/unit/server/coding-cli/codex-activity-wiring.test.ts` (fixture update) +- Test: `test/unit/server/coding-cli/turn-completion-snapshots.test.ts` (fixture update only — pinned snapshot; deliberate, documented protocol decision; assertions unchanged) + +**Interfaces:** +- Consumes (from Task 4): `CodexTurnStartedEvent`/`CodexTurnCompletedEvent` with `threadId`, `turnId?`, `status?`. +- Consumes (existing): `bindTerminal({ terminalId, sessionId, reason, session?, at })` — for codex, `state.sessionId` IS the bound thread id; a codex terminal enters `this.states` only at bind time, so the Node unbound window is inherently silent (parity with the Rust "unbound ⇒ ignore" decision). +- Produces (Task 6 depends on these): + - `private transitionAfterTurnClear(state: CodexTerminalActivity, at: number, record = true): void` + - `private transitionPendingAfterTurnClear(state: CodexTerminalActivity, at: number, record = true): void` + - `private claimTurnKeyIfIdle(state: CodexTerminalActivity, turnKey: number | undefined): void` + +- [ ] **Step 1: Write the failing tracker tests** + +Append to `test/unit/server/coding-cli/codex-activity-tracker.test.ts` (uses the file's existing `createSession`/`createProjects` helpers): + +```ts +describe('thread-scoped app-server turn events (kata codex-turn-thread-scope)', () => { + it('ignores a sub-agent thread completion mid-parent-turn (spike scenario D)', () => { + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'thread-parent', + reason: 'association', + session: createSession('thread-parent'), + at: 1_000, + }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'thread-parent', turnId: 'turn-parent', at: 1_100 }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'busy' }) + + // Sub-agent child thread completes while the parent turn is running. + tracker.onTurnCompleted({ + terminalId: 'term-1', + threadId: 'thread-child', + turnId: 'turn-child', + status: 'completed', + at: 1_200, + }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'busy' }) + expect(completions).toEqual([]) + + // The parent's real completion still rings exactly once. + tracker.onTurnCompleted({ + terminalId: 'term-1', + threadId: 'thread-parent', + turnId: 'turn-parent', + status: 'completed', + at: 1_300, + }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + expect(completions).toEqual([{ terminalId: 'term-1', sessionId: 'thread-parent', at: 1_300, completionSeq: 1 }]) + }) + + it('ignores a foreign thread turn start', () => { + const tracker = new CodexActivityTracker() + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'thread-parent', + reason: 'association', + session: createSession('thread-parent'), + at: 1_000, + }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'thread-child', turnId: 'turn-c', at: 1_100 }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + }) + + it('interrupted status clears busy without recording a completion', () => { + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', at: 1_100 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', status: 'interrupted', at: 1_200 }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + expect(completions).toEqual([]) + }) + + it('failed status clears busy without recording a completion', () => { + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', at: 1_100 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', status: 'failed', at: 1_200 }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + expect(completions).toEqual([]) + }) + + it('inProgress status is a strict no-op', () => { + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', at: 1_100 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', status: 'inProgress', at: 1_200 }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'busy' }) + expect(completions).toEqual([]) + }) + + it('absent status still records a completion (older protocol forms)', () => { + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', at: 1_100 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', at: 1_200 }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + expect(completions).toHaveLength(1) + }) + + it('ignores a stale completion for a previous turn id', () => { + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-2', at: 1_100 }) + // Late echo for an OLDER turn while turn-2 runs: no-op by construction. + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', status: 'completed', at: 1_150 }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'busy' }) + expect(completions).toEqual([]) + // turn-2's real completion still rings. + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-2', status: 'completed', at: 1_300 }) + expect(completions).toHaveLength(1) + }) +}) +``` + +- [ ] **Step 2: Update the wiring test fixtures to the new event shape** + +In `test/unit/server/coding-cli/codex-activity-wiring.test.ts`, the first test's two emits (`:49` and `:56`) become: + +```ts + registry.emit('codex.turn.started', { terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', at: 1_100 }) +``` + +```ts + registry.emit('codex.turn.completed', { + terminalId: 'term-1', + threadId: 'session-1', + turnId: 'turn-1', + status: 'completed', + at: 1_200, + }) +``` + +(The bound `sessionId` in that test is `'session-1'`, so the events must carry `threadId: 'session-1'` to pass the new filter — the old bare `{ terminalId, at }` fixtures would now be dropped, which is exactly the semantic being pinned.) + +Also update the PRE-EXISTING app-server-lane tracker tests to the new event shape. In `test/unit/server/coding-cli/codex-activity-tracker.test.ts`, the `describe('turn.complete emission (server-authoritative)')` block has exactly five `onTurnStarted`/`onTurnCompleted` call sites that pass bare `{ terminalId: 'term-1', at: ... }` (`:1026`, `:1035`, `:1051`, `:1052`, `:1069`). Every test in that block binds with `sessionId: 'session-1'`, so add `threadId: 'session-1'` to each of the five calls, e.g.: + +```ts + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', at: 1_100 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', at: 1_200 }) +``` + +(No `turnId` needed — it stays absent, and absent turn ids fall through the dedupe guard by design.) Without this update, Step 4's thread guard (`state.sessionId !== input.threadId`, i.e. `'session-1' !== undefined`) silently drops these events and three tests fail (`promotes busy from app-server turn started and clears from turn completed`, `does not double-emit when app-server completion is followed by BEL and JSONL completion`, `clears a pending submit from app-server completion even when turn started was missed`); and because Task 4 made `threadId: string` required on both event types, the bare calls are also TypeScript errors that would fail Task 7's `npm run check`. + +Finally, update the pinned snapshot suite `test/unit/server/coding-cli/turn-completion-snapshots.test.ts` — its `CodexActivityTracker` block contains the ONLY other bare `{ terminalId, at }` turn-event call sites in the repo: six calls, four at `:65-68` (all `term-1`, bound via `tracker.bindTerminal({ terminalId: 'term-1', sessionId: 'session-1', ... })` at `:64`) and two at `:71-72` (`term-2`, bound `sessionId: 'session-2'` at `:70`). Add `threadId: 'session-1'` to the four `term-1` calls and `threadId: 'session-2'` to the two `term-2` calls; change nothing else in the file. This file's header pins its ASSERTIONS ("must never change without an explicit protocol decision") — this edit IS that explicit protocol decision, so record it in place: add a comment above the updated calls citing this plan (kata codex-turn-thread-scope: app-server turn events now carry the bound thread's required `threadId`; only the event-construction INPUTS change to the new required shape). The pinned OUTPUTS are untouched and must still pass byte-identical: because each event now carries the `threadId` matching its terminal's bound session (and no `turnId`, which falls through the dedupe guard), Task 5's thread guard passes them through exactly as before — the three-completion `toEqual`, the `completionSeq` sequence `[1, 2, 1]`, and the pinned JSON string all remain valid. If any pinned assertion fails after this fixture update, STOP: that is an implementation bug in Task 5 — never re-pin the snapshot. Without this fixture update, Task 4's required `threadId: string` makes the six bare calls TypeScript errors (failing Task 7 Step 4's `npm run check`), and at runtime the thread guard would silently drop the events (the snapshot's 3 expected completions become 0). + +- [ ] **Step 3: Run to verify RED** + +Run: `npm run test:vitest -- --config config/vitest/vitest.server.config.ts test/unit/server/coding-cli/codex-activity-tracker.test.ts test/unit/server/coding-cli/codex-activity-wiring.test.ts test/unit/server/coding-cli/turn-completion-snapshots.test.ts --run` +Expected: the new `describe` block FAILS (foreign completion currently flips to idle and records; interrupted currently records; stale turn id currently completes). The wiring test, the updated `turn.complete emission (server-authoritative)` tests, AND the updated snapshot suite still PASS (the tracker ignores the extra fields today) — their red would arrive with the Step 4 filter if the fixtures were wrong, so they pin the contract both ways. + +- [ ] **Step 4: Implement the tracker guards** + +4a. Add the in-flight turn id to `CodexTerminalActivity` (`server/coding-cli/codex-activity-tracker.ts:32-49`) — insert after `lastEmittedTurnKey?: number`: + +```ts + /** + * kata codex-turn-thread-scope: the bound thread's in-flight app-server + * turn id (set on turn/started). A turn/completed carrying a DIFFERENT + * turn id is a stale echo of an already-closed turn -- no-op by + * construction. Absent ids fall back to phase semantics. + */ + currentTurnId?: string +``` + +(Also initialize nothing — `undefined` is the natural initial value; verify `bindTerminal`'s state literal compiles without it since the field is optional.) + +4b. Replace `onTurnStarted`/`onTurnCompleted` (`:238-263`) with: + +```ts + onTurnStarted(input: CodexTurnStartedEvent): void { + const state = this.states.get(input.terminalId) + if (!state) return + // Thread scope guard (kata codex-turn-thread-scope, spike scenario D): + // the shared app-server connection relays turn events for EVERY thread + // (sub-agents, review threads, forks). Only the bound thread's turns + // drive this terminal. A codex terminal enters the tracker only at bind + // time, so the unbound window is inherently silent (parity with the + // Rust tracker's unbound => ignore). + if (state.sessionId === undefined || state.sessionId !== input.threadId) return + + const previous = this.toRecord(state) + state.currentTurnId = input.turnId + state.lastSeenTaskStartedAt = maxDefined(state.lastSeenTaskStartedAt, input.at) + this.promoteBusy(state, input.at, input.at) + this.commitState(state, previous) + } + + onTurnCompleted(input: CodexTurnCompletedEvent): void { + const state = this.states.get(input.terminalId) + if (!state) return + // Guard order mirrors the Rust tracker (crates/freshell-activity/src/ + // codex.rs::note_proxy_turn_completed): thread scope -> inProgress -> + // stale turn id -> status. + if (state.sessionId === undefined || state.sessionId !== input.threadId) return + // turn/completed fires for ALL statuses; inProgress is not a turn end. + if (input.status === 'inProgress') return + if (input.turnId !== undefined && state.currentTurnId !== undefined && input.turnId !== state.currentTurnId) { + return + } + // Status guard: only 'completed' (or absent -- older protocol forms) + // records a bell-worthy completion; interrupted/failed clear silently + // (shared/ws-protocol.ts terminal.idle: never after crash/interrupt). + const record = input.status === undefined || input.status === 'completed' + + const previous = this.toRecord(state) + state.lastSeenTaskCompletedAt = maxDefined(state.lastSeenTaskCompletedAt, input.at) + if (state.phase === 'pending' && state.pendingSubmitAt !== undefined) { + this.transitionPendingAfterTurnClear(state, input.at, record) + } else if (state.acceptedStartAt !== undefined) { + this.transitionAfterTurnClear(state, input.at, record) + } else if (state.latentAcceptedStartAt !== undefined) { + this.transitionAfterLatentTurnClear(state, input.at) + } + this.commitState(state, previous) + this.flushCompletions() + } +``` + +4c. Add the optional `record` parameter to both transition helpers. `transitionAfterTurnClear` signature becomes `private transitionAfterTurnClear(state: CodexTerminalActivity, at: number, record = true): void` and its final line becomes: + +```ts + if (record) { + this.recordCompletionIfIdle(state, turnKey, at) + } else { + this.claimTurnKeyIfIdle(state, turnKey) + } +``` + +Apply exactly the same signature + tail change to `transitionPendingAfterTurnClear`. All other call sites (`consumeTurnCompleteSignal` at `:509-531`, `reconcileProjects`) compile unchanged thanks to the default `record = true`. + +4d. Add the claim helper directly below `recordCompletionIfIdle`: + +```ts + /** + * Abort-shaped clears (turn_aborted / status interrupted|failed): claim + * the turn key exactly like recordCompletionIfIdle does, but WITHOUT + * recording, so a later echo of the same physical turn (BEL, JSONL + * reconcile, app-server duplicate -- all share this key space) cannot + * mint a completion. shared/ws-protocol.ts terminal.idle: "Never emitted + * after crash/interrupt/exit". + */ + private claimTurnKeyIfIdle(state: CodexTerminalActivity, turnKey: number | undefined): void { + if (turnKey === undefined) return + if (state.phase !== 'idle') return + state.lastEmittedTurnKey = turnKey + } +``` + +- [ ] **Step 5: Run to verify green** + +Run: `npm run test:vitest -- --config config/vitest/vitest.server.config.ts test/unit/server/coding-cli/codex-activity-tracker.test.ts test/unit/server/coding-cli/codex-activity-wiring.test.ts --run` +Expected: PASS (all new tests + all pre-existing tracker tests — the `turn.complete emission (server-authoritative)` suite now carries `threadId: 'session-1'` per Step 2 so it satisfies the thread guard, and the remaining BEL/reconcile/pending suites drive `noteInput`/`noteOutput`/`reconcileProjects`, which the app-server-lane guards do not touch). + +- [ ] **Step 6: Commit** + +```bash +git add server/coding-cli/codex-activity-tracker.ts test/unit/server/coding-cli/codex-activity-tracker.test.ts test/unit/server/coding-cli/codex-activity-wiring.test.ts test/unit/server/coding-cli/turn-completion-snapshots.test.ts +git commit -m "fix(server): thread-scope and status-guard codex app-server turn events" +``` + +--- + +### Task 6: Node reconcile lane — `turn_aborted` clears without recording a completion + +**Files:** +- Modify: `server/coding-cli/codex-activity-tracker.ts` (`reconcileProjects` `:265-348`) +- Test: `test/unit/server/coding-cli/codex-activity-tracker.test.ts` + +**Interfaces:** +- Consumes (from Task 5): `transitionAfterTurnClear(state, at, record)`, `transitionPendingAfterTurnClear(state, at, record)`, `claimTurnKeyIfIdle`. +- Produces: nothing new (behavior change + tests only). + +- [ ] **Step 1: Write the failing tests** + +Append to `test/unit/server/coding-cli/codex-activity-tracker.test.ts`: + +```ts +describe('reconcile turn_aborted de-chime (kata codex-turn-thread-scope)', () => { + it('turn_aborted clears busy without recording a completion', () => { + // SEMANTIC CHANGE: shared/ws-protocol.ts terminal.idle is "never emitted + // after crash/interrupt/exit" -- an Esc-interrupt (turn_aborted in the + // rollout JSONL) must return the pane to idle silently. + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.noteInput({ terminalId: 'term-1', data: '\r', at: 1_100 }) + tracker.reconcileProjects( + createProjects(createSession('session-1', { latestTaskStartedAt: 1_150 })), + 1_200, + ) + tracker.reconcileProjects( + createProjects(createSession('session-1', { + latestTaskStartedAt: 1_150, + latestTurnAbortedAt: 1_180, + })), + 1_300, + ) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + expect(completions).toEqual([]) + }) + + it('a task_complete at or after an abort still records a completion', () => { + // Tie-break: abort suppresses the chime only when STRICTLY newest. + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.noteInput({ terminalId: 'term-1', data: '\r', at: 1_100 }) + tracker.reconcileProjects( + createProjects(createSession('session-1', { latestTaskStartedAt: 1_150 })), + 1_200, + ) + tracker.reconcileProjects( + createProjects(createSession('session-1', { + latestTaskStartedAt: 1_150, + latestTaskCompletedAt: 1_180, + latestTurnAbortedAt: 1_180, + })), + 1_300, + ) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + expect(completions).toHaveLength(1) + }) +}) +``` + +- [ ] **Step 2: Run to verify RED** + +Run: `npm run test:vitest -- --config config/vitest/vitest.server.config.ts test/unit/server/coding-cli/codex-activity-tracker.test.ts --run` +Expected: the first new test FAILS (an abort-only clear records a completion today); the tie-break test passes already (pins the rule). + +- [ ] **Step 3: Implement** + +In `reconcileProjects` (`server/coding-cli/codex-activity-tracker.ts:265-348`), directly after `const clearedAt = maxDefined(nextCompletedAt, nextTurnAbortedAt)` add: + +```ts + // The newest terminating event decides the clear's shape: an abort + // (Esc-interrupt / turn_aborted) still ends the turn but must not ring + // (shared/ws-protocol.ts terminal.idle: "never emitted after + // crash/interrupt/exit"). Ties go to task_complete: a real completion + // at the same instant still rings. Mirror of the Rust tracker's + // clear_is_abort (crates/freshell-activity/src/codex.rs). + const clearIsAbort = nextTurnAbortedAt !== undefined + && (nextCompletedAt === undefined || nextTurnAbortedAt > nextCompletedAt) +``` + +Then change the two recording transition calls in the same function: +- `this.transitionPendingAfterTurnClear(state, at)` → `this.transitionPendingAfterTurnClear(state, at, !clearIsAbort)` +- `this.transitionAfterTurnClear(state, at)` → `this.transitionAfterTurnClear(state, at, !clearIsAbort)` + +(The two LATENT transitions — `transitionPendingAfterLatentTurnClear`, `transitionAfterLatentTurnClear` — never record completions and stay untouched.) + +- [ ] **Step 4: Run to verify green (including the old abort phase-pin test)** + +Run: `npm run test:vitest -- --config config/vitest/vitest.server.config.ts test/unit/server/coding-cli/codex-activity-tracker.test.ts --run` +Expected: PASS. The pre-existing `clears busy from turn_aborted when BEL is missed` test (`:885-913`) still passes — it pins phase/timestamps only, and the phase-clearing half of abort behavior is unchanged. + +- [ ] **Step 5: Verify the ws-protocol doc comment needs no edit, then commit** + +Read `shared/ws-protocol.ts:199-215` and confirm the `terminal.idle` comment ("Never emitted after crash/interrupt/exit; subagent completions inside a running turn never produce it") now DESCRIBES reality rather than contradicting it — no edit required, no schema touched. + +```bash +git add server/coding-cli/codex-activity-tracker.ts test/unit/server/coding-cli/codex-activity-tracker.test.ts +git commit -m "fix(server): reconcile turn_aborted clears codex phase without recording a completion" +``` + +--- + +### Task 7: Full verification sweep + +No new behavior — prove the whole change set against the repo's targeted and integration suites, honoring AGENTS.md test coordination. + +**Files:** +- Possibly modify (only if a pinned suite requires a deliberate semantic update): `test/server/ws-codex-turn-complete.test.ts`, `test/server/codex-activity-exact-subset.test.ts` + +**Interfaces:** +- Consumes: everything above. Produces: a green verification record in the commit message. + +- [ ] **Step 1: Rust — full targeted crates** + +Run: `cargo test -p freshell-activity -p freshell-ws -p freshell-codex` +Expected: PASS. (`freshell-codex` is untouched but is in the spec's targeted set — its `remote_proxy_relay.rs` proves the event payload the router now consumes.) + +- [ ] **Step 2: Rust — format + lints** + +Run: `cargo fmt --all -- --check` and `cargo clippy -p freshell-activity -p freshell-ws -- -D warnings` +Expected: clean. Fix anything surfaced (refactor step of TDD), re-run Step 1. + +- [ ] **Step 3: Node — targeted unit + integration suites** + +Run: + +```bash +npm run test:vitest -- --config config/vitest/vitest.server.config.ts \ + test/unit/server/coding-cli/codex-activity-tracker.test.ts \ + test/unit/server/coding-cli/codex-activity-wiring.test.ts \ + test/unit/server/coding-cli/turn-completion-snapshots.test.ts \ + test/unit/server/terminal-registry.codex-sidecar.test.ts \ + test/unit/server/coding-cli/codex-app-server/json-rpc-side-effects.test.ts \ + test/server/codex-activity-exact-subset.test.ts \ + test/server/ws-codex-turn-complete.test.ts --run +``` + +Expected: PASS. Failure protocol (be honest, never paper over): +- `ws-codex-turn-complete.test.ts` / `codex-activity-exact-subset.test.ts` drive real registry + wiring: if a fixture emits app-server turn events without a matching bound `threadId`, that is the OLD contract — update the fixture to bind a session and carry the matching `threadId` (deliberate, with a comment citing this plan), never weaken the guard. +- `t2-codex-equivalence-rust.test.ts` is deliberately NOT in this command (load-bearing ledger A12/A14): it is silently DESELECTED under `vitest.server.config.ts` (that config's include excludes `test/unit/port/**`), and under its own `config/vitest/vitest.oracle.config.ts` it is `describe.skip` unless `FRESHELL_RUN_REAL_PROVIDER_CONTRACTS` is set — which drives a real live codex call, off-limits for this plan. Validation proved the oracle harness is a pure WS client on the fresh-agent lane (`port/oracle/harness/t2-live-codex.ts:628-716`) that never touches the terminal-activity surfaces this plan changes, so skipping it forfeits no coverage. Do NOT add it back expecting it to run. + +- [ ] **Step 4: Broad repo check (coordinated)** + +Run: `npm run check` +This is a broad run — it goes through the shared test-coordinator gate; WAIT for the gate if another run holds it (never kill foreign holders). Expected: PASS (typecheck + suite). The contract freeze test inside the suite must pass untouched — this plan changed no wire schema. + +- [ ] **Step 5: Commit any deliberate test updates from Step 3** + +```bash +git add -A +git status --short # review: ONLY test files expected here; nothing else +git commit -m "test: align codex turn integration fixtures with thread-scoped semantics" # only if there are staged changes +``` + +--- + +## Spec coverage map (self-review record) + +| Spec item | Covered by | +|---|---| +| A. Plumb thread_id/turn_id/status through proxy path (router stops discarding; hub API widened) | Task 2 Steps 6–7; pinned by Task 3 router test | +| B. Thread-scope proxy lane in tracker; bound-thread guard; documented unbound-window behavior | Task 2 Steps 1+4 (`unbound_terminal_ignores_proxy_turn_events`, thread-guard tests); decision §"Design decisions" #2 | +| C. Status-guard completions (proxy lane) — interrupted/failed clear w/o completion | Task 2 (status tests + `record` flag) | +| C. Rollout lane: `turn_aborted` clears w/o completion; pinned test rewritten deliberately; matches ws-protocol.ts:200-203 | Task 1 (Rust), Task 6 (Node) | +| D. turn_id matching makes duplicate/stale completions no-ops; swallow flags kept for BEL/reconcile echoes; BEL-only unmanaged path not regressed | Task 2 (`stale_completion_...`, `current_proxy_turn_id`; swallow tests kept green with `turn_id = None`; rebind reset per decision #7, Step 4d + `rebind_clears_stale_in_flight_proxy_turn_state`), Task 5 (`currentTurnId`; Node rebind is naturally safe — fresh state literal) | +| E. IdleGate 2s grace untouched | Global Constraints (no idle.rs / truly-idle-emitter.ts edits anywhere in the plan) | +| F. Node parity: registry event carries threadId/turnId/status; tracker filters by bound session id; status guard; `extractTurnCompletedStatus` path consumed via `codexTurnStatus`; reconcile abort de-chime; ws-protocol doc comments verified unchanged | Tasks 4, 5, 6 | +| G. No client changes; no wire shape changes | Global Constraints; Task 7 Step 4 contract freeze | +| Testing: sub-agent regression (scenario D), interrupted, aborted, unbound window, duplicate/turn-id, ws-layer plumbing tests, Node mirrors, deliberate updates of pinned tests, targeted suites + repo checks per AGENTS.md | Tasks 1–7 throughout | + +**Production-outcome proof (no silent deferrals):** the user-facing outcome — "no green/bell while Codex still works; Esc-interrupt never rings; real completion still rings exactly once" — is proven without stubs at three levels: pure tracker state machines (Tasks 1–2, 5–6), the real hub/router seam emitting real `terminal.turn.complete` frames over a real broadcast channel (Task 3), and the existing end-to-end suites (`ws-codex-turn-complete.test.ts`, freshell-ws `tests/codex_locator_activity.rs`) re-run in Task 7. The `terminal.idle` bell edge itself is downstream of `TurnComplete`/`note_turn_boundary`, which Task 3 proves is no longer reachable by foreign threads; the IdleGate's own grace behavior is pre-existing, wire-pinned, and deliberately untouched (spec item E). diff --git a/server/coding-cli/activity-wiring-factory.ts b/server/coding-cli/activity-wiring-factory.ts index a5497bc33..283a881a5 100644 --- a/server/coding-cli/activity-wiring-factory.ts +++ b/server/coding-cli/activity-wiring-factory.ts @@ -37,7 +37,7 @@ export type PtyActivityTracker = { bindSession(input: { terminalId: string; sessionId: string; at: number }): void noteInput(input: { terminalId: string; data: string; at: number }): void noteOutput(input: { terminalId: string; data: string; at: number }): void - noteExit(input: { terminalId: string }): void + noteExit(input: { terminalId: string; spontaneous?: boolean }): void expire(at: number): void } @@ -85,9 +85,9 @@ export function wirePtyActivityTracker(input: { const onOutput = (event: TerminalOutputRawEvent) => { tracker.noteOutput({ terminalId: event.terminalId, data: event.data, at: event.at }) } - const onExit = (event: { terminalId?: string }) => { + const onExit = (event: { terminalId?: string; spontaneous?: boolean }) => { if (!event.terminalId) return - tracker.noteExit({ terminalId: event.terminalId }) + tracker.noteExit({ terminalId: event.terminalId, spontaneous: event.spontaneous === true }) } registry.on('terminal.created', onCreated) diff --git a/server/coding-cli/amplifier-activity-tracker.ts b/server/coding-cli/amplifier-activity-tracker.ts index af0c3aea7..cce017d73 100644 --- a/server/coding-cli/amplifier-activity-tracker.ts +++ b/server/coding-cli/amplifier-activity-tracker.ts @@ -43,6 +43,8 @@ export type AmplifierTurnCompleteEvent = { export type AmplifierActivityChange = { upsert: AmplifierActivityRecord[] remove: string[] + /** Subset of `remove` caused by a spontaneous PTY death (death-bell input). */ + spontaneousExitRemovals?: string[] } /** @@ -340,9 +342,9 @@ export class AmplifierActivityTracker extends EventEmitter { this.emit('turn.complete', completion) } - noteExit(input: { terminalId: string }): void { + noteExit(input: { terminalId: string; spontaneous?: boolean }): void { // PTY exit is the authoritative end — unconditional (plan §6). - this.removeState(input.terminalId) + this.removeState(input.terminalId, { spontaneousExit: input.spontaneous === true }) } expire(at: number): void { @@ -384,12 +386,16 @@ export class AmplifierActivityTracker extends EventEmitter { this.emit('changed', { upsert: [next], remove: [] } satisfies AmplifierActivityChange) } - private removeState(terminalId: string): void { + private removeState(terminalId: string, opts?: { spontaneousExit?: boolean }): void { const state = this.states.get(terminalId) if (!state) return this.clearSubmitGrace(state) this.states.delete(terminalId) - this.emit('changed', { upsert: [], remove: [terminalId] } satisfies AmplifierActivityChange) + this.emit('changed', { + upsert: [], + remove: [terminalId], + ...(opts?.spontaneousExit ? { spontaneousExitRemovals: [terminalId] } : {}), + } satisfies AmplifierActivityChange) } private toRecord(state: AmplifierTerminalActivity): AmplifierActivityRecord { diff --git a/server/coding-cli/claude-activity-tracker.ts b/server/coding-cli/claude-activity-tracker.ts index 459858aad..d93d6f06e 100644 --- a/server/coding-cli/claude-activity-tracker.ts +++ b/server/coding-cli/claude-activity-tracker.ts @@ -31,6 +31,8 @@ export type ClaudeTurnCompleteEvent = { export type ClaudeActivityChange = { upsert: ClaudeActivityRecord[] remove: string[] + /** Subset of `remove` caused by a spontaneous PTY death (death-bell input). */ + spontaneousExitRemovals?: string[] } type TrackerLogger = { @@ -166,8 +168,8 @@ export class ClaudeActivityTracker extends EventEmitter { } } - noteExit(input: { terminalId: string }): void { - this.removeState(input.terminalId) + noteExit(input: { terminalId: string; spontaneous?: boolean }): void { + this.removeState(input.terminalId, { spontaneousExit: input.spontaneous === true }) } expire(at: number): void { @@ -197,9 +199,13 @@ export class ClaudeActivityTracker extends EventEmitter { this.emit('changed', { upsert: [next], remove: [] } satisfies ClaudeActivityChange) } - private removeState(terminalId: string): void { + private removeState(terminalId: string, opts?: { spontaneousExit?: boolean }): void { if (!this.states.delete(terminalId)) return - this.emit('changed', { upsert: [], remove: [terminalId] } satisfies ClaudeActivityChange) + this.emit('changed', { + upsert: [], + remove: [terminalId], + ...(opts?.spontaneousExit ? { spontaneousExitRemovals: [terminalId] } : {}), + } satisfies ClaudeActivityChange) } private toRecord(state: ClaudeTerminalActivity): ClaudeActivityRecord { diff --git a/server/coding-cli/codex-activity-tracker.ts b/server/coding-cli/codex-activity-tracker.ts index 525de109a..c4d512809 100644 --- a/server/coding-cli/codex-activity-tracker.ts +++ b/server/coding-cli/codex-activity-tracker.ts @@ -8,6 +8,8 @@ import { } from '../../shared/turn-complete-signal.js' import type { TerminalTurnCompletionSnapshot } from '../../shared/ws-protocol.js' import type { + CodexApprovalRequestedEvent, + CodexApprovalResolvedEvent, CodexTurnCompletedEvent, CodexTurnStartedEvent, SessionBindingReason, @@ -45,6 +47,23 @@ export type CodexTerminalActivity = CodexActivityRecord & { lastSeenSessionLastActivityAt?: number lastObservedAt: number lastEmittedTurnKey?: number + /** + * kata codex-turn-thread-scope: the bound thread's in-flight app-server + * turn id (set on turn/started). A turn/completed carrying a DIFFERENT + * turn id is a stale echo of an already-closed turn -- no-op by + * construction. Absent ids fall back to phase semantics. + */ + currentTurnId?: string + /** + * Outstanding server->client approval request ids (managed proxy lane, + * Task 12 / Rust codex.rs pending_approvals). + */ + pendingApprovals: Set + /** + * True when the approval pause demoted a working phase; the resolve + * restores 'busy'. False when the approval arrived while already idle. + */ + resumeBusyAfterApproval: boolean parserState: TurnCompleteSignalParserState } @@ -58,6 +77,15 @@ export type CodexTurnCompleteEvent = { export type CodexActivityChange = { upsert: CodexActivityRecord[] remove: string[] + /** Subset of `remove` caused by a spontaneous PTY death (death-bell input). */ + spontaneousExitRemovals?: string[] + /** + * Subset of `remove` whose pending-approval set was non-empty at removal + * time (read BEFORE deletion). A pane blocked on an approval whose process + * dies spontaneously counts as engaged for the death bell (decision 3 -- + * Node mirror of Rust has_pending_approvals). + */ + approvalPendingRemovals?: string[] } function maxDefined(...values: Array): number | undefined { @@ -76,6 +104,13 @@ function latestClearAt(session?: CodingCliSession): number | undefined { ) } +// Mirrors Rust abort_reason_is_human: missing reason = legacy/uncertainty -> +// silent; 'interrupted'/'replaced' = human-requested -> silent; anything else +// is not human-attributed and records (rings). +function abortReasonIsHuman(reason: string | undefined): boolean { + return reason === undefined || reason === 'interrupted' || reason === 'replaced' +} + function isUnresolvedSession(session?: CodingCliSession): boolean { const startedAt = session?.codexTaskEvents?.latestTaskStartedAt if (startedAt === undefined) return false @@ -148,6 +183,11 @@ export class CodexActivityTracker extends EventEmitter { lastSeenTurnAbortedAt: input.session?.codexTaskEvents?.latestTurnAbortedAt, lastSeenSessionLastActivityAt: input.session?.lastActivityAt, lastClearedAt: latestClearAt(input.session), + // A rebind moves the pane to a different thread: the old thread's + // approval pause state must not survive (fresh state ⇒ inherently + // cleared -- Task 12 mirror of Rust bind_session). + pendingApprovals: new Set(), + resumeBusyAfterApproval: false, parserState: createTurnCompleteSignalParserState(), } @@ -166,9 +206,9 @@ export class CodexActivityTracker extends EventEmitter { this.removeState(input.terminalId) } - noteExit(input: { terminalId: string; at: number }): void { + noteExit(input: { terminalId: string; at: number; spontaneous?: boolean }): void { void input.at - this.removeState(input.terminalId) + this.removeState(input.terminalId, { spontaneousExit: input.spontaneous === true }) } noteInput(input: { terminalId: string; data: string; at: number }): void { @@ -238,8 +278,16 @@ export class CodexActivityTracker extends EventEmitter { onTurnStarted(input: CodexTurnStartedEvent): void { const state = this.states.get(input.terminalId) if (!state) return + // Thread scope guard (kata codex-turn-thread-scope, spike scenario D): + // the shared app-server connection relays turn events for EVERY thread + // (sub-agents, review threads, forks). Only the bound thread's turns + // drive this terminal. A codex terminal enters the tracker only at bind + // time, so the unbound window is inherently silent (parity with the + // Rust tracker's unbound => ignore). + if (state.sessionId === undefined || state.sessionId !== input.threadId) return const previous = this.toRecord(state) + state.currentTurnId = input.turnId state.lastSeenTaskStartedAt = maxDefined(state.lastSeenTaskStartedAt, input.at) this.promoteBusy(state, input.at, input.at) this.commitState(state, previous) @@ -248,13 +296,52 @@ export class CodexActivityTracker extends EventEmitter { onTurnCompleted(input: CodexTurnCompletedEvent): void { const state = this.states.get(input.terminalId) if (!state) return - + // Guard order mirrors the Rust tracker (crates/freshell-activity/src/ + // codex.rs::note_proxy_turn_completed): thread scope -> inProgress -> + // stale turn id -> status. + if (state.sessionId === undefined || state.sessionId !== input.threadId) return + // turn/completed fires for ALL statuses; inProgress is not a turn end. + if (input.status === 'inProgress') return + if (input.turnId !== undefined && state.currentTurnId !== undefined && input.turnId !== state.currentTurnId) { + return + } + // Task 12: an accepted terminal-status completion retires the turn's + // approval pause state ONCE, BEFORE the phase transitions -- a turn that + // completes during an approval pause routes through the idle path (the + // request itself demoted the phase), so a late resolve of the stale + // approval must not flip the pane busy again. + state.pendingApprovals.clear() + state.resumeBusyAfterApproval = false + // Attention-bell policy: completed AND failed record (ring); interrupted is + // the human-requested silent clear. Mirrors Rust codex.rs record predicate. + const record = input.status === undefined || input.status === 'completed' || input.status === 'failed' + + state.currentTurnId = undefined const previous = this.toRecord(state) - state.lastSeenTaskCompletedAt = maxDefined(state.lastSeenTaskCompletedAt, input.at) + if (input.status === undefined || input.status === 'completed') { + state.lastSeenTaskCompletedAt = maxDefined(state.lastSeenTaskCompletedAt, input.at) + } if (state.phase === 'pending' && state.pendingSubmitAt !== undefined) { - this.transitionPendingAfterTurnClear(state, input.at) - } else if (state.acceptedStartAt !== undefined) { - this.transitionAfterTurnClear(state, input.at) + this.transitionPendingAfterTurnClear(state, input.at, record) + } else if (state.phase === 'idle') { + // Mid-pause turn end / stale echo (mirror of the Rust Idle arm, + // crates/freshell-activity/src/codex.rs note_proxy_turn_completed): + // an approval pause demoted the phase, so the pause's turn/completed + // lands here -- no completion, no event (the approval bell already + // covers this attention event). But the anchors this turn planted + // (acceptedStartAt via onTurnStarted's promoteBusy or a mid-pause + // reconcile fold; pendingSubmitAt via a pause keystroke; a latent + // anchor on association bindings) would otherwise survive and let a + // later PTY BEL echo re-mint the same physical turn via + // consumeTurnCompleteSignal. Claim the turn key with the same + // derivation the busy/pending paths use and retire the anchors. + const turnKey = state.acceptedStartAt ?? state.pendingSubmitAt + state.acceptedStartAt = undefined + state.pendingSubmitAt = undefined + state.latentAcceptedStartAt = undefined + this.claimTurnKeyIfIdle(state, turnKey) + } else if ((state.phase === 'busy' || state.phase === 'unknown') && state.acceptedStartAt !== undefined) { + this.transitionAfterTurnClear(state, input.at, record) } else if (state.latentAcceptedStartAt !== undefined) { this.transitionAfterLatentTurnClear(state, input.at) } @@ -262,6 +349,65 @@ export class CodexActivityTracker extends EventEmitter { this.flushCompletions() } + /** + * Approval-request pause (managed proxy lane, Task 12 -- Node mirror of + * Rust note_approval_requested). Thread-scoped like turn events; requests + * without a threadId are accepted (the proxy is per-terminal). The public + * phase maps to the EXISTING not-busy value -- no new wire phase. Queued + * input never suppresses approval bells: still blocked on a human. + */ + onApprovalRequested(input: CodexApprovalRequestedEvent): void { + const state = this.states.get(input.terminalId) + if (!state) return + if (input.threadId !== undefined && state.sessionId !== undefined && input.threadId !== state.sessionId) { + return + } + // Hardening (mirror of Rust note_approval_requested): only a NEWLY + // inserted request id arms the gate. A duplicate request frame (proxy + // retry / reconnect replay) for an id already pending must not re-arm -- + // one boundary per approval pause. + const newlyInserted = !state.pendingApprovals.has(input.requestId) + state.pendingApprovals.add(input.requestId) + const previous = this.toRecord(state) + if (state.phase === 'busy' || state.phase === 'pending' || state.phase === 'unknown') { + state.resumeBusyAfterApproval = true + state.phase = 'idle' + } + state.updatedAt = input.at + this.commitState(state, previous) + // Arms the truly-idle gate WITHOUT minting a turn completion or a + // terminal.turn.complete frame -- an approval pause is not a turn end. + // Emitted AFTER the 'changed' demotion so the gate sees not-busy first. + if (newlyInserted) { + this.emit('attention.boundary', { terminalId: input.terminalId, at: input.at }) + } + } + + /** + * The approval response passed back through the proxy: the turn resumes. + * Cancels a pending bell within the grace (gate sees busy); un-greens the + * pane. Stale/unknown request ids are no-ops. + */ + onApprovalResolved(input: CodexApprovalResolvedEvent): void { + const state = this.states.get(input.terminalId) + if (!state) return + if (!state.pendingApprovals.delete(input.requestId)) return + if (state.pendingApprovals.size > 0 || !state.resumeBusyAfterApproval) return + state.resumeBusyAfterApproval = false + const previous = this.toRecord(state) + state.phase = 'busy' + state.updatedAt = input.at + state.lastObservedAt = input.at + // Audit A9 hazard 2: a mid-pause Enter (the human answering the approval + // prompt in the TUI) planted PTY pending-submit state -- normalize it so + // the next turn clear is not misread as a queued re-arm of the pause + // keystroke (which would suppress a legitimate later bell). + state.pendingSubmitAt = undefined + state.pendingFreshnessAt = undefined + state.pendingUntil = undefined + this.commitState(state, previous) + } + reconcileProjects(projects: ProjectGroup[], at: number): void { const sessions = buildProjectIndex(projects) @@ -275,6 +421,18 @@ export class CodexActivityTracker extends EventEmitter { const nextCompletedAt = session.codexTaskEvents?.latestTaskCompletedAt const nextTurnAbortedAt = session.codexTaskEvents?.latestTurnAbortedAt const clearedAt = maxDefined(nextCompletedAt, nextTurnAbortedAt) + // The newest terminating event decides the clear's shape: an abort + // (Esc-interrupt / turn_aborted) still ends the turn but must not ring + // (shared/ws-protocol.ts terminal.idle: "never emitted after + // crash/interrupt/exit"). Ties go to task_complete: a real completion + // at the same instant still rings. Mirror of the Rust tracker's + // clear_is_abort (crates/freshell-activity/src/codex.rs). + const clearIsAbort = nextTurnAbortedAt !== undefined + && (nextCompletedAt === undefined || nextTurnAbortedAt > nextCompletedAt) + // Abort-shaped clears stay silent only when human-attributed (or the + // legacy reason-less form); a present non-human reason records (rings). + const nextTurnAbortedReason = session.codexTaskEvents?.latestTurnAbortedReason + const record = !clearIsAbort || !abortReasonIsHuman(nextTurnAbortedReason) state.lastSeenSessionLastActivityAt = maxDefined(state.lastSeenSessionLastActivityAt, session.lastActivityAt) if (nextStartedAt !== undefined) { @@ -291,7 +449,17 @@ export class CodexActivityTracker extends EventEmitter { || (state.bindingReason === 'resume' && state.phase === 'idle') ) ) { - this.promoteBusy(state, nextStartedAt, at) + if (state.pendingApprovals.size === 0) { + this.promoteBusy(state, nextStartedAt, at) + } else { + // Lane-interference guard (decision 8 / audit A9): the turn's own + // task_started folding in MID-PAUSE would flip the phase busy, + // feed the gate, and silently cancel the armed approval bell. + // Fold the anchor as usual but defer the busy promotion to the + // approval resolve. + state.acceptedStartAt = nextStartedAt + state.resumeBusyAfterApproval = true + } } else if ( isNewStart && state.bindingReason === 'association' @@ -330,7 +498,7 @@ export class CodexActivityTracker extends EventEmitter { && state.pendingSubmitAt !== undefined && clearedAt >= state.pendingSubmitAt ) { - this.transitionPendingAfterTurnClear(state, at) + this.transitionPendingAfterTurnClear(state, at, record) } if ( @@ -339,7 +507,7 @@ export class CodexActivityTracker extends EventEmitter { && clearedAt >= state.acceptedStartAt && (state.phase === 'busy' || state.phase === 'unknown') ) { - this.transitionAfterTurnClear(state, at) + this.transitionAfterTurnClear(state, at, record) } this.commitState(state, previous) @@ -369,7 +537,7 @@ export class CodexActivityTracker extends EventEmitter { state.lastObservedAt = at } - private transitionAfterTurnClear(state: CodexTerminalActivity, at: number): void { + private transitionAfterTurnClear(state: CodexTerminalActivity, at: number, record = true): void { const turnKey = state.acceptedStartAt const hasQueuedSubmit = this.hasQueuedSubmit(state) state.lastClearedAt = at @@ -390,7 +558,11 @@ export class CodexActivityTracker extends EventEmitter { state.queuedSubmitAt = undefined state.pendingUntil = undefined } - this.recordCompletionIfIdle(state, turnKey, at) + if (record) { + this.recordCompletionIfIdle(state, turnKey, at) + } else { + this.claimTurnKeyIfIdle(state, turnKey) + } } private transitionAfterLatentTurnClear(state: CodexTerminalActivity, at: number): void { @@ -409,7 +581,7 @@ export class CodexActivityTracker extends EventEmitter { state.lastObservedAt = at } - private transitionPendingAfterTurnClear(state: CodexTerminalActivity, at: number): void { + private transitionPendingAfterTurnClear(state: CodexTerminalActivity, at: number, record = true): void { const turnKey = state.pendingSubmitAt state.latentAcceptedStartAt = undefined state.lastClearedAt = at @@ -428,7 +600,11 @@ export class CodexActivityTracker extends EventEmitter { state.pendingUntil = undefined state.queuedSubmitAt = undefined } - this.recordCompletionIfIdle(state, turnKey, at) + if (record) { + this.recordCompletionIfIdle(state, turnKey, at) + } else { + this.claimTurnKeyIfIdle(state, turnKey) + } } /** @@ -451,6 +627,20 @@ export class CodexActivityTracker extends EventEmitter { })) } + /** + * Abort-shaped clears (turn_aborted / status interrupted|failed): claim + * the turn key exactly like recordCompletionIfIdle does, but WITHOUT + * recording, so a later echo of the same physical turn (BEL, JSONL + * reconcile, app-server duplicate -- all share this key space) cannot + * mint a completion. shared/ws-protocol.ts terminal.idle: "Never emitted + * after crash/interrupt/exit". + */ + private claimTurnKeyIfIdle(state: CodexTerminalActivity, turnKey: number | undefined): void { + if (turnKey === undefined) return + if (state.phase !== 'idle') return + state.lastEmittedTurnKey = turnKey + } + private flushCompletions(): void { if (this.pendingCompletions.length === 0) return const out = this.pendingCompletions @@ -488,10 +678,20 @@ export class CodexActivityTracker extends EventEmitter { if (input.reason === 'resume') { if (state.phase === 'idle') { - state.phase = 'busy' - state.acceptedStartAt = maxDefined(state.acceptedStartAt, startedAt) - state.latentAcceptedStartAt = undefined - state.updatedAt = input.at + if (state.pendingApprovals.size > 0) { + // Lane-interference guard (decision 8 / audit A9): a resume + // re-announce landing during a pending approval must not promote + // idle -> busy (it would silently cancel the armed approval bell). + // Fold the anchor; the resolve restores busy. + state.acceptedStartAt = maxDefined(state.acceptedStartAt, startedAt) + state.latentAcceptedStartAt = undefined + state.resumeBusyAfterApproval = true + } else { + state.phase = 'busy' + state.acceptedStartAt = maxDefined(state.acceptedStartAt, startedAt) + state.latentAcceptedStartAt = undefined + state.updatedAt = input.at + } } else if (state.phase === 'pending') { state.latentAcceptedStartAt = maxDefined(state.latentAcceptedStartAt, startedAt) } else { @@ -579,11 +779,19 @@ export class CodexActivityTracker extends EventEmitter { this.emit('changed', { upsert: [next], remove: [] } satisfies CodexActivityChange) } - private removeState(terminalId: string): void { + private removeState(terminalId: string, opts?: { spontaneousExit?: boolean }): void { const existing = this.states.get(terminalId) if (!existing) return + // Death-bell engagement (decision 3): read BEFORE deleting the state -- a + // pane blocked on an approval when it dies must still ring. + const approvalPending = existing.pendingApprovals.size > 0 this.states.delete(terminalId) - this.emit('changed', { upsert: [], remove: [terminalId] } satisfies CodexActivityChange) + this.emit('changed', { + upsert: [], + remove: [terminalId], + ...(opts?.spontaneousExit ? { spontaneousExitRemovals: [terminalId] } : {}), + ...(approvalPending ? { approvalPendingRemovals: [terminalId] } : {}), + } satisfies CodexActivityChange) } private toRecord(state: CodexTerminalActivity): CodexActivityRecord { diff --git a/server/coding-cli/codex-activity-wiring.ts b/server/coding-cli/codex-activity-wiring.ts index f79cce7d9..8c5e82f44 100644 --- a/server/coding-cli/codex-activity-wiring.ts +++ b/server/coding-cli/codex-activity-wiring.ts @@ -4,6 +4,8 @@ import { } from './codex-activity-tracker.js' import type { ProjectGroup } from './types.js' import type { + CodexApprovalRequestedEvent, + CodexApprovalResolvedEvent, CodexTurnCompletedEvent, CodexTurnStartedEvent, TerminalInputRawEvent, @@ -79,8 +81,16 @@ export function wireCodexActivityTracker(input: { tracker.onTurnCompleted(event) } - const onExit = (event: { terminalId: string }) => { - tracker.noteExit({ terminalId: event.terminalId, at: now() }) + const onApprovalRequested = (event: CodexApprovalRequestedEvent) => { + tracker.onApprovalRequested(event) + } + + const onApprovalResolved = (event: CodexApprovalResolvedEvent) => { + tracker.onApprovalResolved(event) + } + + const onExit = (event: { terminalId: string; spontaneous?: boolean }) => { + tracker.noteExit({ terminalId: event.terminalId, at: now(), spontaneous: event.spontaneous === true }) } registry.on('terminal.session.bound', onBound) @@ -89,6 +99,8 @@ export function wireCodexActivityTracker(input: { registry.on('terminal.output.raw', onOutput) registry.on('codex.turn.started', onTurnStarted) registry.on('codex.turn.completed', onTurnCompleted) + registry.on('codex.approval.requested', onApprovalRequested) + registry.on('codex.approval.resolved', onApprovalResolved) registry.on('terminal.exit', onExit) const stopIndexerUpdates = codingCliIndexer.onUpdate((projects) => { @@ -109,6 +121,8 @@ export function wireCodexActivityTracker(input: { registry.off('terminal.output.raw', onOutput) registry.off('codex.turn.started', onTurnStarted) registry.off('codex.turn.completed', onTurnCompleted) + registry.off('codex.approval.requested', onApprovalRequested) + registry.off('codex.approval.resolved', onApprovalResolved) registry.off('terminal.exit', onExit) stopIndexerUpdates() clearIntervalFn(sweepTimer) diff --git a/server/coding-cli/codex-app-server/launch-planner.ts b/server/coding-cli/codex-app-server/launch-planner.ts index 1b3c59388..fb681e4fe 100644 --- a/server/coding-cli/codex-app-server/launch-planner.ts +++ b/server/coding-cli/codex-app-server/launch-planner.ts @@ -9,6 +9,7 @@ import type { import { waitForAllSettledOrThrow } from '../../shutdown-join.js' import { CodexRemoteProxy, + type CodexApprovalRequestEvent, type CodexRemoteProxyCandidate, type CodexRemoteProxyRepairTrigger, } from './remote-proxy.js' @@ -34,6 +35,8 @@ export type CodexLaunchSidecar = { onCandidate?(handler: (candidate: CodexRemoteProxyCandidate) => void): () => void onTurnStarted?(handler: (event: CodexTurnEvent) => void): () => void onTurnCompleted?(handler: (event: CodexTurnEvent) => void): () => void + onApprovalRequested?(handler: (event: CodexApprovalRequestEvent) => void): () => void + onApprovalResolved?(handler: (event: { requestId: string }) => void): () => void onRepairTrigger?(handler: (event: CodexRemoteProxyRepairTrigger) => void): () => void onFsChanged?(handler: (event: { watchId: string; changedPaths: string[] }) => void): () => void onThreadLifecycle?(handler: (event: CodexThreadLifecycleEvent) => void): () => void @@ -80,6 +83,8 @@ type CodexLaunchProxy = Pick< | 'onCandidate' | 'onTurnStarted' | 'onTurnCompleted' + | 'onApprovalRequested' + | 'onApprovalResolved' | 'onRepairTrigger' | 'onThreadLifecycle' | 'onLifecycleLoss' @@ -246,6 +251,8 @@ export class CodexLaunchPlanner { onCandidate: (handler) => getProxy()?.onCandidate(handler) ?? (() => undefined), onTurnStarted: (handler) => getProxy()?.onTurnStarted(handler) ?? (() => undefined), onTurnCompleted: (handler) => getProxy()?.onTurnCompleted(handler) ?? (() => undefined), + onApprovalRequested: (handler) => getProxy()?.onApprovalRequested(handler) ?? (() => undefined), + onApprovalResolved: (handler) => getProxy()?.onApprovalResolved(handler) ?? (() => undefined), onRepairTrigger: (handler) => getProxy()?.onRepairTrigger(handler) ?? (() => undefined), onFsChanged: (handler) => runtime.onFsChanged(handler), onThreadLifecycle: (handler) => getProxy()?.onThreadLifecycle(handler) ?? (() => undefined), diff --git a/server/coding-cli/codex-app-server/remote-proxy.ts b/server/coding-cli/codex-app-server/remote-proxy.ts index b684982fa..c006dd326 100644 --- a/server/coding-cli/codex-app-server/remote-proxy.ts +++ b/server/coding-cli/codex-app-server/remote-proxy.ts @@ -37,6 +37,20 @@ export type CodexRemoteProxyRepairTrigger = | { kind: 'proxy_close' | 'proxy_error' | 'candidate_capture_timeout'; error?: Error; scope?: 'fork_handoff' } | { kind: 'fs_changed'; watchId: string; changedPaths: string[] } +/** + * One sniffed server→client approval REQUEST (a frame carrying BOTH `id` and + * `method`, with the method in `CODEX_APPROVAL_REQUEST_METHODS`) — the codex + * app-server is blocked on a human until it resolves. The frame itself is + * relayed verbatim regardless. + */ +export type CodexApprovalRequestEvent = { + /** Canonicalized request id (string form of the JSON-RPC id). */ + requestId: string + method: string + /** Best-effort params.threadId — undefined for oversized/opaque frames. */ + threadId?: string +} + type JsonRpcId = string | number type ProxyFrame = { @@ -79,6 +93,13 @@ type ProxyConnection = { upstream: WebSocket pendingMethods: Map pendingForkRequests: Map + /** + * Outstanding server→client approval request ids (decision 5). Resolved by + * a client response ({id, result} OR {id, error}), an upstream + * `serverRequest/resolved` notification, or drained with resolutions on + * connection teardown (decision 5b). + */ + pendingServerApprovals: Set } type CodexRemoteProxyOptions = { @@ -109,6 +130,43 @@ const STATEFUL_NOTIFICATION_METHODS = new Set([ 'thread/status/changed', ]) +/** + * Server→client JSON-RPC REQUEST methods that block on a human. Sourced from + * the codex 0.129.0 schema inventory + * (test/fixtures/coding-cli/codex-app-server/schema-inventory.ts:84-94) and + * verified EXACT against the codex `ServerRequest` enum at both 0.129.0 and + * the deployed 0.146.0. Mirrors the Rust proxy's APPROVAL_REQUEST_METHODS + * (crates/freshell-codex/src/remote_proxy.rs). + */ +export const CODEX_APPROVAL_REQUEST_METHODS = new Set([ + 'item/commandExecution/requestApproval', + 'item/fileChange/requestApproval', + 'item/permissions/requestApproval', + 'item/tool/requestUserInput', + 'mcpServer/elicitation/request', + 'applyPatchApproval', + 'execCommandApproval', +]) + +/** + * Machine-serviced server→client requests — never human-attention. + * (`attestation/generate` and `currentTime/read` are new at 0.146.0.) + * Anything outside BOTH lists is debug-logged to catch future drift + * (decision 6) — no bell, just logging. + */ +const AUTOMATED_SERVER_REQUEST_METHODS = new Set([ + 'item/tool/call', + 'account/chatgptAuthTokens/refresh', + 'attestation/generate', + 'currentTime/read', +]) + +/** + * Legacy approval methods carry `params.conversationId` instead of + * `params.threadId` (codex-rs v1.rs:126-158). + */ +const LEGACY_APPROVAL_REQUEST_METHODS = new Set(['applyPatchApproval', 'execCommandApproval']) + export class CodexRemoteProxy { private readonly upstreamWsUrl: string private readonly portAllocator: () => Promise @@ -126,6 +184,8 @@ export class CodexRemoteProxy { private readonly candidateHandlers = new Set<(candidate: CodexRemoteProxyCandidate) => void>() private readonly turnStartedHandlers = new Set<(event: CodexTurnEvent) => void>() private readonly turnCompletedHandlers = new Set<(event: CodexTurnEvent) => void>() + private readonly approvalRequestedHandlers = new Set<(event: CodexApprovalRequestEvent) => void>() + private readonly approvalResolvedHandlers = new Set<(event: { requestId: string }) => void>() private readonly repairTriggerHandlers = new Set<(event: CodexRemoteProxyRepairTrigger) => void>() private readonly lifecycleHandlers = new Set<(event: CodexThreadLifecycleEvent) => void>() private readonly lifecycleLossHandlers = new Set<(event: CodexThreadLifecycleLossEvent) => void>() @@ -193,6 +253,7 @@ export class CodexRemoteProxy { } for (const connection of [...this.connections]) { connection.pendingForkRequests.clear() + this.drainPendingServerApprovals(connection) connection.client.close() connection.upstream.close() } @@ -270,6 +331,16 @@ export class CodexRemoteProxy { return () => this.turnCompletedHandlers.delete(handler) } + onApprovalRequested(handler: (event: CodexApprovalRequestEvent) => void): () => void { + this.approvalRequestedHandlers.add(handler) + return () => this.approvalRequestedHandlers.delete(handler) + } + + onApprovalResolved(handler: (event: { requestId: string }) => void): () => void { + this.approvalResolvedHandlers.add(handler) + return () => this.approvalResolvedHandlers.delete(handler) + } + onRepairTrigger(handler: (event: CodexRemoteProxyRepairTrigger) => void): () => void { this.repairTriggerHandlers.add(handler) return () => this.repairTriggerHandlers.delete(handler) @@ -300,6 +371,7 @@ export class CodexRemoteProxy { upstream, pendingMethods: new Map(), pendingForkRequests: new Map(), + pendingServerApprovals: new Set(), } this.connections.add(connection) if (this.requireCandidatePersistence) { @@ -324,6 +396,7 @@ export class CodexRemoteProxy { const closeBoth = () => { this.connections.delete(connection) connection.pendingForkRequests.clear() + this.drainPendingServerApprovals(connection) client.close() upstream.close() } @@ -469,6 +542,13 @@ export class CodexRemoteProxy { const id = envelope.id if (id !== undefined) { + if (typeof envelope.method === 'string') { + // id + method => a server->client REQUEST (our own responses never + // reach this path). Never consult pendingMethods for these -- the + // server's id space is not ours. + this.handleUpstreamServerRequest(connection, frame, id, envelope.method) + return + } const method = connection.pendingMethods.get(id) const forkRequest = connection.pendingForkRequests.get(id) if (method !== undefined) { @@ -502,6 +582,14 @@ export class CodexRemoteProxy { }, 'Codex remote proxy forwarding upstream notification') } + if (method === 'serverRequest/resolved') { + // Decision 5c: the app-server resolved its own request. Resolve the + // pending approval; relay the notification verbatim regardless. + this.handleServerRequestResolvedNotification(frame) + sendFrameIfOpen(connection.client, frame) + return + } + if (typeof method === 'string' && STATEFUL_NOTIFICATION_METHODS.has(method)) { this.handleStatefulUpstreamNotification(connection, frame, method) return @@ -510,6 +598,87 @@ export class CodexRemoteProxy { sendFrameIfOpen(connection.client, frame) } + /** + * A server->client JSON-RPC REQUEST (upstream frame carrying BOTH id and + * method). Approval-set methods are sniffed (decision 5) and recorded so a + * matching client response resolves them; methods in NEITHER the approval + * set nor the automated set are debug-logged to surface drift (decision 6). + * Every server request relays verbatim -- the proxy observes, never consumes. + */ + private handleUpstreamServerRequest( + connection: ProxyConnection, + frame: ProxyFrame, + id: JsonRpcId, + method: string, + ): void { + if (CODEX_APPROVAL_REQUEST_METHODS.has(method)) { + connection.pendingServerApprovals.add(id) + const threadId = extractApprovalThreadId(frame, method) + this.emitApprovalRequested({ + requestId: String(id), + method, + ...(threadId !== undefined ? { threadId } : {}), + }) + } else if (!AUTOMATED_SERVER_REQUEST_METHODS.has(method)) { + // Decision 6: the method set is version-fluid -- surface drift. + log.debug({ method }, 'unrecognized codex server->client request method (not treated as an approval)') + } + sendFrameIfOpen(connection.client, frame) + } + + /** + * Matches an upstream `serverRequest/resolved` notification's + * `params.requestId` against every connection's pending approval set (the + * request went out on this proxy's single upstream) and emits an approval + * resolution when it was pending. Best-effort: oversized/opaque frames + * resolve nothing (the teardown drain, decision 5b, remains the backstop). + */ + private handleServerRequestResolvedNotification(frame: ProxyFrame): void { + if (frame.byteLength > MAX_FULL_PARSE_BYTES) return + const parsed = parseJsonFrame(frame) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return + const params = (parsed as Record).params + if (!params || typeof params !== 'object' || Array.isArray(params)) return + const value = (params as Record).requestId + // The wire may carry the id as a string ("41") or a number (41); a pending + // numeric id must resolve either way. + const candidates: JsonRpcId[] = [] + let requestId: string + if (typeof value === 'string') { + candidates.push(value) + const numeric = Number(value) + if (Number.isInteger(numeric) && String(numeric) === value) candidates.push(numeric) + requestId = value + } else if (typeof value === 'number') { + candidates.push(value) + requestId = String(value) + } else { + return + } + let resolved = false + for (const connection of this.connections) { + for (const candidate of candidates) { + if (connection.pendingServerApprovals.delete(candidate)) resolved = true + } + } + if (resolved) { + this.emitApprovalResolved(requestId) + } + } + + /** + * Decision 5b: teardown/restart drains ALL pending approvals. A restarted + * app-server's per-process id counter starts at 0 again, so stale pending + * ids would collide with the next incarnation's fresh requests -- resolve + * them now rather than letting a tracker stay paused forever. + */ + private drainPendingServerApprovals(connection: ProxyConnection): void { + for (const id of connection.pendingServerApprovals) { + this.emitApprovalResolved(String(id)) + } + connection.pendingServerApprovals.clear() + } + private maybeEmitThreadStartResponseCandidate(parsed: unknown): void { if (!parsed || typeof parsed !== 'object') return const result = (parsed as Record).result @@ -835,6 +1004,15 @@ export class CodexRemoteProxy { ): void { if (request.id !== undefined && typeof request.method === 'string') { connection.pendingMethods.set(request.id, request.method) + } else if (request.id !== undefined && request.method === undefined) { + // A response frame: {id, result} OR {id, error} -- BOTH resolve a + // pending server approval (decision 5a; codex handles errors via + // process_error). The method-absence check is MANDATORY (decision 5d): + // a client REQUEST whose id numerically collides with a pending server + // approval must not resolve it. + if (connection.pendingServerApprovals.delete(request.id)) { + this.emitApprovalResolved(String(request.id)) + } } sendFrameIfOpen(connection.upstream, frame) } @@ -1137,6 +1315,18 @@ export class CodexRemoteProxy { } } + private emitApprovalRequested(event: CodexApprovalRequestEvent): void { + for (const handler of this.approvalRequestedHandlers) { + handler(event) + } + } + + private emitApprovalResolved(requestId: string): void { + for (const handler of this.approvalResolvedHandlers) { + handler({ requestId }) + } + } + private emitThreadLifecycle(event: CodexThreadLifecycleEvent): void { for (const handler of this.lifecycleHandlers) { handler(event) @@ -1212,6 +1402,22 @@ function extractThreadForkParentThreadId(frame: ProxyFrame): string | undefined return decodeJsonString(raw, threadId.valueStart, threadId.valueEnd) } +/** + * Best-effort thread pointer for a sniffed approval request: v2 methods carry + * `params.threadId`; legacy methods carry `params.conversationId` (decision 7, + * codex-rs v1.rs:126-158). Oversized/opaque frames yield undefined. + */ +function extractApprovalThreadId(frame: ProxyFrame, method: string): string | undefined { + if (frame.byteLength > MAX_FULL_PARSE_BYTES) return undefined + const parsed = parseJsonFrame(frame) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined + const params = (parsed as Record).params + if (!params || typeof params !== 'object' || Array.isArray(params)) return undefined + const key = LEGACY_APPROVAL_REQUEST_METHODS.has(method) ? 'conversationId' : 'threadId' + const value = (params as Record)[key] + return typeof value === 'string' && value.length > 0 ? value : undefined +} + function clientEnvelopeFailureMessage(reason: string): string { if (reason === 'batch_unsupported') { return 'Codex remote proxy rejected a JSON-RPC batch frame.' diff --git a/server/coding-cli/providers/codex.ts b/server/coding-cli/providers/codex.ts index e013be963..e69a297c1 100644 --- a/server/coding-cli/providers/codex.ts +++ b/server/coding-cli/providers/codex.ts @@ -271,6 +271,7 @@ export function parseCodexSessionContent(content: string): ParsedSessionMeta { let latestTaskStartedAt: number | undefined let latestTaskCompletedAt: number | undefined let latestTurnAbortedAt: number | undefined + let latestTurnAbortedReason: string | undefined for (const line of lines) { let obj: any @@ -360,7 +361,14 @@ export function parseCodexSessionContent(content: string): ParsedSessionMeta { latestTaskCompletedAt = maxTimestamp(latestTaskCompletedAt, timestampMs) break case 'turn_aborted': - latestTurnAbortedAt = maxTimestamp(latestTurnAbortedAt, timestampMs) + // Pair the reason with the winning timestamp (newest-wins): a newer + // reason-less abort clears a stale reason. Mirrors the Rust + // latest_turn_aborted_reason pairing. + if (timestampMs !== undefined && (latestTurnAbortedAt === undefined || timestampMs > latestTurnAbortedAt)) { + latestTurnAbortedAt = timestampMs + latestTurnAbortedReason = + typeof obj?.payload?.reason === 'string' ? obj.payload.reason : undefined + } break default: break @@ -380,6 +388,7 @@ export function parseCodexSessionContent(content: string): ParsedSessionMeta { ...(latestTaskStartedAt !== undefined ? { latestTaskStartedAt } : {}), ...(latestTaskCompletedAt !== undefined ? { latestTaskCompletedAt } : {}), ...(latestTurnAbortedAt !== undefined ? { latestTurnAbortedAt } : {}), + ...(latestTurnAbortedReason !== undefined ? { latestTurnAbortedReason } : {}), } satisfies CodexTaskEventSnapshot, } : {}), @@ -417,7 +426,13 @@ export function sanitizeCodexTaskEventsForTruncatedSnippet( ? { latestTaskCompletedAt: preserveTailStart ? tailCompletedAt : snapshot.latestTaskCompletedAt } : {}), ...((preserveTailStart ? tailTurnAbortedAt : snapshot.latestTurnAbortedAt) !== undefined - ? { latestTurnAbortedAt: preserveTailStart ? tailTurnAbortedAt : snapshot.latestTurnAbortedAt } + ? { + latestTurnAbortedAt: preserveTailStart ? tailTurnAbortedAt : snapshot.latestTurnAbortedAt, + // Keep the reason paired with whichever abort timestamp won. + ...((preserveTailStart ? tailSnapshot?.latestTurnAbortedReason : snapshot.latestTurnAbortedReason) !== undefined + ? { latestTurnAbortedReason: preserveTailStart ? tailSnapshot?.latestTurnAbortedReason : snapshot.latestTurnAbortedReason } + : {}), + } : {}), } diff --git a/server/coding-cli/truly-idle-emitter.ts b/server/coding-cli/truly-idle-emitter.ts index e448f7fbc..ffaf61d66 100644 --- a/server/coding-cli/truly-idle-emitter.ts +++ b/server/coding-cli/truly-idle-emitter.ts @@ -26,6 +26,19 @@ export type TrulyIdleActivityUpsert = { export type TrulyIdleActivityChange = { upsert?: TrulyIdleActivityUpsert[] remove?: string[] + /** + * Subset of `remove` caused by a spontaneous process death (PTY exit that no + * requested close — kill/kill_all/shutdown/unbind — asked for). These ring + * the death bell immediately when the terminal was engaged. + */ + spontaneousExitRemovals?: string[] + /** + * Subset of `remove` whose pending-approval set was non-empty at removal + * time (codex approval pauses — Node mirror of Rust has_pending_approvals). + * A pane blocked on an approval whose process dies spontaneously rings even + * after its 2s boundary already rang (busy=false, no armed timer). + */ + approvalPendingRemovals?: string[] } type TerminalIdleState = { @@ -57,7 +70,11 @@ function isBusyPhase(phase: string): boolean { * - A codex busy→pending re-arm (queued submit consumed at turn clear) counts * as queue evidence; codex emits its completion only when the queue drained. * - Deadman/signal-loss idle flips arrive WITHOUT a turn boundary and never - * arm; PTY exit / crash removals cancel any armed timer and never emit. + * arm; requested-close removals (tab close / terminal.close / shutdown / + * unbind) cancel any armed timer and never emit. A SPONTANEOUS exit removal + * (change.spontaneousExitRemovals) while engaged — confirmed busy, or an + * armed grace window — rings immediately: a dead process emits nothing + * further, so the pending bell must not be lost. * - OpenCode's genuine turn end arrives as activityRemove followed by * turnComplete: the removal clears state, the boundary then arms grace-only. * @@ -99,13 +116,25 @@ export class TrulyIdleEmitter extends EventEmitter { state.busy = nextBusy state.pending = nextPending } + const spontaneous = new Set(change.spontaneousExitRemovals ?? []) + const approvalPending = new Set(change.approvalPendingRemovals ?? []) for (const terminalId of change.remove ?? []) { const state = this.states.get(terminalId) if (!state) continue - // Exit/crash (or an opencode idle removal): never emit from here — only - // a subsequent turn boundary may re-arm. + // Engagement for the death bell (decision 3): CONFIRMED busy or an armed + // grace window. phase 'pending' is input-only (the Enter that executes a + // human /quit looks like a prompt submit) and NEVER counts. Approval + // waits (change.approvalPendingRemovals) OR in below: a pane blocked on + // an approval is engaged even after its boundary already rang. + const engaged = (state.busy && !state.pending) || state.graceTimer !== undefined this.cancelGrace(state) this.states.delete(terminalId) + if (spontaneous.has(terminalId) && (engaged || approvalPending.has(terminalId))) { + // Spontaneous process death while working: ring immediately — a dead + // process emits nothing further, and a queued prompt will never run. + // Requested closes (tab close / terminal.close / shutdown) never ring. + this.emit('idle', { terminalId, at: this.now(), reason: 'grace' } satisfies TrulyIdleEvent) + } } } @@ -190,12 +219,20 @@ export function wireTrulyIdleEmitter(input: { const onTurnComplete = (event: { terminalId: string; at: number }) => { emitter.noteTurnComplete(event) } + const onAttentionBoundary = (event: { terminalId: string; at: number }) => { + // Approval-pause boundary (codex only; a no-op stream for the other + // trackers): arms the same grace window — no terminal.turn.complete + // frame is involved. + emitter.noteTurnComplete(event) + } tracker.on('changed', onChanged) tracker.on('turn.complete', onTurnComplete) + tracker.on('attention.boundary', onAttentionBoundary) return { dispose(): void { tracker.off('changed', onChanged) tracker.off('turn.complete', onTurnComplete) + tracker.off('attention.boundary', onAttentionBoundary) emitter.dispose() }, } diff --git a/server/coding-cli/types.ts b/server/coding-cli/types.ts index ac90e9598..81c748da1 100644 --- a/server/coding-cli/types.ts +++ b/server/coding-cli/types.ts @@ -118,6 +118,12 @@ export interface CodexTaskEventSnapshot { latestTaskStartedAt?: number latestTaskCompletedAt?: number latestTurnAbortedAt?: number + /** + * The `reason` on the newest turn_aborted line, paired newest-wins with + * `latestTurnAbortedAt` (a newer reason-less abort clears a stale reason). + * Mirrors Rust `latest_turn_aborted_reason`. Absent on legacy lines. + */ + latestTurnAbortedReason?: string } export interface ErrorPayload { diff --git a/server/terminal-registry.ts b/server/terminal-registry.ts index 8874982b4..9823c5404 100644 --- a/server/terminal-registry.ts +++ b/server/terminal-registry.ts @@ -25,6 +25,8 @@ import type { LoopbackServerEndpoint } from './local-port.js' import { makeSessionKey, parseSessionKey, type CodingCliProviderName } from './coding-cli/types.js' import { SessionBindingAuthority, type BindResult } from './session-binding-authority.js' import type { + CodexApprovalRequestedEvent, + CodexApprovalResolvedEvent, CodexTurnCompletedEvent, CodexTurnStartedEvent, SessionBindingReason, @@ -607,6 +609,8 @@ export type TerminalRecord = { | 'onCandidate' | 'onTurnStarted' | 'onTurnCompleted' + | 'onApprovalRequested' + | 'onApprovalResolved' | 'onRepairTrigger' | 'onFsChanged' | 'watchPath' @@ -1275,6 +1279,21 @@ export function buildSpawnSpec( return { file: cmd, args, cwd: unixCwd, mcpCwd: unixCwd, env: cli ? { ...env, ...cli.env } : env } } +/** + * `params.turn?.status ?? params.status` -- the codex turn/completed status. + * Mirror of `freshell_codex::protocol::turn_status` and adapter.ts:922-923; + * handles both the small-frame nested form and the large-frame flattened form. + */ +function codexTurnStatus(params: Record): string | undefined { + const turn = params.turn + if (turn && typeof turn === 'object') { + const nested = (turn as Record).status + if (typeof nested === 'string') return nested + } + const status = params.status + return typeof status === 'string' ? status : undefined +} + export class TerminalRegistry extends EventEmitter { private terminals = new Map() private bindingAuthority = new SessionBindingAuthority() @@ -1490,6 +1509,12 @@ export class TerminalRegistry extends EventEmitter { record: TerminalRecord, event: { exitCode: number; signal?: number }, ): void { + // Requested closes (kill()/kill_all, shutdownGracefully's direct SIGTERMs) + // mark codexRecoveryFinalClose BEFORE exit dispatch — capture it at ENTRY, + // before markCodexRecoveryFinalClose below marks EVERY finishing record + // and would erase the signal (audit A7: a blanket `true` would ring death + // bells on server shutdown). + const requestedClose = record.codexRecoveryFinalClose === true this.clearCodexPendingCleanExitFinalizer(record) this.markCodexRecoveryFinalClose(record) this.clearCodexInputGate(record) @@ -1512,6 +1537,9 @@ export class TerminalRegistry extends EventEmitter { this.emit('terminal.exit', { terminalId: record.terminalId, exitCode: event.exitCode, + // Internal payload only (NOT the client wire frame above): true when no + // requested close asked for this exit — the death-bell discriminator. + spontaneous: !requestedClose, ...(recoverableForRestore ? { recoverableForRestore: true } : {}), }) this.forgetCodexDurabilityStoreRecord(record, 'pty_exit') @@ -1921,6 +1949,8 @@ export class TerminalRegistry extends EventEmitter { if (!isCurrentSidecar()) return this.emit('codex.turn.started', { terminalId: record.terminalId, + threadId: event.threadId, + ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), at: Date.now(), } satisfies CodexTurnStartedEvent) void this.handleCodexTurnStarted(record.terminalId, event).catch((err) => { @@ -1931,8 +1961,12 @@ export class TerminalRegistry extends EventEmitter { const turnCompletedUnsubscribe = sidecar.onTurnCompleted?.((event) => { if (!isCurrentSidecar()) return + const status = codexTurnStatus(event.params) this.emit('codex.turn.completed', { terminalId: record.terminalId, + threadId: event.threadId, + ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), + ...(status !== undefined ? { status } : {}), at: Date.now(), } satisfies CodexTurnCompletedEvent) void this.handleCodexTurnCompleted(record.terminalId, event).catch((err) => { @@ -1941,6 +1975,27 @@ export class TerminalRegistry extends EventEmitter { }) if (turnCompletedUnsubscribe) unsubscribers.push(turnCompletedUnsubscribe) + const approvalRequestedUnsubscribe = sidecar.onApprovalRequested?.((event) => { + if (!isCurrentSidecar()) return + this.emit('codex.approval.requested', { + terminalId: record.terminalId, + ...(event.threadId !== undefined ? { threadId: event.threadId } : {}), + requestId: event.requestId, + at: Date.now(), + } satisfies CodexApprovalRequestedEvent) + }) + if (approvalRequestedUnsubscribe) unsubscribers.push(approvalRequestedUnsubscribe) + + const approvalResolvedUnsubscribe = sidecar.onApprovalResolved?.((event) => { + if (!isCurrentSidecar()) return + this.emit('codex.approval.resolved', { + terminalId: record.terminalId, + requestId: event.requestId, + at: Date.now(), + } satisfies CodexApprovalResolvedEvent) + }) + if (approvalResolvedUnsubscribe) unsubscribers.push(approvalResolvedUnsubscribe) + const repairUnsubscribe = sidecar.onRepairTrigger?.((event) => { if (!isCurrentSidecar()) return if (event.kind === 'candidate_capture_timeout') { @@ -4070,6 +4125,8 @@ export class TerminalRegistry extends EventEmitter { this.emit('terminal.exit', { terminalId, exitCode: term.exitCode, + // kill() is always a requested close — never a death bell. + spontaneous: false, ...(options.recoverableForRestore ? { recoverableForRestore: true } : {}), }) this.forgetCodexDurabilityStoreRecord(term, 'user_final_close') diff --git a/server/terminal-stream/registry-events.ts b/server/terminal-stream/registry-events.ts index e00bdc8af..5f163b514 100644 --- a/server/terminal-stream/registry-events.ts +++ b/server/terminal-stream/registry-events.ts @@ -37,10 +37,52 @@ export type TerminalSessionUnboundEvent = { export type CodexTurnStartedEvent = { terminalId: string + /** + * The codex thread that emitted `turn/started` -- NOT necessarily the + * terminal's bound thread: sub-agent, review, and fork threads share the + * app-server connection (kata codex-turn-thread-scope, spike scenario D). + * Consumers MUST scope by the terminal's bound session id. + */ + threadId: string + turnId?: string + at: number +} + +/** + * A sniffed server->client approval request (codex remote proxy, Task 12): + * the app-server is blocked on a human until it resolves. Emitted on the + * registry as 'codex.approval.requested'. + */ +export type CodexApprovalRequestedEvent = { + terminalId: string + /** See CodexTurnStartedEvent.threadId -- may be a foreign thread or absent. */ + threadId?: string + /** Canonicalized JSON-RPC request id (string form). */ + requestId: string + at: number +} + +/** + * The approval resolved: a client response, an upstream serverRequest/resolved + * notification, or a proxy-teardown drain. Emitted on the registry as + * 'codex.approval.resolved'. + */ +export type CodexApprovalResolvedEvent = { + terminalId: string + requestId: string at: number } export type CodexTurnCompletedEvent = { terminalId: string + /** See CodexTurnStartedEvent.threadId -- may be a foreign thread. */ + threadId: string + turnId?: string + /** + * Raw turn status: 'completed' | 'interrupted' | 'failed' | 'inProgress' + * (absent on older protocol forms). Only 'completed' is a positive, + * bell-worthy completion -- see shared/ws-protocol.ts terminal.idle. + */ + status?: string at: number } diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index 71a31fad2..a1df2a8aa 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -197,12 +197,24 @@ export const TerminalTurnCompleteSchema = z.object({ }) /** - * Truly-idle edge for terminal-mode CLI panes (claude/codex/opencode/amplifier). - * Emitted once per busy -> truly-idle transition, after a grace window with no - * new session-file activity AND no detectable queued user prompt. Never emitted - * after crash/interrupt/exit; subagent completions inside a running turn never - * produce it. This is the ONLY edge the client rings/shades on for terminal CLI - * panes ('terminal.turn.complete' stays informational for them). + * Attention edge for terminal-mode CLI panes (claude/codex/opencode/amplifier): + * "the agent stopped making progress and you don't already know". Emitted once + * per attention transition. Rings for: completed turns (after a grace window + * with no new activity and no detectable queued prompt), FAILED turns, + * non-human rollout abort reasons (forward-compatible policy — no live codex + * <= 0.147 emits one), spontaneous process death while ENGAGED (confirmed + * turn, armed grace window, or pending approval; immediate — no grace), and + * approval-request pauses (managed codex only; unmanaged/PTY-only codex has + * no approval signal). NEVER emitted after a HUMAN-REQUESTED stop: + * Esc/interrupt (turn.status 'interrupted', abort reason + * 'interrupted'/'replaced'), slash-command quits from an idle pane + * (input-only pending state never counts as death-bell engagement), tab + * close, terminal.close, or server shutdown (including graceful-shutdown + * SIGTERMs). Subagent completions inside a running turn never produce it. + * Queued input suppresses completion bells (work continues) but NOT death + * bells (a dead process never runs the queue) and NOT approval bells (still + * blocked on the human). This is the ONLY edge the client rings/shades on + * for terminal CLI panes ('terminal.turn.complete' stays informational). * * Pinned wire contract shared with the Rust server port - do not change * unilaterally: { terminalId, at (server epoch ms), reason: 'grace' | 'queue-empty' }. diff --git a/test/unit/server/coding-cli/amplifier-activity-tracker.test.ts b/test/unit/server/coding-cli/amplifier-activity-tracker.test.ts index 62e31afb6..c7ef959d5 100644 --- a/test/unit/server/coding-cli/amplifier-activity-tracker.test.ts +++ b/test/unit/server/coding-cli/amplifier-activity-tracker.test.ts @@ -57,6 +57,18 @@ describe('AmplifierActivityTracker', () => { expect(changes.at(-1)).toEqual({ upsert: [], remove: ['t1'] }) }) + it('marks a spontaneous exit removal so the death bell can ring', () => { + const { tracker, changes } = setup() + tracker.trackTerminal({ terminalId: 't1', at: 1000 }) + tracker.noteExit({ terminalId: 't1', spontaneous: true }) + expect(tracker.getActivity('t1')).toBeUndefined() + expect(changes.at(-1)).toEqual({ + upsert: [], + remove: ['t1'], + spontaneousExitRemovals: ['t1'], + }) + }) + it('list() reflects current records', () => { const { tracker } = setup() tracker.trackTerminal({ terminalId: 't1', at: 1000 }) diff --git a/test/unit/server/coding-cli/claude-activity-tracker.test.ts b/test/unit/server/coding-cli/claude-activity-tracker.test.ts index fd900b16b..75007f173 100644 --- a/test/unit/server/coding-cli/claude-activity-tracker.test.ts +++ b/test/unit/server/coding-cli/claude-activity-tracker.test.ts @@ -109,6 +109,18 @@ describe('ClaudeActivityTracker', () => { expect(changes.at(-1)).toEqual({ upsert: [], remove: ['t1'] }) }) + it('marks a spontaneous exit removal so the death bell can ring', () => { + const { tracker, changes } = setup() + tracker.trackTerminal({ terminalId: 't1', at: 1000 }) + tracker.noteExit({ terminalId: 't1', spontaneous: true }) + expect(tracker.getActivity('t1')).toBeUndefined() + expect(changes.at(-1)).toEqual({ + upsert: [], + remove: ['t1'], + spontaneousExitRemovals: ['t1'], + }) + }) + it('list() reflects current records', () => { const { tracker } = setup() tracker.trackTerminal({ terminalId: 't1', at: 1000 }) diff --git a/test/unit/server/coding-cli/codex-activity-tracker.test.ts b/test/unit/server/coding-cli/codex-activity-tracker.test.ts index 4877edcf0..460407d5e 100644 --- a/test/unit/server/coding-cli/codex-activity-tracker.test.ts +++ b/test/unit/server/coding-cli/codex-activity-tracker.test.ts @@ -944,6 +944,36 @@ describe('CodexActivityTracker', () => { expect(tracker.getActivity('term-1')).toBeUndefined() }) + it('marks a spontaneous exit removal so the death bell can ring, while unbind stays flag-less', () => { + const tracker = new CodexActivityTracker() + const changes: Array<{ upsert: unknown[]; remove: string[]; spontaneousExitRemovals?: string[] }> = [] + tracker.on('changed', (change) => changes.push(change)) + + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.noteExit({ terminalId: 'term-1', at: 1_100, spontaneous: true }) + expect(changes.at(-1)).toEqual({ + upsert: [], + remove: ['term-1'], + spontaneousExitRemovals: ['term-1'], + }) + + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-2', + reason: 'association', + session: createSession('session-2'), + at: 1_200, + }) + tracker.unbindTerminal({ terminalId: 'term-1', at: 1_300 }) + expect(changes.at(-1)).toEqual({ upsert: [], remove: ['term-1'] }) + }) + it('expires stale pending back to idle after the fresh-snapshot grace and stale busy to unknown', () => { const tracker = new CodexActivityTracker() @@ -1023,7 +1053,7 @@ describe('CodexActivityTracker', () => { const events = collectCompletions(tracker) tracker.bindTerminal({ terminalId: 'term-1', sessionId: 'session-1', reason: 'association', session: createSession('session-1'), at: 1_000 }) - tracker.onTurnStarted({ terminalId: 'term-1', at: 1_100 }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', at: 1_100 }) expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'busy', @@ -1032,7 +1062,7 @@ describe('CodexActivityTracker', () => { }) expect(tracker.isPromptBlocked('term-1')).toBe(true) - tracker.onTurnCompleted({ terminalId: 'term-1', at: 1_200 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', at: 1_200 }) expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle', @@ -1048,8 +1078,8 @@ describe('CodexActivityTracker', () => { const events = collectCompletions(tracker) tracker.bindTerminal({ terminalId: 'term-1', sessionId: 'session-1', reason: 'association', session: createSession('session-1'), at: 1_000 }) - tracker.onTurnStarted({ terminalId: 'term-1', at: 1_100 }) - tracker.onTurnCompleted({ terminalId: 'term-1', at: 1_200 }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', at: 1_100 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', at: 1_200 }) tracker.noteOutput({ terminalId: 'term-1', data: '\x07', at: 1_250 }) tracker.reconcileProjects(createProjects(createSession('session-1', { latestTaskStartedAt: 1_100, @@ -1066,7 +1096,7 @@ describe('CodexActivityTracker', () => { tracker.bindTerminal({ terminalId: 'term-1', sessionId: 'session-1', reason: 'association', session: createSession('session-1'), at: 1_000 }) tracker.noteInput({ terminalId: 'term-1', data: '\r', at: 1_100 }) - tracker.onTurnCompleted({ terminalId: 'term-1', at: 1_200 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', at: 1_200 }) expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle', @@ -1142,3 +1172,711 @@ describe('CodexActivityTracker', () => { }) }) }) + +describe('thread-scoped app-server turn events (kata codex-turn-thread-scope)', () => { + it('ignores a sub-agent thread completion mid-parent-turn (spike scenario D)', () => { + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'thread-parent', + reason: 'association', + session: createSession('thread-parent'), + at: 1_000, + }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'thread-parent', turnId: 'turn-parent', at: 1_100 }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'busy' }) + + // Sub-agent child thread completes while the parent turn is running. + tracker.onTurnCompleted({ + terminalId: 'term-1', + threadId: 'thread-child', + turnId: 'turn-child', + status: 'completed', + at: 1_200, + }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'busy' }) + expect(completions).toEqual([]) + + // The parent's real completion still rings exactly once. + tracker.onTurnCompleted({ + terminalId: 'term-1', + threadId: 'thread-parent', + turnId: 'turn-parent', + status: 'completed', + at: 1_300, + }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + expect(completions).toEqual([{ terminalId: 'term-1', sessionId: 'thread-parent', at: 1_300, completionSeq: 1 }]) + }) + + it('ignores a foreign thread turn start', () => { + const tracker = new CodexActivityTracker() + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'thread-parent', + reason: 'association', + session: createSession('thread-parent'), + at: 1_000, + }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'thread-child', turnId: 'turn-c', at: 1_100 }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + }) + + it('interrupted status clears busy without recording a completion', () => { + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', at: 1_100 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', status: 'interrupted', at: 1_200 }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + expect(completions).toEqual([]) + }) + + it('records a completion when the bound thread turn fails', () => { + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', at: 1_100 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', status: 'failed', at: 1_200 }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + expect(completions).toHaveLength(1) + }) + + it('failed with a queued submit behaves exactly like completed with a queued submit', () => { + const tracker1 = new CodexActivityTracker() + const completions1: unknown[] = [] + tracker1.on('turn.complete', (event) => completions1.push(event)) + tracker1.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker1.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', at: 1_100 }) + tracker1.noteInput({ terminalId: 'term-1', data: '\r', at: 1_150 }) + tracker1.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', status: 'completed', at: 1_200 }) + + const tracker2 = new CodexActivityTracker() + const completions2: unknown[] = [] + tracker2.on('turn.complete', (event) => completions2.push(event)) + tracker2.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker2.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', at: 1_100 }) + tracker2.noteInput({ terminalId: 'term-1', data: '\r', at: 1_150 }) + tracker2.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', status: 'failed', at: 1_200 }) + + expect(completions1).toEqual(completions2) + }) + + it('does not advance lastSeenTaskCompletedAt on interrupted or failed turns', () => { + const tracker = new CodexActivityTracker() + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + + // First, a completed turn sets the timestamp + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', at: 1_100 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', status: 'completed', at: 1_200 }) + expect(tracker.getActivity('term-1')).toMatchObject({ lastSeenTaskCompletedAt: 1_200 }) + + // An interrupted turn should NOT advance the timestamp + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-2', at: 1_300 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-2', status: 'interrupted', at: 1_400 }) + expect(tracker.getActivity('term-1')).toMatchObject({ lastSeenTaskCompletedAt: 1_200 }) + + // A failed turn should NOT advance the timestamp + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-3', at: 1_500 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-3', status: 'failed', at: 1_600 }) + expect(tracker.getActivity('term-1')).toMatchObject({ lastSeenTaskCompletedAt: 1_200 }) + + // Another completed turn SHOULD advance the timestamp + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-4', at: 1_700 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-4', status: 'completed', at: 1_800 }) + expect(tracker.getActivity('term-1')).toMatchObject({ lastSeenTaskCompletedAt: 1_800 }) + }) + + it('inProgress status is a strict no-op', () => { + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', at: 1_100 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', status: 'inProgress', at: 1_200 }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'busy' }) + expect(completions).toEqual([]) + }) + + it('absent status still records a completion (older protocol forms)', () => { + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', at: 1_100 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', at: 1_200 }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + expect(completions).toHaveLength(1) + }) + + it('ignores a stale completion for a previous turn id', () => { + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-2', at: 1_100 }) + // Late echo for an OLDER turn while turn-2 runs: no-op by construction. + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', status: 'completed', at: 1_150 }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'busy' }) + expect(completions).toEqual([]) + // turn-2's real completion still rings. + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-2', status: 'completed', at: 1_300 }) + expect(completions).toHaveLength(1) + }) + + it('clears the in-flight turn id at accepted completion so the next turn is not swallowed', () => { + // start turn-1, complete it (status 'completed'); start turn-2, complete + // turn-2 — assert the second completion records (not rejected as stale). + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + // start turn-1 and complete it + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', at: 1_100 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', status: 'completed', at: 1_200 }) + expect(completions).toHaveLength(1) + // currentTurnId should have been cleared after turn-1 completed + expect(tracker.getActivity('term-1')?.currentTurnId).toBeUndefined() + // start turn-2 and complete it + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-2', at: 1_300 }) + expect(tracker.getActivity('term-1')?.currentTurnId).toBe('turn-2') + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-2', status: 'completed', at: 1_400 }) + // second completion should have recorded + expect(completions).toHaveLength(2) + // and currentTurnId should have been cleared again + expect(tracker.getActivity('term-1')?.currentTurnId).toBeUndefined() + }) +}) + +describe('reconcile turn_aborted de-chime (kata codex-turn-thread-scope)', () => { + it('turn_aborted clears busy without recording a completion', () => { + // SEMANTIC CHANGE: shared/ws-protocol.ts terminal.idle is "never emitted + // after crash/interrupt/exit" -- an Esc-interrupt (turn_aborted in the + // rollout JSONL) must return the pane to idle silently. + // + // REFINEMENT (abortReasonIsHuman): this snapshot carries no + // latestTurnAbortedReason (legacy line / uncertainty), which stays + // SILENT; only a present, non-human reason rings (see tests below). + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.noteInput({ terminalId: 'term-1', data: '\r', at: 1_100 }) + tracker.reconcileProjects( + createProjects(createSession('session-1', { latestTaskStartedAt: 1_150 })), + 1_200, + ) + tracker.reconcileProjects( + createProjects(createSession('session-1', { + latestTaskStartedAt: 1_150, + latestTurnAbortedAt: 1_180, + })), + 1_300, + ) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + expect(completions).toEqual([]) + }) + + it('a task_complete at or after an abort still records a completion', () => { + // Tie-break: abort suppresses the chime only when STRICTLY newest. + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.noteInput({ terminalId: 'term-1', data: '\r', at: 1_100 }) + tracker.reconcileProjects( + createProjects(createSession('session-1', { latestTaskStartedAt: 1_150 })), + 1_200, + ) + tracker.reconcileProjects( + createProjects(createSession('session-1', { + latestTaskStartedAt: 1_150, + latestTaskCompletedAt: 1_180, + latestTurnAbortedAt: 1_180, + })), + 1_300, + ) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + expect(completions).toHaveLength(1) + }) + + // Locked decision 2 (mirrors Rust abort_reason_is_human): reason + // 'interrupted' or 'replaced' -> human-requested, silent; reason MISSING -> + // legacy/uncertainty, silent; any OTHER present reason -> not + // human-attributed, records a completion (rings). Forward-compatible: no + // live codex writes a ring-worthy reason today. + it('an abort with reason "interrupted" clears busy silently', () => { + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.noteInput({ terminalId: 'term-1', data: '\r', at: 1_100 }) + tracker.reconcileProjects( + createProjects(createSession('session-1', { latestTaskStartedAt: 1_150 })), + 1_200, + ) + tracker.reconcileProjects( + createProjects(createSession('session-1', { + latestTaskStartedAt: 1_150, + latestTurnAbortedAt: 1_180, + latestTurnAbortedReason: 'interrupted', + })), + 1_300, + ) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + expect(completions).toEqual([]) + }) + + it('an abort with reason "replaced" clears busy silently', () => { + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.noteInput({ terminalId: 'term-1', data: '\r', at: 1_100 }) + tracker.reconcileProjects( + createProjects(createSession('session-1', { latestTaskStartedAt: 1_150 })), + 1_200, + ) + tracker.reconcileProjects( + createProjects(createSession('session-1', { + latestTaskStartedAt: 1_150, + latestTurnAbortedAt: 1_180, + latestTurnAbortedReason: 'replaced', + })), + 1_300, + ) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + expect(completions).toEqual([]) + }) + + it('an abort with an unknown (non-human) reason records a completion', () => { + const tracker = new CodexActivityTracker() + const completions: unknown[] = [] + tracker.on('turn.complete', (event) => completions.push(event)) + tracker.bindTerminal({ + terminalId: 'term-1', + sessionId: 'session-1', + reason: 'association', + session: createSession('session-1'), + at: 1_000, + }) + tracker.noteInput({ terminalId: 'term-1', data: '\r', at: 1_100 }) + tracker.reconcileProjects( + createProjects(createSession('session-1', { latestTaskStartedAt: 1_150 })), + 1_200, + ) + tracker.reconcileProjects( + createProjects(createSession('session-1', { + latestTaskStartedAt: 1_150, + latestTurnAbortedAt: 1_180, + latestTurnAbortedReason: 'review_ended', + })), + 1_300, + ) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + expect(completions).toHaveLength(1) + }) +}) + +describe('approval pause semantics (Task 12, Node mirror of Rust Task 7)', () => { + type Collected = { + changes: Array<{ upsert: Array<{ phase: string }>; remove: string[] } & Record> + boundaries: Array<{ terminalId: string; at: number }> + completions: unknown[] + } + + function collect(tracker: CodexActivityTracker): Collected { + const collected: Collected = { changes: [], boundaries: [], completions: [] } + tracker.on('changed', (change) => collected.changes.push(change)) + tracker.on('attention.boundary', (event) => collected.boundaries.push(event)) + tracker.on('turn.complete', (event) => collected.completions.push(event)) + return collected + } + + function busyUpserts(collected: Collected): unknown[] { + return collected.changes.flatMap((change) => change.upsert.filter((record) => record.phase === 'busy')) + } + + function bindBusy(tracker: CodexActivityTracker, reason: 'start' | 'resume' = 'start'): void { + tracker.bindTerminal({ + terminalId: 't1', + sessionId: 'thread-1', + reason, + session: createSession('thread-1'), + at: 1_000, + }) + tracker.onTurnStarted({ terminalId: 't1', threadId: 'thread-1', turnId: 'turn-1', at: 2_000 }) + } + + it('pauses busy to idle and arms an attention boundary without a turn completion', () => { + const tracker = new CodexActivityTracker() + bindBusy(tracker) + const collected = collect(tracker) + + tracker.onApprovalRequested({ terminalId: 't1', threadId: 'thread-1', requestId: '41', at: 3_000 }) + + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'idle' }) + expect(collected.changes).toHaveLength(1) + expect(collected.changes[0]!.upsert).toEqual([expect.objectContaining({ phase: 'idle' })]) + expect(collected.boundaries).toEqual([{ terminalId: 't1', at: 3_000 }]) + expect(collected.completions).toEqual([]) + }) + + it('returns to busy when the approval resolves', () => { + const tracker = new CodexActivityTracker() + bindBusy(tracker) + tracker.onApprovalRequested({ terminalId: 't1', threadId: 'thread-1', requestId: '41', at: 3_000 }) + + tracker.onApprovalResolved({ terminalId: 't1', requestId: '41', at: 4_000 }) + + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'busy' }) + }) + + it('stays idle on resolve when the approval arrived while already idle', () => { + const tracker = new CodexActivityTracker() + tracker.bindTerminal({ + terminalId: 't1', + sessionId: 'thread-1', + reason: 'start', + session: createSession('thread-1'), + at: 1_000, + }) + const collected = collect(tracker) + + tracker.onApprovalRequested({ terminalId: 't1', threadId: 'thread-1', requestId: '41', at: 3_000 }) + tracker.onApprovalResolved({ terminalId: 't1', requestId: '41', at: 4_000 }) + + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'idle' }) + expect(busyUpserts(collected)).toEqual([]) + }) + + it('ignores a foreign-thread approval request (sub-agent approvals must not ring the parent pane)', () => { + const tracker = new CodexActivityTracker() + bindBusy(tracker) + const collected = collect(tracker) + + tracker.onApprovalRequested({ terminalId: 't1', threadId: 'subagent-thread', requestId: '41', at: 3_000 }) + + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'busy' }) + expect(collected.changes).toEqual([]) + expect(collected.boundaries).toEqual([]) + }) + + it('accepts an approval request without a threadId (the proxy is per-terminal)', () => { + const tracker = new CodexActivityTracker() + bindBusy(tracker) + const collected = collect(tracker) + + tracker.onApprovalRequested({ terminalId: 't1', requestId: '41', at: 3_000 }) + + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'idle' }) + expect(collected.boundaries).toEqual([{ terminalId: 't1', at: 3_000 }]) + }) + + it('does not let a queued submit block the approval boundary (still blocked on a human)', () => { + const tracker = new CodexActivityTracker() + bindBusy(tracker) + tracker.noteInput({ terminalId: 't1', data: 'queued message\r', at: 2_500 }) + const collected = collect(tracker) + + tracker.onApprovalRequested({ terminalId: 't1', threadId: 'thread-1', requestId: '41', at: 3_000 }) + + expect(collected.boundaries).toEqual([{ terminalId: 't1', at: 3_000 }]) + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'idle' }) + }) + + it('clears pending approvals at turn completion so a late resolve is a no-op', () => { + const tracker = new CodexActivityTracker() + bindBusy(tracker) + tracker.onApprovalRequested({ terminalId: 't1', threadId: 'thread-1', requestId: '41', at: 3_000 }) + tracker.onTurnCompleted({ + terminalId: 't1', + threadId: 'thread-1', + turnId: 'turn-1', + status: 'completed', + at: 5_000, + }) + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'idle' }) + const collected = collect(tracker) + + tracker.onApprovalResolved({ terminalId: 't1', requestId: '41', at: 6_000 }) + + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'idle' }) + expect(collected.changes).toEqual([]) + }) + + // The next three tests attach the completion collector BEFORE + // onTurnCompleted (the test above attaches it after, which masked the + // mid-pause double-ring): a completion arriving while the approval pause + // holds the phase at idle must be a SILENT claim -- the approval bell + // already covers this attention event, and the surviving anchors must not + // let a later PTY BEL echo re-mint the same physical turn. + it('a turn completing mid-pause records nothing (status completed)', () => { + const tracker = new CodexActivityTracker() + bindBusy(tracker) + tracker.onApprovalRequested({ terminalId: 't1', threadId: 'thread-1', requestId: '41', at: 3_000 }) + const collected = collect(tracker) + + tracker.onTurnCompleted({ + terminalId: 't1', + threadId: 'thread-1', + turnId: 'turn-1', + status: 'completed', + at: 5_000, + }) + + expect(collected.completions).toEqual([]) + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'idle' }) + }) + + it('a turn completing mid-pause records nothing (status failed)', () => { + const tracker = new CodexActivityTracker() + bindBusy(tracker) + tracker.onApprovalRequested({ terminalId: 't1', threadId: 'thread-1', requestId: '41', at: 3_000 }) + const collected = collect(tracker) + + tracker.onTurnCompleted({ + terminalId: 't1', + threadId: 'thread-1', + turnId: 'turn-1', + status: 'failed', + at: 5_000, + }) + + expect(collected.completions).toEqual([]) + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'idle' }) + }) + + it('a BEL echo after a mid-pause turn end mints nothing (anchors are claimed)', () => { + const tracker = new CodexActivityTracker() + bindBusy(tracker) + tracker.onApprovalRequested({ terminalId: 't1', threadId: 'thread-1', requestId: '41', at: 3_000 }) + const collected = collect(tracker) + + tracker.onTurnCompleted({ + terminalId: 't1', + threadId: 'thread-1', + turnId: 'turn-1', + status: 'completed', + at: 5_000, + }) + expect(tracker.getActivity('t1')?.acceptedStartAt).toBeUndefined() + expect(tracker.getActivity('t1')?.pendingSubmitAt).toBeUndefined() + + // The codex TUI's turn-complete BEL echo of that same physical turn. + tracker.noteOutput({ terminalId: 't1', data: '\u0007', at: 5_100 }) + + expect(collected.completions).toEqual([]) + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'idle' }) + }) + + it('a duplicate approval request frame does not re-arm the boundary', () => { + const tracker = new CodexActivityTracker() + bindBusy(tracker) + const collected = collect(tracker) + + tracker.onApprovalRequested({ terminalId: 't1', threadId: 'thread-1', requestId: '41', at: 3_000 }) + tracker.onApprovalRequested({ terminalId: 't1', threadId: 'thread-1', requestId: '41', at: 3_500 }) + + expect(collected.boundaries).toEqual([{ terminalId: 't1', at: 3_000 }]) + }) + + it('reconcile task_started landing mid-pause folds anchors without flipping busy (audit A9)', () => { + const tracker = new CodexActivityTracker() + bindBusy(tracker, 'resume') + tracker.onApprovalRequested({ terminalId: 't1', threadId: 'thread-1', requestId: '41', at: 3_000 }) + const collected = collect(tracker) + + tracker.reconcileProjects( + createProjects(createSession('thread-1', { latestTaskStartedAt: 3_500 })), + 3_500, + ) + + expect(busyUpserts(collected)).toEqual([]) + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'idle', acceptedStartAt: 3_500 }) + + tracker.onApprovalResolved({ terminalId: 't1', requestId: '41', at: 4_000 }) + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'busy' }) + }) + + it('a resume re-announce mid-pause does not promote idle to busy (audit A9)', () => { + const tracker = new CodexActivityTracker() + bindBusy(tracker) + tracker.onApprovalRequested({ terminalId: 't1', threadId: 'thread-1', requestId: '41', at: 3_000 }) + const collected = collect(tracker) + + tracker.bindTerminal({ + terminalId: 't1', + sessionId: 'thread-1', + reason: 'resume', + session: createSession('thread-1', { latestTaskStartedAt: 2_000 }), + at: 3_500, + }) + + expect(busyUpserts(collected)).toEqual([]) + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'idle' }) + + tracker.onApprovalResolved({ terminalId: 't1', requestId: '41', at: 4_000 }) + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'busy' }) + }) + + it('resolve normalizes pending-submit input state planted by a mid-pause Enter (audit A9 hazard 2)', () => { + const tracker = new CodexActivityTracker() + bindBusy(tracker) + tracker.onApprovalRequested({ terminalId: 't1', threadId: 'thread-1', requestId: '41', at: 3_000 }) + tracker.noteInput({ terminalId: 't1', data: '\r', at: 3_500 }) // answering the approval prompt + tracker.onApprovalResolved({ terminalId: 't1', requestId: '41', at: 4_000 }) + + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'busy' }) + expect(tracker.getActivity('t1')?.pendingSubmitAt).toBeUndefined() + expect(tracker.getActivity('t1')?.pendingUntil).toBeUndefined() + expect(tracker.getActivity('t1')?.pendingFreshnessAt).toBeUndefined() + + const collected = collect(tracker) + tracker.onTurnCompleted({ + terminalId: 't1', + threadId: 'thread-1', + turnId: 'turn-1', + status: 'completed', + at: 6_000, + }) + + expect(collected.completions).toHaveLength(1) + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'idle' }) + const pendingUpserts = collected.changes.flatMap((change) => + change.upsert.filter((record) => record.phase === 'pending')) + expect(pendingUpserts).toEqual([]) + }) + + it('rebinding to a different thread drops the pause state', () => { + const tracker = new CodexActivityTracker() + bindBusy(tracker) + tracker.onApprovalRequested({ terminalId: 't1', threadId: 'thread-1', requestId: '41', at: 3_000 }) + + tracker.bindTerminal({ + terminalId: 't1', + sessionId: 'thread-2', + reason: 'resume', + session: createSession('thread-2'), + at: 4_000, + }) + const collected = collect(tracker) + + tracker.onApprovalResolved({ terminalId: 't1', requestId: '41', at: 5_000 }) + + expect(tracker.getActivity('t1')).toMatchObject({ phase: 'idle' }) + expect(collected.changes).toEqual([]) + }) + + it('a removal with a non-empty pending-approval set carries approvalPendingRemovals (decision 3)', () => { + const tracker = new CodexActivityTracker() + bindBusy(tracker) + tracker.onApprovalRequested({ terminalId: 't1', threadId: 'thread-1', requestId: '41', at: 3_000 }) + const collected = collect(tracker) + + tracker.noteExit({ terminalId: 't1', at: 5_000, spontaneous: true }) + + expect(collected.changes).toEqual([{ + upsert: [], + remove: ['t1'], + spontaneousExitRemovals: ['t1'], + approvalPendingRemovals: ['t1'], + }]) + }) + + it('a removal without pending approvals omits approvalPendingRemovals', () => { + const tracker = new CodexActivityTracker() + bindBusy(tracker) + const collected = collect(tracker) + + tracker.noteExit({ terminalId: 't1', at: 5_000, spontaneous: true }) + + expect(collected.changes).toEqual([{ + upsert: [], + remove: ['t1'], + spontaneousExitRemovals: ['t1'], + }]) + }) +}) diff --git a/test/unit/server/coding-cli/codex-activity-wiring.test.ts b/test/unit/server/coding-cli/codex-activity-wiring.test.ts index ad4741ba9..9bf82f30e 100644 --- a/test/unit/server/coding-cli/codex-activity-wiring.test.ts +++ b/test/unit/server/coding-cli/codex-activity-wiring.test.ts @@ -46,14 +46,20 @@ describe('wireCodexActivityTracker', () => { sessionId: 'session-1', reason: 'association', }) - registry.emit('codex.turn.started', { terminalId: 'term-1', at: 1_100 }) + registry.emit('codex.turn.started', { terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', at: 1_100 }) expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'busy', acceptedStartAt: 1_100, }) - registry.emit('codex.turn.completed', { terminalId: 'term-1', at: 1_200 }) + registry.emit('codex.turn.completed', { + terminalId: 'term-1', + threadId: 'session-1', + turnId: 'turn-1', + status: 'completed', + at: 1_200, + }) expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle', @@ -77,10 +83,48 @@ describe('wireCodexActivityTracker', () => { expect(registry.listenerCount('codex.turn.started')).toBe(1) expect(registry.listenerCount('codex.turn.completed')).toBe(1) + expect(registry.listenerCount('codex.approval.requested')).toBe(1) + expect(registry.listenerCount('codex.approval.resolved')).toBe(1) dispose() expect(registry.listenerCount('codex.turn.started')).toBe(0) expect(registry.listenerCount('codex.turn.completed')).toBe(0) + expect(registry.listenerCount('codex.approval.requested')).toBe(0) + expect(registry.listenerCount('codex.approval.resolved')).toBe(0) + }) + + it('feeds approval registry events into the tracker (Task 12)', () => { + const registry = new FakeRegistry() + const indexer = new FakeCodingCliIndexer() + const { tracker, dispose } = wireCodexActivityTracker({ + registry: registry as any, + codingCliIndexer: indexer, + now: () => 1_000, + setIntervalFn: (() => 0) as any, + clearIntervalFn: vi.fn() as any, + }) + + registry.emit('terminal.session.bound', { + terminalId: 'term-1', + provider: 'codex', + sessionId: 'session-1', + reason: 'start', + }) + registry.emit('codex.turn.started', { terminalId: 'term-1', threadId: 'session-1', turnId: 'turn-1', at: 1_100 }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'busy' }) + + registry.emit('codex.approval.requested', { + terminalId: 'term-1', + threadId: 'session-1', + requestId: '41', + at: 1_200, + }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'idle' }) + + registry.emit('codex.approval.resolved', { terminalId: 'term-1', requestId: '41', at: 1_300 }) + expect(tracker.getActivity('term-1')).toMatchObject({ phase: 'busy' }) + + dispose() }) }) diff --git a/test/unit/server/coding-cli/codex-app-server/remote-proxy.test.ts b/test/unit/server/coding-cli/codex-app-server/remote-proxy.test.ts index ffed93b61..b2e1bd0ad 100644 --- a/test/unit/server/coding-cli/codex-app-server/remote-proxy.test.ts +++ b/test/unit/server/coding-cli/codex-app-server/remote-proxy.test.ts @@ -2063,3 +2063,187 @@ describe('CodexRemoteProxy', () => { })) }) }) + +describe('CodexRemoteProxy approval sniffing (decision 5/6/7)', () => { + const approvalRequestFrame = JSON.stringify({ + jsonrpc: '2.0', + id: 41, + method: 'item/commandExecution/requestApproval', + params: { threadId: 'thread-1', command: 'rm -rf /tmp/x' }, + }) + + async function startApprovalHarness(upstreamFrames: string[]): Promise<{ + upstream: UpstreamHandle + proxy: CodexRemoteProxy + tui: WebSocket + requested: unknown[] + resolved: unknown[] + relayed: Promise> + }> { + const upstream = await startUpstream((socket, message) => { + if ((message as { method?: string }).method === 'initialize') { + for (const frame of upstreamFrames) socket.send(frame) + } + }) + const proxy = await startProxy(upstream.wsUrl, { requireCandidatePersistence: false }) + const requested: unknown[] = [] + const resolved: unknown[] = [] + proxy.onApprovalRequested((event) => requested.push(event)) + proxy.onApprovalResolved((event) => resolved.push(event)) + const tui = await connect(proxy.wsUrl) + const relayed = collectRawFrames(tui, upstreamFrames.length) + tui.send(JSON.stringify({ id: 1, method: 'initialize', params: {} })) + return { upstream, proxy, tui, requested, resolved, relayed } + } + + async function upstreamMessageCount(upstream: UpstreamHandle): Promise { + return upstream.messages.length + } + + async function waitForUpstreamMessage(upstream: UpstreamHandle, predicate: (message: any) => boolean, ms = 500): Promise { + const deadline = Date.now() + ms + while (Date.now() < deadline) { + const found = upstream.messages.find((message) => predicate(message)) + if (found !== undefined) return found + await delay(5) + } + throw new Error(`Timed out waiting ${ms}ms for upstream message.`) + } + + // 1. approval request frame emits approval requested and relays verbatim + it('emits approval requested for a sniffed approval request and relays the frame verbatim', async () => { + const { requested, relayed } = await startApprovalHarness([approvalRequestFrame]) + const frames = await relayed + expect(frames[0]!.raw.toString()).toBe(approvalRequestFrame) + expect(requested).toEqual([{ + requestId: '41', + method: 'item/commandExecution/requestApproval', + threadId: 'thread-1', + }]) + }) + + // 2. non-approval server request is relayed without events + it('relays a machine-serviced server request (item/tool/call) without any approval event', async () => { + const frame = JSON.stringify({ + jsonrpc: '2.0', + id: 41, + method: 'item/tool/call', + params: { threadId: 'thread-1' }, + }) + const { requested, resolved, relayed } = await startApprovalHarness([frame]) + const frames = await relayed + expect(frames[0]!.raw.toString()).toBe(frame) + expect(requested).toEqual([]) + expect(resolved).toEqual([]) + }) + + // 3. approval response emits approval resolved and forwards upstream + it('resolves a pending approval on the client {id, result} response and forwards it upstream', async () => { + const { upstream, tui, resolved, relayed } = await startApprovalHarness([approvalRequestFrame]) + await relayed + tui.send(JSON.stringify({ jsonrpc: '2.0', id: 41, result: { decision: 'approved' } })) + await waitForUpstreamMessage(upstream, (message) => message?.id === 41 && message?.result !== undefined) + expect(resolved).toEqual([{ requestId: '41' }]) + }) + + // 4. client response with unknown id emits nothing + it('emits nothing for a client response whose id matches no pending approval', async () => { + const { upstream, tui, resolved, relayed } = await startApprovalHarness([approvalRequestFrame]) + await relayed + tui.send(JSON.stringify({ jsonrpc: '2.0', id: 999, result: {} })) + await waitForUpstreamMessage(upstream, (message) => message?.id === 999) + expect(resolved).toEqual([]) + }) + + // 5. approval request without threadId yields undefined + it('emits threadId undefined when the approval request params lack a threadId', async () => { + const frame = JSON.stringify({ + jsonrpc: '2.0', + id: 41, + method: 'item/commandExecution/requestApproval', + params: { command: 'rm -rf /tmp/x' }, + }) + const { requested, relayed } = await startApprovalHarness([frame]) + await relayed + expect(requested).toEqual([{ + requestId: '41', + method: 'item/commandExecution/requestApproval', + threadId: undefined, + }]) + }) + + // 6. legacy approval reads conversationId (decision 7 / audit A3) + it('populates threadId from params.conversationId for legacy execCommandApproval', async () => { + const frame = JSON.stringify({ + jsonrpc: '2.0', + id: 42, + method: 'execCommandApproval', + params: { conversationId: 'thread-1', command: 'ls' }, + }) + const { requested, relayed } = await startApprovalHarness([frame]) + await relayed + expect(requested).toEqual([{ + requestId: '42', + method: 'execCommandApproval', + threadId: 'thread-1', + }]) + }) + + // 7. error response also resolves (decision 5a / audit A5) + it('resolves a pending approval on the client {id, error} response too', async () => { + const { upstream, tui, resolved, relayed } = await startApprovalHarness([approvalRequestFrame]) + await relayed + tui.send(JSON.stringify({ jsonrpc: '2.0', id: 41, error: { code: -1, message: 'denied' } })) + await waitForUpstreamMessage(upstream, (message) => message?.id === 41 && message?.error !== undefined) + expect(resolved).toEqual([{ requestId: '41' }]) + }) + + // 8. client frame with id AND method never resolves (decision 5d) + it('never resolves on a client REQUEST whose id collides with a pending approval', async () => { + const { upstream, tui, resolved, relayed } = await startApprovalHarness([approvalRequestFrame]) + await relayed + tui.send(JSON.stringify({ jsonrpc: '2.0', id: 41, method: 'thread/start', params: {} })) + await waitForUpstreamMessage(upstream, (message) => message?.id === 41 && message?.method === 'thread/start') + expect(resolved).toEqual([]) + }) + + // 9. serverRequest/resolved notification resolves (decision 5c) + it('resolves a pending approval on an upstream serverRequest/resolved notification and relays it', async () => { + const notification = JSON.stringify({ + jsonrpc: '2.0', + method: 'serverRequest/resolved', + params: { threadId: 'thread-1', requestId: '41' }, + }) + const { resolved, relayed } = await startApprovalHarness([approvalRequestFrame, notification]) + const frames = await relayed + expect(frames[1]!.raw.toString()).toBe(notification) + expect(resolved).toEqual([{ requestId: '41' }]) + }) + + // 10. upstream teardown drains pending approvals (decision 5b) + it('drains every pending approval with a resolution when the upstream connection tears down', async () => { + const { upstream, resolved, relayed } = await startApprovalHarness([approvalRequestFrame]) + await relayed + for (const socket of upstream.sockets) socket.close() + const deadline = Date.now() + 1_000 + while (resolved.length === 0 && Date.now() < deadline) { + await delay(5) + } + expect(resolved).toEqual([{ requestId: '41' }]) + }) + + // 11. unknown server->client request method is logged, not belled (decision 6) + it('relays an unrecognized server request method without emitting an approval event', async () => { + const frame = JSON.stringify({ + jsonrpc: '2.0', + id: 43, + method: 'some/future/method', + params: {}, + }) + const { requested, resolved, relayed } = await startApprovalHarness([frame]) + const frames = await relayed + expect(frames[0]!.raw.toString()).toBe(frame) + expect(requested).toEqual([]) + expect(resolved).toEqual([]) + }) +}) diff --git a/test/unit/server/coding-cli/codex-provider.test.ts b/test/unit/server/coding-cli/codex-provider.test.ts index 9856230ad..28df5587a 100644 --- a/test/unit/server/coding-cli/codex-provider.test.ts +++ b/test/unit/server/coding-cli/codex-provider.test.ts @@ -178,6 +178,90 @@ describe('codex-provider', () => { }) }) + it('captures the turn_aborted reason paired with the abort timestamp', () => { + const meta = parseCodexSessionContent([ + JSON.stringify({ + timestamp: '2026-03-01T00:00:00.000Z', + type: 'session_meta', + payload: { id: 'session-abort-reason', cwd: '/project/codex' }, + }), + JSON.stringify({ + timestamp: '2026-03-01T00:00:06.000Z', + type: 'event_msg', + payload: { type: 'turn_aborted', turn_id: 'turn-1', reason: 'review_ended' }, + }), + ].join('\n')) + + expect(meta.codexTaskEvents).toEqual({ + latestTurnAbortedAt: Date.parse('2026-03-01T00:00:06.000Z'), + latestTurnAbortedReason: 'review_ended', + }) + }) + + it('leaves latestTurnAbortedReason absent for legacy turn_aborted lines without a reason', () => { + const meta = parseCodexSessionContent([ + JSON.stringify({ + timestamp: '2026-03-01T00:00:00.000Z', + type: 'session_meta', + payload: { id: 'session-abort-legacy', cwd: '/project/codex' }, + }), + JSON.stringify({ + timestamp: '2026-03-01T00:00:06.000Z', + type: 'event_msg', + payload: { type: 'turn_aborted', turn_id: 'turn-1' }, + }), + ].join('\n')) + + expect(meta.codexTaskEvents).toEqual({ + latestTurnAbortedAt: Date.parse('2026-03-01T00:00:06.000Z'), + }) + }) + + it('pairs the reason with the newest abort: a newer reason-less abort clears a stale reason', () => { + const sessionMeta = JSON.stringify({ + timestamp: '2026-03-01T00:00:00.000Z', + type: 'session_meta', + payload: { id: 'session-abort-newest', cwd: '/project/codex' }, + }) + + // Older abort carries a reason; the newer reason-less abort wins the pairing. + const staleReasonCleared = parseCodexSessionContent([ + sessionMeta, + JSON.stringify({ + timestamp: '2026-03-01T00:00:06.000Z', + type: 'event_msg', + payload: { type: 'turn_aborted', turn_id: 'turn-1', reason: 'review_ended' }, + }), + JSON.stringify({ + timestamp: '2026-03-01T00:00:08.000Z', + type: 'event_msg', + payload: { type: 'turn_aborted', turn_id: 'turn-2' }, + }), + ].join('\n')) + expect(staleReasonCleared.codexTaskEvents).toEqual({ + latestTurnAbortedAt: Date.parse('2026-03-01T00:00:08.000Z'), + }) + + // And the reverse: a newer abort's reason replaces the older pairing. + const newestReasonWins = parseCodexSessionContent([ + sessionMeta, + JSON.stringify({ + timestamp: '2026-03-01T00:00:06.000Z', + type: 'event_msg', + payload: { type: 'turn_aborted', turn_id: 'turn-1' }, + }), + JSON.stringify({ + timestamp: '2026-03-01T00:00:08.000Z', + type: 'event_msg', + payload: { type: 'turn_aborted', turn_id: 'turn-2', reason: 'review_ended' }, + }), + ].join('\n')) + expect(newestReasonWins.codexTaskEvents).toEqual({ + latestTurnAbortedAt: Date.parse('2026-03-01T00:00:08.000Z'), + latestTurnAbortedReason: 'review_ended', + }) + }) + it('drops head-only unresolved starts from truncated Codex snippets while preserving clear signals', () => { expect(sanitizeCodexTaskEventsForTruncatedSnippet({ latestTaskStartedAt: 100, @@ -187,9 +271,11 @@ describe('codex-provider', () => { latestTaskStartedAt: 100, latestTaskCompletedAt: 90, latestTurnAbortedAt: 110, + latestTurnAbortedReason: 'review_ended', })).toEqual({ latestTaskCompletedAt: 90, latestTurnAbortedAt: 110, + latestTurnAbortedReason: 'review_ended', }) }) diff --git a/test/unit/server/coding-cli/truly-idle-emitter.test.ts b/test/unit/server/coding-cli/truly-idle-emitter.test.ts index f761959e8..02d5fa938 100644 --- a/test/unit/server/coding-cli/truly-idle-emitter.test.ts +++ b/test/unit/server/coding-cli/truly-idle-emitter.test.ts @@ -1,7 +1,9 @@ +import { EventEmitter } from 'events' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { TERMINAL_IDLE_GRACE_MS, TrulyIdleEmitter, + wireTrulyIdleEmitter, type TrulyIdleEvent, } from '../../../../server/coding-cli/truly-idle-emitter.js' @@ -107,16 +109,79 @@ describe('TrulyIdleEmitter', () => { expect(events[0].reason).toBe('grace') }) - it('never emits after a crash/exit (activity remove), even with a grace timer armed', () => { + it('never emits after a REQUESTED close (activity remove without spontaneousExitRemovals), even with a grace timer armed', () => { + // Scoped to requested removals (tab close / terminal.close / shutdown): + // spontaneous exits while engaged now ring immediately (see below). emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) - // PTY exit lands inside the grace window. + // Requested close lands inside the grace window. emitter.noteActivityChanged({ upsert: [], remove: ['t1'] }) vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 3) expect(events).toHaveLength(0) }) + it('emits terminal.idle immediately when a busy terminal is removed by a spontaneous exit', () => { + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + emitter.noteActivityChanged({ upsert: [], remove: ['t1'], spontaneousExitRemovals: ['t1'] }) + + expect(events).toHaveLength(1) + expect(events[0]).toEqual({ terminalId: 't1', at: Date.now(), reason: 'grace' }) + // Immediate edge — no timer left pending, nothing further. + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 3) + expect(events).toHaveLength(1) + }) + + it('stays silent when a busy terminal is removed by a requested close', () => { + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + emitter.noteActivityChanged({ upsert: [], remove: ['t1'] }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 3) + expect(events).toHaveLength(0) + }) + + it('stays silent when an idle terminal exits spontaneously', () => { + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + emitter.noteActivityChanged({ upsert: [], remove: ['t1'], spontaneousExitRemovals: ['t1'] }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 3) + expect(events).toHaveLength(0) + }) + + it('stays silent when an input-pending terminal exits spontaneously (slash-command quit)', () => { + // decision 3 / audit A6: /quit typed into an idle pane arrives as phase + // 'pending' (the executing Enter looks like a prompt submit) — input-only + // pending is never engagement. + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'pending' }], remove: [] }) + emitter.noteActivityChanged({ upsert: [], remove: ['t1'], spontaneousExitRemovals: ['t1'] }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 3) + expect(events).toHaveLength(0) + }) + + it('rings when a spontaneous exit lands during an armed grace window', () => { + // busy → turn complete (arms grace) → spontaneous removal before expiry: + // the pending bell survives death. + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS - 500) + emitter.noteActivityChanged({ upsert: [], remove: ['t1'], spontaneousExitRemovals: ['t1'] }) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ terminalId: 't1', reason: 'grace' }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 3) + expect(events).toHaveLength(1) + }) + + it('queue evidence does not suppress the death bell', () => { + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + // Turn boundary while busy = queued turn evidence. + emitter.noteTurnComplete({ terminalId: 't1', at: Date.now() }) + emitter.noteActivityChanged({ upsert: [], remove: ['t1'], spontaneousExitRemovals: ['t1'] }) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ terminalId: 't1', reason: 'grace' }) + }) + it('never emits on a deadman/signal-loss idle flip (phase idle without a turn boundary)', () => { emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) @@ -205,3 +270,87 @@ describe('TrulyIdleEmitter', () => { expect(events).toHaveLength(0) }) }) + +describe('approval pause bell (Task 12)', () => { + let emitter: TrulyIdleEmitter + let events: TrulyIdleEvent[] + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-23T12:00:00Z')) + emitter = new TrulyIdleEmitter() + events = [] + emitter.on('idle', (event: TrulyIdleEvent) => events.push(event)) + }) + + afterEach(() => { + emitter.dispose() + vi.useRealTimers() + }) + + it('an attention.boundary bridged through the wiring arms the grace window and rings once', () => { + const tracker = new EventEmitter() + const wiring = wireTrulyIdleEmitter({ tracker, emitter }) + tracker.emit('changed', { upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + // Approval pause: the tracker demotes to idle, then arms the boundary. + tracker.emit('changed', { upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + tracker.emit('attention.boundary', { terminalId: 't1', at: Date.now() }) + + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ terminalId: 't1', reason: 'grace' }) + wiring.dispose() + }) + + it('a busy upsert within the grace (the resolve path) cancels the approval bell', () => { + const tracker = new EventEmitter() + const wiring = wireTrulyIdleEmitter({ tracker, emitter }) + tracker.emit('changed', { upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + tracker.emit('changed', { upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + tracker.emit('attention.boundary', { terminalId: 't1', at: Date.now() }) + + vi.advanceTimersByTime(500) + tracker.emit('changed', { upsert: [{ terminalId: 't1', phase: 'busy' }], remove: [] }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 3) + expect(events).toHaveLength(0) + wiring.dispose() + }) + + it('dispose detaches the attention.boundary bridge', () => { + const tracker = new EventEmitter() + const wiring = wireTrulyIdleEmitter({ tracker, emitter }) + wiring.dispose() + expect(tracker.listenerCount('attention.boundary')).toBe(0) + tracker.emit('attention.boundary', { terminalId: 't1', at: Date.now() }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 3) + expect(events).toHaveLength(0) + }) + + it('a spontaneous removal carrying approvalPendingRemovals rings even when not busy and no timer is armed', () => { + // The approval bell already rang (busy=false, grace spent) -- the pane was + // still blocked on a human when its process died. + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + emitter.noteActivityChanged({ + upsert: [], + remove: ['t1'], + spontaneousExitRemovals: ['t1'], + approvalPendingRemovals: ['t1'], + }) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ terminalId: 't1', reason: 'grace' }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 3) + expect(events).toHaveLength(1) + }) + + it('a REQUESTED close of an approval-blocked pane stays silent (no spontaneous exit)', () => { + emitter.noteActivityChanged({ upsert: [{ terminalId: 't1', phase: 'idle' }], remove: [] }) + emitter.noteActivityChanged({ + upsert: [], + remove: ['t1'], + approvalPendingRemovals: ['t1'], + }) + vi.advanceTimersByTime(TERMINAL_IDLE_GRACE_MS * 3) + expect(events).toHaveLength(0) + }) +}) diff --git a/test/unit/server/coding-cli/turn-completion-snapshots.test.ts b/test/unit/server/coding-cli/turn-completion-snapshots.test.ts index 93731d356..bfc66bc95 100644 --- a/test/unit/server/coding-cli/turn-completion-snapshots.test.ts +++ b/test/unit/server/coding-cli/turn-completion-snapshots.test.ts @@ -60,16 +60,19 @@ describe('CodexActivityTracker turn-completion snapshot', () => { const completions: CodexTurnCompleteEvent[] = [] tracker.on('turn.complete', (e: CodexTurnCompleteEvent) => completions.push(e)) + // kata codex-turn-thread-scope: app-server turn events now carry the bound + // thread's required `threadId`; only the event-construction INPUTS change + // to the new required shape. The pinned assertions below are untouched. // term-1: two app-server-delimited turns. tracker.bindTerminal({ terminalId: 'term-1', sessionId: 'session-1', reason: 'association', at: 1000 }) - tracker.onTurnStarted({ terminalId: 'term-1', at: 1100 }) - tracker.onTurnCompleted({ terminalId: 'term-1', at: 1200 }) - tracker.onTurnStarted({ terminalId: 'term-1', at: 2100 }) - tracker.onTurnCompleted({ terminalId: 'term-1', at: 2200 }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', at: 1100 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', at: 1200 }) + tracker.onTurnStarted({ terminalId: 'term-1', threadId: 'session-1', at: 2100 }) + tracker.onTurnCompleted({ terminalId: 'term-1', threadId: 'session-1', at: 2200 }) // term-2: one turn. tracker.bindTerminal({ terminalId: 'term-2', sessionId: 'session-2', reason: 'association', at: 1000 }) - tracker.onTurnStarted({ terminalId: 'term-2', at: 3100 }) - tracker.onTurnCompleted({ terminalId: 'term-2', at: 3200 }) + tracker.onTurnStarted({ terminalId: 'term-2', threadId: 'session-2', at: 3100 }) + tracker.onTurnCompleted({ terminalId: 'term-2', threadId: 'session-2', at: 3200 }) expect(completions).toEqual([ { terminalId: 'term-1', sessionId: 'session-1', at: 1200, completionSeq: 1 }, diff --git a/test/unit/server/terminal-lifecycle.test.ts b/test/unit/server/terminal-lifecycle.test.ts index cd585caa6..7b3b31c70 100644 --- a/test/unit/server/terminal-lifecycle.test.ts +++ b/test/unit/server/terminal-lifecycle.test.ts @@ -1537,6 +1537,37 @@ describe('shutdownGracefully', () => { } }) + it('emits every shutdown exit with spontaneous: false (server shutdown is a requested stop)', async () => { + // Audit A7: shutdownGracefully SIGTERMs ptys directly WITHOUT setting + // status='exited', so its exits flow through finishTerminalPtyExit. They + // must never look spontaneous — no death bell may ring on server shutdown. + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + try { + registry.create({ mode: 'shell' }) + registry.create({ mode: 'shell' }) + const exits: Array<{ terminalId: string; spontaneous?: boolean }> = [] + registry.on('terminal.exit', (event: { terminalId: string; spontaneous?: boolean }) => { + exits.push(event) + }) + + for (const pty of mockPtyProcess.instances) { + pty.kill.mockImplementation(() => { + setTimeout(() => pty._emitExit(0), 10) + }) + } + + await registry.shutdownGracefully(5000) + + expect(exits).toHaveLength(2) + for (const exit of exits) { + expect(exit.spontaneous).toBe(false) + } + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + it('should skip signal argument on Windows', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) @@ -1557,6 +1588,44 @@ describe('shutdownGracefully', () => { }) }) +describe('internal terminal.exit spontaneous discriminator', () => { + let registry: TerminalRegistry + + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + mockPtyProcess.instances = [] + registry = new TerminalRegistry(createTestSettings()) + }) + + it('marks an unrequested pty death spontaneous: true', () => { + registry.create({ mode: 'shell' }) + const pty = mockPtyProcess.instances[0] + const exits: Array<{ terminalId: string; spontaneous?: boolean }> = [] + registry.on('terminal.exit', (event: { terminalId: string; spontaneous?: boolean }) => { + exits.push(event) + }) + + pty._emitExit(1) + + expect(exits).toHaveLength(1) + expect(exits[0].spontaneous).toBe(true) + }) + + it('marks a kill() (requested close) exit spontaneous: false', () => { + const term = registry.create({ mode: 'shell' }) + const exits: Array<{ terminalId: string; spontaneous?: boolean }> = [] + registry.on('terminal.exit', (event: { terminalId: string; spontaneous?: boolean }) => { + exits.push(event) + }) + + registry.kill(term.terminalId) + + expect(exits).toHaveLength(1) + expect(exits[0].spontaneous).toBe(false) + }) +}) + describe('ChunkRingBuffer edge cases for lifecycle', () => { it('should handle concurrent appends correctly', () => { const buffer = new ChunkRingBuffer(100) diff --git a/test/unit/server/terminal-registry.codex-recovery.test.ts b/test/unit/server/terminal-registry.codex-recovery.test.ts index 8db9deab1..921542eda 100644 --- a/test/unit/server/terminal-registry.codex-recovery.test.ts +++ b/test/unit/server/terminal-registry.codex-recovery.test.ts @@ -240,6 +240,7 @@ describe.sequential('TerminalRegistry Codex durable recovery', () => { terminalId: record.terminalId, exitCode: 0, recoverableForRestore: true, + spontaneous: true, }) }) @@ -324,6 +325,7 @@ describe.sequential('TerminalRegistry Codex durable recovery', () => { terminalId: record.terminalId, exitCode: 0, recoverableForRestore: true, + spontaneous: true, }) }) @@ -446,6 +448,7 @@ describe.sequential('TerminalRegistry Codex durable recovery', () => { terminalId: record.terminalId, exitCode: 0, recoverableForRestore: true, + spontaneous: true, }) }) @@ -570,6 +573,7 @@ describe.sequential('TerminalRegistry Codex durable recovery', () => { terminalId: record.terminalId, exitCode: 0, recoverableForRestore: true, + spontaneous: true, }) }) @@ -763,6 +767,7 @@ describe.sequential('TerminalRegistry Codex durable recovery', () => { terminalId: record.terminalId, exitCode: 0, recoverableForRestore: true, + spontaneous: true, }) }) @@ -976,6 +981,7 @@ describe.sequential('TerminalRegistry Codex durable recovery', () => { terminalId: record.terminalId, exitCode: 0, recoverableForRestore: true, + spontaneous: true, }) }) @@ -1091,6 +1097,7 @@ describe.sequential('TerminalRegistry Codex durable recovery', () => { terminalId: record.terminalId, exitCode: 0, recoverableForRestore: true, + spontaneous: true, }) }) @@ -1470,6 +1477,7 @@ describe.sequential('TerminalRegistry Codex durable recovery', () => { terminalId: record.terminalId, exitCode: 0, recoverableForRestore: true, + spontaneous: true, }) }) @@ -1512,6 +1520,7 @@ describe.sequential('TerminalRegistry Codex durable recovery', () => { terminalId: record.terminalId, exitCode: 0, recoverableForRestore: true, + spontaneous: true, }) }) @@ -1661,6 +1670,7 @@ describe.sequential('TerminalRegistry Codex durable recovery', () => { terminalId: record.terminalId, exitCode: 0, recoverableForRestore: true, + spontaneous: true, }) expect(planCreate).not.toHaveBeenCalled() }) @@ -1930,7 +1940,7 @@ describe.sequential('TerminalRegistry Codex durable recovery', () => { pty.onExit.mock.calls[0][0]({ exitCode: 2, signal: 0 }) expect(record.status).toBe('exited') - expect(exited).toHaveBeenCalledWith({ terminalId: record.terminalId, exitCode: 2 }) + expect(exited).toHaveBeenCalledWith({ terminalId: record.terminalId, exitCode: 2, spontaneous: true }) }) it('does not start durable recovery for an explicit user close', async () => { @@ -1984,6 +1994,7 @@ describe.sequential('TerminalRegistry Codex durable recovery', () => { terminalId: record.terminalId, exitCode: 9, recoverableForRestore: true, + spontaneous: true, }) }) }) diff --git a/test/unit/server/terminal-registry.codex-sidecar.test.ts b/test/unit/server/terminal-registry.codex-sidecar.test.ts index 2c02c0058..f6ecee5dc 100644 --- a/test/unit/server/terminal-registry.codex-sidecar.test.ts +++ b/test/unit/server/terminal-registry.codex-sidecar.test.ts @@ -890,11 +890,29 @@ describe('TerminalRegistry Codex sidecar ownership', () => { registry.on('codex.turn.completed', (event) => turnEvents.push({ type: 'completed', event })) sidecar.emitTurnStarted({ threadId: 'thread-durable', turnId: 'turn-1', params: {} }) - sidecar.emitTurnCompleted({ threadId: 'thread-durable', turnId: 'turn-1', params: {} }) + sidecar.emitTurnCompleted({ + threadId: 'thread-durable', + turnId: 'turn-1', + // Nested like the real app-server's small-frame form -- pins that the + // registry reads params.turn?.status ?? params.status. + params: { turn: { status: 'completed' } }, + }) expect(turnEvents).toEqual([ - { type: 'started', event: { terminalId: term.terminalId, at: 4_200 } }, - { type: 'completed', event: { terminalId: term.terminalId, at: 4_200 } }, + { + type: 'started', + event: { terminalId: term.terminalId, threadId: 'thread-durable', turnId: 'turn-1', at: 4_200 }, + }, + { + type: 'completed', + event: { + terminalId: term.terminalId, + threadId: 'thread-durable', + turnId: 'turn-1', + status: 'completed', + at: 4_200, + }, + }, ]) expect(record.codexDurability).toMatchObject({ state: 'durable', @@ -2754,7 +2772,8 @@ describe('TerminalRegistry Codex sidecar ownership', () => { await vi.waitFor(() => expect(registry.get(term.terminalId)?.codexRecoveryBlockedError).toBe(teardownError)) await vi.waitFor(() => expect(registry.get(term.terminalId)?.status).toBe('exited')) expect(planCreate).toHaveBeenCalledTimes(1) - expect(exited).toHaveBeenCalledWith({ terminalId: term.terminalId, exitCode: 0 }) + // spontaneous: false = Task-11 exit discriminator, indicates requested close (recovery-final-close path) + expect(exited).toHaveBeenCalledWith({ terminalId: term.terminalId, exitCode: 0, spontaneous: false }) }) it('keeps unpublished candidate teardown failure retryable for final close', async () => { @@ -3085,7 +3104,8 @@ describe('TerminalRegistry Codex sidecar ownership', () => { expect(registry.get(term.terminalId)?.codexRecoveryBlockedError?.message).toContain('failed 3 consecutive times') }) await vi.waitFor(() => expect(registry.get(term.terminalId)?.status).toBe('exited')) - expect(exited).toHaveBeenCalledWith({ terminalId: term.terminalId, exitCode: 0 }) + // spontaneous: false = Task-11 exit discriminator, indicates requested close (recovery-final-close path) + expect(exited).toHaveBeenCalledWith({ terminalId: term.terminalId, exitCode: 0, spontaneous: false }) await new Promise((resolve) => setTimeout(resolve, 25)) expect(planCreate).toHaveBeenCalledTimes(3) diff --git a/test/unit/server/terminal-registry.test.ts b/test/unit/server/terminal-registry.test.ts index 5ba7236ac..52cd6743d 100644 --- a/test/unit/server/terminal-registry.test.ts +++ b/test/unit/server/terminal-registry.test.ts @@ -2034,7 +2034,7 @@ describe('TerminalRegistry', () => { expect(registry.get(record.terminalId)?.status).toBe('exited') expect(exited).toHaveBeenCalledTimes(1) - expect(exited).toHaveBeenCalledWith({ terminalId: record.terminalId, exitCode: 7 }) + expect(exited).toHaveBeenCalledWith({ terminalId: record.terminalId, exitCode: 7, spontaneous: true }) }) it('explicit kill emits one terminal.exit', () => { @@ -2046,7 +2046,8 @@ describe('TerminalRegistry', () => { registry.kill(record.terminalId) expect(exited).toHaveBeenCalledTimes(1) - expect(exited).toHaveBeenCalledWith({ terminalId: record.terminalId, exitCode: 0 }) + // spontaneous: false = Task-11 exit discriminator, indicates requested close (explicit kill) + expect(exited).toHaveBeenCalledWith({ terminalId: record.terminalId, exitCode: 0, spontaneous: false }) }) it('marks idle detached auto-kill exits as recoverable for restore', async () => { @@ -2064,10 +2065,12 @@ describe('TerminalRegistry', () => { await registry.enforceIdleKillsForTest() + // spontaneous: false = Task-11 exit discriminator, indicates requested close (idle auto-kill) expect(exited).toHaveBeenCalledWith({ terminalId: record.terminalId, exitCode: 0, recoverableForRestore: true, + spontaneous: false, }) }) @@ -2095,6 +2098,7 @@ describe('TerminalRegistry', () => { terminalId: durableExit.terminalId, exitCode: 0, recoverableForRestore: true, + spontaneous: true, }) }) }) @@ -2126,16 +2130,20 @@ describe('TerminalRegistry', () => { expect(exited).toHaveBeenCalledWith({ terminalId: nonDurableExit.terminalId, exitCode: 7, + spontaneous: true, }) await vi.waitFor(() => { expect(exited).toHaveBeenCalledWith({ terminalId: attachedDurableExit.terminalId, exitCode: 0, + spontaneous: true, }) }) + // spontaneous: false = Task-11 exit discriminator, indicates requested close (explicit kill) expect(exited).toHaveBeenCalledWith({ terminalId: explicitKill.terminalId, exitCode: 0, + spontaneous: false, }) expect(exited.mock.calls.some(([payload]) => payload?.recoverableForRestore === true)).toBe(false) })