feat(persistence): P8d — swap cross-profile préserve l'id de paire + handoff
Capstone du chantier handoff : un agent change de moteur (Claude↔Codex) en gardant la continuité du travail. - clean_conversation → invalidate_engine_link : préserve conversation_id (id de paire stable) et n'efface que engine_session_id (lien moteur étranger) ; renvoie l'id de paire préservé - relaunch_if_live relance avec l'id de paire (repli for_pair si session de fond) ⇒ handoff P7 réinjecté dans le nouveau moteur, resume P8c routé via providers.json[nouveau provider] (vide ⇒ SessionPlan::None : l'ancien resumable n'est jamais repassé ; fidélité par le handoff) - tests : 4 cas swap (préservation id de paire, engine_session_id vidé, handoff réinjecté, pas de --resume de l'ancien moteur, repli for_pair) ; change_agent_profile 12 verts, agrégat 829 passed 0 failed Chantier persistance conversationnelle + handoff cross-profile (P1→P8d) complet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -469,16 +469,19 @@ impl ChangeAgentProfile {
|
||||
.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)
|
||||
// 5. Invalidate the engine link on every persisted layout: for each leaf
|
||||
// hosting this agent, drop the (now foreign) engine resumable cache and
|
||||
// reset the running flag, while **preserving** the stable IdeA pair id.
|
||||
// Mirrors `SnapshotRunningAgents`: resolve_doc → walk agent_leaves →
|
||||
// mutate → persist_doc (if changed). Returns the preserved pair id.
|
||||
let pair_id = self
|
||||
.invalidate_engine_link(&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?;
|
||||
// new profile, carrying the **preserved** pair id (so the handoff is
|
||||
// re-injected and resume routes via providers.json[new provider]).
|
||||
let relaunched = self.relaunch_if_live(&input, pair_id).await?;
|
||||
|
||||
// 7. Publish the profile change and return.
|
||||
self.events.publish(DomainEvent::AgentProfileChanged {
|
||||
@ -489,28 +492,52 @@ impl ChangeAgentProfile {
|
||||
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(
|
||||
/// Invalidates the **engine link** on every persisted layout leaf hosting
|
||||
/// `agent_id` (step 5), while **preserving** the IdeA pair conversation id.
|
||||
///
|
||||
/// Post-P8a, `LeafCell::conversation_id` is a stable, provider-independent
|
||||
/// **pair id** (User↔agent) that retrieves the work log + handoff and must
|
||||
/// survive a profile swap. Only the engine-side cache
|
||||
/// (`LeafCell::engine_session_id`, a foreign resumable for the new engine) is
|
||||
/// cleared; the source of truth for resume routing is `providers.json`. The
|
||||
/// running flag is reset too.
|
||||
///
|
||||
/// Returns the **preserved pair id** of the agent (the first non-`None` leaf;
|
||||
/// every leaf of this agent carries the same User↔agent pair id), or `None`
|
||||
/// when no hosting leaf was found. Persists only when something actually
|
||||
/// changed (a project with no such leaf is a no-op write).
|
||||
async fn invalidate_engine_link(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: &AgentId,
|
||||
) -> Result<(), AppError> {
|
||||
) -> Result<Option<String>, 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;
|
||||
let mut pair_id: Option<String> = None;
|
||||
|
||||
for named in &mut doc.layouts {
|
||||
for (leaf_id, leaf_agent) in named.tree.agent_leaves() {
|
||||
if &leaf_agent != agent_id {
|
||||
continue;
|
||||
}
|
||||
// Capture the preserved pair id (stable across all leaves of this
|
||||
// agent) — used to relaunch with handoff re-injection (P7).
|
||||
if pair_id.is_none() {
|
||||
if let Some(cid) =
|
||||
named.tree.leaf(leaf_id).and_then(|l| l.conversation_id.clone())
|
||||
{
|
||||
pair_id = Some(cid);
|
||||
}
|
||||
}
|
||||
// Pure ops — only NodeNotFound is possible, which cannot happen
|
||||
// since `leaf_id` came from this very tree.
|
||||
//
|
||||
// Preserve `conversation_id` (stable pair id); only drop the
|
||||
// engine-side resumable cache (foreign to the new engine).
|
||||
named.tree = named
|
||||
.tree
|
||||
.set_cell_conversation(leaf_id, None)
|
||||
.set_cell_engine_session(leaf_id, None)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
named.tree = named
|
||||
.tree
|
||||
@ -523,15 +550,24 @@ impl ChangeAgentProfile {
|
||||
if changed {
|
||||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
||||
}
|
||||
Ok(())
|
||||
Ok(pair_id)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// `pair_id` is the **preserved** IdeA pair id (User↔agent) captured at step 5;
|
||||
/// it is threaded into the relaunch so the handoff (P7) is re-injected into the
|
||||
/// new engine and resume (P8c) is routed via `providers.json[new provider]` —
|
||||
/// the old engine's resumable is **never** replayed (providers.json for the new
|
||||
/// provider is empty ⇒ `SessionPlan::None`, fidelity carried by the handoff).
|
||||
/// When step 5 found no hosting leaf (e.g. a background session without a cell),
|
||||
/// the id is **derived** deterministically via `for_pair(User, agent)`.
|
||||
async fn relaunch_if_live(
|
||||
&self,
|
||||
input: &ChangeAgentProfileInput,
|
||||
pair_id: Option<String>,
|
||||
) -> Result<Option<TerminalSession>, AppError> {
|
||||
// Résolution **polymorphe** de la session vivante sur les deux registres
|
||||
// (§17.4) : structuré d'abord, puis PTY. Un agent ne vit que dans un seul des
|
||||
@ -542,6 +578,17 @@ impl ChangeAgentProfile {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Pair id préservé (step 5) ; en repli (session de fond sans cellule), on
|
||||
// dérive l'id de paire déterministe User↔agent — les cellules sont toujours
|
||||
// User↔agent, donc cette dérivation coïncide avec ce qu'eût porté la feuille.
|
||||
let conversation_id = Some(pair_id.unwrap_or_else(|| {
|
||||
ConversationId::for_pair(
|
||||
ConversationParty::User,
|
||||
ConversationParty::agent(input.agent_id),
|
||||
)
|
||||
.to_string()
|
||||
}));
|
||||
|
||||
let output = self
|
||||
.launch
|
||||
.execute(LaunchAgentInput {
|
||||
@ -550,9 +597,11 @@ impl ChangeAgentProfile {
|
||||
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,
|
||||
// Pair id **préservé** (id de paire IdeA stable) : le handoff (P7) est
|
||||
// réinjecté dans le nouveau moteur, et le resume (P8c) est routé via
|
||||
// providers.json[nouveau provider] — l'ancien resumable n'est jamais
|
||||
// repassé (providers.json du nouveau provider vide ⇒ moteur neuf).
|
||||
conversation_id,
|
||||
// Internal relaunch (no app-tauri composition root in scope) ⇒ the
|
||||
// MCP runtime is not injected here; `apply_mcp_config` falls back to
|
||||
// the minimal declaration. A profile-hot-swap that needs the real
|
||||
|
||||
Reference in New Issue
Block a user