diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index ac7034f..4f00d72 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -22,17 +22,17 @@ use application::{ GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects, - ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LiveSessions, LoadLayout, - McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, - OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, - ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, - ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, - ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile, - SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent, StructuredSessions, - SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, - UpdateAgentContext, UpdateAgentPermissions, UpdateMemory, UpdateProjectContext, - UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory, WriteToTerminal, - AGENT_MEMORY_RECALL_BUDGET, + ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LiveSessions, + LiveStateProvider, LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, + OpenProject, OpenTerminal, OrchestratorService, PermissionProjectorRegistry, ProposeContext, + ReadAgentContext, ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, + RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, + RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, + SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent, + StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, + UnassignSkillFromAgent, UpdateAgentContext, UpdateAgentPermissions, UpdateLiveState, + UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, + WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector, @@ -52,8 +52,8 @@ use infrastructure::{ embedder_from_profile, AdaptiveMemoryRecall, ClaudePermissionProjector, ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector, EmbedderEnvProbe, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, - FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore, - FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository, + FsLiveStateStore, FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, + FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository, HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, @@ -130,6 +130,29 @@ impl application::ConversationLogProvider for AppConversationLogProvider { } } +/// Implémente [`LiveStateProvider`] (programme live-state, lot LS3) en matérialisant un +/// [`UpdateLiveState`] dont le [`FsLiveStateStore`] cible le **project root** du tour. +/// +/// Même raison d'être que [`AppRecordTurnProvider`] : l'[`OrchestratorService`] est +/// unique pour tous les projets, alors que le live-state est **par project root** +/// (`/.ideai/live-state.json`) et le store fixe sa racine à la construction. On +/// construit donc un `UpdateLiveState` frais par transition, ciblant le bon dossier. +/// Porte l'horloge (port [`Clock`]) injectée au composition root — `UpdateLiveState` +/// l'utilise pour estampiller `updated_at_ms`. +struct AppLiveStateProvider { + clock: Arc, +} + +impl LiveStateProvider for AppLiveStateProvider { + fn live_state_for(&self, root: &domain::project::ProjectPath) -> Option> { + let store = Arc::new(FsLiveStateStore::new(root)); + Some(Arc::new(UpdateLiveState::new( + store, + Arc::clone(&self.clock), + ))) + } +} + /// Implémente [`ProviderSessionProvider`](application::ProviderSessionProvider) (lot /// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du /// lancement en cours. @@ -1196,7 +1219,15 @@ impl AppState { ))) // Outil MCP `idea_skill_read` (feature « skills à la MCP ») : sans ça, // `skill.read` renverrait « not configured ». Compose le SkillStore existant. - .with_read_skill(Arc::clone(&read_skill)), + .with_read_skill(Arc::clone(&read_skill)) + // Auto-update du live-state (programme live-state, lot LS3) : dérivé des + // transitions de délégation (ask→Working, reply→Done), zéro token agent. + // Provider par root (`/.ideai/live-state.json`) car le port fixe sa + // racine à la construction. Best-effort strict : un échec n'altère jamais la + // délégation. + .with_live_state(Arc::new(AppLiveStateProvider { + clock: Arc::clone(&clock) as Arc, + }) as Arc), // NB (régression corrigée) : on ne câble PAS `.with_structured(...)` ici. // Décision produit lot B-2 (« Option 1 Terminal + MCP », cf. construction // de `LaunchAgent` plus haut) : la fabrique structurée est décâblée, donc diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 516034e..3050d35 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -81,8 +81,8 @@ pub use memory::{ UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, }; pub use orchestrator::{ - ContextGuardUseCases, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, - ProposeContext, ReadContext, ReadMemory, RecordTurnProvider, WriteMemory, + ContextGuardUseCases, LiveStateProvider, McpRuntimeProvider, OrchestratorOutcome, + OrchestratorService, ProposeContext, ReadContext, ReadMemory, RecordTurnProvider, WriteMemory, }; pub use permission::{ GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput, diff --git a/crates/application/src/orchestrator/mod.rs b/crates/application/src/orchestrator/mod.rs index 4df8b38..768b0db 100644 --- a/crates/application/src/orchestrator/mod.rs +++ b/crates/application/src/orchestrator/mod.rs @@ -12,6 +12,6 @@ pub use context_guard::{ ReadMemoryInput, WriteMemory, WriteMemoryInput, }; pub use service::{ - ContextGuardUseCases, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, - RecordTurnProvider, + ContextGuardUseCases, LiveStateProvider, McpRuntimeProvider, OrchestratorOutcome, + OrchestratorService, RecordTurnProvider, }; diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 7e2a992..70dd4ba 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -45,6 +45,7 @@ use crate::orchestrator::{ }; use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput}; use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions}; +use crate::workstate::{UpdateLiveState, UpdateLiveStateInput}; /// Default terminal geometry for an orchestrator-launched agent cell. The UI /// resizes the PTY to the real cell size on attach; these are sane starting rows @@ -214,6 +215,26 @@ pub trait RecordTurnProvider: Send + Sync { fn record_turn_for(&self, root: &ProjectPath) -> Option>; } +/// Provider **par project root** d'un [`UpdateLiveState`] (lot LS3), calqué sur +/// [`RecordTurnProvider`]. +/// +/// Même tension résolue de la même façon : l'[`OrchestratorService`] est **unique** +/// pour tous les projets, mais le live-state est **par project root** +/// (`/.ideai/live-state.json`) — et le port [`domain::ports::LiveStateStore`] +/// fixe son root à la construction (pas de paramètre `root` par appel, contrairement +/// au `MemoryStore` du harvest). `ask_agent` connaît le `project.root` du tour et +/// demande au provider de matérialiser un `UpdateLiveState` ciblant le **bon** dossier +/// (les adapters `Fs*` ne font que des jointures de chemin, leur construction est +/// triviale). +/// +/// `None` ⇒ aucun auto-update : zéro régression. Implémenté dans app-tauri (seul +/// détenteur des adapters `Fs*` et de l'horloge). +pub trait LiveStateProvider: Send + Sync { + /// Construit l'[`UpdateLiveState`] dont le store cible `root`. Appelé une fois par + /// transition best-effort ; `None` ⇒ on saute silencieusement l'auto-update. + fn live_state_for(&self, root: &ProjectPath) -> Option>; +} + /// Dispatches validated orchestrator commands to the agent/terminal use cases. pub struct OrchestratorService { create_agent: Arc, @@ -308,6 +329,14 @@ pub struct OrchestratorService { /// port). Injecté via [`Self::with_read_skill`] ; `None` ⇒ la commande /// `skill.read` renvoie une erreur typée (call sites/tests legacy restent verts). read_skill: Option>, + /// Auto-update du **live-state** (programme live-state, lot LS3), dérivé des + /// transitions de délégation déjà existantes (zéro token agent, zéro appel + /// modèle) : la cible passe `Working` quand un `ask` est accepté, puis `Done` + /// (+ `last_delegation`) quand elle rend son résultat. Injecté via + /// [`Self::with_live_state`] ; `None` (défaut) ⇒ aucun auto-update (zéro + /// régression). **Best-effort strict** : un échec de persistance ne transforme + /// **jamais** un succès de délégation/reply en erreur. + live_state: Option>, } /// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés @@ -373,6 +402,7 @@ impl OrchestratorService { structured: None, memory_harvest: None, read_skill: None, + live_state: None, } } @@ -489,6 +519,94 @@ impl OrchestratorService { self } + /// Branche l'auto-update du live-state (lot LS3). Builder additif (signature de + /// [`Self::new`] inchangée) ; `None` (défaut) ⇒ aucun auto-update. Prend un + /// **provider par root** (calqué sur `with_record_turn`) car le live-state est par + /// project root et le port fixe son root à la construction. + #[must_use] + pub fn with_live_state(mut self, live_state: Arc) -> Self { + self.live_state = Some(live_state); + self + } + + /// Distille l'objectif d'un ticket en un `intent` court pour le live-state : + /// whitespace normalisé puis tronqué à [`domain::live_state::FIELD_PREVIEW_MAX_CHARS`] + /// (même mécanique que la preview du workstate). Borner **ici** garantit qu'on ne + /// dépasse jamais le seuil dur anti-dump du domaine (un `ask` peut porter une tâche + /// volumineuse, que `LiveEntry::new` rejetterait sinon — l'auto-update serait alors + /// silencieusement perdu). + fn distill_intent(task: &str) -> String { + task.split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(domain::live_state::FIELD_PREVIEW_MAX_CHARS) + .collect() + } + + /// Pose **best-effort** la cible d'une délégation acceptée en `Working` sur le + /// live-state (lot LS3). No-op silencieux quand l'auto-update n'est pas câblé. + /// Tout échec est avalé (un diag, jamais une erreur propagée) : l'auto-update + /// dérivé ne doit **jamais** faire échouer une délégation. N'écrit **que** sur + /// cette transition d'`ask` (jamais par tour/outil) ⇒ pas de write-storm. + async fn mark_target_working_best_effort( + &self, + root: &ProjectPath, + target: AgentId, + ticket: TicketId, + intent: String, + ) { + let Some(provider) = &self.live_state else { + return; + }; + let Some(update) = provider.live_state_for(root) else { + return; + }; + let outcome = update + .execute(UpdateLiveStateInput { + agent_id: target, + ticket: Some(ticket), + intent, + status: domain::live_state::WorkStatus::Working, + progress: None, + last_delegation: None, + }) + .await; + if let Err(e) = outcome { + crate::diag!("[live-state] working auto-update failed: target={target} err={e}"); + } + } + + /// Pose **best-effort** la cible en `Done` (+ `last_delegation` = ticket résolu) + /// sur le live-state (lot LS3), quand elle vient de rendre son résultat. Mêmes + /// garanties best-effort/no-storm que [`Self::mark_target_working_best_effort`]. + async fn mark_target_done_best_effort( + &self, + root: &ProjectPath, + target: AgentId, + ticket: TicketId, + ) { + let Some(provider) = &self.live_state else { + return; + }; + let Some(update) = provider.live_state_for(root) else { + return; + }; + let outcome = update + .execute(UpdateLiveStateInput { + agent_id: target, + ticket: Some(ticket), + intent: String::new(), + status: domain::live_state::WorkStatus::Done, + progress: None, + last_delegation: Some(ticket), + }) + .await; + if let Err(e) = outcome { + crate::diag!("[live-state] done auto-update failed: target={target} err={e}"); + } + } + /// Persiste **best-effort** un tour (`Prompt`/`Response`) dans `conversation`. /// /// No-op silencieux quand le provider/horloge ne sont pas câblés, quand le provider @@ -1069,6 +1187,9 @@ impl OrchestratorService { task.clone(), ) .await; + // Live-state (lot LS3) : distiller l'intent AVANT que `task` ne soit déplacé + // dans le `Ticket`. Posé sur la cible après l'enqueue (délégation acceptée). + let working_intent = Self::distill_intent(&task); let ticket = match requester { Some(from) => { Ticket::from_agent(ticket_id, from, conversation_id, requester_label, task) @@ -1080,6 +1201,10 @@ impl OrchestratorService { // le médiateur AVANT l'enqueue (consommé au start_turn). let turn_timeout = self.turn_timeout_for(project, agent_id).await; let pending = input.enqueue(agent_id, ticket); + // Auto-update live-state (lot LS3), best-effort : la cible passe `Working` sur + // cette transition d'`ask` acceptée. N'altère jamais le succès de la délégation. + self.mark_target_working_best_effort(&project.root, agent_id, ticket_id, working_intent) + .await; // Rendezvous beacon (diagnostics) : l'ask est désormais en attente du // `idea_reply` (ou prompt-ready) de la cible. Si la cible termine son tour en // texte SANS appeler `idea_reply`, ce beacon « ask started » n'aura pas de @@ -1123,6 +1248,10 @@ impl OrchestratorService { // Best-effort strict — n'altère jamais ce succès de délégation. self.harvest_memory_best_effort(&project.root, &result) .await; + // Auto-update live-state (lot LS3), best-effort : la cible vient de rendre + // son résultat ⇒ `Done` + `last_delegation` = ticket résolu. + self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) + .await; // Succès : désarmer le garde AVANT de retourner. Le `mark_idle` propre // sur cette branche est porté par le médiateur (prompt-ready / idea_reply // qui a résolu le `pending`) ; on ne veut ni re-`cancel_head` un ticket @@ -1240,6 +1369,16 @@ impl OrchestratorService { // l'enqueue qui démarre le tour (le médiateur arme alors sa fenêtre de vivacité). let turn_timeout = self.turn_timeout_for(project, agent_id).await; let pending = input.enqueue(agent_id, ticket); + // Auto-update live-state (lot LS3), best-effort : la cible passe `Working` sur + // cette transition d'`ask` acceptée (chemin structuré). `task` est encore vivant + // ici (utilisé par le drain plus bas), on le distille directement. + self.mark_target_working_best_effort( + &project.root, + agent_id, + ticket_id, + Self::distill_intent(&task), + ) + .await; // Garde RAII de fin de tour, armé JUSTE après l'enqueue (cible `Busy`). Couvre // toutes les sorties — `return Err` des bras du select, OU **futur abandonné // (drop)** — en ramenant la cible `Idle` au Drop (cf. [`BusyTurnGuard`]). C'est @@ -1346,6 +1485,10 @@ impl OrchestratorService { // Best-effort strict — un échec n'altère jamais ce succès de délégation. self.harvest_memory_best_effort(&project.root, &result) .await; + // Auto-update live-state (lot LS3), best-effort : la cible vient de rendre son + // résultat (chemin structuré) ⇒ `Done` + `last_delegation` = ticket résolu. + self.mark_target_done_best_effort(&project.root, agent_id, ticket_id) + .await; Ok(self.reply_outcome(agent_id, target, result)) } diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 6e8fb0f..92ae633 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -40,8 +40,11 @@ use uuid::Uuid; use application::{ CloseTerminal, CreateAgentFromScratch, CreateSkill, HarvestMemoryFromTurn, LaunchAgent, - ListAgents, OrchestratorService, TerminalSessions, UpdateAgentContext, + ListAgents, LiveStateProvider, OrchestratorService, TerminalSessions, UpdateAgentContext, + UpdateLiveState, }; +use domain::live_state::{LiveEntry, LiveState, WorkStatus}; +use domain::ports::LiveStateStore; // --------------------------------------------------------------------------- // Fakes (mirror agent_lifecycle.rs) @@ -431,6 +434,73 @@ impl MemoryStore for HarvestMemories { } } +/// In-memory [`LiveStateStore`] for the live-state auto-update hook (lot LS3): keeps a +/// keyed last-writer-wins [`LiveState`] and counts upserts (to prove no write-storm), +/// with an optional forced failure to prove the auto-update stays best-effort. +#[derive(Default)] +struct RecordingLiveState { + state: Mutex, + upserts: Mutex, + fail: bool, +} +impl RecordingLiveState { + fn entry_for(&self, agent: AgentId) -> Option { + self.state + .lock() + .unwrap() + .entries + .iter() + .find(|e| e.agent_id == agent) + .cloned() + } + fn upsert_count(&self) -> usize { + *self.upserts.lock().unwrap() + } +} +#[async_trait] +impl LiveStateStore for RecordingLiveState { + async fn load(&self) -> Result { + if self.fail { + return Err(StoreError::Io("forced failure".to_owned())); + } + Ok(self.state.lock().unwrap().clone()) + } + async fn upsert(&self, entry: LiveEntry) -> Result<(), StoreError> { + if self.fail { + return Err(StoreError::Io("forced failure".to_owned())); + } + *self.upserts.lock().unwrap() += 1; + self.state.lock().unwrap().upsert(entry); + Ok(()) + } + async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError> { + if self.fail { + return Err(StoreError::Io("forced failure".to_owned())); + } + self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n); + Ok(()) + } +} + +/// A [`LiveStateProvider`] that always yields the **same** [`UpdateLiveState`] over a +/// shared recording store (root-agnostic for the test). +struct TestLiveStateProvider { + update: Arc, +} +impl LiveStateProvider for TestLiveStateProvider { + fn live_state_for(&self, _root: &ProjectPath) -> Option> { + Some(Arc::clone(&self.update)) + } +} + +/// Fixed millis clock for deterministic `updated_at_ms`. +struct FixedMillisClock(i64); +impl Clock for FixedMillisClock { + fn now_millis(&self) -> i64 { + self.0 + } +} + struct SeqIds(Mutex); impl SeqIds { fn new() -> Self { @@ -1114,18 +1184,44 @@ struct AskFixture { /// The auto-memory store wired into the harvest hook (Lot E1): lets a test /// assert which notes a successful reply persisted. memories: Arc, + /// The live-state store wired into the auto-update hook (lot LS3): lets a test + /// assert the target's Working/Done transitions and the upsert count. + live: Arc, } /// Builds an orchestrator wired with a real [`InMemoryMailbox`] + PTY (B-3) and a /// plain (non-structured) [`LaunchAgent`] — so a dead target is launched as a PTY, /// exactly like production after B-2. fn ask_fixture(contexts: FakeContexts) -> AskFixture { - ask_fixture_with_memory(contexts, false) + ask_fixture_full(contexts, false, false, true) } /// Like [`ask_fixture`] but lets a test force the auto-memory store to fail its /// `save`, proving the harvest stays best-effort (the reply still succeeds). fn ask_fixture_with_memory(contexts: FakeContexts, fail_memory: bool) -> AskFixture { + ask_fixture_full(contexts, fail_memory, false, true) +} + +/// Like [`ask_fixture`] but lets a test force the live-state store to fail, proving +/// the auto-update stays best-effort (the ask/reply still succeeds). +fn ask_fixture_with_live(contexts: FakeContexts, fail_live: bool) -> AskFixture { + ask_fixture_full(contexts, false, fail_live, true) +} + +/// Like [`ask_fixture`] but leaves the live-state auto-update **unwired** (`None`), +/// to prove no write happens when the hook is absent (zéro régression). +fn ask_fixture_without_live(contexts: FakeContexts) -> AskFixture { + ask_fixture_full(contexts, false, false, false) +} + +/// Full builder: wires the auto-memory harvest (Lot E1) and, when `wire_live`, the +/// live-state auto-update (lot LS3), each with an optional forced failure. +fn ask_fixture_full( + contexts: FakeContexts, + fail_memory: bool, + fail_live: bool, + wire_live: bool, +) -> AskFixture { let profiles = Arc::new(FakeProfiles::new(vec![claude_profile()])); let sessions = Arc::new(TerminalSessions::new()); let pty = FakePty::new(sid(777)); @@ -1135,6 +1231,10 @@ fn ask_fixture_with_memory(contexts: FakeContexts, fail_memory: bool) -> AskFixt saved: Mutex::new(Vec::new()), fail_save: fail_memory, }); + let live = Arc::new(RecordingLiveState { + fail: fail_live, + ..Default::default() + }); let create = Arc::new(CreateAgentFromScratch::new( Arc::new(contexts.clone()), @@ -1170,28 +1270,35 @@ fn ask_fixture_with_memory(contexts: FakeContexts, fail_memory: bool) -> AskFixt Arc::new(pty.clone()) as Arc, )); let conversations = Arc::new(TestConversations::new()) as Arc; - let service = Arc::new( - OrchestratorService::new( - create, - launch, - list, - close, - update, - create_skill, - Arc::clone(&profiles) as Arc, - Arc::clone(&sessions), - ) - .with_input_mediator( - Arc::clone(&mediator) as Arc, - Arc::clone(&mailbox) as Arc, - ) - .with_conversations(conversations) - .with_events(Arc::new(bus.clone())) - .with_memory_harvest(Arc::new(HarvestMemoryFromTurn::new( - Arc::clone(&memories) as Arc, - Arc::new(bus.clone()), - ))), - ); + let mut builder = OrchestratorService::new( + create, + launch, + list, + close, + update, + create_skill, + Arc::clone(&profiles) as Arc, + Arc::clone(&sessions), + ) + .with_input_mediator( + Arc::clone(&mediator) as Arc, + Arc::clone(&mailbox) as Arc, + ) + .with_conversations(conversations) + .with_events(Arc::new(bus.clone())) + .with_memory_harvest(Arc::new(HarvestMemoryFromTurn::new( + Arc::clone(&memories) as Arc, + Arc::new(bus.clone()), + ))); + if wire_live { + builder = builder.with_live_state(Arc::new(TestLiveStateProvider { + update: Arc::new(UpdateLiveState::new( + Arc::clone(&live) as Arc, + Arc::new(FixedMillisClock(1_234)) as Arc, + )), + }) as Arc); + } + let service = Arc::new(builder); AskFixture { service, @@ -1201,6 +1308,7 @@ fn ask_fixture_with_memory(contexts: FakeContexts, fail_memory: bool) -> AskFixt pty, mediator, memories, + live, } } @@ -1302,6 +1410,147 @@ async fn ask_live_pty_target_writes_task_and_returns_reply() { assert_eq!(fx.mailbox.pending(&aid(1)), 0, "ticket drained on reply"); } +// --- LS3: live-state auto-update from the delegation cycle ------------------ + +/// A successful `ask` flips the **target** to `Working` with the distilled intent and +/// the current ticket — derived from the existing delegation transition (zéro token). +#[tokio::test] +async fn ask_sets_target_working_with_intent_and_ticket() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + seed_live_pty(&fx.sessions, aid(1), sid(800)); + + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); + + // The Working transition is posted right after the enqueue, before any reply. + await_until(|| fx.live.entry_for(aid(1)).is_some()).await; + let entry = fx + .live + .entry_for(aid(1)) + .expect("target live entry present"); + assert_eq!(entry.status, WorkStatus::Working); + assert_eq!( + entry.intent, "Analyse §17", + "intent distilled from the task" + ); + assert!(entry.ticket.is_some(), "current ticket recorded"); + assert!( + entry.last_delegation.is_none(), + "no delegation resolved yet" + ); + // The live-state path does not touch the memory store (canonical effects separate). + assert!( + fx.memories.slugs().is_empty(), + "no memory write on this path" + ); + + // Drain the ask so the spawned task completes cleanly. + fx.service + .dispatch(&project(), reply_cmd(aid(1), "done")) + .await + .expect("reply ok"); + timeout(TEST_GUARD, ask) + .await + .expect("ask completes") + .expect("join ok") + .expect("ask ok"); +} + +/// A successful `reply` flips the target to `Done` and records `last_delegation` = the +/// resolved ticket. +#[tokio::test] +async fn reply_sets_target_done_with_last_delegation() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + seed_live_pty(&fx.sessions, aid(1), sid(800)); + + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + + fx.service + .dispatch(&project(), reply_cmd(aid(1), "the answer")) + .await + .expect("reply ok"); + timeout(TEST_GUARD, ask) + .await + .expect("ask completes") + .expect("join ok") + .expect("ask ok"); + + let entry = fx + .live + .entry_for(aid(1)) + .expect("target live entry present"); + assert_eq!( + entry.status, + WorkStatus::Done, + "target marked Done on reply" + ); + assert!( + entry.last_delegation.is_some(), + "last_delegation set to the resolved ticket" + ); + // Working then Done ⇒ exactly two upserts for one delegation (no write-storm). + assert_eq!( + fx.live.upsert_count(), + 2, + "one Working + one Done write per delegation" + ); +} + +/// A live-state store that fails must NOT fail the `ask`/`reply`: the delegation +/// still returns its reply (best-effort hook, exactly like the memory harvest). +#[tokio::test] +async fn live_state_failure_does_not_break_delegation() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture_with_live(FakeContexts::with_agent(&agent, "# persona"), true); + seed_live_pty(&fx.sessions, aid(1), sid(800)); + + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + + fx.service + .dispatch(&project(), reply_cmd(aid(1), "still works")) + .await + .expect("reply ok despite failing live-state"); + let out = timeout(TEST_GUARD, ask) + .await + .expect("ask completes") + .expect("join ok") + .expect("ask ok despite failing live-state"); + assert_eq!(out.reply.as_deref(), Some("still works")); + // The failing store recorded nothing. + assert!(fx.live.entry_for(aid(1)).is_none()); +} + +/// When the live-state hook is **unwired** (`None`), the delegation behaves exactly as +/// before — no live-state write at all (zéro régression). +#[tokio::test] +async fn no_live_state_wired_means_no_write() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture_without_live(FakeContexts::with_agent(&agent, "# persona")); + seed_live_pty(&fx.sessions, aid(1), sid(800)); + + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + fx.service + .dispatch(&project(), reply_cmd(aid(1), "ok")) + .await + .expect("reply ok"); + timeout(TEST_GUARD, ask) + .await + .expect("ask completes") + .expect("join ok") + .expect("ask ok"); + + assert_eq!(fx.live.upsert_count(), 0, "no write when hook is None"); + assert!(fx.live.entry_for(aid(1)).is_none()); +} + /// The target returned to its prompt **without** `idea_reply`: the mediator's grace /// window expired and completed the turn "no reply". The awaiting ask must error out /// promptly with the typed, retryable [`AppError::TargetReturnedNoReply`] — never hang @@ -3000,6 +3249,8 @@ fn ask_fixture_with_record( // Harvest is intentionally not wired here: these fixtures exercise the // RecordTurn checkpoint in isolation (proving it stays unchanged). memories: Arc::new(HarvestMemories::default()), + // Live-state likewise unwired here (RecordTurn-focused fixtures). + live: Arc::new(RecordingLiveState::default()), } }