Skip to content

Commit ca987d2

Browse files
authored
fix(peer-device): reconcile live session snapshots (#1708)
1 parent fb325ae commit ca987d2

13 files changed

Lines changed: 1129 additions & 29 deletions

File tree

docs/architecture/peer-device-mode.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ FS) and must not be mixed with Peer Device Mode.
7575
controllers; controller re-emits the same event names locally. This includes
7676
SSH-backed remote PTY Ready / Data / Exit events created on B, not only B's
7777
local terminal service events.
78+
- Because DeviceEvent delivery has no ACK/replay contract, the controller also
79+
reconciles its active chat session from the Peer Host every 3s, immediately
80+
after session/visibility changes, and after detecting a dropped data event.
81+
Realtime events remain the primary path; snapshot reconciliation repairs a
82+
controller that attached after turn/round lifecycle events or crossed a
83+
transient relay gap. The host overlays its authoritative in-memory session
84+
state onto the persisted view so an executing turn is not misclassified as
85+
interrupted history.
7886
- CLI Peer Host forwards only turns submitted through Peer Host and linked
7987
child turns. A background-result follow-up inherits ownership only when its
8088
Core-internal metadata identifies the exact tracked parent and source child
@@ -96,8 +104,7 @@ FS) and must not be mixed with Peer Device Mode.
96104
delivery lease serializes detach or offline removal with the local Relay
97105
enqueue attempt. An explicit disconnect still restores the local controller
98106
UI, but reports a warning when host cancellation was not confirmed. This
99-
boundary does not change the Relay envelope or add ACK, replay, or reconnect
100-
recovery.
107+
boundary does not change the Relay envelope or add ACK or replay.
101108
- Relay `POST /api/devices/:id/rpc` waits up to **120s** for the peer response;
102109
reverse proxies in front of the relay must use a matching (or higher) read
103110
timeout or they will return 504 first.

src/apps/cli/src/peer_host/commands/session.rs

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,15 @@ fn session_to_json(session: Session, turn_count: usize) -> Value {
6969
})
7070
}
7171

72+
fn overlay_live_session_state(restored: &mut Session, live: Option<Session>) {
73+
let Some(live) = live else {
74+
return;
75+
};
76+
if live.session_id == restored.session_id {
77+
restored.state = live.state;
78+
}
79+
}
80+
7281
fn restored_session_to_json(restored: AgentSessionRestoreResult) -> Value {
7382
let session = restored.session;
7483
json!({
@@ -165,7 +174,7 @@ pub(crate) async fn restore_session_view(
165174
.filter(|n| *n > 0)
166175
.map(|n| n.min(16));
167176

168-
let (session, turns, total_turn_count, timings) = state
177+
let (mut session, turns, total_turn_count, timings) = state
169178
.compatibility
170179
.restore_session_view_for_workspace(
171180
storage_request,
@@ -175,6 +184,11 @@ pub(crate) async fn restore_session_view(
175184
)
176185
.await
177186
.map_err(|e| format!("Failed to restore session view: {e}"))?;
187+
let live_session = state
188+
.compatibility
189+
.loaded_session_snapshot(&session_id)
190+
.map_err(|e| format!("Failed to read live session state: {e}"))?;
191+
overlay_live_session_state(&mut session, live_session);
178192

179193
let loaded_turn_count = turns.len();
180194
let is_partial = loaded_turn_count < total_turn_count;
@@ -533,8 +547,13 @@ pub(crate) async fn save_session_turn(
533547

534548
#[cfg(test)]
535549
mod tests {
536-
use super::{restored_session_to_json, session_stats_validation_error};
550+
use super::{
551+
overlay_live_session_state, restored_session_to_json, session_stats_validation_error,
552+
};
537553
use bitfun_agent_runtime::sdk::{AgentSessionRestoreResult, AgentSessionSummary, SessionState};
554+
use bitfun_core::agentic::core::{
555+
ProcessingPhase, Session as CoreSession, SessionConfig, SessionState as CoreSessionState,
556+
};
538557

539558
#[test]
540559
fn basic_restore_keeps_peer_host_session_shape() {
@@ -572,4 +591,29 @@ mod tests {
572591
"Failed to get session stats: Validation error: session_id cannot contain path separators"
573592
);
574593
}
594+
595+
#[test]
596+
fn view_restore_uses_the_cli_hosts_live_processing_state() {
597+
let mut restored = CoreSession::new_with_id(
598+
"session_1".to_string(),
599+
"Main".to_string(),
600+
"agentic".to_string(),
601+
SessionConfig::default(),
602+
);
603+
let mut live = restored.clone();
604+
live.state = CoreSessionState::Processing {
605+
current_turn_id: "turn_1".to_string(),
606+
phase: ProcessingPhase::Streaming,
607+
};
608+
609+
overlay_live_session_state(&mut restored, Some(live));
610+
611+
assert!(matches!(
612+
restored.state,
613+
CoreSessionState::Processing {
614+
ref current_turn_id,
615+
..
616+
} if current_turn_id == "turn_1"
617+
));
618+
}
575619
}

src/apps/desktop/src/runtime/session_application.rs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,21 @@ pub(crate) struct DesktopSessionWithTurnsRestore {
7676
pub turns: Vec<DialogTurnData>,
7777
}
7878

79+
fn overlay_live_session_state(restored: &mut Session, live: Option<Session>) {
80+
let Some(live) = live else {
81+
return;
82+
};
83+
if live.session_id != restored.session_id {
84+
return;
85+
}
86+
87+
// Disk state deliberately stores Processing as Idle so a process restart
88+
// never revives work. A view served by the process that still owns the
89+
// runtime must expose its live state, otherwise a Peer controller treats
90+
// an executing turn as interrupted history and drops subsequent chunks.
91+
restored.state = live.state;
92+
}
93+
7994
#[derive(Clone)]
8095
struct ResolvedDesktopSessionScope {
8196
workspace_path: String,
@@ -510,7 +525,7 @@ impl DesktopSessionApplication {
510525
let resolve_storage_path_duration_ms =
511526
path_started_at.elapsed().as_millis().min(u64::MAX as u128) as u64;
512527
on_storage_path_resolved(resolve_storage_path_duration_ms);
513-
let (session, turns, total_turn_count, mut timings) = self
528+
let (mut session, turns, total_turn_count, mut timings) = self
514529
.compatibility
515530
.restore_session_view_from_storage_path(
516531
&storage_path,
@@ -520,6 +535,11 @@ impl DesktopSessionApplication {
520535
)
521536
.await
522537
.map_err(|error| DesktopSessionApplicationError::Core(error.to_string()))?;
538+
let live_session = self
539+
.compatibility
540+
.loaded_session_snapshot(session_id)
541+
.map_err(|error| DesktopSessionApplicationError::Core(error.to_string()))?;
542+
overlay_live_session_state(&mut session, live_session);
523543
timings.resolve_storage_path_duration_ms = resolve_storage_path_duration_ms;
524544
Ok(DesktopSessionViewRestore {
525545
session,
@@ -765,6 +785,48 @@ mod tests {
765785
);
766786
}
767787

788+
#[test]
789+
fn live_processing_state_overlays_sanitized_view_state() {
790+
let mut restored = Session::new_with_id(
791+
"session-1".to_string(),
792+
"Restored".to_string(),
793+
"agentic".to_string(),
794+
Default::default(),
795+
);
796+
let mut live = restored.clone();
797+
live.state = bitfun_core::agentic::core::SessionState::Processing {
798+
current_turn_id: "turn-1".to_string(),
799+
phase: bitfun_core::agentic::core::ProcessingPhase::Streaming,
800+
};
801+
802+
overlay_live_session_state(&mut restored, Some(live));
803+
804+
assert!(matches!(
805+
restored.state,
806+
bitfun_core::agentic::core::SessionState::Processing {
807+
ref current_turn_id,
808+
..
809+
} if current_turn_id == "turn-1"
810+
));
811+
}
812+
813+
#[test]
814+
fn missing_live_session_keeps_persisted_view_state() {
815+
let mut restored = Session::new_with_id(
816+
"session-1".to_string(),
817+
"Restored".to_string(),
818+
"agentic".to_string(),
819+
Default::default(),
820+
);
821+
822+
overlay_live_session_state(&mut restored, None);
823+
824+
assert!(matches!(
825+
restored.state,
826+
bitfun_core::agentic::core::SessionState::Idle
827+
));
828+
}
829+
768830
#[tokio::test]
769831
async fn local_session_storage_identity_survives_workspace_removal() {
770832
let root = std::env::temp_dir().join(format!(

src/crates/assembly/core/src/product_runtime.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,21 @@ impl CoreAgentRuntimeCompatibility {
541541
.is_some())
542542
}
543543

544+
/// Return the authoritative in-memory session snapshot when this process
545+
/// currently owns the session runtime.
546+
///
547+
/// Persisted session state intentionally sanitizes `Processing` to `Idle`
548+
/// so a later process restart cannot resurrect work. Live read-only views
549+
/// (for example Peer Device controllers) still need the current process
550+
/// state to distinguish an executing session from interrupted history.
551+
pub fn loaded_session_snapshot(&self, session_id: &str) -> BitFunResult<Option<Session>> {
552+
validate_persisted_session_id(session_id)?;
553+
Ok(self
554+
.coordinator
555+
.get_session_manager()
556+
.get_session(session_id))
557+
}
558+
544559
pub async fn update_loaded_session_title(
545560
&self,
546561
session_id: &str,
@@ -1056,6 +1071,7 @@ mod tests {
10561071
let _ = build;
10571072
let _ = CoreAgentRuntimeCompatibility::list_persisted_sessions;
10581073
let _ = CoreAgentRuntimeCompatibility::load_persisted_session_turns;
1074+
let _ = CoreAgentRuntimeCompatibility::loaded_session_snapshot;
10591075
let _ = CoreAgentRuntimeCompatibility::unload_persisted_session;
10601076
}
10611077

src/web-ui/src/flow_chat/services/FlowChatManager.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
updateImageAnalysisItem as updateImageAnalysisItemModule,
5050
updateSessionMetadata,
5151
} from './flow-chat-manager';
52+
import { installPeerSessionRefresh } from './flow-chat-manager/PeerSessionRefreshModule';
5253

5354
const log = createLogger('FlowChatManager');
5455

@@ -61,6 +62,7 @@ export class FlowChatManager {
6162
private eventListenerCleanup: (() => void) | null = null;
6263
private initializationRequests = new Map<string, Promise<boolean>>();
6364
private latestInitializationRequestKey: string | null = null;
65+
private peerSessionRefreshCleanup: (() => void) | null = null;
6466
private disposed = false;
6567

6668
private constructor() {
@@ -88,6 +90,7 @@ export class FlowChatManager {
8890

8991
this.agentService = AgentService.getInstance();
9092
installPendingQueueDrainListener(this.context);
93+
this.peerSessionRefreshCleanup = installPeerSessionRefresh(this.context);
9194
}
9295

9396
/** Public hook used by the queue panel "send now" fallback to drain head item. */
@@ -414,6 +417,8 @@ export class FlowChatManager {
414417
this.initializationRequests.clear();
415418
this.latestInitializationRequestKey = null;
416419
this.cleanupEventListeners();
420+
this.peerSessionRefreshCleanup?.();
421+
this.peerSessionRefreshCleanup = null;
417422
this.context.eventBatcher.destroy();
418423
}
419424

src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import {
8080
clearRuntimeStatus,
8181
scheduleModelResponseStatus,
8282
} from './RuntimeStatusModule';
83+
import { requestPeerSessionRefresh } from './PeerSessionRefreshModule';
8384

8485
const log = createLogger('EventHandlerModule');
8586
const TURN_COMPLETION_QUIET_WINDOW_MS = 500;
@@ -167,6 +168,7 @@ function logDroppedDataEvent(
167168
turnId: string | null,
168169
details: Record<string, unknown>
169170
): void {
171+
requestPeerSessionRefresh(sessionId);
170172
log.debug('Dropped agentic data event', {
171173
eventName,
172174
sessionId,
@@ -1623,6 +1625,7 @@ function handleTextChunk(context: FlowChatContext, event: any): void {
16231625

16241626
const dialogTurn = session.dialogTurns.find((turn: DialogTurn) => turn.id === turnId);
16251627
if (!dialogTurn) {
1628+
requestPeerSessionRefresh(sessionId);
16261629
log.debug('Dialog turn not found', { turnId });
16271630
return;
16281631
}
@@ -1795,6 +1798,7 @@ function handleModelRoundStart(context: FlowChatContext, event: ModelRoundStarte
17951798

17961799
const dialogTurn = session.dialogTurns.find((turn: DialogTurn) => turn.id === turnId);
17971800
if (!dialogTurn) {
1801+
requestPeerSessionRefresh(sessionId);
17981802
log.debug('Dialog turn not found (model round start)', { turnId });
17991803
return;
18001804
}

0 commit comments

Comments
 (0)