feat(persistence): P8b — écriture providers.json (resumable par provider)
Au lancement structuré, le resumable exposé par le moteur est rangé dans providers.json sous (id de paire, provider) — best-effort, n'altère jamais le lancement. - domaine : StructuredAdapter::provider_key() ⇒ "claude"/"codex" (clé stable par famille, lisible, indépendante de l'uuid d'instance) - application : port ProviderSessionProvider (root par appel) + with_provider_session_provider ; helper persist_provider_session dans launch_structured (skip si provider/adapter/id moteur absents ou set KO) - app-tauri : AppProviderSessionProvider (FsProviderSessionStore sur le root) - tests : 6 cas (nominal claude/codex, no-op sans provider/sans id moteur, best-effort, mapping provider_key) ; domain+application verts Lecture du store pour --resume = P8c. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -21,8 +21,8 @@ use domain::ports::{
|
||||
use domain::{
|
||||
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId,
|
||||
ConversationParty, DomainEvent, Handoff, HandoffStore, ManifestEntry, MarkdownDoc,
|
||||
MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project, ProjectPath, PtySize, SessionId,
|
||||
SessionKind, SessionStatus, Skill, TerminalSession,
|
||||
MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project, ProjectPath, ProviderSessionStore,
|
||||
PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
@ -61,6 +61,25 @@ pub trait HandoffProvider: Send + Sync {
|
||||
fn handoff_store_for(&self, root: &ProjectPath) -> Option<Arc<dyn HandoffStore>>;
|
||||
}
|
||||
|
||||
/// Fournit le [`ProviderSessionStore`] **lié au project root** du lancement en cours
|
||||
/// (lot P8b). Jumeau stateless de [`HandoffProvider`] : même tension (instance
|
||||
/// [`LaunchAgent`] partagée vs adapter `Fs*` à racine fixée à la construction),
|
||||
/// même réponse (matérialiser le store ciblant le **bon** dossier à chaque appel).
|
||||
///
|
||||
/// Sert à ranger, après un lancement structuré, l'id de session **moteur**
|
||||
/// (resumable du provider) sous la clé de paire IdeA dans `providers.json`. `None`
|
||||
/// ⇒ aucune écriture (best-effort absente) : zéro régression pour les call
|
||||
/// sites/tests qui ne le branchent pas. Implémenté dans `app-tauri`.
|
||||
pub trait ProviderSessionProvider: Send + Sync {
|
||||
/// Construit le [`ProviderSessionStore`] dont la persistance cible `root`. Appelé
|
||||
/// une fois par lancement best-effort ; `None` ⇒ on saute silencieusement
|
||||
/// l'écriture du resumable.
|
||||
fn provider_session_store_for(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
) -> Option<Arc<dyn ProviderSessionStore>>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateAgentFromScratch
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -805,6 +824,13 @@ pub struct LaunchAgent {
|
||||
/// reprise (zéro régression pour les call sites/tests legacy). Un handoff
|
||||
/// absent/illisible ⇒ lancement normal, jamais d'échec.
|
||||
handoffs: Option<Arc<dyn HandoffProvider>>,
|
||||
/// Provider du [`ProviderSessionStore`] **par project root** (lot P8b), pour ranger
|
||||
/// **best-effort** l'id de session moteur (resumable du provider) sous la clé de
|
||||
/// paire IdeA dans `providers.json` après un lancement structuré. Injecté au câblage
|
||||
/// via [`Self::with_provider_session_provider`] ; `None` ⇒ aucune écriture (zéro
|
||||
/// régression pour les call sites/tests legacy). Une écriture en échec ⇒ lancement
|
||||
/// normal, jamais d'échec.
|
||||
provider_sessions: Option<Arc<dyn ProviderSessionProvider>>,
|
||||
}
|
||||
|
||||
impl LaunchAgent {
|
||||
@ -839,9 +865,27 @@ impl LaunchAgent {
|
||||
session_factory: None,
|
||||
structured: None,
|
||||
handoffs: None,
|
||||
provider_sessions: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Branche le provider de **store de sessions provider (lot P8b)** sur ce launcher :
|
||||
/// après un lancement structuré exposant un id de session moteur, range
|
||||
/// `(pair_conversation_id, provider_key) → engine_session_id` dans `providers.json`
|
||||
/// du projet. Sans cet appel (cas legacy / tests existants), aucune écriture —
|
||||
/// signature de [`Self::new`] **inchangée**, donc aucun appelant existant ne casse.
|
||||
///
|
||||
/// Builder additif (consomme `self`, retourne `Self`) : le câblage fait
|
||||
/// `LaunchAgent::new(...).with_provider_session_provider(provider)`.
|
||||
#[must_use]
|
||||
pub fn with_provider_session_provider(
|
||||
mut self,
|
||||
provider_sessions: Arc<dyn ProviderSessionProvider>,
|
||||
) -> Self {
|
||||
self.provider_sessions = Some(provider_sessions);
|
||||
self
|
||||
}
|
||||
|
||||
/// Branche le provider de **handoff de reprise (lot P7)** sur ce launcher : au
|
||||
/// (re)lancement, si la cellule porte une `conversation_id` et qu'un [`Handoff`]
|
||||
/// existe pour elle, son `summary_md` (et son objectif) sont injectés comme section
|
||||
@ -1224,6 +1268,7 @@ impl LaunchAgent {
|
||||
&run_dir,
|
||||
&session_plan,
|
||||
pair_conversation_id,
|
||||
&input.project.root,
|
||||
input.node_id,
|
||||
size,
|
||||
)
|
||||
@ -1284,6 +1329,7 @@ impl LaunchAgent {
|
||||
run_dir: &ProjectPath,
|
||||
session_plan: &SessionPlan,
|
||||
pair_conversation_id: String,
|
||||
root: &ProjectPath,
|
||||
node_id: Option<NodeId>,
|
||||
size: PtySize,
|
||||
) -> Result<LaunchAgentOutput, AppError> {
|
||||
@ -1309,6 +1355,20 @@ impl LaunchAgent {
|
||||
// sur `conversation_id`. `None` si le moteur n'expose encore aucun id.
|
||||
let engine_session_id = session.conversation_id();
|
||||
|
||||
// ── P8b — range le resumable moteur par provider dans `providers.json`
|
||||
// (best-effort, jamais bloquant) ──
|
||||
// On persiste `(pair_conversation_id, provider_key) → engine_session_id`
|
||||
// **uniquement** quand le moteur expose un id ET que le provider est câblé.
|
||||
// La clé provider est dérivée de la **famille de moteur** (`structured_adapter`),
|
||||
// pas de l'uuid d'instance du profil : un swap vers la même famille réutilise la
|
||||
// même entrée. Toute défaillance (provider absent, pair_conversation_id non-UUID,
|
||||
// erreur du store) dégrade silencieusement — un lancement n'est **jamais** cassé
|
||||
// par cette persistance (lot P8c lira ce store pour `--resume`).
|
||||
if let Some(engine_id) = engine_session_id.as_deref() {
|
||||
self.persist_provider_session(root, profile, &pair_conversation_id, engine_id)
|
||||
.await;
|
||||
}
|
||||
|
||||
let snapshot = structured_snapshot(&session, agent.id, node_id, size);
|
||||
|
||||
self.events.publish(DomainEvent::AgentLaunched {
|
||||
@ -1331,6 +1391,44 @@ impl LaunchAgent {
|
||||
})
|
||||
}
|
||||
|
||||
/// Range **best-effort** (lot P8b) le resumable moteur `engine_id` sous la clé
|
||||
/// `(pair_conversation_id, provider_key)` dans `providers.json` du projet.
|
||||
///
|
||||
/// `provider_key` est dérivée de la **famille de moteur** du profil
|
||||
/// ([`StructuredAdapter::provider_key`]) ; un profil sans `structured_adapter` (ne
|
||||
/// devrait pas arriver sur ce chemin structuré) ⇒ skip. `pair_conversation_id` est
|
||||
/// une chaîne : un id non-UUID (donnée legacy/corrompue) ⇒ skip. Le provider absent
|
||||
/// ([`Self::with_provider_session_provider`] non appelé) ⇒ skip. Toute erreur du
|
||||
/// store est tracée et avalée : **jamais** d'échec ni de panique — le lancement a
|
||||
/// déjà réussi à ce stade.
|
||||
async fn persist_provider_session(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
profile: &AgentProfile,
|
||||
pair_conversation_id: &str,
|
||||
engine_id: &str,
|
||||
) {
|
||||
let Some(provider) = self.provider_sessions.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(adapter) = profile.structured_adapter else {
|
||||
return;
|
||||
};
|
||||
// Le `conversation_id` d'une cellule est un id de paire (UUID, §C3).
|
||||
let Ok(uuid) = uuid::Uuid::parse_str(pair_conversation_id) else {
|
||||
return;
|
||||
};
|
||||
let conversation = ConversationId::from_uuid(uuid);
|
||||
let Some(store) = provider.provider_session_store_for(root) else {
|
||||
return;
|
||||
};
|
||||
// Best-effort : toute erreur du store est avalée — le lancement a déjà réussi,
|
||||
// la persistance du resumable ne doit jamais le casser (lot P8c lira ce store).
|
||||
let _ = store
|
||||
.set(conversation, adapter.provider_key(), engine_id)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Resolves the [`SessionPlan`] for a launch from the profile's session
|
||||
/// strategy and the cell's current `conversation_id` (T4).
|
||||
///
|
||||
|
||||
@ -26,6 +26,7 @@ pub use resume::{
|
||||
pub use lifecycle::{
|
||||
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
|
||||
ProviderSessionProvider,
|
||||
LaunchAgent,
|
||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, McpRuntime,
|
||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
|
||||
|
||||
@ -33,7 +33,7 @@ pub use agent::{
|
||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
|
||||
HandoffProvider,
|
||||
HandoffProvider, ProviderSessionProvider,
|
||||
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
|
||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, McpRuntime,
|
||||
|
||||
Reference in New Issue
Block a user