Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 54 additions & 13 deletions src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Comment thread
kit-code-agent[bot] marked this conversation as resolved.
} 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;
Expand Down Expand Up @@ -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();
Expand Down
97 changes: 93 additions & 4 deletions src/tui/progress_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -880,14 +893,23 @@ 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,
2,
1,
progress_node("b", ProgressState::Succeeded, true),
);
assert!(!render(&mut app, 140, 50).contains("# call @"));
assert!(render(&mut app, 140, 50).contains("# call @"));
}
}

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
}
Expand All @@ -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 @"));
}
}
}
}
Loading