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)
|
.save_manifest(&input.project, &manifest)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 5. Clean the conversation on every persisted layout: for each leaf
|
// 5. Invalidate the engine link on every persisted layout: for each leaf
|
||||||
// hosting this agent, drop the (now foreign) conversation id and reset
|
// hosting this agent, drop the (now foreign) engine resumable cache and
|
||||||
// the running flag. Mirrors `SnapshotRunningAgents`:
|
// reset the running flag, while **preserving** the stable IdeA pair id.
|
||||||
// resolve_doc → walk agent_leaves → mutate → persist_doc (if changed).
|
// Mirrors `SnapshotRunningAgents`: resolve_doc → walk agent_leaves →
|
||||||
self.clean_conversation(&input.project, &input.agent_id)
|
// mutate → persist_doc (if changed). Returns the preserved pair id.
|
||||||
|
let pair_id = self
|
||||||
|
.invalidate_engine_link(&input.project, &input.agent_id)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 6. A live session? Kill its PTY then relaunch in the same cell with the
|
// 6. A live session? Kill its PTY then relaunch in the same cell with the
|
||||||
// new profile and a discarded conversation id.
|
// new profile, carrying the **preserved** pair id (so the handoff is
|
||||||
let relaunched = self.relaunch_if_live(&input).await?;
|
// 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.
|
// 7. Publish the profile change and return.
|
||||||
self.events.publish(DomainEvent::AgentProfileChanged {
|
self.events.publish(DomainEvent::AgentProfileChanged {
|
||||||
@ -489,28 +492,52 @@ impl ChangeAgentProfile {
|
|||||||
Ok(ChangeAgentProfileOutput { agent, relaunched })
|
Ok(ChangeAgentProfileOutput { agent, relaunched })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears the conversation id and resets the running flag on every persisted
|
/// Invalidates the **engine link** on every persisted layout leaf hosting
|
||||||
/// layout leaf hosting `agent_id` (step 5). Persists only when something
|
/// `agent_id` (step 5), while **preserving** the IdeA pair conversation id.
|
||||||
/// actually changed (a project with no such leaf is a no-op write).
|
///
|
||||||
async fn clean_conversation(
|
/// 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,
|
&self,
|
||||||
project: &Project,
|
project: &Project,
|
||||||
agent_id: &AgentId,
|
agent_id: &AgentId,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<Option<String>, AppError> {
|
||||||
let project = self.projects.load_project(project.id).await?;
|
let project = self.projects.load_project(project.id).await?;
|
||||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
|
let mut pair_id: Option<String> = None;
|
||||||
|
|
||||||
for named in &mut doc.layouts {
|
for named in &mut doc.layouts {
|
||||||
for (leaf_id, leaf_agent) in named.tree.agent_leaves() {
|
for (leaf_id, leaf_agent) in named.tree.agent_leaves() {
|
||||||
if &leaf_agent != agent_id {
|
if &leaf_agent != agent_id {
|
||||||
continue;
|
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
|
// Pure ops — only NodeNotFound is possible, which cannot happen
|
||||||
// since `leaf_id` came from this very tree.
|
// 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
|
named.tree = named
|
||||||
.tree
|
.tree
|
||||||
.set_cell_conversation(leaf_id, None)
|
.set_cell_engine_session(leaf_id, None)
|
||||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
named.tree = named
|
named.tree = named
|
||||||
.tree
|
.tree
|
||||||
@ -523,15 +550,24 @@ impl ChangeAgentProfile {
|
|||||||
if changed {
|
if changed {
|
||||||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
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
|
/// 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
|
/// the new profile (step 6), composing [`LaunchAgent::execute`]. Returns the
|
||||||
/// relaunched session, or `None` when the agent had no live session.
|
/// 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(
|
async fn relaunch_if_live(
|
||||||
&self,
|
&self,
|
||||||
input: &ChangeAgentProfileInput,
|
input: &ChangeAgentProfileInput,
|
||||||
|
pair_id: Option<String>,
|
||||||
) -> Result<Option<TerminalSession>, AppError> {
|
) -> Result<Option<TerminalSession>, AppError> {
|
||||||
// Résolution **polymorphe** de la session vivante sur les deux registres
|
// 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
|
// (§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);
|
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
|
let output = self
|
||||||
.launch
|
.launch
|
||||||
.execute(LaunchAgentInput {
|
.execute(LaunchAgentInput {
|
||||||
@ -550,9 +597,11 @@ impl ChangeAgentProfile {
|
|||||||
rows: input.rows,
|
rows: input.rows,
|
||||||
cols: input.cols,
|
cols: input.cols,
|
||||||
node_id,
|
node_id,
|
||||||
// Conversation id discarded: the previous one belonged to the old
|
// Pair id **préservé** (id de paire IdeA stable) : le handoff (P7) est
|
||||||
// engine; the new profile starts (or assigns) a fresh one.
|
// réinjecté dans le nouveau moteur, et le resume (P8c) est routé via
|
||||||
conversation_id: None,
|
// 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
|
// Internal relaunch (no app-tauri composition root in scope) ⇒ the
|
||||||
// MCP runtime is not injected here; `apply_mcp_config` falls back to
|
// MCP runtime is not injected here; `apply_mcp_config` falls back to
|
||||||
// the minimal declaration. A profile-hot-swap that needs the real
|
// the minimal declaration. A profile-hot-swap that needs the real
|
||||||
|
|||||||
@ -301,7 +301,23 @@ impl FileSystem for FakeFs {
|
|||||||
// FakeRuntime (AgentRuntime) — minimal; only exercised on a relaunch
|
// 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 {
|
impl AgentRuntime for FakeRuntime {
|
||||||
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||||
@ -312,8 +328,9 @@ impl AgentRuntime for FakeRuntime {
|
|||||||
profile: &AgentProfile,
|
profile: &AgentProfile,
|
||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
cwd: &ProjectPath,
|
cwd: &ProjectPath,
|
||||||
_session: &SessionPlan,
|
session: &SessionPlan,
|
||||||
) -> Result<SpawnSpec, RuntimeError> {
|
) -> Result<SpawnSpec, RuntimeError> {
|
||||||
|
self.plans.lock().unwrap().push(session.clone());
|
||||||
Ok(SpawnSpec {
|
Ok(SpawnSpec {
|
||||||
command: profile.command.clone(),
|
command: profile.command.clone(),
|
||||||
args: profile.args.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`).
|
/// Seeds a valid `layouts.json` (a single active layout holding `tree`).
|
||||||
fn seed_layouts(fs: &FakeFs, id: LayoutId, tree: &LayoutTree) {
|
fn seed_layouts(fs: &FakeFs, id: LayoutId, tree: &LayoutTree) {
|
||||||
let doc = serde_json::json!({
|
let doc = serde_json::json!({
|
||||||
@ -561,6 +593,22 @@ fn leaf_state(fs: &FakeFs, node: NodeId) -> Option<(Option<String>, bool)> {
|
|||||||
find(&tree.root, node)
|
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.
|
/// Seeds a live agent session pinned on `node` into the registry.
|
||||||
fn seed_live_agent_session(
|
fn seed_live_agent_session(
|
||||||
sessions: &TerminalSessions,
|
sessions: &TerminalSessions,
|
||||||
@ -580,6 +628,61 @@ fn seed_live_agent_session(
|
|||||||
sessions.insert(PtyHandle { session_id }, 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
|
// Fixture
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -592,6 +695,10 @@ struct Fixture {
|
|||||||
pty: FakePty,
|
pty: FakePty,
|
||||||
bus: SpyBus,
|
bus: SpyBus,
|
||||||
sessions: Arc<TerminalSessions>,
|
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
|
/// 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 pty = FakePty::new(sid(777));
|
||||||
let sessions = Arc::new(TerminalSessions::new());
|
let sessions = Arc::new(TerminalSessions::new());
|
||||||
let bus = SpyBus::default();
|
let bus = SpyBus::default();
|
||||||
|
let runtime = FakeRuntime::new();
|
||||||
|
let handoffs = FakeHandoffs::default();
|
||||||
|
|
||||||
// Register the project so ProjectStore::load_project resolves.
|
// Register the project so ProjectStore::load_project resolves.
|
||||||
store.save(&project()).await;
|
store.save(&project()).await;
|
||||||
@ -615,7 +724,7 @@ async fn fixture_with_profiles(agent: &Agent, profiles: Vec<AgentProfile>) -> Fi
|
|||||||
let launch = LaunchAgent::new(
|
let launch = LaunchAgent::new(
|
||||||
Arc::new(contexts.clone()),
|
Arc::new(contexts.clone()),
|
||||||
Arc::new(profiles.clone()),
|
Arc::new(profiles.clone()),
|
||||||
Arc::new(FakeRuntime),
|
Arc::new(runtime.clone()),
|
||||||
Arc::new(fs.clone()),
|
Arc::new(fs.clone()),
|
||||||
Arc::new(pty.clone()),
|
Arc::new(pty.clone()),
|
||||||
Arc::new(FakeSkills),
|
Arc::new(FakeSkills),
|
||||||
@ -624,7 +733,10 @@ async fn fixture_with_profiles(agent: &Agent, profiles: Vec<AgentProfile>) -> Fi
|
|||||||
Arc::new(SeqIds::new()),
|
Arc::new(SeqIds::new()),
|
||||||
Arc::new(FakeRecall),
|
Arc::new(FakeRecall),
|
||||||
None,
|
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(
|
let swap = ChangeAgentProfile::new(
|
||||||
Arc::new(contexts.clone()),
|
Arc::new(contexts.clone()),
|
||||||
@ -644,6 +756,8 @@ async fn fixture_with_profiles(agent: &Agent, profiles: Vec<AgentProfile>) -> Fi
|
|||||||
pty,
|
pty,
|
||||||
bus,
|
bus,
|
||||||
sessions,
|
sessions,
|
||||||
|
runtime,
|
||||||
|
handoffs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -757,20 +871,27 @@ async fn success_mutates_manifest_to_new_profile() {
|
|||||||
assert!(out.relaunched.is_none());
|
assert!(out.relaunched.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Conversation cleanup: a layout cell hosting the agent with a `conversation_id`
|
/// Engine-link invalidation (P8d new contract): a layout cell hosting the agent
|
||||||
/// and `agent_was_running = true` is reset to `(None, false)` on the persisted
|
/// **preserves** its stable pair `conversation_id`, **clears** the foreign engine
|
||||||
/// layouts after the swap.
|
/// resumable cache (`engine_session_id`) and resets `agent_was_running` on the
|
||||||
|
/// persisted layouts after the swap.
|
||||||
#[tokio::test]
|
#[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 agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
||||||
let f = fixture(&agent).await;
|
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);
|
let leaf = nid(10);
|
||||||
seed_layouts(
|
seed_layouts(
|
||||||
&f.fs,
|
&f.fs,
|
||||||
lid(1),
|
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
|
f.swap
|
||||||
@ -778,11 +899,17 @@ async fn cleans_conversation_on_persisted_layouts() {
|
|||||||
.await
|
.await
|
||||||
.expect("swap succeeds");
|
.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!(
|
assert_eq!(
|
||||||
leaf_state(&f.fs, leaf),
|
leaf_state(&f.fs, leaf),
|
||||||
Some((None, false)),
|
Some((Some("pair-stable".to_owned()), false)),
|
||||||
"conversation_id and agent_was_running must be reset"
|
"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,
|
direction: domain::Direction::Row,
|
||||||
children: vec![
|
children: vec![
|
||||||
domain::WeightedChild {
|
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,
|
weight: 1.0,
|
||||||
},
|
},
|
||||||
domain::WeightedChild {
|
domain::WeightedChild {
|
||||||
node: LayoutNode::Leaf(agent_leaf(
|
node: LayoutNode::Leaf(agent_leaf_with_engine(
|
||||||
other,
|
other,
|
||||||
Some(other_agent),
|
Some(other_agent),
|
||||||
Some("conv-other"),
|
Some("conv-other"),
|
||||||
|
Some("engine-other"),
|
||||||
true,
|
true,
|
||||||
)),
|
)),
|
||||||
weight: 1.0,
|
weight: 1.0,
|
||||||
@ -822,16 +956,33 @@ async fn cleanup_leaves_other_agents_untouched() {
|
|||||||
.await
|
.await
|
||||||
.expect("swap succeeds");
|
.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!(
|
assert_eq!(
|
||||||
leaf_state(&f.fs, other),
|
leaf_state(&f.fs, other),
|
||||||
Some((Some("conv-other".to_owned()), true)),
|
Some((Some("conv-other".to_owned()), true)),
|
||||||
"the other agent's cell is untouched"
|
"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
|
/// 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]
|
#[tokio::test]
|
||||||
async fn live_agent_is_killed_and_relaunched_in_same_cell() {
|
async fn live_agent_is_killed_and_relaunched_in_same_cell() {
|
||||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
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);
|
let host = nid(5);
|
||||||
seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
|
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(
|
seed_layouts(
|
||||||
&f.fs,
|
&f.fs,
|
||||||
lid(1),
|
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
|
let out = f
|
||||||
@ -928,3 +1085,201 @@ async fn publishes_agent_profile_changed_once_on_success() {
|
|||||||
"AgentProfileChanged published exactly once with the new profile"
|
"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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user