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:
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user