fix(chat): le transcript s'affiche à nouveau après redémarrage automatique d'un agent — #295 (app-tauri ChatBridge: cartes successors/owners, rebind_session déplace sink+scrolloback du logical chat vers la session de remplacement et canonical_session résout les écritures avec l'ancien id sur toutes les opérations register/scrollback/detach/send_output; backend stream: ReplayOutputBridge::rekey prépare le scrollback source devant le destination et invalide les générations pré-rekey des deux pumps; commands: agent_send binde l'owner stable (projectId,agentId) au tour humain et rebind avant le flux de continuation au handoff restart; reattach_agent_chat résout le successeur du bridge puis fallback identité stable projectId/agentId explicite ou owner bindé, renvoie sessionId effectif + conversationId, NOT_FOUND sinon; web-server chat.attach même fallback stale→live par project/agent; DTO ReattachChatDto/ReattachResultDto documentent l'id effectif post-restart; frontend: ReattachAgentChatOptions, conversationId dans ChatAttachedPayload, CustomAgentChatView réattache sur agentRestartCompleted (dédupe par restartId, scrollback appliqué, tours restart préservés), retry identité stable sur NOT_FOUND dans openOrAttach avant fallback launch frais, publication sessionId effectif + conversationId, état vide «Conversation liée» quand une conversation/run/workstate lié existe; QA verte reconfirmée par Git: git diff --check, cargo fmt --all --check, app-tauri chat_bridge 34/34 dont 3 nouveaux restart_rebind + dto_chat 24/24, cargo check -p app-tauri, backend 91+7 ignored permissions socket, web-server websocket_chat_attach 3/3, npm test 77/77 CustomAgentChatView+agentGateway, npm run build; réserves non bloquantes: warnings Vite esbuild/oxc et chunk >500kB connus)
This commit is contained in:
@ -24,12 +24,13 @@
|
||||
//! [`unregister_if`]: ChatBridge::unregister_if
|
||||
//! [`send_output`]: ChatBridge::send_output
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use backend::stream::ReplayOutputBridge;
|
||||
use tauri::ipc::Channel;
|
||||
|
||||
use domain::ids::SessionId;
|
||||
use domain::ids::{AgentId, ProjectId, SessionId};
|
||||
use domain::ports::{
|
||||
ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage,
|
||||
};
|
||||
@ -54,6 +55,8 @@ pub const MAX_CHAT_SCROLLBACK_BYTES: usize = 512 * 1024;
|
||||
/// the twin of [`crate::pty::PtyBridge`].
|
||||
pub struct ChatBridge {
|
||||
inner: ReplayOutputBridge<SessionId, ReplyChunk>,
|
||||
successors: Mutex<HashMap<SessionId, SessionId>>,
|
||||
owners: Mutex<HashMap<SessionId, (ProjectId, AgentId)>>,
|
||||
}
|
||||
|
||||
impl Default for ChatBridge {
|
||||
@ -72,9 +75,78 @@ impl ChatBridge {
|
||||
MAX_CHAT_SCROLLBACK_BYTES,
|
||||
reply_chunk_bytes,
|
||||
),
|
||||
successors: Mutex::new(HashMap::new()),
|
||||
owners: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_session(&self, session: &SessionId) -> SessionId {
|
||||
let Ok(successors) = self.successors.lock() else {
|
||||
return *session;
|
||||
};
|
||||
let mut current = *session;
|
||||
for _ in 0..successors.len() {
|
||||
let Some(next) = successors.get(¤t).copied() else {
|
||||
break;
|
||||
};
|
||||
if next == current {
|
||||
break;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
/// Returns the latest transport session id for a logical chat that crossed
|
||||
/// one or more structured-runtime restarts.
|
||||
#[must_use]
|
||||
pub fn successor(&self, session: &SessionId) -> SessionId {
|
||||
self.canonical_session(session)
|
||||
}
|
||||
|
||||
/// Associates a transport session with the stable runtime owner used to
|
||||
/// recover the current session after an out-of-band restart.
|
||||
pub fn bind_owner(&self, session: SessionId, project_id: ProjectId, agent_id: AgentId) {
|
||||
let session = self.canonical_session(&session);
|
||||
if let Ok(mut owners) = self.owners.lock() {
|
||||
owners.insert(session, (project_id, agent_id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable owner previously bound to this logical chat.
|
||||
#[must_use]
|
||||
pub fn owner(&self, session: &SessionId) -> Option<(ProjectId, AgentId)> {
|
||||
let canonical = self.canonical_session(session);
|
||||
self.owners
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|owners| owners.get(&canonical).copied())
|
||||
}
|
||||
|
||||
/// Moves a logical chat to the replacement runtime session while preserving
|
||||
/// its sink and retained transcript. Future writes using either the old or
|
||||
/// new id resolve to the replacement id.
|
||||
pub fn rebind_session(&self, previous: SessionId, replacement: SessionId) {
|
||||
let previous = self.canonical_session(&previous);
|
||||
if previous == replacement {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut successors) = self.successors.lock() {
|
||||
for successor in successors.values_mut() {
|
||||
if *successor == previous {
|
||||
*successor = replacement;
|
||||
}
|
||||
}
|
||||
successors.insert(previous, replacement);
|
||||
}
|
||||
if let Ok(mut owners) = self.owners.lock() {
|
||||
if let Some(owner) = owners.remove(&previous) {
|
||||
owners.entry(replacement).or_insert(owner);
|
||||
}
|
||||
}
|
||||
self.inner.rekey(&previous, replacement);
|
||||
}
|
||||
|
||||
/// Registers (or replaces) the reply channel for a session and returns the
|
||||
/// **generation** of this registration. Each call for a session bumps the
|
||||
/// generation, so the caller's pump can later tear down *only its own*
|
||||
@ -83,6 +155,7 @@ impl ChatBridge {
|
||||
/// channel and generation change), mirroring how the PTY ring buffer survives
|
||||
/// a `reattach_terminal`.
|
||||
pub fn register(&self, session: SessionId, channel: Channel<ReplyChunk>) -> u64 {
|
||||
let session = self.canonical_session(&session);
|
||||
self.inner
|
||||
.register(session, Arc::new(TauriChannelSink::new(channel)))
|
||||
}
|
||||
@ -95,7 +168,7 @@ impl ChatBridge {
|
||||
/// `PtyPort::scrollback`.
|
||||
#[must_use]
|
||||
pub fn scrollback(&self, session: &SessionId) -> Vec<ReplyChunk> {
|
||||
self.inner.scrollback(session)
|
||||
self.inner.scrollback(&self.canonical_session(session))
|
||||
}
|
||||
|
||||
/// Clears only the retained conversation scrollback for a live session,
|
||||
@ -105,21 +178,29 @@ impl ChatBridge {
|
||||
/// clears its mounted view from the command response, and future re-attaches
|
||||
/// no longer replay the old conversation.
|
||||
pub fn clear_scrollback(&self, session: &SessionId) -> usize {
|
||||
self.inner.clear_scrollback(session)
|
||||
self.inner
|
||||
.clear_scrollback(&self.canonical_session(session))
|
||||
}
|
||||
|
||||
/// Removes a session's transport state **and** its retained scrollback
|
||||
/// unconditionally (chat cell explicitly closed). Twin of
|
||||
/// [`PtyBridge::unregister`](crate::pty::PtyBridge::unregister).
|
||||
pub fn unregister(&self, session: &SessionId) {
|
||||
self.inner.unregister(session);
|
||||
let canonical = self.canonical_session(session);
|
||||
self.inner.unregister(&canonical);
|
||||
if let Ok(mut successors) = self.successors.lock() {
|
||||
successors.retain(|from, to| *from != canonical && *to != canonical);
|
||||
}
|
||||
if let Ok(mut owners) = self.owners.lock() {
|
||||
owners.remove(&canonical);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detaches the current output channel while preserving the live session's
|
||||
/// retained scrollback. This is the view-unmount path; it is intentionally
|
||||
/// distinct from [`unregister`](Self::unregister), which is the real close.
|
||||
pub fn detach(&self, session: &SessionId) {
|
||||
self.inner.detach(session);
|
||||
self.inner.detach(&self.canonical_session(session));
|
||||
}
|
||||
|
||||
/// Detaches a session's channel **only if** `gen` is still the current
|
||||
@ -133,7 +214,7 @@ impl ChatBridge {
|
||||
///
|
||||
/// [`unregister`]: ChatBridge::unregister
|
||||
pub fn detach_if(&self, session: &SessionId, gen: u64) {
|
||||
self.inner.detach_if(session, gen);
|
||||
self.inner.detach_if(&self.canonical_session(session), gen);
|
||||
}
|
||||
|
||||
/// Records a chunk in the session's scrollback and, if a view is attached at
|
||||
@ -145,7 +226,8 @@ impl ChatBridge {
|
||||
/// The pump keeps draining either way so the turn still completes and the
|
||||
/// scrollback stays whole.
|
||||
pub fn send_output(&self, session: &SessionId, chunk: ReplyChunk) -> bool {
|
||||
self.inner.send_output(session, chunk)
|
||||
self.inner
|
||||
.send_output(&self.canonical_session(session), chunk)
|
||||
}
|
||||
|
||||
/// Sends `chunk`, first completing any previous running progress entity when
|
||||
|
||||
Reference in New Issue
Block a user