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:
2026-06-12 18:52:50 +02:00
parent d87b8f6ed2
commit 4509f0db9d
2 changed files with 440 additions and 36 deletions

View File

@ -301,7 +301,23 @@ impl FileSystem for FakeFs {
// FakeRuntime (AgentRuntime) — minimal; only exercised on a relaunch
// ---------------------------------------------------------------------------
struct FakeRuntime;
/// Records the [`SessionPlan`] handed to `prepare_invocation` on the (re)launch,
/// so a swap test can prove the relaunch routes a fresh `SessionPlan::None`
/// (the foreign engine resumable is **never** replayed) rather than a `Resume`.
#[derive(Clone, Default)]
struct FakeRuntime {
plans: Arc<Mutex<Vec<SessionPlan>>>,
}
impl FakeRuntime {
fn new() -> Self {
Self::default()
}
/// The last `SessionPlan` the launcher built for a (re)launch, if any.
fn last_plan(&self) -> Option<SessionPlan> {
self.plans.lock().unwrap().last().cloned()
}
}
impl AgentRuntime for FakeRuntime {
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
@ -312,8 +328,9 @@ impl AgentRuntime for FakeRuntime {
profile: &AgentProfile,
_ctx: &PreparedContext,
cwd: &ProjectPath,
_session: &SessionPlan,
session: &SessionPlan,
) -> Result<SpawnSpec, RuntimeError> {
self.plans.lock().unwrap().push(session.clone());
Ok(SpawnSpec {
command: profile.command.clone(),
args: profile.args.clone(),
@ -533,6 +550,21 @@ fn agent_leaf(
}
}
/// Like [`agent_leaf`], additionally seeding an `engine_session_id` (the engine
/// resumable cache) so we can assert it is invalidated on a profile swap.
fn agent_leaf_with_engine(
node: NodeId,
agent: Option<AgentId>,
conversation_id: Option<&str>,
engine_session_id: Option<&str>,
agent_was_running: bool,
) -> LeafCell {
LeafCell {
engine_session_id: engine_session_id.map(str::to_owned),
..agent_leaf(node, agent, conversation_id, agent_was_running)
}
}
/// Seeds a valid `layouts.json` (a single active layout holding `tree`).
fn seed_layouts(fs: &FakeFs, id: LayoutId, tree: &LayoutTree) {
let doc = serde_json::json!({
@ -561,6 +593,22 @@ fn leaf_state(fs: &FakeFs, node: NodeId) -> Option<(Option<String>, bool)> {
find(&tree.root, node)
}
/// Reads back the persisted `engine_session_id` of leaf `node`.
fn leaf_engine_session(fs: &FakeFs, node: NodeId) -> Option<Option<String>> {
let bytes = fs.read_file(LAYOUTS_PATH)?;
let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let tree: LayoutTree = serde_json::from_value(doc["layouts"][0]["tree"].clone()).unwrap();
fn find(n: &LayoutNode, target: NodeId) -> Option<Option<String>> {
match n {
LayoutNode::Leaf(l) if l.id == target => Some(l.engine_session_id.clone()),
LayoutNode::Leaf(_) => None,
LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)),
LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)),
}
}
find(&tree.root, node)
}
/// Seeds a live agent session pinned on `node` into the registry.
fn seed_live_agent_session(
sessions: &TerminalSessions,
@ -580,6 +628,61 @@ fn seed_live_agent_session(
sessions.insert(PtyHandle { session_id }, session);
}
// ---------------------------------------------------------------------------
// FakeHandoffs (HandoffProvider) — root-scoped store seeded with one handoff
// ---------------------------------------------------------------------------
/// A [`HandoffProvider`] backed by an in-memory [`HandoffStore`] keyed by pair id.
/// Wiring this into the relaunch's `LaunchAgent` lets a test prove the **threaded
/// pair id** end-to-end: the `# Reprise de la conversation` section appears in the
/// relaunch's convention file **only if** the relaunch carried the exact
/// `conversation_id` under which the handoff was seeded.
#[derive(Clone, Default)]
struct FakeHandoffs(Arc<Mutex<HashMap<Uuid, domain::Handoff>>>);
impl FakeHandoffs {
/// Seeds a handoff under conversation id `conv` (a UUID-shaped pair id string).
fn seed(&self, conv: &str, summary: &str, objective: Option<&str>) {
let uuid = Uuid::parse_str(conv).expect("seed conv id must be a UUID");
let handoff = domain::Handoff::new(
summary.to_owned(),
domain::TurnId::from_uuid(Uuid::from_u128(0xABCD)),
objective.map(str::to_owned),
);
self.0.lock().unwrap().insert(uuid, handoff);
}
}
#[async_trait]
impl domain::HandoffStore for FakeHandoffs {
async fn load(
&self,
conversation: domain::ConversationId,
) -> Result<Option<domain::Handoff>, StoreError> {
Ok(self.0.lock().unwrap().get(&conversation.as_uuid()).cloned())
}
async fn save(
&self,
conversation: domain::ConversationId,
handoff: domain::Handoff,
) -> Result<(), StoreError> {
self.0
.lock()
.unwrap()
.insert(conversation.as_uuid(), handoff);
Ok(())
}
}
impl application::HandoffProvider for FakeHandoffs {
fn handoff_store_for(
&self,
_root: &ProjectPath,
) -> Option<Arc<dyn domain::HandoffStore>> {
Some(Arc::new(self.clone()))
}
}
// ---------------------------------------------------------------------------
// Fixture
// ---------------------------------------------------------------------------
@ -592,6 +695,10 @@ struct Fixture {
pty: FakePty,
bus: SpyBus,
sessions: Arc<TerminalSessions>,
/// The runtime used by the composed relaunch — records the `SessionPlan`.
runtime: FakeRuntime,
/// The handoff store wired into the relaunch (seed via [`FakeHandoffs::seed`]).
handoffs: FakeHandoffs,
}
/// Wires a [`ChangeAgentProfile`] over fakes. The agent starts on `pid(1)`; the
@ -608,6 +715,8 @@ async fn fixture_with_profiles(agent: &Agent, profiles: Vec<AgentProfile>) -> Fi
let pty = FakePty::new(sid(777));
let sessions = Arc::new(TerminalSessions::new());
let bus = SpyBus::default();
let runtime = FakeRuntime::new();
let handoffs = FakeHandoffs::default();
// Register the project so ProjectStore::load_project resolves.
store.save(&project()).await;
@ -615,7 +724,7 @@ async fn fixture_with_profiles(agent: &Agent, profiles: Vec<AgentProfile>) -> Fi
let launch = LaunchAgent::new(
Arc::new(contexts.clone()),
Arc::new(profiles.clone()),
Arc::new(FakeRuntime),
Arc::new(runtime.clone()),
Arc::new(fs.clone()),
Arc::new(pty.clone()),
Arc::new(FakeSkills),
@ -624,7 +733,10 @@ async fn fixture_with_profiles(agent: &Agent, profiles: Vec<AgentProfile>) -> Fi
Arc::new(SeqIds::new()),
Arc::new(FakeRecall),
None,
);
)
// Wire the handoff provider so a relaunch re-injects the (pair-keyed) handoff
// into the new engine's convention file — the end-to-end proof of P7+P8d.
.with_handoff_provider(Arc::new(handoffs.clone()));
let swap = ChangeAgentProfile::new(
Arc::new(contexts.clone()),
@ -644,6 +756,8 @@ async fn fixture_with_profiles(agent: &Agent, profiles: Vec<AgentProfile>) -> Fi
pty,
bus,
sessions,
runtime,
handoffs,
}
}
@ -757,20 +871,27 @@ async fn success_mutates_manifest_to_new_profile() {
assert!(out.relaunched.is_none());
}
/// Conversation cleanup: a layout cell hosting the agent with a `conversation_id`
/// and `agent_was_running = true` is reset to `(None, false)` on the persisted
/// layouts after the swap.
/// Engine-link invalidation (P8d new contract): a layout cell hosting the agent
/// **preserves** its stable pair `conversation_id`, **clears** the foreign engine
/// resumable cache (`engine_session_id`) and resets `agent_was_running` on the
/// persisted layouts after the swap.
#[tokio::test]
async fn cleans_conversation_on_persisted_layouts() {
async fn invalidates_engine_link_preserving_pair_id_on_persisted_layouts() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture(&agent).await;
// Seed a layout: leaf nid(10) hosts the agent with a live conversation.
// Seed a layout: leaf nid(10) hosts the agent with a pair id + engine cache.
let leaf = nid(10);
seed_layouts(
&f.fs,
lid(1),
&LayoutTree::single(agent_leaf(leaf, Some(agent.id), Some("conv-old"), true)),
&LayoutTree::single(agent_leaf_with_engine(
leaf,
Some(agent.id),
Some("pair-stable"),
Some("engine-old"),
true,
)),
);
f.swap
@ -778,11 +899,17 @@ async fn cleans_conversation_on_persisted_layouts() {
.await
.expect("swap succeeds");
// The leaf's conversation id is cleared and the running flag reset.
// P8d: the stable pair id is PRESERVED; only the running flag is reset.
assert_eq!(
leaf_state(&f.fs, leaf),
Some((None, false)),
"conversation_id and agent_was_running must be reset"
Some((Some("pair-stable".to_owned()), false)),
"pair conversation_id must be preserved; agent_was_running reset"
);
// The foreign engine resumable cache is invalidated.
assert_eq!(
leaf_engine_session(&f.fs, leaf),
Some(None),
"engine_session_id must be cleared"
);
}
@ -801,14 +928,21 @@ async fn cleanup_leaves_other_agents_untouched() {
direction: domain::Direction::Row,
children: vec![
domain::WeightedChild {
node: LayoutNode::Leaf(agent_leaf(mine, Some(agent.id), Some("conv-mine"), true)),
node: LayoutNode::Leaf(agent_leaf_with_engine(
mine,
Some(agent.id),
Some("conv-mine"),
Some("engine-mine"),
true,
)),
weight: 1.0,
},
domain::WeightedChild {
node: LayoutNode::Leaf(agent_leaf(
node: LayoutNode::Leaf(agent_leaf_with_engine(
other,
Some(other_agent),
Some("conv-other"),
Some("engine-other"),
true,
)),
weight: 1.0,
@ -822,16 +956,33 @@ async fn cleanup_leaves_other_agents_untouched() {
.await
.expect("swap succeeds");
assert_eq!(leaf_state(&f.fs, mine), Some((None, false)), "mine cleaned");
// P8d: my pair id is preserved; running reset; engine cache cleared.
assert_eq!(
leaf_state(&f.fs, mine),
Some((Some("conv-mine".to_owned()), false)),
"mine: pair id preserved, running reset"
);
assert_eq!(
leaf_engine_session(&f.fs, mine),
Some(None),
"mine: engine_session_id cleared"
);
// The other agent's cell is entirely untouched (pair id, engine, running).
assert_eq!(
leaf_state(&f.fs, other),
Some((Some("conv-other".to_owned()), true)),
"the other agent's cell is untouched"
);
assert_eq!(
leaf_engine_session(&f.fs, other),
Some(Some("engine-other".to_owned())),
"the other agent's engine cache is untouched"
);
}
/// Live agent ⇒ the PTY is killed and the session is relaunched in the SAME cell
/// (node N) with a discarded conversation id (`LaunchAgent` invoked at node N).
/// (node N). Post-P8d the stable pair id is **preserved** and threaded into the
/// relaunch (`LaunchAgent` invoked at node N); the engine cache is invalidated.
#[tokio::test]
async fn live_agent_is_killed_and_relaunched_in_same_cell() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
@ -841,11 +992,17 @@ async fn live_agent_is_killed_and_relaunched_in_same_cell() {
let host = nid(5);
seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
// Seed a layout cell for that node carrying the now-foreign conversation.
// Seed a layout cell for that node carrying the stable pair id + engine cache.
seed_layouts(
&f.fs,
lid(1),
&LayoutTree::single(agent_leaf(host, Some(agent.id), Some("conv-old"), true)),
&LayoutTree::single(agent_leaf_with_engine(
host,
Some(agent.id),
Some("pair-stable"),
Some("engine-old"),
true,
)),
);
let out = f
@ -928,3 +1085,201 @@ async fn publishes_agent_profile_changed_once_on_success() {
"AgentProfileChanged published exactly once with the new profile"
);
}
// ---------------------------------------------------------------------------
// P8d capstone — cross-profile swap threads the PRESERVED pair id into the
// relaunch, never replays the old engine resumable, and re-injects the handoff.
// ---------------------------------------------------------------------------
/// The relaunch's convention file path: `<root>/.ideai/run/<agent-id>/CLAUDE.md`
/// (the `FakeRuntime` plan targets `CLAUDE.md`, written into the agent run dir).
fn relaunch_convention_path(agent: &AgentId) -> String {
format!("{ROOT}/.ideai/run/{agent}/CLAUDE.md")
}
/// Case 2 — live agent, swap relaunches threading the **preserved pair id** and a
/// **fresh** `SessionPlan::None` (the old engine resumable is NEVER replayed).
///
/// The pair id on the leaf is a real UUID under which a handoff is seeded. After
/// the swap the new engine's convention file carries `# Reprise de la conversation`
/// — provable only if the relaunch's `conversation_id` equalled that exact pair id.
/// And the `SessionPlan` built for the relaunch is `None`, never `Resume{engine}`.
#[tokio::test]
async fn live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture(&agent).await;
// A UUID-shaped pair id (so resolve_handoff can parse it) and a seeded handoff.
let pair = "11111111-1111-1111-1111-111111111111";
f.handoffs
.seed(pair, "Résumé : on a fini l'étape 2.", Some("Livrer le lot P8d"));
// Live agent on cell N with a foreign engine resumable cache.
let host = nid(5);
seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
seed_layouts(
&f.fs,
lid(1),
&LayoutTree::single(agent_leaf_with_engine(
host,
Some(agent.id),
Some(pair),
Some("engine-old"),
true,
)),
);
let out = f
.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("hot swap succeeds");
// The old PTY was killed and a single relaunch happened in the SAME cell.
assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed");
let relaunched = out.relaunched.expect("a live agent is relaunched");
assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell");
// The pair id is preserved on the persisted leaf; the engine cache is cleared.
assert_eq!(
leaf_state(&f.fs, host),
Some((Some(pair.to_owned()), false)),
"pair id preserved, running reset"
);
assert_eq!(
leaf_engine_session(&f.fs, host),
Some(None),
"engine_session_id cleared"
);
// The relaunch threaded the PRESERVED pair id: the handoff seeded under that
// exact id is re-injected into the new engine's convention file.
let conv = String::from_utf8(
f.fs.read_file(&relaunch_convention_path(&agent.id))
.expect("relaunch wrote a convention file"),
)
.unwrap();
assert!(
conv.contains("# Reprise de la conversation"),
"relaunch must re-inject the handoff under the preserved pair id: {conv}"
);
assert!(
conv.contains("Résumé : on a fini l'étape 2."),
"handoff summary present in the new engine's convention file: {conv}"
);
// The old engine resumable is NEVER replayed: the relaunch's SessionPlan is
// None (providers.json[new provider] is empty ⇒ fresh engine, fidelity carried
// by the handoff). It is emphatically NOT Resume{"engine-old"}.
let plan = f.runtime.last_plan().expect("the relaunch prepared an invocation");
assert_eq!(
plan,
SessionPlan::None,
"the foreign engine resumable must never be replayed on a swap"
);
}
/// Case 3 — live agent with NO hosting cell (background session ⇒ step 5 returns
/// `None`). The relaunch must derive the pair id deterministically via
/// `ConversationId::for_pair(User, agent)` (== the agent's own UUID).
///
/// Proven end-to-end: a handoff seeded under `for_pair(User, agent)` is re-injected
/// into the relaunch's convention file, which can only happen if the relaunch
/// threaded that derived id as its `conversation_id`.
#[tokio::test]
async fn live_swap_without_cell_relaunches_with_for_pair_id() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture(&agent).await;
// The deterministic User↔agent pair id equals the agent's own UUID.
let derived = domain::ConversationId::for_pair(
domain::ConversationParty::User,
domain::ConversationParty::agent(agent.id),
)
.to_string();
assert_eq!(
derived,
agent.id.to_string(),
"for_pair(User, agent) == agent uuid (sanity)"
);
f.handoffs
.seed(&derived, "Repris depuis une session de fond.", None);
// Live agent but NO seeded layout cell ⇒ node_for_agent yields None
// (background session) ⇒ step 5 finds no hosting leaf ⇒ pair_id None.
let host = nid(5);
seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
// Intentionally NO seed_layouts: there is no persisted hosting cell.
let out = f
.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("hot swap succeeds");
assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed");
assert!(out.relaunched.is_some(), "a live agent is relaunched");
// The relaunch derived the pair id via for_pair: the handoff seeded under it is
// re-injected into the convention file (proves the threaded conversation id).
let conv = String::from_utf8(
f.fs.read_file(&relaunch_convention_path(&agent.id))
.expect("relaunch wrote a convention file"),
)
.unwrap();
assert!(
conv.contains("# Reprise de la conversation"),
"relaunch must use for_pair(User, agent) as the conversation id: {conv}"
);
assert!(
conv.contains("Repris depuis une session de fond."),
"handoff (keyed by for_pair) re-injected: {conv}"
);
// Still no engine resume: a swap never replays a foreign resumable.
assert_eq!(
f.runtime.last_plan(),
Some(SessionPlan::None),
"no engine resume on a swap (for_pair path)"
);
}
/// Case 1 (completeness) — preservation + invalidation read straight off the
/// persisted leaf with a **UUID** pair id (the realistic post-P8a shape), pairing
/// the existing `pair-stable` opaque-string test. The pair id survives untouched;
/// the engine cache is cleared; the running flag is reset.
#[tokio::test]
async fn swap_preserves_uuid_pair_id_and_clears_engine_cache() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
let f = fixture(&agent).await;
let leaf = nid(10);
let pair = "22222222-2222-2222-2222-222222222222";
seed_layouts(
&f.fs,
lid(1),
&LayoutTree::single(agent_leaf_with_engine(
leaf,
Some(agent.id),
Some(pair),
Some("engine-old"),
true,
)),
);
f.swap
.execute(change_input(agent.id, pid(2)))
.await
.expect("swap succeeds");
assert_eq!(
leaf_state(&f.fs, leaf),
Some((Some(pair.to_owned()), false)),
"UUID pair id preserved; agent_was_running reset"
);
assert_eq!(
leaf_engine_session(&f.fs, leaf),
Some(None),
"engine_session_id cleared on swap"
);
}