feat(terminals): reprise de conversation par cellule + fix ordre d'écriture
Permet de recharger la conversation CLI précédente de chaque cellule à la
réouverture du projet, de façon universelle (indépendant du modèle/CLI).
- profil AgentRuntime: bloc déclaratif optionnel `session { assignFlag, resumeFlag }`
- LeafCell: `conversationId` (persistant, distinct du SessionId PTY) + `agentWasRunning`
- runtime: SessionPlan (None/Assign/Resume) + composition pure des args
- LaunchAgent: décide Assign vs Resume, génère l'UUID, remonte l'id assigné
(persistance par l'appelant via setCellConversation — découplage SRP)
- close: SnapshotRunningAgents fige `agentWasRunning` avant le kill-all
(statut clot/en cours universel, sans parsing CLI)
- SessionInspector: port optionnel best-effort + adapter ClaudeTranscriptInspector
- popup de reprise par cellule (statut + sujet/tokens si dispo), intercalée
avant le Resume auto, jamais sur le chemin reattach
fix(terminals): sérialise les écritures PTY (file FIFO par handle) — corrige
les caractères mélangés/accents dus au réordonnancement des invoke Tauri concurrents
fix(layout): l'opération `move` préservait mal les champs du leaf (perdait `agent`)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -21,9 +21,13 @@
|
||||
//! `TerminalSessions` registry, keyed by that same `SessionId`.
|
||||
|
||||
mod management;
|
||||
mod snapshot;
|
||||
mod store;
|
||||
mod usecases;
|
||||
|
||||
pub use snapshot::{
|
||||
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
|
||||
};
|
||||
pub use management::{
|
||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
|
||||
|
||||
113
crates/application/src/layout/snapshot.rs
Normal file
113
crates/application/src/layout/snapshot.rs
Normal file
@ -0,0 +1,113 @@
|
||||
//! [`SnapshotRunningAgents`] — freeze, at close time, which agent cells still
|
||||
//! held a live PTY (feature: "conversation resume", task T5).
|
||||
//!
|
||||
//! When the IDE (or a single project) closes, every live PTY is about to be
|
||||
//! killed. *Before* that happens, we record on each agent-bearing leaf whether
|
||||
//! its agent process was still running (`agent_was_running = true`) or had
|
||||
//! already exited / never launched (`false`). On reopen, that flag is what tells
|
||||
//! the resume popup "this conversation was still in progress" vs "it was closed".
|
||||
//!
|
||||
//! The decision is **universal**: it is derived purely from the process
|
||||
//! lifecycle (the live-session registry), never from parsing CLI output. The use
|
||||
//! case itself is a thin orchestrator over:
|
||||
//! - the persisted layouts store ([`super::store`]),
|
||||
//! - the pure domain operation [`domain::LayoutTree::set_agent_running`],
|
||||
//! - a [`LiveAgentRegistry`] liveness query.
|
||||
//!
|
||||
//! **Ordering contract:** this snapshot reads the registry *as it is at call
|
||||
//! time*. The composition root (app-tauri) is responsible for calling it
|
||||
//! **before** the global PTY kill; if the kill ran first, every agent would look
|
||||
//! "closed". See `app-tauri`'s `CloseRequested` hook and `close.rs` callers.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{FileSystem, ProjectStore};
|
||||
use domain::ProjectId;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::terminal::LiveAgentRegistry;
|
||||
|
||||
use super::store::{persist_doc, resolve_doc};
|
||||
|
||||
/// Input for [`SnapshotRunningAgents::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SnapshotRunningAgentsInput {
|
||||
/// The project whose layouts must be frozen.
|
||||
pub project_id: ProjectId,
|
||||
}
|
||||
|
||||
/// Output of [`SnapshotRunningAgents::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct SnapshotRunningAgentsOutput {
|
||||
/// Number of agent-bearing leaves that were found running at snapshot time.
|
||||
pub running: usize,
|
||||
/// Number of agent-bearing leaves that were found stopped at snapshot time.
|
||||
pub stopped: usize,
|
||||
}
|
||||
|
||||
/// Freezes the `agent_was_running` flag on every agent leaf of a project's
|
||||
/// layouts, from the live-session registry, then persists the layouts.
|
||||
pub struct SnapshotRunningAgents {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
live: Arc<dyn LiveAgentRegistry>,
|
||||
}
|
||||
|
||||
impl SnapshotRunningAgents {
|
||||
/// Builds the use case from its injected ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
live: Arc<dyn LiveAgentRegistry>,
|
||||
) -> Self {
|
||||
Self { store, fs, live }
|
||||
}
|
||||
|
||||
/// Executes the snapshot for one project.
|
||||
///
|
||||
/// Walks every named layout of the project; for each agent-bearing leaf it
|
||||
/// applies [`domain::LayoutTree::set_agent_running`] with the agent's *current*
|
||||
/// liveness, then persists the whole layouts store. A project with no
|
||||
/// agent leaf is a no-op (the doc is still resolved/healed, never written if
|
||||
/// unchanged).
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the project is unknown,
|
||||
/// - [`AppError::FileSystem`] on persistence failure,
|
||||
/// - [`AppError::Store`] on registry I/O failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: SnapshotRunningAgentsInput,
|
||||
) -> Result<SnapshotRunningAgentsOutput, AppError> {
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
|
||||
let mut out = SnapshotRunningAgentsOutput::default();
|
||||
let mut changed = false;
|
||||
|
||||
for named in &mut doc.layouts {
|
||||
for (leaf_id, agent_id) in named.tree.agent_leaves() {
|
||||
let running = self.live.is_agent_live(&agent_id);
|
||||
if running {
|
||||
out.running += 1;
|
||||
} else {
|
||||
out.stopped += 1;
|
||||
}
|
||||
// Pure op: only NodeNotFound is possible, which cannot happen
|
||||
// since `leaf_id` came from this very tree.
|
||||
named.tree = named
|
||||
.tree
|
||||
.set_agent_running(leaf_id, running)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@ -103,6 +103,8 @@ pub fn default_tree() -> LayoutTree {
|
||||
id: NodeId::new_random(),
|
||||
session: None,
|
||||
agent: None,
|
||||
conversation_id: None,
|
||||
agent_was_running: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -76,6 +76,17 @@ pub enum LayoutOperation {
|
||||
/// Agent to associate, or `None` to clear.
|
||||
agent: Option<AgentId>,
|
||||
},
|
||||
/// Record (or clear) the persistent CLI conversation id on a leaf (T4b).
|
||||
///
|
||||
/// Persisting the id minted at first launch is what makes session resume
|
||||
/// effective: on the next open, the leaf carries the id and the launch
|
||||
/// resumes the conversation instead of assigning a new one.
|
||||
SetCellConversation {
|
||||
/// The hosting leaf.
|
||||
target: NodeId,
|
||||
/// Conversation id to record, or `None` to clear.
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LayoutOperation {
|
||||
@ -94,6 +105,8 @@ impl LayoutOperation {
|
||||
id: *new_leaf,
|
||||
session: None,
|
||||
agent: None,
|
||||
conversation_id: None,
|
||||
agent_was_running: false,
|
||||
},
|
||||
*container,
|
||||
),
|
||||
@ -105,6 +118,10 @@ impl LayoutOperation {
|
||||
Self::Move { from, to } => tree.move_session(*from, *to),
|
||||
Self::SetSession { target, session } => tree.set_session(*target, *session),
|
||||
Self::SetCellAgent { target, agent } => tree.set_cell_agent(*target, *agent),
|
||||
Self::SetCellConversation {
|
||||
target,
|
||||
conversation_id,
|
||||
} => tree.set_cell_conversation(*target, conversation_id.clone()),
|
||||
};
|
||||
result.map_err(map_layout_err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user