feat(app): auto-update live-state depuis le cycle de délégation (LS3)

Branche la mise à jour automatique du live-state sur le cycle de
délégation de l'orchestrateur, en best-effort non bloquant.
- Trait `LiveStateProvider` (résolution par root) + champ `Option` dans
  l'`OrchestratorService`, injecté via le builder `with_live_state` ;
  `None` ⇒ aucun effet (zéro régression).
- Transitions dérivées du cycle : `ask` → entrée `Working`,
  `reply` → `Done` + `last_delegation`.
- Helpers best-effort : un échec de mise à jour du live-state ne
  transforme jamais un succès de délégation en erreur.
- `AppLiveStateProvider` + wiring côté app-tauri (provider par root).

cargo test -p application : 0 échec ; --test orchestrator_service : 55/0
(dont 4 nouveaux tests LS3) ; cargo fmt --all --check : exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 19:28:02 +02:00
parent 18401116aa
commit 9d46e6cd21
5 changed files with 467 additions and 42 deletions

View File

@ -22,17 +22,17 @@ use application::{
GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn,
HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput,
ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects, ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects,
ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LiveSessions, LoadLayout, ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LiveSessions,
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, LiveStateProvider, LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView,
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, OpenProject, OpenTerminal, OrchestratorService, PermissionProjectorRegistry, ProposeContext,
ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReadAgentContext, ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill,
ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles,
ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile, RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile,
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent, StructuredSessions, SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StopLiveAgent,
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions,
UpdateAgentContext, UpdateAgentPermissions, UpdateMemory, UpdateProjectContext, UnassignSkillFromAgent, UpdateAgentContext, UpdateAgentPermissions, UpdateLiveState,
UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory, WriteToTerminal, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
AGENT_MEMORY_RECALL_BUDGET, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
}; };
use domain::ports::{ use domain::ports::{
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector, AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
@ -52,8 +52,8 @@ use infrastructure::{
embedder_from_profile, AdaptiveMemoryRecall, ClaudePermissionProjector, embedder_from_profile, AdaptiveMemoryRecall, ClaudePermissionProjector,
ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector, EmbedderEnvProbe, ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector, EmbedderEnvProbe,
FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore,
FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore, FsLiveStateStore, FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore,
FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository, FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, 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**
/// (`<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<dyn Clock>,
}
impl LiveStateProvider for AppLiveStateProvider {
fn live_state_for(&self, root: &domain::project::ProjectPath) -> Option<Arc<UpdateLiveState>> {
let store = Arc::new(FsLiveStateStore::new(root));
Some(Arc::new(UpdateLiveState::new(
store,
Arc::clone(&self.clock),
)))
}
}
/// Implémente [`ProviderSessionProvider`](application::ProviderSessionProvider) (lot /// Implémente [`ProviderSessionProvider`](application::ProviderSessionProvider) (lot
/// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du /// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du
/// lancement en cours. /// lancement en cours.
@ -1196,7 +1219,15 @@ impl AppState {
))) )))
// Outil MCP `idea_skill_read` (feature « skills à la MCP ») : sans ça, // Outil MCP `idea_skill_read` (feature « skills à la MCP ») : sans ça,
// `skill.read` renverrait « not configured ». Compose le SkillStore existant. // `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 (`<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<dyn Clock>,
}) as Arc<dyn LiveStateProvider>),
// NB (régression corrigée) : on ne câble PAS `.with_structured(...)` ici. // 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 // 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 // de `LaunchAgent` plus haut) : la fabrique structurée est décâblée, donc

View File

@ -81,8 +81,8 @@ pub use memory::{
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
}; };
pub use orchestrator::{ pub use orchestrator::{
ContextGuardUseCases, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, ContextGuardUseCases, LiveStateProvider, McpRuntimeProvider, OrchestratorOutcome,
ProposeContext, ReadContext, ReadMemory, RecordTurnProvider, WriteMemory, OrchestratorService, ProposeContext, ReadContext, ReadMemory, RecordTurnProvider, WriteMemory,
}; };
pub use permission::{ pub use permission::{
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput, GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,

View File

@ -12,6 +12,6 @@ pub use context_guard::{
ReadMemoryInput, WriteMemory, WriteMemoryInput, ReadMemoryInput, WriteMemory, WriteMemoryInput,
}; };
pub use service::{ pub use service::{
ContextGuardUseCases, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, ContextGuardUseCases, LiveStateProvider, McpRuntimeProvider, OrchestratorOutcome,
RecordTurnProvider, OrchestratorService, RecordTurnProvider,
}; };

View File

@ -45,6 +45,7 @@ use crate::orchestrator::{
}; };
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput}; use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions}; use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
use crate::workstate::{UpdateLiveState, UpdateLiveStateInput};
/// Default terminal geometry for an orchestrator-launched agent cell. The UI /// 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 /// 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<Arc<RecordTurn>>; fn record_turn_for(&self, root: &ProjectPath) -> Option<Arc<RecordTurn>>;
} }
/// 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**
/// (`<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<Arc<UpdateLiveState>>;
}
/// Dispatches validated orchestrator commands to the agent/terminal use cases. /// Dispatches validated orchestrator commands to the agent/terminal use cases.
pub struct OrchestratorService { pub struct OrchestratorService {
create_agent: Arc<CreateAgentFromScratch>, create_agent: Arc<CreateAgentFromScratch>,
@ -308,6 +329,14 @@ pub struct OrchestratorService {
/// port). Injecté via [`Self::with_read_skill`] ; `None` ⇒ la commande /// port). Injecté via [`Self::with_read_skill`] ; `None` ⇒ la commande
/// `skill.read` renvoie une erreur typée (call sites/tests legacy restent verts). /// `skill.read` renvoie une erreur typée (call sites/tests legacy restent verts).
read_skill: Option<Arc<ReadSkill>>, read_skill: Option<Arc<ReadSkill>>,
/// 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<Arc<dyn LiveStateProvider>>,
} }
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés /// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
@ -373,6 +402,7 @@ impl OrchestratorService {
structured: None, structured: None,
memory_harvest: None, memory_harvest: None,
read_skill: None, read_skill: None,
live_state: None,
} }
} }
@ -489,6 +519,94 @@ impl OrchestratorService {
self 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<dyn LiveStateProvider>) -> 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::<Vec<_>>()
.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`. /// 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 /// No-op silencieux quand le provider/horloge ne sont pas câblés, quand le provider
@ -1069,6 +1187,9 @@ impl OrchestratorService {
task.clone(), task.clone(),
) )
.await; .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 { let ticket = match requester {
Some(from) => { Some(from) => {
Ticket::from_agent(ticket_id, from, conversation_id, requester_label, task) 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). // le médiateur AVANT l'enqueue (consommé au start_turn).
let turn_timeout = self.turn_timeout_for(project, agent_id).await; let turn_timeout = self.turn_timeout_for(project, agent_id).await;
let pending = input.enqueue(agent_id, ticket); 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 // 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 // `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 // 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. // Best-effort strict — n'altère jamais ce succès de délégation.
self.harvest_memory_best_effort(&project.root, &result) self.harvest_memory_best_effort(&project.root, &result)
.await; .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 // 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 // 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 // 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é). // 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 turn_timeout = self.turn_timeout_for(project, agent_id).await;
let pending = input.enqueue(agent_id, ticket); 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 // 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é // toutes les sorties — `return Err` des bras du select, OU **futur abandonné
// (drop)** — en ramenant la cible `Idle` au Drop (cf. [`BusyTurnGuard`]). C'est // (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. // Best-effort strict — un échec n'altère jamais ce succès de délégation.
self.harvest_memory_best_effort(&project.root, &result) self.harvest_memory_best_effort(&project.root, &result)
.await; .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)) Ok(self.reply_outcome(agent_id, target, result))
} }

View File

@ -40,8 +40,11 @@ use uuid::Uuid;
use application::{ use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, HarvestMemoryFromTurn, LaunchAgent, 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) // 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<LiveState>,
upserts: Mutex<usize>,
fail: bool,
}
impl RecordingLiveState {
fn entry_for(&self, agent: AgentId) -> Option<LiveEntry> {
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<LiveState, StoreError> {
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<UpdateLiveState>,
}
impl LiveStateProvider for TestLiveStateProvider {
fn live_state_for(&self, _root: &ProjectPath) -> Option<Arc<UpdateLiveState>> {
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<u128>); struct SeqIds(Mutex<u128>);
impl SeqIds { impl SeqIds {
fn new() -> Self { fn new() -> Self {
@ -1114,18 +1184,44 @@ struct AskFixture {
/// The auto-memory store wired into the harvest hook (Lot E1): lets a test /// The auto-memory store wired into the harvest hook (Lot E1): lets a test
/// assert which notes a successful reply persisted. /// assert which notes a successful reply persisted.
memories: Arc<HarvestMemories>, memories: Arc<HarvestMemories>,
/// 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<RecordingLiveState>,
} }
/// Builds an orchestrator wired with a real [`InMemoryMailbox`] + PTY (B-3) and a /// 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, /// plain (non-structured) [`LaunchAgent`] — so a dead target is launched as a PTY,
/// exactly like production after B-2. /// exactly like production after B-2.
fn ask_fixture(contexts: FakeContexts) -> AskFixture { 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 /// 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). /// `save`, proving the harvest stays best-effort (the reply still succeeds).
fn ask_fixture_with_memory(contexts: FakeContexts, fail_memory: bool) -> AskFixture { 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 profiles = Arc::new(FakeProfiles::new(vec![claude_profile()]));
let sessions = Arc::new(TerminalSessions::new()); let sessions = Arc::new(TerminalSessions::new());
let pty = FakePty::new(sid(777)); 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()), saved: Mutex::new(Vec::new()),
fail_save: fail_memory, fail_save: fail_memory,
}); });
let live = Arc::new(RecordingLiveState {
fail: fail_live,
..Default::default()
});
let create = Arc::new(CreateAgentFromScratch::new( let create = Arc::new(CreateAgentFromScratch::new(
Arc::new(contexts.clone()), Arc::new(contexts.clone()),
@ -1170,8 +1270,7 @@ fn ask_fixture_with_memory(contexts: FakeContexts, fail_memory: bool) -> AskFixt
Arc::new(pty.clone()) as Arc<dyn PtyPort>, Arc::new(pty.clone()) as Arc<dyn PtyPort>,
)); ));
let conversations = Arc::new(TestConversations::new()) as Arc<dyn ConversationRegistry>; let conversations = Arc::new(TestConversations::new()) as Arc<dyn ConversationRegistry>;
let service = Arc::new( let mut builder = OrchestratorService::new(
OrchestratorService::new(
create, create,
launch, launch,
list, list,
@ -1190,8 +1289,16 @@ fn ask_fixture_with_memory(contexts: FakeContexts, fail_memory: bool) -> AskFixt
.with_memory_harvest(Arc::new(HarvestMemoryFromTurn::new( .with_memory_harvest(Arc::new(HarvestMemoryFromTurn::new(
Arc::clone(&memories) as Arc<dyn MemoryStore>, Arc::clone(&memories) as Arc<dyn MemoryStore>,
Arc::new(bus.clone()), 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<dyn LiveStateStore>,
Arc::new(FixedMillisClock(1_234)) as Arc<dyn Clock>,
)),
}) as Arc<dyn LiveStateProvider>);
}
let service = Arc::new(builder);
AskFixture { AskFixture {
service, service,
@ -1201,6 +1308,7 @@ fn ask_fixture_with_memory(contexts: FakeContexts, fail_memory: bool) -> AskFixt
pty, pty,
mediator, mediator,
memories, 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"); 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 /// 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 /// window expired and completed the turn "no reply". The awaiting ask must error out
/// promptly with the typed, retryable [`AppError::TargetReturnedNoReply`] — never hang /// 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 // Harvest is intentionally not wired here: these fixtures exercise the
// RecordTurn checkpoint in isolation (proving it stays unchanged). // RecordTurn checkpoint in isolation (proving it stays unchanged).
memories: Arc::new(HarvestMemories::default()), memories: Arc::new(HarvestMemories::default()),
// Live-state likewise unwired here (RecordTurn-focused fixtures).
live: Arc::new(RecordingLiveState::default()),
} }
} }