feat(persistence): P8c — routage --resume moteur via providers.json (structuré)
Referme la régression latente de P8a : pour un profil structuré, le resumable
passé au moteur vient de providers.json[provider] (vrai id Claude/Codex), jamais
de l'id de paire désormais porté par la cellule.
- resolve_session_plan devient async + prend root ; branche structurée câblée :
get(pair, provider_key) ⇒ Resume{engine_id}, sinon None ; repli gracieux sur
l'ancien comportement si le store n'est pas câblé (zéro régression tests) ;
branche non structurée strictement inchangée
- tests : 6 cas (claude/codex Resume{engine} avec assert_ne! vs id de paire,
discrimination de clé provider, store vide/cellule neuve/non-uuid ⇒ None) ;
structured_launch_d3 22 verts, domain+application+app-tauri sans régression
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1175,8 +1175,9 @@ impl LaunchAgent {
|
|||||||
// of the *cell*, not the PTY: the caller (which owns the layout) passes
|
// of the *cell*, not the PTY: the caller (which owns the layout) passes
|
||||||
// the cell's current `conversation_id`. Any id this launch *assigns* is
|
// the cell's current `conversation_id`. Any id this launch *assigns* is
|
||||||
// returned in the output so the caller persists it on the leaf.
|
// returned in the output so the caller persists it on the leaf.
|
||||||
let (session_plan, assigned_conversation_id) =
|
let (session_plan, assigned_conversation_id) = self
|
||||||
self.resolve_session_plan(&profile, input.conversation_id.clone());
|
.resolve_session_plan(&profile, input.conversation_id.clone(), &input.project.root)
|
||||||
|
.await;
|
||||||
let mut spec =
|
let mut spec =
|
||||||
self.runtime
|
self.runtime
|
||||||
.prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?;
|
.prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?;
|
||||||
@ -1444,22 +1445,63 @@ impl LaunchAgent {
|
|||||||
/// [`SessionPlan::None`] (nothing to resume on a first launch; the adapter
|
/// [`SessionPlan::None`] (nothing to resume on a first launch; the adapter
|
||||||
/// uses the bare resume flag only on later reopens);
|
/// uses the bare resume flag only on later reopens);
|
||||||
/// - profile without a `session` block ⇒ [`SessionPlan::None`] (legacy).
|
/// - profile without a `session` block ⇒ [`SessionPlan::None`] (legacy).
|
||||||
fn resolve_session_plan(
|
async fn resolve_session_plan(
|
||||||
&self,
|
&self,
|
||||||
profile: &AgentProfile,
|
profile: &AgentProfile,
|
||||||
cell_conversation_id: Option<String>,
|
cell_conversation_id: Option<String>,
|
||||||
|
root: &ProjectPath,
|
||||||
) -> (SessionPlan, Option<String>) {
|
) -> (SessionPlan, Option<String>) {
|
||||||
// No session strategy at all: behave exactly as before — **sauf** pour un
|
// Profil **structuré** (§17, lot P8c) : le `conversation_id` de la cellule est
|
||||||
// profil **structuré** (§17), dont la reprise ne dépend pas d'un bloc
|
// un **id de paire IdeA**, jamais le resumable du moteur. Le passer en `--resume`
|
||||||
// `session` mais de l'adapter (Claude `--resume` / Codex `exec resume`). Pour
|
// serait invalide (Claude/Codex attendent *leur* id). On route donc le resume
|
||||||
// un tel profil, une conversation présente sur la cellule ⇒ `Resume`, l'id
|
// moteur via `providers.json`, keyé par `(id de paire, provider_key)` (écrit en
|
||||||
// étant capté/attribué par le moteur au premier tour (rien à pré-attribuer).
|
// P8b). Rien à pré-attribuer ici : l'id de paire est géré en aval (P8a), le
|
||||||
let Some(session) = &profile.session else {
|
// resumable moteur est capté/rapporté par P8b au fil de l'exécution.
|
||||||
if profile.structured_adapter.is_some() {
|
if let Some(adapter) = profile.structured_adapter {
|
||||||
if let Some(conversation_id) = cell_conversation_id {
|
// Store câblé : on lit le resumable moteur rangé pour cette paire+provider.
|
||||||
return (SessionPlan::Resume { conversation_id }, None);
|
if let Some(provider) = self.provider_sessions.as_ref() {
|
||||||
}
|
let engine = match &cell_conversation_id {
|
||||||
|
// L'id de paire (UUID) → resumable moteur via providers.json.
|
||||||
|
Some(raw) => match uuid::Uuid::parse_str(raw) {
|
||||||
|
Ok(uuid) => match provider.provider_session_store_for(root) {
|
||||||
|
Some(store) => store
|
||||||
|
.get(ConversationId::from_uuid(uuid), adapter.provider_key())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten(),
|
||||||
|
None => None,
|
||||||
|
},
|
||||||
|
Err(_) => None,
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
return match engine {
|
||||||
|
// Vrai resumable moteur connu ⇒ resume propre du moteur.
|
||||||
|
Some(engine_id) => (
|
||||||
|
SessionPlan::Resume {
|
||||||
|
conversation_id: engine_id,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
// Aucun resumable (None / parse KO / store absent / pas d'id de
|
||||||
|
// cellule) ⇒ premier lancement propre. Le moteur attribuera/rapportera
|
||||||
|
// son id (capté par P8b) ; la continuité du travail est assurée par le
|
||||||
|
// handoff (P7). On ne passe **jamais** l'id de paire en `--resume`.
|
||||||
|
None => (SessionPlan::None, None),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
// Store **non câblé** (tests/legacy) : repli gracieux sur l'ancien
|
||||||
|
// comportement — une conversation sur la cellule ⇒ `Resume`, sinon `None`.
|
||||||
|
// Garantit zéro régression des tests qui ne câblent pas le store.
|
||||||
|
return match cell_conversation_id {
|
||||||
|
Some(conversation_id) => (SessionPlan::Resume { conversation_id }, None),
|
||||||
|
None => (SessionPlan::None, None),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profils **non structurés** (PTY/TUI) : la cellule porte toujours l'id moteur.
|
||||||
|
// Comportement **inchangé** depuis T4.
|
||||||
|
let Some(session) = &profile.session else {
|
||||||
return (SessionPlan::None, None);
|
return (SessionPlan::None, None);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -566,6 +566,15 @@ impl FakeProviderSessionStore {
|
|||||||
fail_set: true,
|
fail_set: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Pre-seeds a `(conversation, provider) → resumable_id` mapping synchronously,
|
||||||
|
/// so a P8c launch reading via [`ProviderSessionStore::get`] resolves the engine
|
||||||
|
/// resumable for that pair (mirrors what a previous P8b launch would have stored).
|
||||||
|
fn seed_sync(&self, conversation: ConversationId, provider: &str, resumable_id: &str) {
|
||||||
|
self.entries.lock().unwrap().insert(
|
||||||
|
(conversation, provider.to_owned()),
|
||||||
|
resumable_id.to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
/// Observed resumable id for a `(conversation, provider)` couple, if any.
|
/// Observed resumable id for a `(conversation, provider)` couple, if any.
|
||||||
fn get_sync(&self, conversation: ConversationId, provider: &str) -> Option<String> {
|
fn get_sync(&self, conversation: ConversationId, provider: &str) -> Option<String> {
|
||||||
self.entries
|
self.entries
|
||||||
@ -1452,3 +1461,207 @@ async fn p8b_store_set_error_does_not_fail_launch() {
|
|||||||
// Le store n'a (logiquement) rien persisté.
|
// Le store n'a (logiquement) rien persisté.
|
||||||
assert_eq!(store.len(), 0, "failing set wrote nothing");
|
assert_eq!(store.len(), 0, "failing set wrote nothing");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// LOT P8c — routage du `--resume` moteur via providers.json
|
||||||
|
//
|
||||||
|
// Prouve `resolve_session_plan` (privée) **par son contrat observable** : le
|
||||||
|
// `SessionPlan` que `LaunchAgent::execute` transmet à `factory.start` (capturé par
|
||||||
|
// la `FakeFactory`). Pour un profil **structuré** avec un store provider câblé, le
|
||||||
|
// resume moteur est lu dans `providers.json` keyé par (id de paire, provider_key) —
|
||||||
|
// **jamais** l'id de paire lui-même n'est passé en `--resume`.
|
||||||
|
//
|
||||||
|
// Le fallback (provider non câblé) et le chemin non structuré sont déjà attestés par
|
||||||
|
// `structured_profile_with_cell_conversation_resolves_to_resume`,
|
||||||
|
// `structured_profile_fresh_cell_resolves_to_none` (cas 3 structuré non câblé) et les
|
||||||
|
// suites existantes ; on ajoute ici le **cœur P8c** : la lecture du store.
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// Variante de [`launch_fixture_p8b`] qui **rend aussi la factory** (clone partageant
|
||||||
|
/// `starts` via `Arc`), pour observer le `SessionPlan` transmis à `start`.
|
||||||
|
fn launch_fixture_p8c(
|
||||||
|
profile: AgentProfile,
|
||||||
|
factory: FakeFactory,
|
||||||
|
store: Option<Arc<FakeProviderSessionStore>>,
|
||||||
|
) -> (Arc<LaunchAgent>, Agent, FakeFactory) {
|
||||||
|
let observed = factory.clone();
|
||||||
|
let (launch, agent) = launch_fixture_p8b(profile, factory, store);
|
||||||
|
(launch, agent, observed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Cas 1 — Claude câblé, store contient l'engine id** : la cellule porte l'id de
|
||||||
|
/// paire (UUID) ; le store mappe `(pair, "claude") → "engine-x"`. Le lancement doit
|
||||||
|
/// transmettre `SessionPlan::Resume{ conversation_id: "engine-x" }` à la factory —
|
||||||
|
/// **et surtout PAS** l'id de paire.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn p8c_structured_claude_resumes_engine_id_from_store_not_pair() {
|
||||||
|
let store = Arc::new(FakeProviderSessionStore::new());
|
||||||
|
// Le moteur n'attribue pas d'id neuf (None) : le resume vient du store, pas du run.
|
||||||
|
let factory = FakeFactory::new(500, None);
|
||||||
|
let (launch, agent, observed) =
|
||||||
|
launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||||
|
|
||||||
|
// Pré-remplit le store : (pair, "claude") → "engine-x".
|
||||||
|
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||||
|
store.seed_sync(pair, "claude", "engine-x");
|
||||||
|
|
||||||
|
// La cellule porte l'id de **paire** (UUID), jamais l'id moteur.
|
||||||
|
let mut input = launch_input(agent.id);
|
||||||
|
input.conversation_id = Some(pair.to_string());
|
||||||
|
|
||||||
|
launch.execute(input).await.expect("structured launch resumes");
|
||||||
|
|
||||||
|
// Le SessionPlan transmis à factory.start est Resume avec l'**engine id du store**.
|
||||||
|
let starts = observed.starts();
|
||||||
|
assert_eq!(starts.len(), 1, "factory.start called once");
|
||||||
|
assert_eq!(
|
||||||
|
starts[0].1,
|
||||||
|
SessionPlan::Resume {
|
||||||
|
conversation_id: "engine-x".to_owned()
|
||||||
|
},
|
||||||
|
"resume must carry the engine resumable id from providers.json"
|
||||||
|
);
|
||||||
|
// GARDE-FOU EXPLICITE : ce n'est PAS l'id de paire qui part en --resume.
|
||||||
|
match &starts[0].1 {
|
||||||
|
SessionPlan::Resume { conversation_id } => {
|
||||||
|
assert_ne!(
|
||||||
|
conversation_id,
|
||||||
|
&pair.to_string(),
|
||||||
|
"the pair id must NEVER be passed as the engine --resume"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected Resume, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Cas 1bis — Codex câblé, store contient l'engine id sous la clé `"codex"`** :
|
||||||
|
/// même contrat, clé provider `"codex"`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn p8c_structured_codex_resumes_engine_id_from_store_codex_key() {
|
||||||
|
let store = Arc::new(FakeProviderSessionStore::new());
|
||||||
|
let factory = FakeFactory::new(500, None);
|
||||||
|
let (launch, agent, observed) = launch_fixture_p8c(
|
||||||
|
structured_codex_profile(pid(9)),
|
||||||
|
factory,
|
||||||
|
Some(Arc::clone(&store)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let pair = ConversationId::from_uuid(Uuid::from_u128(456));
|
||||||
|
store.seed_sync(pair, "codex", "engine-codex-x");
|
||||||
|
|
||||||
|
let mut input = launch_input(agent.id);
|
||||||
|
input.conversation_id = Some(pair.to_string());
|
||||||
|
|
||||||
|
launch.execute(input).await.expect("structured codex launch");
|
||||||
|
|
||||||
|
let starts = observed.starts();
|
||||||
|
assert_eq!(starts.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
starts[0].1,
|
||||||
|
SessionPlan::Resume {
|
||||||
|
conversation_id: "engine-codex-x".to_owned()
|
||||||
|
},
|
||||||
|
"codex resume reads its own provider key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Cas 1ter — la clé provider discrimine** : le store ne contient l'engine id que
|
||||||
|
/// sous `"codex"`, mais le profil est **Claude** ⇒ le lookup sous `"claude"` ne trouve
|
||||||
|
/// rien ⇒ `SessionPlan::None` (premier lancement propre, jamais l'id de paire).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn p8c_provider_key_mismatch_falls_back_to_none() {
|
||||||
|
let store = Arc::new(FakeProviderSessionStore::new());
|
||||||
|
let factory = FakeFactory::new(500, None);
|
||||||
|
let (launch, agent, observed) =
|
||||||
|
launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||||
|
|
||||||
|
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||||
|
// Engine id rangé sous une AUTRE clé provider.
|
||||||
|
store.seed_sync(pair, "codex", "engine-codex");
|
||||||
|
|
||||||
|
let mut input = launch_input(agent.id);
|
||||||
|
input.conversation_id = Some(pair.to_string());
|
||||||
|
|
||||||
|
launch.execute(input).await.expect("structured launch");
|
||||||
|
|
||||||
|
let starts = observed.starts();
|
||||||
|
assert_eq!(starts.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
starts[0].1,
|
||||||
|
SessionPlan::None,
|
||||||
|
"a claude profile must not pick up a codex-keyed resumable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Cas 2 — store câblé mais vide** (`get` → `None`) : la cellule porte un id de
|
||||||
|
/// paire valide mais aucun resumable n'a été rangé ⇒ `SessionPlan::None` (premier
|
||||||
|
/// lancement propre). On vérifie aussi que l'id de paire n'est jamais transmis.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn p8c_structured_store_empty_resolves_to_none() {
|
||||||
|
let store = Arc::new(FakeProviderSessionStore::new());
|
||||||
|
let factory = FakeFactory::new(500, None);
|
||||||
|
let (launch, agent, observed) =
|
||||||
|
launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||||
|
|
||||||
|
let pair = ConversationId::from_uuid(Uuid::from_u128(123));
|
||||||
|
let mut input = launch_input(agent.id);
|
||||||
|
input.conversation_id = Some(pair.to_string());
|
||||||
|
|
||||||
|
launch.execute(input).await.expect("structured launch");
|
||||||
|
|
||||||
|
let starts = observed.starts();
|
||||||
|
assert_eq!(starts.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
starts[0].1,
|
||||||
|
SessionPlan::None,
|
||||||
|
"empty store ⇒ clean first launch, never the pair id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Cas 2bis — store câblé, cellule neuve (pas d'id de paire)** : aucun id à
|
||||||
|
/// chercher ⇒ `SessionPlan::None`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn p8c_structured_store_wired_fresh_cell_resolves_to_none() {
|
||||||
|
let store = Arc::new(FakeProviderSessionStore::new());
|
||||||
|
let factory = FakeFactory::new(500, None);
|
||||||
|
let (launch, agent, observed) =
|
||||||
|
launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||||
|
|
||||||
|
// Cellule neuve : conversation_id = None.
|
||||||
|
launch
|
||||||
|
.execute(launch_input(agent.id))
|
||||||
|
.await
|
||||||
|
.expect("fresh structured launch");
|
||||||
|
|
||||||
|
let starts = observed.starts();
|
||||||
|
assert_eq!(starts.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
starts[0].1,
|
||||||
|
SessionPlan::None,
|
||||||
|
"no cell id ⇒ nothing to look up ⇒ None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Robustesse — id de paire non-UUID + store câblé** : un `conversation_id` qui ne
|
||||||
|
/// parse pas en UUID (donnée legacy/corrompue) ⇒ pas de lookup possible ⇒
|
||||||
|
/// `SessionPlan::None`, jamais l'id de paire passé tel quel en `--resume`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn p8c_non_uuid_pair_id_with_store_resolves_to_none() {
|
||||||
|
let store = Arc::new(FakeProviderSessionStore::new());
|
||||||
|
let factory = FakeFactory::new(500, None);
|
||||||
|
let (launch, agent, observed) =
|
||||||
|
launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store)));
|
||||||
|
|
||||||
|
let mut input = launch_input(agent.id);
|
||||||
|
input.conversation_id = Some("not-a-uuid".to_owned());
|
||||||
|
|
||||||
|
launch.execute(input).await.expect("structured launch");
|
||||||
|
|
||||||
|
let starts = observed.starts();
|
||||||
|
assert_eq!(starts.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
starts[0].1,
|
||||||
|
SessionPlan::None,
|
||||||
|
"a non-UUID pair id must degrade to None, never resume the raw string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user