Files
IdeA/crates/backend/src/stream.rs
Blomios 1b1476de52 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)
2026-09-09 18:44:01 +02:00

275 lines
7.6 KiB
Rust

use std::collections::HashMap;
use std::hash::Hash;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OutputSinkError {
Closed,
Full,
Other(String),
}
pub trait OutputSink<T>: Send + Sync + 'static {
fn send(&self, item: T) -> Result<(), OutputSinkError>;
}
type SharedOutputSink<T> = Arc<dyn OutputSink<T>>;
type SinkRegistry<K, T> = HashMap<K, (u64, SharedOutputSink<T>)>;
pub struct OutputBridge<K, T>
where
K: Copy + Eq + Hash,
{
sinks: Mutex<SinkRegistry<K, T>>,
}
impl<K, T> Default for OutputBridge<K, T>
where
K: Copy + Eq + Hash,
T: 'static,
{
fn default() -> Self {
Self::new()
}
}
impl<K, T> OutputBridge<K, T>
where
K: Copy + Eq + Hash,
T: 'static,
{
#[must_use]
pub fn new() -> Self {
Self {
sinks: Mutex::new(HashMap::new()),
}
}
pub fn register(&self, key: K, sink: SharedOutputSink<T>) -> u64 {
if let Ok(mut map) = self.sinks.lock() {
let gen = map.get(&key).map_or(0, |(g, _)| g.wrapping_add(1));
map.insert(key, (gen, sink));
gen
} else {
0
}
}
pub fn unregister(&self, key: &K) {
if let Ok(mut map) = self.sinks.lock() {
map.remove(key);
}
}
pub fn unregister_if(&self, key: &K, gen: u64) {
if let Ok(mut map) = self.sinks.lock() {
if matches!(map.get(key), Some((g, _)) if *g == gen) {
map.remove(key);
}
}
}
pub fn try_send_output(&self, key: &K, item: T) -> Result<(), OutputSinkError> {
let sink = {
let Ok(map) = self.sinks.lock() else {
return Err(OutputSinkError::Closed);
};
map.get(key)
.map(|(_, sink)| Arc::clone(sink))
.ok_or(OutputSinkError::Closed)?
};
sink.send(item)
}
pub fn send_output(&self, key: &K, item: T) -> bool {
self.try_send_output(key, item).is_ok()
}
#[must_use]
pub fn active_sessions(&self) -> usize {
self.sinks.lock().map(|m| m.len()).unwrap_or(0)
}
}
struct ReplayEntry<T> {
generation: u64,
sink: Option<SharedOutputSink<T>>,
scrollback: Vec<T>,
}
pub struct ReplayOutputBridge<K, T>
where
K: Copy + Eq + Hash,
{
entries: Mutex<HashMap<K, ReplayEntry<T>>>,
max_chunks: usize,
max_bytes: usize,
measure: fn(&T) -> usize,
}
impl<K, T> ReplayOutputBridge<K, T>
where
K: Copy + Eq + Hash,
T: Clone + 'static,
{
#[must_use]
pub fn new(max_chunks: usize, max_bytes: usize, measure: fn(&T) -> usize) -> Self {
Self {
entries: Mutex::new(HashMap::new()),
max_chunks,
max_bytes,
measure,
}
}
pub fn register(&self, key: K, sink: SharedOutputSink<T>) -> u64 {
if let Ok(mut map) = self.entries.lock() {
match map.get_mut(&key) {
Some(entry) => {
entry.generation = entry.generation.wrapping_add(1);
entry.sink = Some(sink);
entry.generation
}
None => {
map.insert(
key,
ReplayEntry {
generation: 0,
sink: Some(sink),
scrollback: Vec::new(),
},
);
0
}
}
} else {
0
}
}
#[must_use]
pub fn scrollback(&self, key: &K) -> Vec<T> {
self.entries
.lock()
.ok()
.and_then(|m| m.get(key).map(|e| e.scrollback.clone()))
.unwrap_or_default()
}
pub fn clear_scrollback(&self, key: &K) -> usize {
self.entries
.lock()
.ok()
.and_then(|mut m| {
m.get_mut(key).map(|entry| {
let cleared = entry.scrollback.len();
entry.scrollback.clear();
cleared
})
})
.unwrap_or_default()
}
/// Moves the retained stream state from `from` to `to`.
///
/// When `to` already exists, its live sink wins and the source scrollback is
/// prepended to the destination scrollback. This is used when a logical chat
/// survives a provider restart under a fresh transport session id.
pub fn rekey(&self, from: &K, to: K) {
if *from == to {
return;
}
if let Ok(mut map) = self.entries.lock() {
let Some(source) = map.remove(from) else {
return;
};
match map.get_mut(&to) {
Some(destination) => {
// Invalidate both pre-rekey pump generations. In particular,
// the dying source pump must never detach a sink that was
// already attached to the replacement key.
let mut generation = destination.generation.wrapping_add(1);
if generation == source.generation {
generation = generation.wrapping_add(1);
}
destination.generation = generation;
let mut scrollback = source.scrollback;
scrollback.append(&mut destination.scrollback);
destination.scrollback = scrollback;
trim_scrollback(destination, self.max_chunks, self.max_bytes, self.measure);
}
None => {
map.insert(to, source);
}
}
}
}
pub fn unregister(&self, key: &K) {
if let Ok(mut map) = self.entries.lock() {
map.remove(key);
}
}
pub fn detach(&self, key: &K) {
if let Ok(mut map) = self.entries.lock() {
if let Some(entry) = map.get_mut(key) {
entry.sink = None;
entry.generation = entry.generation.wrapping_add(1);
}
}
}
pub fn detach_if(&self, key: &K, gen: u64) {
if let Ok(mut map) = self.entries.lock() {
if let Some(entry) = map.get_mut(key) {
if entry.generation == gen {
entry.sink = None;
}
}
}
}
pub fn try_send_output(&self, key: &K, item: T) -> Result<(), OutputSinkError> {
let sink = {
let Ok(mut map) = self.entries.lock() else {
return Err(OutputSinkError::Closed);
};
let Some(entry) = map.get_mut(key) else {
return Err(OutputSinkError::Closed);
};
entry.scrollback.push(item.clone());
trim_scrollback(entry, self.max_chunks, self.max_bytes, self.measure);
entry.sink.as_ref().map(Arc::clone)
};
match sink {
Some(sink) => sink.send(item),
None => Err(OutputSinkError::Closed),
}
}
pub fn send_output(&self, key: &K, item: T) -> bool {
self.try_send_output(key, item).is_ok()
}
#[must_use]
pub fn active_sessions(&self) -> usize {
self.entries.lock().map(|m| m.len()).unwrap_or(0)
}
}
fn trim_scrollback<T>(
entry: &mut ReplayEntry<T>,
max_chunks: usize,
max_bytes: usize,
measure: fn(&T) -> usize,
) {
while !entry.scrollback.is_empty()
&& (entry.scrollback.len() > max_chunks
|| entry.scrollback.iter().map(measure).sum::<usize>() > max_bytes)
{
entry.scrollback.remove(0);
}
}