feat(agent): backend hot-swap profil (A0+A1) + inventaire reprise session (B1) — L15

Cadrage Architecture §15 (figé) : « agent = entité à session persistante ».

- A0 (domaine) : Agent::with_profile, LayoutTree::leaf, event AgentProfileChanged
  (+ DTO miroir DomainEventDto camelCase).
- A1 (application) : use case ChangeAgentProfile — no-op si profil identique,
  mutation manifeste, nettoyage conversation_id/agent_was_running sur layouts
  persistés, swap à chaud (kill PTY + relance même cellule via composition de
  LaunchAgent), event AgentProfileChanged. Décision : repartir à neuf (on garde
  .md + mémoire, on jette l'historique de conversation).
- B1 (application) : use case ListResumableAgents (lecture seule) — inventaire des
  cellules was_running||conversation_id, resume_supported selon profil, best-effort.

Aucun nouveau port/adapter (composition de l'existant). Hexagonal strict.
Tests : domaine 11 + app-tauri dto + ChangeAgentProfile 9 + ListResumableAgents 8,
suite application complète verte (0 régression).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 09:55:44 +02:00
parent 62bd5130fb
commit 2433e173a1
15 changed files with 2630 additions and 11 deletions

View File

@ -15,8 +15,8 @@ use std::sync::Arc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, FsError,
IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, PtyPort, RemotePath,
SessionPlan, SkillStore, SpawnSpec, StoreError,
IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, ProjectStore, PtyPort,
RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent,
@ -25,6 +25,7 @@ use domain::{
};
use crate::error::AppError;
use crate::layout::{persist_doc, resolve_doc};
use crate::project::project_context_path;
use crate::terminal::TerminalSessions;
@ -269,6 +270,242 @@ impl UpdateAgentContext {
}
}
// ---------------------------------------------------------------------------
// ChangeAgentProfile
// ---------------------------------------------------------------------------
/// Input for [`ChangeAgentProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangeAgentProfileInput {
/// The owning project.
pub project: Project,
/// The agent whose runtime profile to hot-swap.
pub agent_id: AgentId,
/// The new runtime profile.
pub profile_id: ProfileId,
/// Terminal height in rows for a possible hot relaunch.
pub rows: u16,
/// Terminal width in columns for a possible hot relaunch.
pub cols: u16,
}
/// Output of [`ChangeAgentProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangeAgentProfileOutput {
/// The mutated agent (now carrying the new profile).
pub agent: Agent,
/// The freshly relaunched session, when a live session was hot-swapped.
pub relaunched: Option<TerminalSession>,
}
/// Hot-swaps an existing agent's runtime profile (ARCHITECTURE §15.1).
///
/// **Single Responsibility**: mutate the profile in the manifest, clear the now
/// foreign conversation id on every persisted layout cell hosting the agent, and
/// — if the agent is live — kill its PTY and re-sequence the session in the same
/// cell with the new engine. The relaunch is **composed**, not duplicated: this
/// use case *calls* [`LaunchAgent::execute`] rather than re-implementing the spawn.
///
/// Ports consumed (ISP — only what is needed): [`AgentContextStore`] (manifest),
/// [`ProfileStore`] (validate the target profile), [`ProjectStore`] +
/// [`FileSystem`] (clean the conversation id on persisted layouts, exactly like
/// [`crate::layout::SnapshotRunningAgents`]), [`TerminalSessions`] + [`PtyPort`]
/// (detect/kill a live session), an [`Arc<LaunchAgent>`] for the hot relaunch, and
/// [`EventBus`] to publish.
pub struct ChangeAgentProfile {
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
projects: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
sessions: Arc<TerminalSessions>,
pty: Arc<dyn PtyPort>,
launch: Arc<LaunchAgent>,
events: Arc<dyn EventBus>,
}
impl ChangeAgentProfile {
/// Builds the use case from its injected ports (and the composed launcher).
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new(
contexts: Arc<dyn AgentContextStore>,
profiles: Arc<dyn ProfileStore>,
projects: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
sessions: Arc<TerminalSessions>,
pty: Arc<dyn PtyPort>,
launch: Arc<LaunchAgent>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
contexts,
profiles,
projects,
fs,
sessions,
pty,
launch,
events,
}
}
/// Executes the hot-swap, following the 7-step algorithm of §15.1.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent or the target profile is unknown,
/// - [`AppError::Invalid`] on a manifest/layout invariant violation,
/// - [`AppError::Store`] / [`AppError::FileSystem`] / [`AppError::Process`] on
/// the respective port failures (manifest, layouts, PTY kill, relaunch).
pub async fn execute(
&self,
input: ChangeAgentProfileInput,
) -> Result<ChangeAgentProfileOutput, AppError> {
// 1. Load the manifest and resolve the agent's entry (NotFound otherwise).
let manifest = self.contexts.load_manifest(&input.project).await?;
let entry = manifest
.entries
.iter()
.find(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
// 2. Same profile ⇒ no-op: return the agent unchanged, no kill/relaunch,
// no event.
if entry.profile_id == input.profile_id {
let agent = entry
.to_agent()
.map_err(|e| AppError::Invalid(e.to_string()))?;
return Ok(ChangeAgentProfileOutput {
agent,
relaunched: None,
});
}
// 3. Validate that the target profile is a known one (ProfileStore.list).
let known = self
.profiles
.list()
.await?
.into_iter()
.any(|p| p.id == input.profile_id);
if !known {
return Err(AppError::NotFound(format!("profile {}", input.profile_id)));
}
// 4. Mutate the entry (new profile), re-validate (to_agent + manifest)
// and persist.
let mut entries = manifest.entries;
let mut mutated_agent = None;
for e in &mut entries {
if e.agent_id == input.agent_id {
e.profile_id = input.profile_id;
let agent = e
.to_agent()
.map_err(|err| AppError::Invalid(err.to_string()))?;
mutated_agent = Some(agent);
}
}
let agent = mutated_agent
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
// 5. Clean the conversation on every persisted layout: for each leaf
// hosting this agent, drop the (now foreign) conversation id and reset
// the running flag. Mirrors `SnapshotRunningAgents`:
// resolve_doc → walk agent_leaves → mutate → persist_doc (if changed).
self.clean_conversation(&input.project, &input.agent_id)
.await?;
// 6. A live session? Kill its PTY then relaunch in the same cell with the
// new profile and a discarded conversation id.
let relaunched = self.relaunch_if_live(&input).await?;
// 7. Publish the profile change and return.
self.events.publish(DomainEvent::AgentProfileChanged {
agent_id: input.agent_id,
profile_id: input.profile_id,
});
Ok(ChangeAgentProfileOutput { agent, relaunched })
}
/// Clears the conversation id and resets the running flag on every persisted
/// layout leaf hosting `agent_id` (step 5). Persists only when something
/// actually changed (a project with no such leaf is a no-op write).
async fn clean_conversation(
&self,
project: &Project,
agent_id: &AgentId,
) -> Result<(), AppError> {
let project = self.projects.load_project(project.id).await?;
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
let mut changed = false;
for named in &mut doc.layouts {
for (leaf_id, leaf_agent) in named.tree.agent_leaves() {
if &leaf_agent != agent_id {
continue;
}
// Pure ops — only NodeNotFound is possible, which cannot happen
// since `leaf_id` came from this very tree.
named.tree = named
.tree
.set_cell_conversation(leaf_id, None)
.map_err(|e| AppError::Invalid(e.to_string()))?;
named.tree = named
.tree
.set_agent_running(leaf_id, false)
.map_err(|e| AppError::Invalid(e.to_string()))?;
changed = true;
}
}
if changed {
persist_doc(self.fs.as_ref(), &project, &doc).await?;
}
Ok(())
}
/// Kills the agent's live PTY (if any) and relaunches it in the same cell with
/// the new profile (step 6), composing [`LaunchAgent::execute`]. Returns the
/// relaunched session, or `None` when the agent had no live session.
async fn relaunch_if_live(
&self,
input: &ChangeAgentProfileInput,
) -> Result<Option<TerminalSession>, AppError> {
let Some(session_id) = self.sessions.session_for_agent(&input.agent_id) else {
return Ok(None);
};
// The hosting cell of the live session — the relaunch reopens here.
let node_id = self.sessions.node_for_agent(&input.agent_id);
// Kill the PTY: remove from the registry first (so the relaunch's
// one-live-session-per-agent guard sees no live session), then kill the
// process.
if let Some(handle) = self.sessions.remove(&session_id) {
self.pty.kill(&handle).await?;
}
let output = self
.launch
.execute(LaunchAgentInput {
project: input.project.clone(),
agent_id: input.agent_id,
rows: input.rows,
cols: input.cols,
node_id,
// Conversation id discarded: the previous one belonged to the old
// engine; the new profile starts (or assigns) a fresh one.
conversation_id: None,
})
.await?;
Ok(Some(output.session))
}
}
// ---------------------------------------------------------------------------
// DeleteAgent
// ---------------------------------------------------------------------------