diff --git a/src/tui/app.rs b/src/tui/app.rs index f0d4f30..67a67ff 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1891,7 +1891,8 @@ impl App { self.cleaned_agent_ids.clear(); self.cleaned_agent_ancestors.clear(); self.agents_scroll = 0; - self.runtime_session_id = None; + // Attachment identity survives transport loss; ACP emits it only when + // attaching. Keep it distinct from session_id to reject another session. self.compacting = false; self.storage_pending = false; self.storage_exhausted = false; @@ -1935,28 +1936,37 @@ impl App { // Check expiry before any frame can refresh the lease or revive a // lifecycle map. Loss applies to all runtime events, not only progress. self.progress_activity(); + if let RuntimeEvent::RunletTransport { available } = event { + if available { + if self.progress_unavailable { + // A heartbeat restores transport, not the observations lost + // during the gap. Keep cleared state and progress tombstones. + self.note("Runtime status resumed; earlier agent, child, compaction and storage state remains unknown"); + } + self.progress_unavailable = false; + self.progress_last_frame = Some(Instant::now()); + } else { + self.disable_runtime(); + } + return; + } + // Attachment markers identify the stream even during a gap, but do not + // restore transport health or any lifecycle observations. + if let RuntimeEvent::SessionStarted { session_id } = event { + self.runtime_session_id = Some(session_id); + return; + } if self.runtime_unavailable() { return; } let parent = event.parent_call().map(str::to_string); let owner_id = match event { - RuntimeEvent::RunletTransport { available } => { - if available { - self.progress_activity(); - } else { - self.disable_runtime(); - } - return; - } + RuntimeEvent::RunletTransport { .. } | RuntimeEvent::SessionStarted { .. } => return, RuntimeEvent::StorageStatus { pending, exhausted } => { self.storage_pending = pending; self.storage_exhausted = exhausted; return; } - RuntimeEvent::SessionStarted { session_id } => { - self.runtime_session_id = Some(session_id); - return; - } _ if self.session_id.is_some() && self.runtime_session_id != self.session_id => return, RuntimeEvent::CompactionStarted { .. } => { self.compacting = true; @@ -6533,6 +6543,37 @@ mod tests { assert!(!app.needs_redraw_tick()); } + #[test] + fn healthy_heartbeat_expires_old_state_before_renewing_lease() { + let mut app = app(); + app.apply(Update::Runtime(RuntimeEvent::StorageStatus { + pending: true, + exhausted: true, + })); + app.progress_last_frame = Some(Instant::now() - crate::runlet_progress::transport::LEASE); + app.apply(Update::Runtime(RuntimeEvent::RunletTransport { + available: true, + })); + assert!(!app.runtime_unavailable()); + assert!(!app.storage_pending && !app.storage_exhausted); + assert!(app.needs_redraw_tick()); + assert!( + matches!(app.blocks.last(), Some(Block::Notice(text)) if text.contains("state remains unknown")) + ); + + let blocks = app.blocks.len(); + app.apply(Update::Runtime(RuntimeEvent::RunletTransport { + available: true, + })); + assert_eq!(app.blocks.len(), blocks); + app.tick(); + assert!(!app.runtime_unavailable()); + + app.progress_last_frame = Some(Instant::now() - crate::runlet_progress::transport::LEASE); + app.tick(); + assert!(app.runtime_unavailable()); + } + #[test] fn redraw_ticks_only_while_time_dependent_ui_is_visible() { let mut app = app(); diff --git a/src/tui/progress_tests.rs b/src/tui/progress_tests.rs index 874dfac..c194164 100644 --- a/src/tui/progress_tests.rs +++ b/src/tui/progress_tests.rs @@ -854,10 +854,23 @@ fn authoritative_progress_conflicting_duplicates_fail_neutral() { } } +fn start_progress_session(app: &mut App, session_id: &str) { + app.start_session(session_id.into()); + app.apply(Update::ToolStarted { + id: "call-1".into(), + title: "compose".into(), + kind: ToolKind::Other, + script: Some(SCRIPT.into()), + backgrounded: false, + }); +} + #[test] -fn authoritative_progress_reset_and_expired_lease_cannot_be_revived() { +fn authoritative_progress_recovery_preserves_old_incarnation_tombstones() { for explicit in [true, false] { let mut app = sample(); + start_progress_session(&mut app, "session"); + progress_wire(&mut app, RuntimeEvent::SessionStarted { session_id: "session".into() }); progress_wire(&mut app, RuntimeEvent::RunletTransport { available: true }); progress_start(&mut app, SCRIPT, 1, false); progress_step( @@ -880,6 +893,15 @@ fn authoritative_progress_reset_and_expired_lease_cannot_be_revived() { } assert!(!render(&mut app, 140, 50).contains("# call @")); progress_wire(&mut app, RuntimeEvent::RunletTransport { available: true }); + assert!(!app.runtime_unavailable()); + progress_start(&mut app, SCRIPT, 1, false); + progress_step( + &mut app, + 1, + 1, + progress_node("a", ProgressState::Succeeded, true), + ); + assert!(!render(&mut app, 140, 50).contains("# call @")); progress_start(&mut app, SCRIPT, 2, false); progress_step( &mut app, @@ -887,7 +909,7 @@ fn authoritative_progress_reset_and_expired_lease_cannot_be_revived() { 1, progress_node("b", ProgressState::Succeeded, true), ); - assert!(!render(&mut app, 140, 50).contains("# call @")); + assert!(render(&mut app, 140, 50).contains("# call @")); } } @@ -990,6 +1012,8 @@ fn authoritative_progress_terminal_conflicts_invalidate_completed_display() { fn authoritative_progress_loss_invalidates_all_runtime_lifecycle_state() { for explicit in [false, true] { let mut app = sample(); + start_progress_session(&mut app, "session"); + progress_wire(&mut app, RuntimeEvent::SessionStarted { session_id: "session".into() }); let agent = RuntimeEvent::SubagentStateChanged { id: "child-agent".into(), name: "Child worker".into(), @@ -1066,8 +1090,7 @@ fn authoritative_progress_loss_invalidates_all_runtime_lifecycle_state() { compacted: true, millis: 2, }, - RuntimeEvent::RunletTransport { available: true }, - agent, + agent.clone(), ] { progress_wire(&mut app, event); } @@ -1086,5 +1109,71 @@ fn authoritative_progress_loss_invalidates_all_runtime_lifecycle_state() { assert!(!frame.contains("working child")); assert!(!frame.contains("compacting context")); assert!(!frame.contains("context compacted")); + + progress_wire(&mut app, RuntimeEvent::RunletTransport { available: true }); + assert!(!app.runtime_unavailable()); + assert_eq!(app.agent_counts().total, 0); + assert!(!app.storage_pending && !app.storage_exhausted && !app.compacting); + let frame = render(&mut app, 140, 50); + assert!(!frame.contains("Runtime status unavailable")); + assert!(frame.contains("Runtime status resumed")); + assert!(frame.contains("state remains unknown")); + assert!(!frame.contains("Child worker")); + assert!(!frame.contains("working child")); + + // Fresh observations are accepted without reviving cleared state. + progress_wire(&mut app, agent); + progress_wire( + &mut app, + RuntimeEvent::StorageStatus { pending: true, exhausted: false }, + ); + assert_eq!(app.agent_counts().working, 1); + assert!(app.storage_pending); + assert!(app.needs_redraw_tick()); + app.progress_tick_at(std::time::Instant::now() + crate::runlet_progress::transport::LEASE); + assert!(app.runtime_unavailable()); + assert_eq!(app.agent_counts().total, 0); + assert!(!app.storage_pending); + } +} + +#[test] +fn runtime_recovery_keeps_session_filtering_across_attachment_gaps() { + for explicit in [false, true] { + for attach_during_gap in [false, true] { + let mut app = sample(); + start_progress_session(&mut app, "old"); + progress_wire(&mut app, RuntimeEvent::SessionStarted { session_id: "old".into() }); + if explicit { + progress_wire(&mut app, RuntimeEvent::RunletTransport { available: false }); + } else { + app.progress_tick_at(std::time::Instant::now() + crate::runlet_progress::transport::LEASE); + } + start_progress_session(&mut app, "new"); + if attach_during_gap { + progress_wire(&mut app, RuntimeEvent::SessionStarted { session_id: "new".into() }); + } + let compaction = RuntimeEvent::CompactionStarted { reason: "test".into(), at: 1 }; + progress_wire(&mut app, compaction.clone()); + progress_start(&mut app, SCRIPT, 1, false); + assert!(app.runtime_unavailable()); + assert!(!app.compacting); + assert!(!render(&mut app, 140, 50).contains("# call @")); + progress_wire(&mut app, RuntimeEvent::RunletTransport { available: true }); + progress_wire(&mut app, compaction.clone()); + progress_start(&mut app, SCRIPT, 2, false); + progress_step(&mut app, 2, 1, progress_node("a", ProgressState::Succeeded, true)); + assert_eq!(app.compacting, attach_during_gap); + assert_eq!(render(&mut app, 140, 50).contains("# call @"), attach_during_gap); + // A heartbeat must not guess that the stream belongs to the selected session. + if !attach_during_gap { + progress_wire(&mut app, RuntimeEvent::SessionStarted { session_id: "new".into() }); + progress_wire(&mut app, compaction); + progress_start(&mut app, SCRIPT, 3, false); + progress_step(&mut app, 3, 1, progress_node("a", ProgressState::Succeeded, true)); + assert!(app.compacting); + assert!(render(&mut app, 140, 50).contains("# call @")); + } + } } }