diff --git a/.gitignore b/.gitignore index 45a16fe..d6a4cca 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ frontend/coverage/ # Runtime file-protocol orchestration requests/responses — transient I/O, not # durable project state (curation .ideai §chantier secondaire). .ideai/requests/ +# Volatile agent live-state snapshot ("who is doing what right now", lot LS2): +# rebuilt at runtime, keyed last-writer-wins — not versioned (unlike .ideai/memory/). +.ideai/live-state.json # ─── Editors / OS ─────────────────────────────────────────────────────────── .idea/ diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index ac7034f..61875ea 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -18,11 +18,12 @@ use application::{ CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, - FirstRunState, GetMemory, GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, - 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, + FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState, + GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, + GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent, + LaunchAgentInput, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, + ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, + LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, @@ -30,9 +31,9 @@ use application::{ 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, + 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 +53,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 +131,67 @@ 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 [`LiveStateLeanProvider`] (injection au lancement, lot LS4) **et** +/// [`LiveStateReadProvider`] (outil `idea_workstate_read`) en matérialisant un +/// [`GetLiveStateLean`] dont le [`FsLiveStateStore`] cible le **project root** courant. +/// +/// Même raison d'être que [`AppLiveStateProvider`] (côté écriture) : [`LaunchAgent`] et +/// l'[`OrchestratorService`] sont uniques 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 `GetLiveStateLean` frais par appel, ciblant le +/// bon dossier (prune-on-read + snapshot lean). Porte l'horloge (port [`Clock`]). +struct AppLiveStateLeanProvider { + clock: Arc, +} + +impl AppLiveStateLeanProvider { + fn getter(&self, root: &domain::project::ProjectPath) -> Arc { + let store = Arc::new(FsLiveStateStore::new(root)); + Arc::new(GetLiveStateLean::new(store, Arc::clone(&self.clock))) + } +} + +impl LiveStateLeanProvider for AppLiveStateLeanProvider { + fn live_state_lean_for( + &self, + root: &domain::project::ProjectPath, + ) -> Option> { + Some(self.getter(root)) + } +} + +impl LiveStateReadProvider for AppLiveStateLeanProvider { + fn live_state_lean_for( + &self, + root: &domain::project::ProjectPath, + ) -> Option> { + Some(self.getter(root)) + } +} + /// Implémente [`ProviderSessionProvider`](application::ProviderSessionProvider) (lot /// P8b) en matérialisant un [`FsProviderSessionStore`] ciblant le **project root** du /// lancement en cours. @@ -833,7 +895,13 @@ impl AppState { // Projection des permissions au (re)lancement (lot LP3-5) : avec le // permission store câblé ci-dessus, `resolve` a une source ⇒ le projecteur // du profil matérialise la config de permission de la CLI dans le run dir. - .with_permission_projectors(Arc::clone(&permission_projectors)), + .with_permission_projectors(Arc::clone(&permission_projectors)) + // Aperçu live-state des autres agents (lot LS4) : section `# État du projet` + // injectée au lancement. Best-effort strict : provider absent / erreur / + // parse ⇒ section omise, jamais d'échec de lancement. + .with_live_state_lean(Arc::new(AppLiveStateLeanProvider { + clock: Arc::clone(&clock) as Arc, + }) as Arc), ); // Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/ @@ -1196,7 +1264,21 @@ 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) + // Lecture du live-state (lot LS4) pour l'outil `idea_workstate_read` : snapshot + // lean (prune-on-read) enrichi du nom d'agent. Provider par root (le store fixe + // sa racine à la construction). Sans ça, l'outil renverrait « not configured ». + .with_live_state_read(Arc::new(AppLiveStateLeanProvider { + 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 @@ -3052,6 +3134,9 @@ mod mcp_serve_peer_tests { "idea_memory_write", // Skill-awareness : lecture à la demande du corps d'un skill. "idea_skill_read", + // Live-state (programme live-state, lot LS4). + "idea_workstate_read", + "idea_workstate_set", ] { assert!( names.contains(&expected), @@ -3060,8 +3145,8 @@ mod mcp_serve_peer_tests { } assert_eq!( tools.len(), - 12, - "exactly the twelve idea_* tools (7 base + 4 FileGuard C7 + idea_skill_read); got {names:?}" + 14, + "exactly the fourteen idea_* tools (7 base + 4 FileGuard C7 + idea_skill_read + 2 live-state LS4); got {names:?}" ); drop(client); // EOF ⇒ serve loop ends @@ -3498,6 +3583,133 @@ mod mcp_serve_peer_tests { ConversationParty::agent(AgentId::from_uuid(Uuid::from_u128(n))) } + // ----------------------------------------------------------------------- + // LS4 — live-state round-trip through the app-tauri-wired service. + // ----------------------------------------------------------------------- + + /// In-memory [`LiveStateStore`] (keyed LWW + prune), no I/O — so the LS4 round-trip + /// exercises the real `UpdateLiveState`/`GetLiveStateLean` use cases behind the + /// service's write/read providers without touching the filesystem. + #[derive(Default)] + struct InMemLiveStore { + state: Mutex, + } + #[async_trait] + impl domain::ports::LiveStateStore for InMemLiveStore { + async fn load(&self) -> Result { + Ok(self.state.lock().unwrap().clone()) + } + async fn upsert(&self, entry: domain::live_state::LiveEntry) -> Result<(), StoreError> { + self.state.lock().unwrap().upsert(entry); + Ok(()) + } + async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError> { + self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n); + Ok(()) + } + } + + struct WsWriteProvider { + update: Arc, + } + impl application::LiveStateProvider for WsWriteProvider { + fn live_state_for(&self, _root: &ProjectPath) -> Option> { + Some(Arc::clone(&self.update)) + } + } + struct WsReadProvider { + getter: Arc, + } + impl application::LiveStateReadProvider for WsReadProvider { + fn live_state_lean_for( + &self, + _root: &ProjectPath, + ) -> Option> { + Some(Arc::clone(&self.getter)) + } + } + + /// Like [`build_service_with_guard`] but additively wires the LS4 write+read + /// providers over a SHARED in-memory store, exactly the prod builder's + /// `.with_live_state(...).with_live_state_read(...)`. + fn build_service_with_live_state(contexts: FakeContexts) -> Arc { + let store = Arc::new(InMemLiveStore::default()) as Arc; + let clock = Arc::new(FixedClock) as Arc; + let write = Arc::new(WsWriteProvider { + update: Arc::new(application::UpdateLiveState::new( + Arc::clone(&store), + Arc::clone(&clock), + )), + }); + let read = Arc::new(WsReadProvider { + getter: Arc::new(application::GetLiveStateLean::new( + Arc::clone(&store), + Arc::clone(&clock), + )), + }); + let service = build_service(contexts); + let service = Arc::try_unwrap(service) + .map_err(|_| ()) + .expect("freshly built service is uniquely owned") + .with_live_state(write as Arc) + .with_live_state_read(read as Arc); + Arc::new(service) + } + + /// END-TO-END (lot LS4) — through the **app-tauri-wired** `OrchestratorService`, + /// `idea_workstate_set` then `idea_workstate_read` round-trips: the current agent's + /// row comes back with the declared status/intent and the **resolved display name** + /// (via `ListAgents`), and `progress` never surfaces. + #[tokio::test] + async fn wired_serves_workstate_set_then_read_round_trip() { + let proj = project(); + let contexts = FakeContexts::new(); + let agent = contexts.seed_agent("architect"); + let service = build_service_with_live_state(contexts); + + // set — keyed on the agent identity; a progress note is supplied (must not leak). + let ack = service + .dispatch( + &proj, + domain::OrchestratorCommand::SetWorkState { + agent, + status: domain::live_state::WorkStatus::Working, + intent: Some("ship LS4".to_owned()), + progress: Some("hidden note".to_owned()), + ticket: None, + last_delegation: None, + }, + ) + .await + .expect("set must succeed when live-state is wired"); + assert!(ack.reply.is_none(), "set is ACK only: {ack:?}"); + + // read — over the SAME store, the architect's row comes back resolved. + let out = service + .dispatch( + &proj, + domain::OrchestratorCommand::ReadWorkState { + requester: ConversationParty::User, + }, + ) + .await + .expect("read must succeed when live-state is wired"); + let json: Value = serde_json::from_str(out.reply.as_deref().expect("read reply")).unwrap(); + let rows = json.as_array().expect("array"); + assert_eq!(rows.len(), 1, "exactly the current agent's row: {json}"); + assert_eq!( + rows[0]["agent"], + json!("architect"), + "name resolved: {json}" + ); + assert_eq!(rows[0]["status"], json!("working")); + assert_eq!(rows[0]["intent"], json!("ship LS4")); + assert!( + rows[0].get("progress").is_none(), + "progress must never surface on read: {json}" + ); + } + /// WIRING — with `.with_context_guard(...)`, `memory.write` then /// `memory.read` round-trips the content instead of erroring "not /// configured". Proves the four use cases reached the dispatch. diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index c755a19..2cc1b10 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -30,10 +30,13 @@ use domain::{ SessionId, SessionKind, SessionStatus, Skill, TerminalSession, }; +use domain::live_state::WorkStatus; + use crate::error::AppError; use crate::layout::{persist_doc, resolve_doc}; use crate::project::project_context_path; use crate::terminal::{StructuredSessions, TerminalSessions}; +use crate::workstate::GetLiveStateLean; /// Directory (relative to `.ideai/`) under which agent contexts are written. const AGENTS_SUBDIR: &str = "agents"; @@ -85,6 +88,45 @@ pub trait ProviderSessionProvider: Send + Sync { ) -> Option>; } +/// Cap on the number of live-state rows injected into the convention file at +/// activation (lot LS4). A project runs a handful of agents; `16` bounds the +/// injected section so a transient fan-out never bloats every agent's context. +pub const LIVE_STATE_INJECT_MAX: usize = 16; + +/// One distilled live-state row ready to inject into the convention file's +/// `# État du projet` section (lot LS4). +/// +/// Deliberately minimal: the **other** agent's display name, its coarse status and +/// a short intent — never the ticket/lastDelegation UUIDs, `progress`, or any +/// transcript. Built by [`LaunchAgent::resolve_live_state`] (which owns the manifest +/// join + ordering + self-exclusion) so [`compose_convention_file`] stays pure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InjectedLiveRow { + /// The other agent's display name. + pub name: String, + /// Coarse status at a glance (rendered as its [`WorkStatus::label`]). + pub status: WorkStatus, + /// Short intent free-text; empty ⇒ the intent suffix is omitted. + pub intent: String, +} + +/// Fournit le [`GetLiveStateLean`] **lié au project root** du lancement en cours +/// (lot LS4), pour injecter l'aperçu `# État du projet` dans le contexte composé. +/// +/// Même tension/réponse que [`HandoffProvider`] : [`LaunchAgent`] est unique pour tous +/// les projets, alors que le live-state est **par project root** +/// (`/.ideai/live-state.json`) et le port [`domain::ports::LiveStateStore`] fixe +/// sa racine à la construction. On matérialise donc un `GetLiveStateLean` ciblant le +/// **bon** dossier à chaque résolution (prune-on-read + snapshot lean). +/// +/// `None` ⇒ aucune injection d'état (best-effort absente) : zéro régression pour les +/// call sites/tests qui ne le branchent pas. Implémenté dans `app-tauri`. +pub trait LiveStateLeanProvider: Send + Sync { + /// Construit le [`GetLiveStateLean`] dont le store cible `root`. Appelé une fois par + /// lancement best-effort ; `None` ⇒ on saute silencieusement l'injection. + fn live_state_lean_for(&self, root: &ProjectPath) -> Option>; +} + // --------------------------------------------------------------------------- // CreateAgentFromScratch // --------------------------------------------------------------------------- @@ -1080,6 +1122,12 @@ pub struct LaunchAgent { /// câblage via [`Self::with_permission_projectors`] ; `None` ⇒ aucune projection /// (comportement historique préservé pour les call sites/tests legacy). projectors: Option>, + /// Provider du [`GetLiveStateLean`] **par project root** (lot LS4), pour injecter + /// l'aperçu `# État du projet` (que font les autres agents) dans le convention file + /// au lancement. Injecté via [`Self::with_live_state_lean`] ; `None` ⇒ aucune + /// injection (zéro régression). Best-effort strict : provider absent / erreur / + /// parse ⇒ section omise, jamais d'échec de lancement. + live_state_lean: Option>, } impl LaunchAgent { @@ -1117,9 +1165,20 @@ impl LaunchAgent { handoffs: None, provider_sessions: None, projectors: None, + live_state_lean: None, } } + /// Branche le provider de **live-state lean (lot LS4)** : au lancement, l'aperçu + /// `# État du projet` (status + intent des autres agents) est injecté dans le + /// convention file. Sans cet appel (cas legacy / tests existants), aucune section + /// n'est injectée — signature de [`Self::new`] **inchangée**. Builder additif. + #[must_use] + pub fn with_live_state_lean(mut self, provider: Arc) -> Self { + self.live_state_lean = Some(provider); + self + } + /// Injects the per-CLI permission **projector registry** (lot LP3-3) used to /// translate the resolved [`EffectivePermissions`] into the launched CLI's /// concrete permission config. Without this call (legacy call sites / tests), @@ -1262,6 +1321,58 @@ impl LaunchAgent { store.load(conversation).await.ok().flatten() } + /// Résout **best-effort** l'aperçu live-state des **autres** agents (lot LS4) à + /// injecter dans la section `# État du projet`, calqué sur [`Self::resolve_handoff`]. + /// + /// Ordre **du manifeste** (déterministe). L'agent lancé (`self_agent_id`) est + /// **exclu** (il ne se voit pas lui-même). Les lignes du live-state qui ne + /// correspondent à **aucun** agent du manifeste sont droppées (la frontière du + /// manifeste prime). Le résultat est borné à [`LIVE_STATE_INJECT_MAX`]. + /// + /// **Jamais bloquant** : provider absent, erreur du store, ou parse en échec + /// dégradent vers un `Vec` vide (section omise), exactement comme une mémoire/reprise + /// absente — un lancement n'est jamais cassé par cet aperçu. + async fn resolve_live_state( + &self, + root: &ProjectPath, + manifest: &AgentManifest, + self_agent_id: AgentId, + ) -> Vec { + let Some(provider) = self.live_state_lean.as_ref() else { + return Vec::new(); + }; + let Some(getter) = provider.live_state_lean_for(root) else { + return Vec::new(); + }; + // Toute erreur de lecture/prune ⇒ pas d'aperçu (best-effort). + let Ok(lean) = getter.execute().await else { + return Vec::new(); + }; + let by_agent: HashMap = + lean.entries.iter().map(|e| (e.agent_id, e)).collect(); + + let mut rows = Vec::new(); + for entry in &manifest.entries { + if rows.len() >= LIVE_STATE_INJECT_MAX { + break; + } + // Exclure la ligne de l'agent lancé : il ne s'observe pas lui-même. + if entry.agent_id == self_agent_id { + continue; + } + // Droppe les lignes live-state hors manifeste (on n'itère que le manifeste). + let Some(live) = by_agent.get(&entry.agent_id) else { + continue; + }; + rows.push(InjectedLiveRow { + name: entry.name.clone(), + status: live.status, + intent: live.intent.clone(), + }); + } + rows + } + /// Reads the shared project context from `.ideai/CONTEXT.md`. /// /// A missing file is normal for existing projects and simply omits the @@ -1472,6 +1583,12 @@ impl LaunchAgent { let handoff = self .resolve_handoff(&input.project.root, input.conversation_id.as_deref()) .await; + // Aperçu live-state des autres agents (lot LS4) : best-effort, additif. Injecté + // comme section `# État du projet`. Ordre manifeste, self exclu, borné, vide ⇒ + // section omise. Indépendant du handoff/mémoire. + let live_rows = self + .resolve_live_state(&input.project.root, &manifest, agent.id) + .await; // Best-effort contextual embedder suggestion (LOT C3, §14.5.5): the agent // has just read the project memory, so this is the moment to check whether a // semantic embedder would now help. Fully isolated from the launch outcome — @@ -1492,6 +1609,7 @@ impl LaunchAgent { &skills, &memory, handoff.as_ref(), + &live_rows, profile.mcp.is_some(), &mut spec, ) @@ -1970,6 +2088,7 @@ impl LaunchAgent { skills: &[Skill], memory: &[MemoryIndexEntry], handoff: Option<&Handoff>, + live_rows: &[InjectedLiveRow], mcp_enabled: bool, spec: &mut SpawnSpec, ) -> Result<(), AppError> { @@ -1989,6 +2108,7 @@ impl LaunchAgent { skills, memory, handoff, + live_rows, mcp_enabled, ); let path = RemotePath::new(join(&spec.cwd, &target)); @@ -2589,6 +2709,7 @@ pub(crate) fn compose_convention_file( skills: &[Skill], memory: &[MemoryIndexEntry], handoff: Option<&Handoff>, + live_rows: &[InjectedLiveRow], mcp_enabled: bool, ) -> String { let mut out = String::new(); @@ -2742,6 +2863,31 @@ pub(crate) fn compose_convention_file( } } + // État du projet (lot LS4) : aperçu lean de ce que font les autres agents en ce + // moment, placé APRÈS la mémoire projet et AVANT la reprise de conversation. Une + // ligne par agent (`- **Nom** — status · intent`), intent omis si vide. Jamais + // d'UUID (ticket/lastDelegation), de progress ni de transcript. `live_rows` vide ⇒ + // section entièrement omise (document octet-identique à sans-section). + if !live_rows.is_empty() { + out.push_str("\n\n---\n\n# État du projet\n\n"); + out.push_str( + "Aperçu de ce que font les autres agents en ce moment (last-writer-wins, \ + non temps-réel). Pour le détail ou pour publier ton propre statut, utilise \ + `idea_workstate_read` / `idea_workstate_set`.\n\n", + ); + for row in live_rows { + out.push_str("- **"); + out.push_str(&row.name); + out.push_str("** — "); + out.push_str(row.status.label()); + if !row.intent.trim().is_empty() { + out.push_str(" · "); + out.push_str(row.intent.trim()); + } + out.push('\n'); + } + } + // Reprise conversationnelle (lot P7) : section finale, la plus situationnelle // (« où on en était »), placée après la mémoire projet. Omise sans handoff. if let Some(handoff) = handoff { @@ -2866,6 +3012,7 @@ mod tests { &[], &[], None, + &[], false, ); @@ -2896,6 +3043,7 @@ mod tests { &[], &[], None, + &[], false, ); @@ -2930,6 +3078,7 @@ mod tests { ], &[], None, + &[], false, ); @@ -2966,6 +3115,7 @@ mod tests { &[], &[], None, + &[], false, ); @@ -2984,8 +3134,16 @@ mod tests { // file-protocol modes, positioned right after the Skills awareness and // before the orchestration block's closing separator / project context. for mcp_enabled in [true, false] { - let doc = - compose_convention_file("/root", "ctx", "# Persona", &[], &[], None, mcp_enabled); + let doc = compose_convention_file( + "/root", + "ctx", + "# Persona", + &[], + &[], + None, + &[], + mcp_enabled, + ); assert!( doc.contains("**Mémoire IdeA**"), "memory harvest directive present (mcp_enabled={mcp_enabled})" @@ -3009,14 +3167,15 @@ mod tests { #[test] fn compose_convention_file_skill_awareness_uses_mcp_or_file_creation_path() { - let mcp_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); + let mcp_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); assert!(mcp_doc.contains("idea_create_skill")); assert!( !mcp_doc.contains("skill.create"), "MCP awareness must point to the native IdeA tool" ); - let file_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); + let file_doc = + compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false); assert!(file_doc.contains("skill.create")); assert!( !file_doc.contains("idea_create_skill"), @@ -3051,6 +3210,7 @@ mod tests { ], &[], None, + &[], true, // mcp_enabled ); @@ -3084,7 +3244,7 @@ mod tests { // NB : l'outil `idea_skill_read` reste évoqué par le briefing « capacités IdeA » // (toujours présent), donc on n'asserte plus son absence — seulement celle de la // section d'affordances, qui, elle, dépend bien des skills assignés. - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); assert!(!doc.contains("# Skills disponibles")); } @@ -3102,8 +3262,16 @@ mod tests { fn compose_convention_file_empty_memory_is_identical_to_no_memory() { // An empty `memory` must yield exactly the previous document: no section, // byte-for-byte identical to the no-skills/no-memory composition. - let with_empty = - compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, false); + let with_empty = compose_convention_file( + "/root", + "", + "# Persona\n\nDo X.", + &[], + &[], + None, + &[], + false, + ); assert!( !with_empty.contains("# Mémoire projet"), "no memory ⇒ no memory section" @@ -3112,7 +3280,16 @@ mod tests { // 4-arg call; this pins the omission contract explicitly). assert_eq!( with_empty, - compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, false) + compose_convention_file( + "/root", + "", + "# Persona\n\nDo X.", + &[], + &[], + None, + &[], + false + ) ); } @@ -3133,6 +3310,7 @@ mod tests { ), ], None, + &[], false, ); @@ -3171,6 +3349,7 @@ mod tests { std::slice::from_ref(&skill), &[mem("note", "Note", "a hook", MemoryType::Project)], None, + &[], false, ); @@ -3190,7 +3369,7 @@ mod tests { fn compose_convention_file_mcp_prose_points_to_idea_tools_and_keeps_subagent_ban() { // mcp_enabled = true ⇒ prose exposes the native `idea_*` tools while keeping // the ban on the provider's native subagents (cadrage v3, Décision 3). - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); // Native IdeA orchestration tools surfaced. assert!( @@ -3215,7 +3394,7 @@ mod tests { fn compose_convention_file_mcp_prose_carries_the_idea_reply_delegation_protocol() { // B-5 — the solicited-agent side of the protocol: a delegated task arrives as // `[IdeA · tâche …]` and MUST be answered via `idea_reply`, never plain text. - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); assert!( doc.contains("idea_reply"), "MCP prose must instruct answering via idea_reply" @@ -3226,7 +3405,8 @@ mod tests { ); // Negative: the non-MCP (file-protocol) prose must NOT carry the idea_reply // instruction (zero regression on the file path). - let file_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); + let file_doc = + compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false); assert!(!file_doc.contains("idea_reply")); } @@ -3234,7 +3414,7 @@ mod tests { fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() { // mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged, // and crucially WITHOUT the `idea_*` tools (zero regression). - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false); assert!( doc.contains(".ideai/requests"), @@ -3257,7 +3437,7 @@ mod tests { // est INCONDITIONNEL. Projet/agent neuf et vide (zéro skill, zéro mémoire, // contexte vide) ⇒ les 3 capacités (contexte, mémoire, skills) DOIVENT quand // même être décrites, avec les noms des outils MCP correspondants. - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); // Capacité contexte projet. assert!( @@ -3283,7 +3463,7 @@ mod tests { // appliquée directement. On asserte sur la nuance réellement écrite par le dev : // « single-writer » + le fait qu'une proposition sans `target` est enregistrée // « pour validation » (et donc « n'est PAS appliquée directement »). - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); assert!( doc.contains("single-writer"), @@ -3303,7 +3483,7 @@ mod tests { fn compose_convention_file_non_mcp_briefs_capabilities_via_ideai_files_no_tools() { // Surface SANS MCP : mêmes 3 capacités décrites UNIQUEMENT via les fichiers // `.ideai/` — et AUCUN nom d'outil `idea_*` (cloisonnement strict des surfaces). - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false); // Capacités décrites par leurs fichiers/dossiers. assert!( @@ -3351,8 +3531,8 @@ mod tests { fn compose_convention_file_surfaces_stay_cloistered_on_capabilities() { // Cloisonnement : la prose MCP ne mentionne pas le protocole fichier // `.ideai/requests`, et la prose fichier ne mentionne aucun nom d'outil. - let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); - let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); + let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); + let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false); assert!( !mcp.contains(".ideai/requests"), @@ -3382,7 +3562,7 @@ mod tests { fn compose_convention_file_capability_brief_precedes_the_persona_in_both_modes() { // Ordre : le briefing capacités apparaît AVANT le persona dans les 2 modes. // On ancre le brief sur un marqueur stable propre à chaque surface. - let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, true); + let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true); let mcp_brief_at = mcp .find("idea_context_read") .expect("MCP capability brief present"); @@ -3392,7 +3572,7 @@ mod tests { "MCP capability brief must come before the persona" ); - let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); + let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false); let file_brief_at = file .find(".ideai/CONTEXT.md") .expect("file capability brief present"); @@ -3478,7 +3658,8 @@ mod tests { MemoryType::Project, )]; let h = handoff("Résumé : on a fini l'étape 2.", Some("Livrer le lot P7")); - let doc = compose_convention_file("/root", "", "# Persona", &[], &memory, Some(&h), false); + let doc = + compose_convention_file("/root", "", "# Persona", &[], &memory, Some(&h), &[], false); assert!( doc.contains("# Reprise de la conversation"), @@ -3514,7 +3695,7 @@ mod tests { // objective = None ⇒ the section and the summary are present, but NO // `**Objectif :**` line. let h = handoff("Juste le résumé.", None); - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), false); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), &[], false); assert!( doc.contains("# Reprise de la conversation"), @@ -3532,7 +3713,7 @@ mod tests { // objective = Some(" ") (whitespace only) ⇒ trimmed to empty ⇒ no objective // line, but the section + summary still render. let h = handoff("Le résumé.", Some(" ")); - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), false); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), &[], false); assert!(doc.contains("# Reprise de la conversation")); assert!(doc.contains("Le résumé.")); @@ -3546,7 +3727,7 @@ mod tests { fn compose_convention_file_without_handoff_omits_resume_section() { // handoff = None ⇒ no `# Reprise de la conversation` section at all // (zero regression for launches with no resume). - let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false); + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false); assert!( !doc.contains("# Reprise de la conversation"), "no handoff ⇒ no resume section: {doc}" @@ -3557,6 +3738,118 @@ mod tests { ); } + // ----------------------------------------------------------------------- + // État du projet (lot LS4) — live-state preview section in the composer. + // The composer is pure: it renders the already-distilled `InjectedLiveRow`s + // (self-exclusion/cap/ordering are `resolve_live_state`'s job, covered by the + // launch-level test). Here we pin the rendering contract. + // ----------------------------------------------------------------------- + + fn live_row(name: &str, status: WorkStatus, intent: &str) -> InjectedLiveRow { + InjectedLiveRow { + name: name.to_owned(), + status, + intent: intent.to_owned(), + } + } + + #[test] + fn compose_convention_file_renders_live_state_rows_in_order_with_labels() { + let rows = [ + live_row("Architect", WorkStatus::Working, "cadrage LS4"), + live_row("DevBackend", WorkStatus::Blocked, "attend la review"), + // Empty intent ⇒ the ` · intent` suffix is omitted (status only). + live_row("QA", WorkStatus::Idle, ""), + ]; + let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &rows, true); + + // Section present, after the persona. + assert!(doc.contains("# État du projet"), "section present: {doc}"); + let persona_at = doc.find("# Persona").unwrap(); + let section_at = doc.find("# État du projet").unwrap(); + assert!( + persona_at < section_at, + "live-state comes after the persona" + ); + + // Exact line format `- **Name** — status[ · intent]`, status = WorkStatus::label. + assert!( + doc.contains("- **Architect** — working · cadrage LS4"), + "working row with intent: {doc}" + ); + assert!( + doc.contains("- **DevBackend** — blocked · attend la review"), + "blocked row with intent: {doc}" + ); + // QA has an empty intent ⇒ no ` · ` suffix, no trailing separator. + assert!( + doc.contains("- **QA** — idle\n"), + "idle row, intent omitted: {doc}" + ); + assert!( + !doc.contains("**QA** — idle ·"), + "empty intent must not render a ` · ` suffix: {doc}" + ); + + // Manifest order is preserved: Architect before DevBackend before QA. + let a = doc.find("**Architect**").unwrap(); + let b = doc.find("**DevBackend**").unwrap(); + let c = doc.find("**QA**").unwrap(); + assert!(a < b && b < c, "rows rendered in the given order: {doc}"); + + // No leakage: the section never carries UUIDs/progress/transcripts (none in the + // rows ⇒ none in the output). + assert!(!doc.contains("progress"), "progress never surfaced: {doc}"); + } + + #[test] + fn compose_convention_file_empty_live_state_is_identical_to_no_section() { + // No live rows ⇒ the `# État du projet` section is omitted entirely, and the + // document is byte-for-byte identical to a composition that never had one. + let baseline = + compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, &[], true); + assert!( + !baseline.contains("# État du projet"), + "no live rows ⇒ no section" + ); + // Strict byte equality: threading an empty slice changes nothing. + let empty = + compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, &[], true); + assert_eq!(baseline, empty, "empty live-state ⇒ identical document"); + } + + #[test] + fn compose_convention_file_live_state_placed_after_memory_before_resume() { + // Placement contract: État du projet sits AFTER `# Mémoire projet` and BEFORE + // `# Reprise de la conversation`. + let memory = [mem( + "git-optional", + "Git optionnel", + "git reste un simple tool", + MemoryType::Project, + )]; + let h = handoff("Résumé : étape 2 finie.", Some("Livrer LS4")); + let rows = [live_row("Architect", WorkStatus::Waiting, "revue")]; + let doc = compose_convention_file( + "/root", + "", + "# Persona", + &[], + &memory, + Some(&h), + &rows, + true, + ); + + let memory_at = doc.find("# Mémoire projet").unwrap(); + let live_at = doc.find("# État du projet").unwrap(); + let resume_at = doc.find("# Reprise de la conversation").unwrap(); + assert!( + memory_at < live_at && live_at < resume_at, + "order must be Mémoire → État du projet → Reprise: {doc}" + ); + } + // NB (lot LP3-3): the former `claude_settings_seed_*` unit tests were removed — // that translation now lives in `infrastructure::permission::ClaudePermissionProjector` // and is covered by its own tests (LP3-2). Likewise the Codex sandbox/approval diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 43d7867..95b0d98 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -29,10 +29,11 @@ pub use inspect::{InspectConversation, InspectConversationInput, InspectConversa pub use lifecycle::{ ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider, - LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, - ListAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider, - ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor, - UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, + InjectedLiveRow, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, + ListAgentsOutput, LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, + ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, + StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput, + AGENT_MEMORY_RECALL_BUDGET, LIVE_STATE_INJECT_MAX, }; pub use resume::{ ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index c0c3f65..c7b702e 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -37,15 +37,15 @@ pub use agent::{ ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider, - InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent, - LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, - ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, - ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProfileAvailability, - ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, - ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput, - SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome, - UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, - RESUME_PROMPT, + InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput, + LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, + ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents, + ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime, + PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext, + ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, + ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService, + StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput, + AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT, }; pub use conversation::RecordTurn; pub use embedder::{ @@ -81,8 +81,9 @@ pub use memory::{ UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, }; pub use orchestrator::{ - ContextGuardUseCases, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, - ProposeContext, ReadContext, ReadMemory, RecordTurnProvider, WriteMemory, + ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider, + OrchestratorOutcome, OrchestratorService, ProposeContext, ReadContext, ReadMemory, + RecordTurnProvider, WriteMemory, }; pub use permission::{ GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput, @@ -122,7 +123,8 @@ pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindow pub use workstate::{ AgentTicketState, AgentWorkState, AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, ConversationLogProvider, ConversationPreviewStatus, ConversationTurnWorkPreview, - ConversationWorkSummary, GetProjectWorkState, GetProjectWorkStateInput, LiveWorkSession, - ProjectWorkState, StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput, TicketWorkSource, - TicketWorkStatus, + ConversationWorkSummary, GetLiveStateLean, GetProjectWorkState, GetProjectWorkStateInput, + LeanLiveEntry, LeanLiveState, LiveWorkSession, ProjectWorkState, StopLiveAgent, + StopLiveAgentInput, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, UpdateLiveState, + UpdateLiveStateInput, LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS, }; diff --git a/crates/application/src/orchestrator/mod.rs b/crates/application/src/orchestrator/mod.rs index 4df8b38..963fbed 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, LiveStateReadProvider, McpRuntimeProvider, + OrchestratorOutcome, OrchestratorService, RecordTurnProvider, }; diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 7e2a992..1474e9c 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::{GetLiveStateLean, 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,43 @@ 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>; +} + +/// Provider **par project root** d'un [`GetLiveStateLean`] (lot LS4), pour servir +/// l'outil de lecture `idea_workstate_read`. +/// +/// Même tension/réponse que [`LiveStateProvider`] : l'[`OrchestratorService`] est +/// **unique** pour tous les projets, mais le live-state est **par project root** et le +/// port [`domain::ports::LiveStateStore`] fixe son root à la construction. `read_workstate` +/// connaît le `project.root` et demande au provider un `GetLiveStateLean` ciblant le +/// **bon** dossier (prune-on-read + snapshot lean). +/// +/// `None` ⇒ l'outil renvoie « not configured » (call sites/tests legacy restent verts). +/// Implémenté dans app-tauri (seul détenteur des adapters `Fs*` et de l'horloge). +pub trait LiveStateReadProvider: Send + Sync { + /// Construit le [`GetLiveStateLean`] dont le store cible `root`. `None` ⇒ l'outil + /// `idea_workstate_read` n'est pas servi pour ce root. + fn live_state_lean_for(&self, root: &ProjectPath) -> Option>; +} + /// Dispatches validated orchestrator commands to the agent/terminal use cases. pub struct OrchestratorService { create_agent: Arc, @@ -308,6 +346,20 @@ 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>, + /// Provider de lecture du **live-state** (lot LS4) pour l'outil + /// `idea_workstate_read`. Distinct de [`Self::live_state`] (écriture) : la lecture + /// applique le prune-on-read et renvoie le snapshot lean enrichi du nom d'agent. + /// Injecté via [`Self::with_live_state_read`] ; `None` ⇒ l'outil renvoie une erreur + /// typée « not configured » (call sites/tests legacy restent verts). + live_state_read: Option>, } /// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés @@ -373,6 +425,8 @@ impl OrchestratorService { structured: None, memory_harvest: None, read_skill: None, + live_state: None, + live_state_read: None, } } @@ -489,6 +543,104 @@ 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 + } + + /// Branche le provider de **lecture** du live-state (lot LS4) pour l'outil + /// `idea_workstate_read`. Builder additif (signature de [`Self::new`] inchangée) ; + /// `None` (défaut) ⇒ l'outil renvoie « not configured ». Provider **par root** car + /// le live-state est par project root et le port fixe son root à la construction. + #[must_use] + pub fn with_live_state_read(mut self, provider: Arc) -> Self { + self.live_state_read = Some(provider); + 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 @@ -611,6 +763,28 @@ impl OrchestratorService { content, requester, } => self.write_memory(project, slug, content, requester).await, + OrchestratorCommand::ReadWorkState { requester } => { + self.read_workstate(project, requester).await + } + OrchestratorCommand::SetWorkState { + agent, + status, + intent, + progress, + ticket, + last_delegation, + } => { + self.set_workstate( + project, + agent, + status, + intent, + progress, + ticket, + last_delegation, + ) + .await + } } } @@ -725,6 +899,105 @@ impl OrchestratorService { }) } + /// `workstate.read` / `idea_workstate_read` → the project's agent **live-state** + /// (lot LS4): the lean snapshot (prune-on-read) enriched with each agent's display + /// name, returned inline as a JSON array. Entries whose agent is no longer in the + /// manifest are dropped; `progress` is **never** exposed (the lean DTO has none). + /// + /// Shape: `[{"agent","status","intent","ticket?","lastDelegation?"}]`. + async fn read_workstate( + &self, + project: &Project, + _requester: ConversationParty, + ) -> Result { + let provider = self + .live_state_read + .as_deref() + .ok_or_else(|| AppError::Invalid("idea_workstate_read not configured".to_owned()))?; + let getter = provider + .live_state_lean_for(&project.root) + .ok_or_else(|| AppError::Invalid("idea_workstate_read not configured".to_owned()))?; + let lean = getter.execute().await?; + + // Cross with the manifest to enrich each row with the agent's display name and + // to drop rows whose agent left the manifest (boundary preserved). + let listed = self + .list_agents + .execute(ListAgentsInput { + project: project.clone(), + }) + .await?; + let names: HashMap = + listed.agents.into_iter().map(|a| (a.id, a.name)).collect(); + + let rows: Vec = lean + .entries + .into_iter() + .filter_map(|entry| { + let name = names.get(&entry.agent_id)?; + let mut row = serde_json::json!({ + "agent": name, + "status": entry.status, + "intent": entry.intent, + }); + if let Some(ticket) = entry.ticket { + row["ticket"] = serde_json::json!(ticket); + } + if let Some(last) = entry.last_delegation { + row["lastDelegation"] = serde_json::json!(last); + } + Some(row) + }) + .collect(); + + let reply = serde_json::to_string(&rows) + .map_err(|e| AppError::Invalid(format!("failed to serialise work-state: {e}")))?; + Ok(OrchestratorOutcome { + detail: format!("read work-state ({} agent rows)", rows.len()), + reply: Some(reply), + }) + } + + /// `workstate.set` / `idea_workstate_set` → publishes (insert/replace) the **current + /// agent's** live-state row (lot LS4). The key is the handshake identity (`agent`), + /// so an agent only ever writes its own row. Reuses the LS3 write provider + /// ([`Self::live_state`]); `updated_at_ms` is stamped by the use case's `Clock`, and + /// `intent`/`progress` are domain-bounded ([`UpdateLiveState`] via `LiveEntry::new`). + /// **ACK only** (no inline reply). + #[allow(clippy::too_many_arguments)] + async fn set_workstate( + &self, + project: &Project, + agent: AgentId, + status: domain::live_state::WorkStatus, + intent: Option, + progress: Option, + ticket: Option, + last_delegation: Option, + ) -> Result { + let provider = self + .live_state + .as_deref() + .ok_or_else(|| AppError::Invalid("idea_workstate_set not configured".to_owned()))?; + let update = provider + .live_state_for(&project.root) + .ok_or_else(|| AppError::Invalid("idea_workstate_set not configured".to_owned()))?; + update + .execute(UpdateLiveStateInput { + agent_id: agent, + ticket, + intent: intent.unwrap_or_default(), + status, + progress, + last_delegation, + }) + .await?; + Ok(OrchestratorOutcome { + detail: format!("set work-state for agent {agent}"), + reply: None, + }) + } + /// `memory.write` → writes a note under an exclusive write-lease. async fn write_memory( &self, @@ -1069,6 +1342,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 +1356,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 +1403,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 +1524,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 +1640,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/src/workstate/live.rs b/crates/application/src/workstate/live.rs new file mode 100644 index 0000000..b7f32ae --- /dev/null +++ b/crates/application/src/workstate/live.rs @@ -0,0 +1,343 @@ +//! Live-state write/read use cases (programme live-state, lot LS2). +//! +//! The **live-state** is the durable-but-volatile "who is doing what right now" +//! projection: one [`LiveEntry`] per agent, persisted through the +//! [`LiveStateStore`] port (keyed last-writer-wins, never a journal). +//! +//! This module owns the two use cases over that port: +//! - [`UpdateLiveState`] — publish/replace an agent's current row (write side), +//! stamping `updated_at_ms` from the injected [`Clock`] and enforcing the +//! domain field bounds via [`LiveEntry::new`]. +//! - [`GetLiveStateLean`] — prune-on-read then return a **distilled** snapshot +//! ([`LeanLiveState`]) for agent context injection and the future MCP tools. +//! +//! ## Boundary (programme invariant) +//! +//! The live-state stores **only what cannot be re-derived**: an agent's declared +//! `intent`/`progress`, its current `ticket` and its `last_delegation`. Busy +//! state, live sessions and the delegation queue are **computed elsewhere** +//! ([`crate::workstate::GetProjectWorkState`]) and are deliberately *not* copied +//! here. The lean DTO is also distinct from that rich, human-facing read model. + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use domain::live_state::{LiveEntry, LiveState, WorkStatus}; +use domain::ports::{Clock, LiveStateStore}; +use domain::{AgentId, TicketId}; + +use crate::error::AppError; + +/// Default TTL for a live-state row, in milliseconds: **6 hours**. +/// +/// An entry not refreshed within this window is considered stale and pruned on +/// the next read. Six hours comfortably spans a normal working session (an agent +/// that has not published anything for that long is effectively offline), while +/// still bounding the file so a forgotten/abandoned project does not accumulate +/// dead rows indefinitely. +pub const LIVE_STATE_TTL_MS: u64 = 6 * 60 * 60 * 1_000; + +/// Default cardinality bound applied after the TTL sweep: keep at most this many +/// most-recently-updated rows. +/// +/// A project runs a handful of agents; `64` is a generous ceiling that still +/// absorbs transient fan-out (an orchestrator delegating to many agents at once) +/// while keeping the snapshot small and the injected context bounded. +pub const LIVE_STATE_MAX_ENTRIES: usize = 64; + +/// Input for [`UpdateLiveState::execute`] — the elements of one live-state +/// transition. `updated_at_ms` is **not** part of the input: it is stamped by the +/// use case from the injected [`Clock`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateLiveStateInput { + /// The agent publishing the transition (the keyed identity). + pub agent_id: AgentId, + /// The ticket it is currently handling, if any. + pub ticket: Option, + /// Short description of the current intent (domain-bounded on construction). + pub intent: String, + /// Coarse status at a glance. + pub status: WorkStatus, + /// Optional finer-grained progress note (domain-bounded on construction). + pub progress: Option, + /// The ticket of the most recent delegation this agent issued, if any. + pub last_delegation: Option, +} + +/// Publishes (inserts or replaces) an agent's live-state row. +pub struct UpdateLiveState { + store: Arc, + clock: Arc, +} + +impl UpdateLiveState { + /// Builds the use case from the injected store and clock ports. + #[must_use] + pub fn new(store: Arc, clock: Arc) -> Self { + Self { store, clock } + } + + /// Stamps `updated_at_ms` from the clock, builds a validated [`LiveEntry`] + /// (applying the soft truncation / hard rejection bounds) and upserts it. + /// + /// # Errors + /// [`AppError::Invalid`] if a text field exceeds the domain hard threshold; + /// [`AppError::Store`] on a persistence failure. + pub async fn execute(&self, input: UpdateLiveStateInput) -> Result<(), AppError> { + let now_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0); + let entry = LiveEntry::new( + input.agent_id, + input.ticket, + input.intent, + input.status, + input.progress, + input.last_delegation, + now_ms, + ) + .map_err(|e| AppError::Invalid(e.to_string()))?; + self.store.upsert(entry).await?; + Ok(()) + } +} + +/// A single distilled live-state row for injection / MCP — the **lean** DTO, +/// deliberately narrower than the rich human read model (no `progress` free-text, +/// no timestamp). All free-text is already domain-bounded. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LeanLiveEntry { + /// The agent this row describes. + pub agent_id: AgentId, + /// Short description of what the agent is doing (domain-bounded). + pub intent: String, + /// Coarse status at a glance. + pub status: WorkStatus, + /// The ticket it is currently handling, if any. + pub ticket: Option, + /// The ticket of the most recent delegation it issued, if any. + pub last_delegation: Option, +} + +impl From for LeanLiveEntry { + fn from(e: LiveEntry) -> Self { + Self { + agent_id: e.agent_id, + intent: e.intent, + status: e.status, + ticket: e.ticket, + last_delegation: e.last_delegation, + } + } +} + +/// The distilled live-state snapshot returned by [`GetLiveStateLean`]. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LeanLiveState { + /// The current rows (already pruned), one per agent. + pub entries: Vec, +} + +impl From for LeanLiveState { + fn from(state: LiveState) -> Self { + Self { + entries: state.entries.into_iter().map(LeanLiveEntry::from).collect(), + } + } +} + +/// Reads the live-state, pruning stale/excess rows first, and returns the lean +/// snapshot. +pub struct GetLiveStateLean { + store: Arc, + clock: Arc, +} + +impl GetLiveStateLean { + /// Builds the use case from the injected store and clock ports. + #[must_use] + pub fn new(store: Arc, clock: Arc) -> Self { + Self { store, clock } + } + + /// Applies the TTL + cardinality policy ([`LIVE_STATE_TTL_MS`], + /// [`LIVE_STATE_MAX_ENTRIES`]) at read time, then returns the distilled + /// snapshot. + /// + /// # Errors + /// [`AppError::Store`] on a persistence failure. + pub async fn execute(&self) -> Result { + let now_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0); + self.store + .prune(now_ms, LIVE_STATE_TTL_MS, LIVE_STATE_MAX_ENTRIES) + .await?; + let state = self.store.load().await?; + Ok(LeanLiveState::from(state)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::Mutex; + + use domain::live_state::FIELD_MAX_BYTES; + use domain::ports::StoreError; + + /// In-memory [`LiveStateStore`] double mirroring the FS adapter's pure + /// semantics (keyed upsert + prune), with no I/O. + #[derive(Default)] + struct InMemoryLiveStateStore { + state: Mutex, + } + + #[async_trait::async_trait] + impl LiveStateStore for InMemoryLiveStateStore { + async fn load(&self) -> Result { + Ok(self.state.lock().unwrap().clone()) + } + async fn upsert(&self, entry: LiveEntry) -> Result<(), StoreError> { + self.state.lock().unwrap().upsert(entry); + Ok(()) + } + async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError> { + self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n); + Ok(()) + } + } + + /// A fixed clock returning a preset epoch-ms value. + struct FixedClock(i64); + impl Clock for FixedClock { + fn now_millis(&self) -> i64 { + self.0 + } + } + + fn aid(n: u128) -> AgentId { + AgentId::from_uuid(uuid::Uuid::from_u128(n)) + } + + #[tokio::test] + async fn update_stamps_clock_and_persists() { + let store = Arc::new(InMemoryLiveStateStore::default()); + let clock = Arc::new(FixedClock(4_242)); + let uc = UpdateLiveState::new(store.clone(), clock); + + uc.execute(UpdateLiveStateInput { + agent_id: aid(1), + ticket: None, + intent: "shipping".to_owned(), + status: WorkStatus::Working, + progress: None, + last_delegation: None, + }) + .await + .unwrap(); + + let state = store.load().await.unwrap(); + assert_eq!(state.entries.len(), 1); + assert_eq!(state.entries[0].intent, "shipping"); + assert_eq!( + state.entries[0].updated_at_ms, 4_242, + "updated_at_ms stamped from the clock" + ); + } + + #[tokio::test] + async fn update_applies_domain_bounds_truncation() { + let store = Arc::new(InMemoryLiveStateStore::default()); + let uc = UpdateLiveState::new(store.clone(), Arc::new(FixedClock(1))); + + let long = "x".repeat(500); + uc.execute(UpdateLiveStateInput { + agent_id: aid(1), + ticket: None, + intent: long, + status: WorkStatus::Working, + progress: None, + last_delegation: None, + }) + .await + .unwrap(); + + let state = store.load().await.unwrap(); + assert!( + state.entries[0].intent.chars().count() <= 200, + "soft bound truncated the over-long intent" + ); + } + + #[tokio::test] + async fn update_rejects_oversize_field() { + let store = Arc::new(InMemoryLiveStateStore::default()); + let uc = UpdateLiveState::new(store, Arc::new(FixedClock(1))); + + let huge = "x".repeat(FIELD_MAX_BYTES + 1); + let err = uc + .execute(UpdateLiveStateInput { + agent_id: aid(1), + ticket: None, + intent: huge, + status: WorkStatus::Working, + progress: None, + last_delegation: None, + }) + .await + .expect_err("oversize intent must be rejected"); + assert!(matches!(err, AppError::Invalid(_))); + } + + #[tokio::test] + async fn get_lean_prunes_on_read_and_returns_lean_dto() { + let store = Arc::new(InMemoryLiveStateStore::default()); + // One fresh row (now-ish) and one ancient row (well beyond the TTL). + let now: i64 = 10 * 60 * 60 * 1_000; // 10h in ms + store + .upsert( + LiveEntry::new( + aid(1), + None, + "fresh", + WorkStatus::Working, + Some("detailed progress note".to_owned()), + None, + u64::try_from(now).unwrap(), + ) + .unwrap(), + ) + .await + .unwrap(); + store + .upsert(LiveEntry::new(aid(2), None, "stale", WorkStatus::Idle, None, None, 0).unwrap()) + .await + .unwrap(); + + let lean = GetLiveStateLean::new(store.clone(), Arc::new(FixedClock(now))) + .execute() + .await + .unwrap(); + + // The ancient row (age 10h > 6h TTL) was pruned on read. + assert_eq!(lean.entries.len(), 1); + let entry = &lean.entries[0]; + assert_eq!(entry.agent_id, aid(1)); + assert_eq!(entry.intent, "fresh"); + assert_eq!(entry.status, WorkStatus::Working); + + // Lean DTO is narrow: no `progress` free-text leaks through. The rich + // serialized form must not carry it. + let json = serde_json::to_string(&lean).unwrap(); + assert!( + !json.contains("progress"), + "lean DTO drops progress: {json}" + ); + assert!(!json.contains("detailed progress note")); + assert!(json.contains("\"agentId\""), "camelCase lean DTO"); + + // Prune persisted: the store no longer holds the stale row either. + assert_eq!(store.load().await.unwrap().entries.len(), 1); + } +} diff --git a/crates/application/src/workstate/mod.rs b/crates/application/src/workstate/mod.rs index 2e70414..b5435a0 100644 --- a/crates/application/src/workstate/mod.rs +++ b/crates/application/src/workstate/mod.rs @@ -6,11 +6,16 @@ //! durable projection. mod actions; +mod live; pub use actions::{ AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput, }; +pub use live::{ + GetLiveStateLean, LeanLiveEntry, LeanLiveState, UpdateLiveState, UpdateLiveStateInput, + LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS, +}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 92513e0..0ce5897 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -3188,3 +3188,215 @@ async fn resolved_deny_posture_reflected_as_plan_mode() { "project root flowed into the projection ctx" ); } + +// --------------------------------------------------------------------------- +// État du projet injection at launch (lot LS4) — `resolve_live_state` owns the +// manifest join + self-exclusion + ordering + cap; the composer renders it. +// --------------------------------------------------------------------------- + +/// In-memory [`LiveStateStore`] seeded with fixed rows (no I/O), to drive the +/// live-state preview injected into the convention file at launch. +struct SeededLiveStore { + state: Mutex, +} +#[async_trait] +impl domain::ports::LiveStateStore for SeededLiveStore { + async fn load(&self) -> Result { + Ok(self.state.lock().unwrap().clone()) + } + async fn upsert( + &self, + entry: domain::live_state::LiveEntry, + ) -> Result<(), domain::ports::StoreError> { + self.state.lock().unwrap().upsert(entry); + Ok(()) + } + async fn prune( + &self, + now_ms: u64, + ttl_ms: u64, + max_n: usize, + ) -> Result<(), domain::ports::StoreError> { + self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n); + Ok(()) + } +} + +/// Fixed clock so the seeded rows are never pruned for age at read time. +struct InjectClock(i64); +impl domain::ports::Clock for InjectClock { + fn now_millis(&self) -> i64 { + self.0 + } +} + +/// A [`LiveStateLeanProvider`] yielding the same [`GetLiveStateLean`] over a shared +/// seeded store (root-agnostic for the test). +struct SeededLeanProvider { + getter: Arc, +} +impl application::LiveStateLeanProvider for SeededLeanProvider { + fn live_state_lean_for( + &self, + _root: &ProjectPath, + ) -> Option> { + Some(Arc::clone(&self.getter)) + } +} + +#[tokio::test] +async fn launch_injects_live_state_excluding_self_capped_in_manifest_order() { + use domain::live_state::{LiveEntry, LiveState, WorkStatus}; + + let injection = ContextInjection::convention_file("CLAUDE.md").unwrap(); + let plan = Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }); + + // The launched agent (self) — must be EXCLUDED from its own preview. + let me = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); + let contexts = FakeContexts::with_agent(&me, "# ctx body"); + + // 18 OTHER agents in the manifest, in a deterministic order (aid 2..=19). + { + let mut inner = contexts.0.lock().unwrap(); + for n in 2..=19u128 { + let other = scratch_agent( + aid(n), + &format!("A{n:02}"), + &format!("agents/a{n}.md"), + pid(9), + ); + inner + .manifest + .entries + .push(ManifestEntry::from_agent(&other)); + } + } + + // Seed the live-state store: a row for self (must not surface), a row for each + // of the 18 manifest peers, plus one OFF-manifest row (aid 99) that must drop. + let now = 1_000_000_i64; + let mut state = LiveState::default(); + state.upsert( + LiveEntry::new( + aid(1), + None, + "my own work", + WorkStatus::Working, + None, + None, + now as u64, + ) + .unwrap(), + ); + for n in 2..=19u128 { + state.upsert( + LiveEntry::new( + aid(n), + None, + format!("intent {n}"), + WorkStatus::Working, + None, + None, + now as u64, + ) + .unwrap(), + ); + } + state.upsert( + LiveEntry::new( + aid(99), + None, + "ghost", + WorkStatus::Idle, + None, + None, + now as u64, + ) + .unwrap(), + ); + + let store = Arc::new(SeededLiveStore { + state: Mutex::new(state), + }); + let getter = Arc::new(application::GetLiveStateLean::new( + store as Arc, + Arc::new(InjectClock(now)), + )); + let provider = Arc::new(SeededLeanProvider { getter }); + + let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]); + let tr = trace(); + let fs = FakeFs::new(Arc::clone(&tr)); + let pty = FakePty::new(Arc::clone(&tr), sid(777)); + let sessions = Arc::new(TerminalSessions::new()); + let bus = SpyBus::default(); + let launch = LaunchAgent::new( + Arc::new(contexts), + Arc::new(profiles), + Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)), + Arc::new(fs.clone()), + Arc::new(pty.clone()), + Arc::new(FakeSkills::default()), + Arc::clone(&sessions), + Arc::new(bus.clone()), + Arc::new(SeqIds::new()), + Arc::new(FakeRecall::default()), + None, + ) + .with_live_state_lean(provider as Arc); + + launch.execute(launch_input(me.id)).await.expect("launch"); + + let writes = fs.context_writes(); + assert_eq!(writes.len(), 1, "exactly one convention file written"); + let doc = String::from_utf8(writes[0].1.clone()).unwrap(); + + // The section is present. + assert!( + doc.contains("# État du projet"), + "live-state section present: {doc}" + ); + + // Self is EXCLUDED: the launched agent never sees its own row. + assert!( + !doc.contains("- **Backend**"), + "the launched agent must not see its own live row: {doc}" + ); + // The off-manifest row never surfaces (its name is unknown to the manifest). + assert!( + !doc.contains("ghost"), + "off-manifest rows are dropped: {doc}" + ); + + // Cap: exactly LIVE_STATE_INJECT_MAX (16) peer rows are injected, in manifest + // order ⇒ A02..=A17 present, A18/A19 dropped by the cap. The only `- **` bullets + // in this composition are the live rows (no skills/memory bullets here). + assert_eq!( + doc.matches("- **").count(), + 16, + "capped to LIVE_STATE_INJECT_MAX peer rows: {doc}" + ); + assert!( + doc.contains("- **A02** — working · intent 2"), + "first peer row: {doc}" + ); + assert!( + doc.contains("- **A17** — working · intent 17"), + "16th peer row: {doc}" + ); + assert!( + !doc.contains("- **A18**"), + "18th agent dropped by the cap: {doc}" + ); + assert!( + !doc.contains("- **A19**"), + "19th agent dropped by the cap: {doc}" + ); + + // Manifest order preserved across the kept rows. + let a02 = doc.find("**A02**").unwrap(); + let a17 = doc.find("**A17**").unwrap(); + assert!(a02 < a17, "rows follow manifest order: {doc}"); +} diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 6e8fb0f..1b32add 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -39,9 +39,12 @@ use domain::{OrchestratorCommand, OrchestratorRequest, PtySize, SessionId}; use uuid::Uuid; use application::{ - CloseTerminal, CreateAgentFromScratch, CreateSkill, HarvestMemoryFromTurn, LaunchAgent, - ListAgents, OrchestratorService, TerminalSessions, UpdateAgentContext, + CloseTerminal, CreateAgentFromScratch, CreateSkill, GetLiveStateLean, HarvestMemoryFromTurn, + LaunchAgent, ListAgents, LiveStateProvider, LiveStateReadProvider, OrchestratorService, + TerminalSessions, UpdateAgentContext, UpdateLiveState, }; +use domain::live_state::{LiveEntry, LiveState, WorkStatus}; +use domain::ports::LiveStateStore; // --------------------------------------------------------------------------- // Fakes (mirror agent_lifecycle.rs) @@ -431,6 +434,84 @@ 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)) + } +} + +/// A [`LiveStateReadProvider`] (lot LS4, `idea_workstate_read`) yielding the same +/// [`GetLiveStateLean`] over a shared recording store (root-agnostic for the test). +struct TestLiveStateReadProvider { + getter: Arc, +} +impl LiveStateReadProvider for TestLiveStateReadProvider { + fn live_state_lean_for(&self, _root: &ProjectPath) -> Option> { + Some(Arc::clone(&self.getter)) + } +} + +/// 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 +1195,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 +1242,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 +1281,43 @@ 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); + // LS4 read side over the SAME store, so `idea_workstate_set` then + // `idea_workstate_read` round-trips (no separate write provider would). + builder = builder.with_live_state_read(Arc::new(TestLiveStateReadProvider { + getter: Arc::new(GetLiveStateLean::new( + Arc::clone(&live) as Arc, + Arc::new(FixedMillisClock(1_234)) as Arc, + )), + }) as Arc); + } + let service = Arc::new(builder); AskFixture { service, @@ -1201,6 +1327,7 @@ fn ask_fixture_with_memory(contexts: FakeContexts, fail_memory: bool) -> AskFixt pty, mediator, memories, + live, } } @@ -1302,6 +1429,319 @@ 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()); +} + +// --------------------------------------------------------------------------- +// LS4 — `idea_workstate_set` / `idea_workstate_read` service round-trip. +// --------------------------------------------------------------------------- + +/// `idea_workstate_set` writes the current agent's row, then `idea_workstate_read` +/// returns it enriched with the **resolved display name** (via `ListAgents`), with the +/// declared status/intent — and **never** the `progress` field. +#[tokio::test] +async fn workstate_set_then_read_round_trips_with_resolved_name_and_no_progress() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + + // set — keyed on the handshake agent id; carries a progress note (must not leak). + let ack = fx + .service + .dispatch( + &project(), + OrchestratorCommand::SetWorkState { + agent: aid(1), + status: WorkStatus::Working, + intent: Some("ship LS4".to_owned()), + progress: Some("secret internal note".to_owned()), + ticket: None, + last_delegation: None, + }, + ) + .await + .expect("set ok"); + // ACK only — no inline reply on the write path. + assert!(ack.reply.is_none(), "set is ACK only, no reply: {ack:?}"); + + // read — the JSON array carries one row for the architect. + let out = fx + .service + .dispatch( + &project(), + OrchestratorCommand::ReadWorkState { + requester: ConversationParty::User, + }, + ) + .await + .expect("read ok"); + let json: serde_json::Value = + serde_json::from_str(out.reply.as_deref().expect("read carries a reply")).unwrap(); + let rows = json.as_array().expect("array"); + assert_eq!(rows.len(), 1, "one live row expected: {json}"); + let row = &rows[0]; + // Name resolved from the manifest (not the bare UUID). + assert_eq!(row["agent"], serde_json::json!("architect")); + assert_eq!(row["status"], serde_json::json!("working")); + assert_eq!(row["intent"], serde_json::json!("ship LS4")); + // progress is NEVER exposed on read (the lean DTO has no such field). + assert!( + row.get("progress").is_none(), + "progress must never surface on read: {row}" + ); +} + +/// `idea_workstate_set` is last-writer-wins: a second set for the same agent replaces +/// the row in place (never a duplicate), and the read reflects the latest write. +#[tokio::test] +async fn workstate_set_is_last_writer_wins() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + + for (status, intent) in [ + (WorkStatus::Working, "first"), + (WorkStatus::Blocked, "second"), + ] { + fx.service + .dispatch( + &project(), + OrchestratorCommand::SetWorkState { + agent: aid(1), + status, + intent: Some(intent.to_owned()), + progress: None, + ticket: None, + last_delegation: None, + }, + ) + .await + .expect("set ok"); + } + + let out = fx + .service + .dispatch( + &project(), + OrchestratorCommand::ReadWorkState { + requester: ConversationParty::User, + }, + ) + .await + .expect("read ok"); + let json: serde_json::Value = serde_json::from_str(out.reply.as_deref().unwrap()).unwrap(); + let rows = json.as_array().unwrap(); + assert_eq!(rows.len(), 1, "LWW ⇒ exactly one row, no duplicate: {json}"); + assert_eq!(rows[0]["status"], serde_json::json!("blocked")); + assert_eq!(rows[0]["intent"], serde_json::json!("second")); +} + +/// `idea_workstate_read` drops rows whose agent left (or never was in) the manifest — +/// the manifest boundary is authoritative. +#[tokio::test] +async fn workstate_read_drops_off_manifest_rows() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + + // aid(1) is in the manifest; aid(2) is NOT. + for who in [aid(1), aid(2)] { + fx.service + .dispatch( + &project(), + OrchestratorCommand::SetWorkState { + agent: who, + status: WorkStatus::Working, + intent: Some("x".to_owned()), + progress: None, + ticket: None, + last_delegation: None, + }, + ) + .await + .expect("set ok"); + } + + let out = fx + .service + .dispatch( + &project(), + OrchestratorCommand::ReadWorkState { + requester: ConversationParty::User, + }, + ) + .await + .expect("read ok"); + let json: serde_json::Value = serde_json::from_str(out.reply.as_deref().unwrap()).unwrap(); + let rows = json.as_array().unwrap(); + assert_eq!(rows.len(), 1, "the off-manifest row is dropped: {json}"); + assert_eq!(rows[0]["agent"], serde_json::json!("architect")); +} + +/// An oversize `intent` (above the domain hard byte threshold) is **rejected** by the +/// write path (the domain bound surfaces as `AppError::Invalid`), never silently stored. +#[tokio::test] +async fn workstate_set_oversize_intent_is_rejected() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + + let huge = "x".repeat(domain::live_state::FIELD_MAX_BYTES + 1); + let err = fx + .service + .dispatch( + &project(), + OrchestratorCommand::SetWorkState { + agent: aid(1), + status: WorkStatus::Working, + intent: Some(huge), + progress: None, + ticket: None, + last_delegation: None, + }, + ) + .await + .expect_err("oversize intent must be rejected"); + assert!( + matches!(err, application::AppError::Invalid(_)), + "oversize ⇒ AppError::Invalid, got {err:?}" + ); +} + /// 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 +3440,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()), } } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 740cc56..2f604e2 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -40,6 +40,7 @@ pub mod git; pub mod ids; pub mod input; pub mod layout; +pub mod live_state; pub mod mailbox; pub mod markdown; pub mod memory; @@ -95,6 +96,8 @@ pub use conversation::{ pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource}; +pub use live_state::{LiveEntry, LiveState, WorkStatus, FIELD_MAX_BYTES, FIELD_PREVIEW_MAX_CHARS}; + pub use readiness::{ReadinessPolicy, ReadinessSignal}; pub use session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit}; @@ -152,8 +155,8 @@ pub use ports::{ EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, - MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore, - PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, - PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, - SpawnSpec, StoreError, TemplateStore, + LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, + PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, + PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, + Scheduler, SpawnSpec, StoreError, TemplateStore, }; diff --git a/crates/domain/src/live_state.rs b/crates/domain/src/live_state.rs new file mode 100644 index 0000000..4d5e774 --- /dev/null +++ b/crates/domain/src/live_state.rs @@ -0,0 +1,391 @@ +//! Agent **live-state** — the volatile, "what is each agent doing right now" +//! projection (programme live-state, lot LS1). +//! +//! Unlike the conversation log (an append-only journal) this is a **keyed, +//! last-writer-wins** snapshot: there is exactly **one** [`LiveEntry`] per +//! [`AgentId`], replaced in place on every update. There is deliberately **no +//! append API** — stacking duplicate rows per agent is an architectural +//! anti-pattern here (it would turn a live snapshot back into a journal). The +//! only mutations are [`LiveState::upsert`] (keyed replace-or-insert) and +//! [`LiveState::prune`] (TTL + cardinality bound). +//! +//! The type is **pure** (zero I/O). Persistence is the job of the +//! [`crate::ports::LiveStateStore`] port, implemented by infrastructure in a +//! later lot. + +use serde::{Deserialize, Serialize}; + +use crate::error::DomainError; +use crate::ids::AgentId; +use crate::mailbox::TicketId; + +/// Soft bound (in characters) applied to free-text fields (`intent`, +/// `progress`): values longer than this are **truncated**, not rejected. Aligned +/// with the workstate UI task preview (`TASK_PREVIEW_MAX_CHARS`, ≈160 chars) so +/// the live state and its rendering agree on excerpt length. +pub const FIELD_PREVIEW_MAX_CHARS: usize = 160; + +/// Hard anti-dump threshold (in **bytes**) for any single free-text field. A +/// value above this is **rejected** with [`DomainError::Invariant`] rather than +/// silently truncated: the soft bound trims ordinary over-long lines, while this +/// guards against a caller dumping long content (file bodies, logs) into the +/// live state. Distinct semantics: soft = truncate, hard = reject. +pub const FIELD_MAX_BYTES: usize = 2 * 1024; + +/// What an agent is doing right now, at a glance. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WorkStatus { + /// No active task — available. + Idle, + /// Actively working on its current `intent`. + Working, + /// Stuck / cannot proceed (e.g. needs a decision or input). + Blocked, + /// Suspended awaiting an external event (e.g. a delegation reply). + Waiting, + /// Current task finished. + Done, +} + +impl WorkStatus { + /// Parses a status label (case-insensitive) into a [`WorkStatus`]. + /// + /// Accepts exactly the five canonical labels (`idle`, `working`, `blocked`, + /// `waiting`, `done`), ignoring surrounding whitespace and ASCII case. Returns + /// `None` for anything else, so the caller can raise a typed error rather than + /// guess a default. + #[must_use] + pub fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "idle" => Some(Self::Idle), + "working" => Some(Self::Working), + "blocked" => Some(Self::Blocked), + "waiting" => Some(Self::Waiting), + "done" => Some(Self::Done), + _ => None, + } + } + + /// The canonical, human-readable label of this status (the inverse of + /// [`WorkStatus::parse`]): `idle`, `working`, `blocked`, `waiting` or `done`. + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Working => "working", + Self::Blocked => "blocked", + Self::Waiting => "waiting", + Self::Done => "done", + } + } +} + +/// A single agent's live-state row. One per [`AgentId`] in a [`LiveState`]. +/// +/// Construct via [`LiveEntry::new`], which enforces the field invariants +/// (soft-truncation of `intent`/`progress`, hard rejection of oversize input). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LiveEntry { + /// The agent this row describes (the keyed identity for last-writer-wins). + pub agent_id: AgentId, + /// The ticket the agent is currently handling, if any. + pub ticket: Option, + /// Short human-readable description of the current intent (soft-bounded). + pub intent: String, + /// Coarse status at a glance. + pub status: WorkStatus, + /// Optional finer-grained progress note (soft-bounded). + pub progress: Option, + /// The ticket of the most recent delegation this agent issued, if any. + pub last_delegation: Option, + /// Wall-clock update time in epoch milliseconds (drives `prune` ordering). + pub updated_at_ms: u64, +} + +impl LiveEntry { + /// Builds a validated live-state entry. + /// + /// `intent` and `progress` are bounded: each is **rejected** if it exceeds + /// the hard [`FIELD_MAX_BYTES`] anti-dump threshold, otherwise **truncated** + /// to [`FIELD_PREVIEW_MAX_CHARS`] characters (never mid-codepoint). + /// + /// # Errors + /// [`DomainError::Invariant`] if `intent` or `progress` exceeds + /// [`FIELD_MAX_BYTES`] bytes. + pub fn new( + agent_id: AgentId, + ticket: Option, + intent: impl Into, + status: WorkStatus, + progress: Option, + last_delegation: Option, + updated_at_ms: u64, + ) -> Result { + let intent = bound_text("intent", intent.into())?; + let progress = progress.map(|p| bound_text("progress", p)).transpose()?; + Ok(Self { + agent_id, + ticket, + intent, + status, + progress, + last_delegation, + updated_at_ms, + }) + } +} + +/// Enforces the field bounds: reject above the hard byte threshold, otherwise +/// truncate to the soft character bound (char-boundary safe). +fn bound_text(field: &'static str, value: String) -> Result { + if value.len() > FIELD_MAX_BYTES { + return Err(DomainError::Invariant(format!( + "live-state field `{field}` exceeds the max size of {FIELD_MAX_BYTES} bytes ({} bytes)", + value.len() + ))); + } + if value.chars().count() <= FIELD_PREVIEW_MAX_CHARS { + Ok(value) + } else { + Ok(value.chars().take(FIELD_PREVIEW_MAX_CHARS).collect()) + } +} + +/// The live-state snapshot: one [`LiveEntry`] per agent, keyed last-writer-wins. +/// +/// There is **no append API by design** (anti-journal invariant): the only +/// mutations are [`LiveState::upsert`] and [`LiveState::prune`]. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LiveState { + /// The current rows, at most one per [`AgentId`]. + pub entries: Vec, +} + +impl LiveState { + /// Inserts or **replaces** the row for `entry.agent_id` (last-writer-wins). + /// + /// Never produces a duplicate row for an agent: if an entry with the same + /// `agent_id` already exists it is overwritten in place; otherwise the entry + /// is appended as a new key. (This is *keyed* upsert, not an append of + /// duplicates — see the type-level anti-journal invariant.) + pub fn upsert(&mut self, entry: LiveEntry) { + if let Some(slot) = self + .entries + .iter_mut() + .find(|e| e.agent_id == entry.agent_id) + { + *slot = entry; + } else { + self.entries.push(entry); + } + } + + /// Drops entries older than `ttl_ms` relative to `now_ms`, then bounds the + /// result to `max_n`, keeping the most recently updated rows. + /// + /// Age is `now_ms.saturating_sub(updated_at_ms)`; a row is kept while its + /// age is `<= ttl_ms`. After the TTL sweep, if more than `max_n` rows remain + /// they are ordered by `updated_at_ms` (most recent first, stable on ties) + /// and truncated to `max_n`. + pub fn prune(&mut self, now_ms: u64, ttl_ms: u64, max_n: usize) { + self.entries + .retain(|e| now_ms.saturating_sub(e.updated_at_ms) <= ttl_ms); + if self.entries.len() > max_n { + self.entries + .sort_by(|a, b| b.updated_at_ms.cmp(&a.updated_at_ms)); + self.entries.truncate(max_n); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn aid(n: u128) -> AgentId { + AgentId::from_uuid(uuid::Uuid::from_u128(n)) + } + + fn tid(n: u128) -> TicketId { + TicketId::from_uuid(uuid::Uuid::from_u128(n)) + } + + #[test] + fn work_status_parse_is_case_insensitive_and_rejects_unknown() { + assert_eq!(WorkStatus::parse("idle"), Some(WorkStatus::Idle)); + assert_eq!(WorkStatus::parse(" Working "), Some(WorkStatus::Working)); + assert_eq!(WorkStatus::parse("BLOCKED"), Some(WorkStatus::Blocked)); + assert_eq!(WorkStatus::parse("waiting"), Some(WorkStatus::Waiting)); + assert_eq!(WorkStatus::parse("Done"), Some(WorkStatus::Done)); + assert_eq!(WorkStatus::parse("busy"), None); + assert_eq!(WorkStatus::parse(""), None); + } + + #[test] + fn work_status_label_round_trips_through_parse() { + for status in [ + WorkStatus::Idle, + WorkStatus::Working, + WorkStatus::Blocked, + WorkStatus::Waiting, + WorkStatus::Done, + ] { + assert_eq!(WorkStatus::parse(status.label()), Some(status)); + } + } + + #[test] + fn new_truncates_intent_and_progress_at_soft_bound() { + let long = "x".repeat(FIELD_PREVIEW_MAX_CHARS + 50); + let entry = LiveEntry::new( + aid(1), + None, + long.clone(), + WorkStatus::Working, + Some(long.clone()), + None, + 10, + ) + .expect("under the hard threshold ⇒ accepted, just truncated"); + + assert_eq!(entry.intent.chars().count(), FIELD_PREVIEW_MAX_CHARS); + assert_eq!( + entry.progress.as_deref().map(|p| p.chars().count()), + Some(FIELD_PREVIEW_MAX_CHARS) + ); + } + + #[test] + fn new_keeps_short_text_verbatim() { + let entry = + LiveEntry::new(aid(1), None, "ship it", WorkStatus::Done, None, None, 5).unwrap(); + assert_eq!(entry.intent, "ship it"); + assert!(entry.progress.is_none()); + } + + #[test] + fn new_rejects_intent_over_hard_byte_threshold() { + let huge = "x".repeat(FIELD_MAX_BYTES + 1); + let err = LiveEntry::new(aid(1), None, huge, WorkStatus::Working, None, None, 0) + .expect_err("oversize intent must be rejected, not truncated"); + assert!(matches!(err, DomainError::Invariant(_))); + } + + #[test] + fn new_rejects_progress_over_hard_byte_threshold() { + let huge = "y".repeat(FIELD_MAX_BYTES + 1); + let err = LiveEntry::new(aid(1), None, "ok", WorkStatus::Working, Some(huge), None, 0) + .expect_err("oversize progress must be rejected, not truncated"); + assert!(matches!(err, DomainError::Invariant(_))); + } + + #[test] + fn upsert_is_last_writer_wins_per_agent_no_duplicates() { + let mut state = LiveState::default(); + state.upsert( + LiveEntry::new(aid(1), None, "first", WorkStatus::Working, None, None, 1).unwrap(), + ); + state.upsert( + LiveEntry::new( + aid(1), + Some(tid(9)), + "second", + WorkStatus::Blocked, + None, + None, + 2, + ) + .unwrap(), + ); + + // A second upsert for the same agent replaces, never appends. + assert_eq!(state.entries.len(), 1, "same agent ⇒ exactly one row"); + let row = &state.entries[0]; + assert_eq!(row.intent, "second"); + assert_eq!(row.status, WorkStatus::Blocked); + assert_eq!(row.ticket, Some(tid(9))); + } + + #[test] + fn upsert_distinct_agents_coexist() { + let mut state = LiveState::default(); + state + .upsert(LiveEntry::new(aid(1), None, "a", WorkStatus::Working, None, None, 1).unwrap()); + state.upsert(LiveEntry::new(aid(2), None, "b", WorkStatus::Idle, None, None, 1).unwrap()); + assert_eq!(state.entries.len(), 2, "distinct agents ⇒ distinct rows"); + } + + #[test] + fn prune_drops_entries_older_than_ttl() { + let mut state = LiveState::default(); + state.upsert( + LiveEntry::new(aid(1), None, "stale", WorkStatus::Idle, None, None, 100).unwrap(), + ); + state.upsert( + LiveEntry::new(aid(2), None, "fresh", WorkStatus::Working, None, None, 900).unwrap(), + ); + + // now=1000, ttl=200 ⇒ age(aid1)=900 > 200 dropped; age(aid2)=100 kept. + state.prune(1000, 200, 100); + + assert_eq!(state.entries.len(), 1); + assert_eq!(state.entries[0].agent_id, aid(2)); + } + + #[test] + fn prune_bounds_to_max_n_keeping_most_recent() { + let mut state = LiveState::default(); + for n in 1..=5u128 { + state.upsert( + LiveEntry::new( + aid(n), + None, + "x", + WorkStatus::Working, + None, + None, + n as u64 * 10, + ) + .unwrap(), + ); + } + + // All within TTL; bound to 2 ⇒ keep the two highest updated_at_ms (40,50). + state.prune(60, 1_000, 2); + + assert_eq!(state.entries.len(), 2); + let kept: Vec = state.entries.iter().map(|e| e.updated_at_ms).collect(); + assert_eq!(kept, vec![50, 40], "most recent first, oldest dropped"); + } + + #[test] + fn serde_round_trip_is_camel_case() { + let mut state = LiveState::default(); + state.upsert( + LiveEntry::new( + aid(7), + Some(tid(3)), + "review PR", + WorkStatus::Waiting, + Some("waiting on QA".to_owned()), + Some(tid(4)), + 1234, + ) + .unwrap(), + ); + + let json = serde_json::to_string(&state).unwrap(); + // Field names are camelCase… + assert!(json.contains("\"agentId\"")); + assert!(json.contains("\"lastDelegation\"")); + assert!(json.contains("\"updatedAtMs\"")); + // …and the enum variant is camelCase too. + assert!(json.contains("\"waiting\"")); + + let back: LiveState = serde_json::from_str(&json).unwrap(); + assert_eq!(back, state, "round-trip is lossless"); + } +} diff --git a/crates/domain/src/orchestrator.rs b/crates/domain/src/orchestrator.rs index a772dbd..b1afc58 100644 --- a/crates/domain/src/orchestrator.rs +++ b/crates/domain/src/orchestrator.rs @@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize}; use crate::conversation::ConversationParty; use crate::ids::{AgentId, NodeId}; +use crate::live_state::WorkStatus; use crate::mailbox::TicketId; use crate::skill::SkillScope; @@ -56,6 +57,15 @@ pub enum OrchestratorError { /// The offending id value. value: String, }, + /// The `status` field of `workstate.set` is present but not a recognised + /// [`WorkStatus`] label. + #[error("unknown work status `{status}` for action `{action}`")] + UnknownWorkStatus { + /// The action being validated. + action: String, + /// The offending status value. + status: String, + }, } /// The raw, wire-level orchestrator request as deserialised from a request file. @@ -132,6 +142,23 @@ pub struct OrchestratorRequest { /// the aggregated index). Ignored by the other actions. #[serde(default, skip_serializing_if = "Option::is_none")] pub slug: Option, + /// Live-state status label for `workstate.set` (`idle|working|blocked|waiting|done`, + /// case-insensitive). Required by that action, ignored otherwise. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Short current-intent free-text for `workstate.set` (domain-bounded downstream). + /// Optional, ignored by the other actions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intent: Option, + /// Optional finer-grained progress note for `workstate.set` (domain-bounded + /// downstream). Ignored by the other actions. **Never** exposed on read. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub progress: Option, + /// Optional `lastDelegation` ticket id for `workstate.set` (the most recent + /// delegation the agent issued). Blank ⇒ none; non-uuid ⇒ rejected. Ignored by + /// the other actions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_delegation: Option, } /// A validated orchestrator command — the only thing the application layer acts on. @@ -269,6 +296,33 @@ pub enum OrchestratorCommand { /// The writing party (handshake identity). requester: ConversationParty, }, + /// Read the project's agent **live-state** (`idea_workstate_read`, programme + /// live-state, lot LS4): the lean "who is doing what right now" snapshot, enriched + /// with each agent's display name. Read-only; `progress` is never exposed. + ReadWorkState { + /// The party that issued the read (handshake identity). Carried for symmetry + /// with the other read tools; the read is project-wide (no per-requester + /// filtering). + requester: ConversationParty, + }, + /// Publish (insert or replace) the **current agent's** live-state row + /// (`idea_workstate_set`, lot LS4). The key is the handshake `requestedBy` + /// identity — an agent only ever writes its own row (no agent argument). + SetWorkState { + /// The agent whose row is written (the handshake identity, keyed + /// last-writer-wins). Never a model-managed value. + agent: AgentId, + /// Coarse status the agent declares. + status: WorkStatus, + /// Short current-intent free-text (domain-bounded on write); `None` ⇒ cleared. + intent: Option, + /// Optional finer-grained progress note (domain-bounded on write). + progress: Option, + /// The ticket the agent is currently handling, if any. + ticket: Option, + /// The ticket of the most recent delegation it issued, if any. + last_delegation: Option, + }, } /// Where IdeA should place a launched agent session. @@ -365,6 +419,20 @@ impl OrchestratorRequest { content: self.require("content", action, self.content.as_deref())?, requester: self.requester_party(), }), + "workstate.read" => Ok(OrchestratorCommand::ReadWorkState { + requester: self.requester_party(), + }), + "workstate.set" => Ok(OrchestratorCommand::SetWorkState { + // The key is the handshake identity (`requestedBy`), exactly like + // `agent.reply`: an agent writes only its own row, never a peer's. + agent: self.require_from(action)?, + status: self.require_work_status(action)?, + intent: self.intent.clone(), + progress: self.progress.clone(), + ticket: self.optional_ticket(action)?, + last_delegation: self + .parse_optional_ticket(action, self.last_delegation.as_deref())?, + }), "list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents), "create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill { name: self.require_name(action)?, @@ -454,17 +522,40 @@ impl OrchestratorRequest { /// Absent/blank ⇒ `None` (head-of-queue fallback); present-but-non-uuid ⇒ /// [`OrchestratorError::InvalidAgentId`] (reused as a generic invalid-id error). fn optional_ticket(&self, action: &str) -> Result, OrchestratorError> { - match self.ticket.as_deref().map(str::trim) { + self.parse_optional_ticket(action, self.ticket.as_deref()) + } + + /// Parses an **optional** ticket-id field (`ticket`, `lastDelegation`) into a + /// [`TicketId`]. Absent/blank ⇒ `None`; present-but-non-uuid ⇒ + /// [`OrchestratorError::InvalidAgentId`] (reused as a generic invalid-id error). + #[allow(clippy::unused_self)] + fn parse_optional_ticket( + &self, + action: &str, + raw: Option<&str>, + ) -> Result, OrchestratorError> { + match raw.map(str::trim) { None | Some("") => Ok(None), - Some(raw) => uuid::Uuid::parse_str(raw) + Some(value) => uuid::Uuid::parse_str(value) .map(|u| Some(TicketId::from_uuid(u))) .map_err(|_| OrchestratorError::InvalidAgentId { action: action.to_owned(), - value: raw.to_owned(), + value: value.to_owned(), }), } } + /// Parses the **required** `status` of `workstate.set` into a [`WorkStatus`]. + /// Missing/blank ⇒ [`OrchestratorError::MissingField`]; present-but-unknown ⇒ + /// [`OrchestratorError::UnknownWorkStatus`]. + fn require_work_status(&self, action: &str) -> Result { + let raw = self.require("status", action, self.status.as_deref())?; + WorkStatus::parse(&raw).ok_or_else(|| OrchestratorError::UnknownWorkStatus { + action: action.to_owned(), + status: raw, + }) + } + /// The optional target agent name for the FileGuard context tools: `targetAgent` /// (or legacy `name`), trimmed; absent/blank ⇒ `None` (the global project context). fn optional_target_agent(&self) -> Option { @@ -939,6 +1030,115 @@ mod tests { ); } + #[test] + fn workstate_read_derives_requester_party() { + let uid = uuid::Uuid::from_u128(5); + let r = req(&format!( + r#"{{ "type":"workstate.read", "requestedBy":"{uid}" }}"# + )); + assert_eq!( + r.validate().unwrap(), + OrchestratorCommand::ReadWorkState { + requester: ConversationParty::agent(AgentId::from_uuid(uid)), + } + ); + // No requester ⇒ user party (still valid: read is project-wide). + assert_eq!( + req(r#"{ "type":"workstate.read" }"#).validate().unwrap(), + OrchestratorCommand::ReadWorkState { + requester: ConversationParty::User, + } + ); + } + + #[test] + fn workstate_set_keys_on_requester_and_parses_fields() { + let from = uuid::Uuid::from_u128(3); + let tkt = uuid::Uuid::from_u128(9); + let last = uuid::Uuid::from_u128(11); + let r = req(&format!( + r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"Working", + "intent":"ship LS4", "progress":"wiring", "ticket":"{tkt}", + "lastDelegation":"{last}" }}"# + )); + assert_eq!( + r.validate().unwrap(), + OrchestratorCommand::SetWorkState { + agent: AgentId::from_uuid(from), + status: WorkStatus::Working, + intent: Some("ship LS4".to_owned()), + progress: Some("wiring".to_owned()), + ticket: Some(TicketId::from_uuid(tkt)), + last_delegation: Some(TicketId::from_uuid(last)), + } + ); + } + + #[test] + fn workstate_set_requires_status_and_requester() { + let from = uuid::Uuid::from_u128(3); + // Missing status ⇒ MissingField. + assert_eq!( + req(&format!( + r#"{{ "type":"workstate.set", "requestedBy":"{from}" }}"# + )) + .validate(), + Err(OrchestratorError::MissingField { + action: "workstate.set".to_owned(), + field: "status".to_owned(), + }) + ); + // Unknown status ⇒ UnknownWorkStatus. + assert_eq!( + req(&format!( + r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"busy" }}"# + )) + .validate(), + Err(OrchestratorError::UnknownWorkStatus { + action: "workstate.set".to_owned(), + status: "busy".to_owned(), + }) + ); + // Missing requester ⇒ MissingField on `requestedBy`. + assert_eq!( + req(r#"{ "type":"workstate.set", "status":"idle" }"#).validate(), + Err(OrchestratorError::MissingField { + action: "workstate.set".to_owned(), + field: "requestedBy".to_owned(), + }) + ); + // Non-uuid lastDelegation ⇒ InvalidAgentId (generic invalid-id error). + assert!(matches!( + req(&format!( + r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"idle", "lastDelegation":"nope" }}"# + )) + .validate(), + Err(OrchestratorError::InvalidAgentId { .. }) + )); + // Non-uuid `ticket` ⇒ InvalidAgentId too (same parse_optional_ticket path). + assert!(matches!( + req(&format!( + r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"idle", "ticket":"nope" }}"# + )) + .validate(), + Err(OrchestratorError::InvalidAgentId { .. }) + )); + // Non-uuid `requestedBy` ⇒ InvalidAgentId (the handshake key must be a real id). + assert!(matches!( + req(r#"{ "type":"workstate.set", "requestedBy":"not-a-uuid", "status":"idle" }"#) + .validate(), + Err(OrchestratorError::InvalidAgentId { .. }) + )); + // Blank `requestedBy` ⇒ MissingField (trimmed-empty is treated as absent). + assert_eq!( + req(r#"{ "type":"workstate.set", "requestedBy":" ", "status":"idle" }"#).validate(), + Err(OrchestratorError::MissingField { + action: "workstate.set".to_owned(), + field: "requestedBy".to_owned(), + }) + ); + } + #[test] fn unknown_action_is_rejected() { let r = req(r#"{ "action": "delete_everything", "name": "a" }"#); diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 5cd37ae..087b828 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -816,6 +816,35 @@ pub trait SkillStore: Send + Sync { ) -> Result<(), StoreError>; } +/// Persistence for the agent **live-state** snapshot (programme live-state). +/// +/// Mirrors the other stores ([`SkillStore`], [`MemoryStore`]): a driven port the +/// infrastructure implements. The contract is **keyed last-writer-wins**, never +/// append — [`upsert`](LiveStateStore::upsert) replaces the row for an agent and +/// [`prune`](LiveStateStore::prune) applies the TTL + cardinality bound, matching +/// [`LiveState`](crate::live_state::LiveState)'s pure semantics. +#[async_trait] +pub trait LiveStateStore: Send + Sync { + /// Loads the current live-state snapshot. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn load(&self) -> Result; + + /// Inserts or replaces (keyed by `agent_id`) a single live-state row. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn upsert(&self, entry: crate::live_state::LiveEntry) -> Result<(), StoreError>; + + /// Drops rows older than `ttl_ms` relative to `now_ms`, then bounds the set + /// to `max_n`, keeping the most recently updated rows. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError>; +} + /// CRUD for project [`Memory`] notes — the `.md` knowledge base under /// `.ideai/memory/` (LOT A, étage 1). Notes are the single source of truth; the /// aggregated `MEMORY.md` index is derived and kept in sync on every write. diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 96a0a7b..9b4ce0b 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -72,8 +72,8 @@ pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT}; pub use store::{ embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector, AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, - FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore, - FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, - StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, + FsLiveStateStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, + FsSkillStore, FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, + OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index e7fdc6d..8b1fb74 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -56,6 +56,7 @@ pub fn tool_returns_reply(tool: &str) -> bool { | "idea_context_read" | "idea_memory_read" | "idea_skill_read" + | "idea_workstate_read" ) } @@ -220,6 +221,37 @@ pub fn catalogue() -> Vec { "additionalProperties": false }), }, + ToolDef { + name: "idea_workstate_read", + description: "Read what every IdeA agent is doing right now (the project live-state). \ + Returns a JSON array, one row per agent: name, status \ + (idle|working|blocked|waiting|done), short intent, and optional \ + current/last-delegation ticket. Last-writer-wins, not real-time.", + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_workstate_set", + description: "Publish your own current status to the project live-state so other \ + agents can coordinate. Writes only YOUR row (keyed on your identity). \ + `status` is required; `intent`/`progress`/`ticket`/`lastDelegation` are \ + optional. Acknowledged only (no reply).", + input_schema: json!({ + "type": "object", + "properties": { + "status": { "type": "string", "enum": ["idle", "working", "blocked", "waiting", "done"], "description": "Your coarse status right now." }, + "intent": { "type": "string", "description": "Short description of what you are doing." }, + "progress": { "type": "string", "description": "Optional finer-grained progress note." }, + "ticket": { "type": "string", "description": "Optional id of the ticket you are currently handling." }, + "lastDelegation": { "type": "string", "description": "Optional id of the most recent delegation you issued." } + }, + "required": ["status"], + "additionalProperties": false + }), + }, ToolDef { name: "idea_create_skill", description: "Create a reusable IdeA skill (Markdown) in the project or global scope.", @@ -347,6 +379,25 @@ pub fn map_tool_call( name: s("name"), ..base() }, + "idea_workstate_read" => OrchestratorRequest { + request_type: Some("workstate.read".to_owned()), + // The requester (handshake identity) is carried for symmetry with the + // other read tools; the read is project-wide. + requested_by: Some(requester.to_owned()), + ..base() + }, + "idea_workstate_set" => OrchestratorRequest { + request_type: Some("workstate.set".to_owned()), + // The written key is the connected peer's handshake identity (never a tool + // argument): injected as `requestedBy` so `validate` keys the row on it. + requested_by: Some(requester.to_owned()), + status: s("status"), + intent: s("intent"), + progress: s("progress"), + ticket: s("ticket"), + last_delegation: s("lastDelegation"), + ..base() + }, "idea_create_skill" => OrchestratorRequest { request_type: Some("skill.create".to_owned()), name: s("name"), @@ -405,6 +456,10 @@ fn base() -> OrchestratorRequest { ticket: None, content: None, slug: None, + status: None, + intent: None, + progress: None, + last_delegation: None, } } @@ -670,4 +725,90 @@ mod tests { // ACK only: the result is routed to the awaiting requester, not echoed. assert!(!tool_returns_reply("idea_reply")); } + + #[test] + fn workstate_read_maps_with_requester_party() { + let cmd = map_tool_call("idea_workstate_read", &json!({}), REQ).unwrap(); + assert_eq!( + cmd, + OrchestratorCommand::ReadWorkState { + requester: domain::ConversationParty::agent(domain::AgentId::from_uuid( + uuid::Uuid::parse_str(REQ).unwrap() + )), + } + ); + // Read carries an inline reply (the live-state JSON array); set does not. + assert!(tool_returns_reply("idea_workstate_read")); + assert!(!tool_returns_reply("idea_workstate_set")); + } + + #[test] + fn workstate_set_keys_on_handshake_requester() { + let tkt = "22222222-2222-2222-2222-222222222222"; + let cmd = map_tool_call( + "idea_workstate_set", + &json!({ "status": "working", "intent": "ship", "ticket": tkt }), + REQ, + ) + .unwrap(); + assert_eq!( + cmd, + OrchestratorCommand::SetWorkState { + agent: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()), + status: domain::live_state::WorkStatus::Working, + intent: Some("ship".to_owned()), + progress: None, + ticket: Some(domain::mailbox::TicketId::from_uuid( + uuid::Uuid::parse_str(tkt).unwrap() + )), + last_delegation: None, + } + ); + // Missing status ⇒ validation error. + let err = map_tool_call("idea_workstate_set", &json!({ "intent": "x" }), REQ); + assert!(matches!(err, Err(ToolMapError::Invalid(_)))); + // Status outside the enum ⇒ validation error too (UnknownWorkStatus surfaces + // as the generic Invalid map error). + let bad = map_tool_call("idea_workstate_set", &json!({ "status": "busy" }), REQ); + assert!( + matches!(bad, Err(ToolMapError::Invalid(_))), + "an out-of-enum status must be rejected, got {bad:?}" + ); + } + + /// The catalogue advertises the two live-state tools (lot LS4) with schemas + /// matching the frozen contract: `set` requires `status` constrained to the exact + /// five-label enum; `read` takes no parameter. + #[test] + fn catalogue_advertises_workstate_tools_with_conformant_schemas() { + let cat = catalogue(); + let by_name = |name: &str| { + cat.iter() + .find(|t| t.name == name) + .unwrap_or_else(|| panic!("catalogue must contain {name}")) + }; + + // read — object schema, no declared properties, no required fields. + let read = by_name("idea_workstate_read"); + assert_eq!(read.input_schema["type"], json!("object")); + assert_eq!( + read.input_schema["properties"], + json!({}), + "read takes no parameter" + ); + assert!( + read.input_schema.get("required").is_none(), + "read requires nothing" + ); + + // set — `status` required, enum exactly the five canonical labels. + let set = by_name("idea_workstate_set"); + assert_eq!(set.input_schema["type"], json!("object")); + assert_eq!(set.input_schema["required"], json!(["status"])); + assert_eq!( + set.input_schema["properties"]["status"]["enum"], + json!(["idle", "working", "blocked", "waiting", "done"]), + "status enum must match WorkStatus labels exactly" + ); + } } diff --git a/crates/infrastructure/src/store/live_state.rs b/crates/infrastructure/src/store/live_state.rs new file mode 100644 index 0000000..b780e59 --- /dev/null +++ b/crates/infrastructure/src/store/live_state.rs @@ -0,0 +1,128 @@ +//! [`FsLiveStateStore`] — l'adapter `tokio::fs` du port [`LiveStateStore`] +//! (programme live-state, lot LS2). +//! +//! Le **live-state** est la projection volatile « qui fait quoi en ce moment » : +//! une entrée [`LiveEntry`](domain::live_state::LiveEntry) par agent, sémantique +//! *keyed last-writer-wins* (jamais un journal). On en garde **un seul** fichier +//! par projet, à la racine du `.ideai/` : +//! +//! ```text +//! /.ideai/live-state.json +//! ``` +//! +//! Le format est le JSON camelCase de [`LiveState`](domain::live_state::LiveState) +//! (dérivé serde du domaine), lisible à l'œil. +//! +//! ## Robustesse +//! +//! - **Écriture atomique** : on écrit dans `live-state.json.tmp` puis on `rename` +//! — un lecteur ne voit jamais de fichier à moitié écrit (le `rename` est +//! atomique sur le FS). Même pattern que [`FsHandoffStore`](crate::FsHandoffStore). +//! - **Fichier absent** ⇒ [`LiveState`] vide (jamais une erreur) : c'est l'état +//! normal d'un projet qui n'a encore rien publié. +//! - **Fichier présent mais illisible** (JSON corrompu/partiel) ⇒ +//! [`StoreError::Serialization`]. Choix **aligné** sur les autres stores FS +//! (`FsHandoffStore`, l'`index.json` de `FsSkillStore`) : on ne panique jamais, +//! mais on ne masque pas non plus une corruption — l'écriture atomique rend ce +//! cas très improbable, et le fichier étant runtime/non-versionné, le supprimer +//! suffit à repartir d'un état vide. +//! +//! Le project root est fourni au constructeur (le port [`LiveStateStore`] n'a pas +//! de paramètre `root` par appel, contrairement à `SkillStore`/`MemoryStore`) ; +//! une instance sert donc **un** projet. + +use std::path::PathBuf; + +use async_trait::async_trait; + +use domain::live_state::{LiveEntry, LiveState}; +use domain::ports::{LiveStateStore, StoreError}; +use domain::project::ProjectPath; + +/// Le dossier `.ideai/` dans une racine de projet. +const IDEAI_DIR: &str = ".ideai"; + +/// Nom du fichier de live-state, par projet. +const LIVE_STATE_FILE: &str = "live-state.json"; + +/// Nom du fichier temporaire d'écriture atomique (renommé sur `live-state.json`). +const LIVE_STATE_TMP_FILE: &str = "live-state.json.tmp"; + +/// Adapter `tokio::fs` du store de live-state, un `live-state.json` par projet. +/// +/// Même convention de construction que [`FsHandoffStore`](crate::FsHandoffStore) : +/// le **project root** est fourni au constructeur, la base `/.ideai` en +/// dérive, et le fichier est créé paresseusement à la première écriture. +pub struct FsLiveStateStore { + /// Racine `/.ideai`. + dir: PathBuf, +} + +impl FsLiveStateStore { + /// Construit l'adapter à partir du **project root**. + #[must_use] + pub fn new(root: &ProjectPath) -> Self { + let dir = PathBuf::from(root.as_str()).join(IDEAI_DIR); + Self { dir } + } + + /// `/.ideai/live-state.json` — le fichier cible. + fn path(&self) -> PathBuf { + self.dir.join(LIVE_STATE_FILE) + } + + /// `/.ideai/live-state.json.tmp` — le tmp d'écriture atomique. + fn tmp_path(&self) -> PathBuf { + self.dir.join(LIVE_STATE_TMP_FILE) + } + + /// Lit l'état courant : fichier absent ⇒ état vide ; JSON corrompu ⇒ erreur. + async fn read_state(&self) -> Result { + match tokio::fs::read(self.path()).await { + Ok(bytes) => { + serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string())) + } + // Absent ⇒ état vide (jamais une erreur). + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LiveState::default()), + Err(e) => Err(StoreError::Io(e.to_string())), + } + } + + /// Réécrit l'état **atomiquement** : tmp puis `rename` sur la cible. + async fn write_state(&self, state: &LiveState) -> Result<(), StoreError> { + tokio::fs::create_dir_all(&self.dir) + .await + .map_err(|e| StoreError::Io(e.to_string()))?; + + let bytes = serde_json::to_vec_pretty(state) + .map_err(|e| StoreError::Serialization(e.to_string()))?; + + let tmp = self.tmp_path(); + tokio::fs::write(&tmp, &bytes) + .await + .map_err(|e| StoreError::Io(e.to_string()))?; + tokio::fs::rename(&tmp, self.path()) + .await + .map_err(|e| StoreError::Io(e.to_string()))?; + Ok(()) + } +} + +#[async_trait] +impl LiveStateStore for FsLiveStateStore { + async fn load(&self) -> Result { + self.read_state().await + } + + async fn upsert(&self, entry: LiveEntry) -> Result<(), StoreError> { + let mut state = self.read_state().await?; + state.upsert(entry); + self.write_state(&state).await + } + + async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError> { + let mut state = self.read_state().await?; + state.prune(now_ms, ttl_ms, max_n); + self.write_state(&state).await + } +} diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index 40030c2..3d9204f 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -6,6 +6,7 @@ mod context; mod embedder; +mod live_state; mod memory; mod permission; mod profile; @@ -24,6 +25,7 @@ pub use embedder::{ HashEmbedder, OnnxModelInfo, StubEmbedder, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; +pub use live_state::FsLiveStateStore; pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall}; pub use permission::FsPermissionStore; pub use profile::{FsEmbedderProfileStore, FsProfileStore}; diff --git a/crates/infrastructure/tests/live_state_store.rs b/crates/infrastructure/tests/live_state_store.rs new file mode 100644 index 0000000..9d30072 --- /dev/null +++ b/crates/infrastructure/tests/live_state_store.rs @@ -0,0 +1,157 @@ +//! LS2 integration tests for [`FsLiveStateStore`] against a **real** temp +//! directory (programme live-state). +//! +//! These lock the durable behaviour an in-memory double cannot prove: +//! - round-trip `upsert`/`load` survives a fresh instance ("restart"); +//! - a missing file => empty `LiveState` (never an error); +//! - the write is atomic (no `.tmp` residue left behind); +//! - `upsert` is keyed last-writer-wins, persisted (no duplicate per agent); +//! - `prune` (TTL + max_n) is persisted. +//! +//! Convention: a hand-rolled [`TempDir`] over the OS temp dir (calqué sur +//! `conversation_log.rs`) — absolute path for `ProjectPath::new`, cleaned on drop. + +use std::path::PathBuf; + +use domain::live_state::{LiveEntry, WorkStatus}; +use domain::ports::LiveStateStore; +use domain::project::ProjectPath; +use domain::AgentId; +use infrastructure::FsLiveStateStore; +use uuid::Uuid; + +struct TempDir(PathBuf); +impl TempDir { + fn new() -> Self { + let p = std::env::temp_dir().join(format!("idea-ls2-livestate-{}", Uuid::new_v4())); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + fn project_path(&self) -> ProjectPath { + ProjectPath::new(self.0.to_string_lossy().into_owned()).unwrap() + } + fn file_path(&self) -> PathBuf { + self.0.join(".ideai").join("live-state.json") + } + fn tmp_path(&self) -> PathBuf { + self.0.join(".ideai").join("live-state.json.tmp") + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn aid(n: u128) -> AgentId { + AgentId::from_uuid(Uuid::from_u128(n)) +} + +fn entry(agent: u128, intent: &str, status: WorkStatus, updated_at_ms: u64) -> LiveEntry { + LiveEntry::new(aid(agent), None, intent, status, None, None, updated_at_ms).unwrap() +} + +#[tokio::test] +async fn missing_file_loads_empty_without_error() { + let tmp = TempDir::new(); + let store = FsLiveStateStore::new(&tmp.project_path()); + + let state = store.load().await.unwrap(); + assert!(state.entries.is_empty(), "no file ⇒ empty live-state"); + // load() must not have created the file. + assert!(!tmp.file_path().exists(), "load does not write"); +} + +#[tokio::test] +async fn upsert_round_trips_across_a_fresh_instance() { + let tmp = TempDir::new(); + { + let store = FsLiveStateStore::new(&tmp.project_path()); + store + .upsert(entry(1, "building", WorkStatus::Working, 100)) + .await + .unwrap(); + } + // A fresh instance on the same root relits the persisted row. + let store = FsLiveStateStore::new(&tmp.project_path()); + let state = store.load().await.unwrap(); + assert_eq!(state.entries.len(), 1); + assert_eq!(state.entries[0].intent, "building"); + assert_eq!(state.entries[0].status, WorkStatus::Working); + + // Persisted JSON is camelCase. + let raw = std::fs::read_to_string(tmp.file_path()).unwrap(); + assert!(raw.contains("\"agentId\""), "camelCase on disk: {raw}"); + assert!(raw.contains("\"updatedAtMs\"")); +} + +#[tokio::test] +async fn write_is_atomic_no_tmp_residue() { + let tmp = TempDir::new(); + let store = FsLiveStateStore::new(&tmp.project_path()); + store + .upsert(entry(1, "a", WorkStatus::Working, 1)) + .await + .unwrap(); + store + .upsert(entry(2, "b", WorkStatus::Idle, 2)) + .await + .unwrap(); + + assert!(tmp.file_path().exists(), "final file present"); + assert!( + !tmp.tmp_path().exists(), + "tmp file must be renamed away, not left behind" + ); +} + +#[tokio::test] +async fn upsert_is_last_writer_wins_persisted() { + let tmp = TempDir::new(); + let store = FsLiveStateStore::new(&tmp.project_path()); + + store + .upsert(entry(1, "first", WorkStatus::Working, 1)) + .await + .unwrap(); + store + .upsert(entry(1, "second", WorkStatus::Blocked, 2)) + .await + .unwrap(); + + let state = store.load().await.unwrap(); + assert_eq!(state.entries.len(), 1, "same agent ⇒ one row, no duplicate"); + assert_eq!(state.entries[0].intent, "second"); + assert_eq!(state.entries[0].status, WorkStatus::Blocked); +} + +#[tokio::test] +async fn prune_ttl_and_max_n_persisted() { + let tmp = TempDir::new(); + let store = FsLiveStateStore::new(&tmp.project_path()); + + // Three rows at t=10/20/30; now=50, ttl=35 ⇒ t=10 (age 40>35) dropped, + // t=20 (age 30) and t=30 (age 20) kept. + store + .upsert(entry(1, "old", WorkStatus::Idle, 10)) + .await + .unwrap(); + store + .upsert(entry(2, "mid", WorkStatus::Working, 20)) + .await + .unwrap(); + store + .upsert(entry(3, "new", WorkStatus::Working, 30)) + .await + .unwrap(); + + store.prune(50, 35, 100).await.unwrap(); + let after_ttl = store.load().await.unwrap(); + assert_eq!(after_ttl.entries.len(), 2, "TTL dropped the oldest"); + + // Now bound to 1 ⇒ keep the most recent (t=30). + store.prune(50, 35, 1).await.unwrap(); + let after_cap = store.load().await.unwrap(); + assert_eq!(after_cap.entries.len(), 1); + assert_eq!(after_cap.entries[0].updated_at_ms, 30); +} diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 0da2353..a98d560 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -513,6 +513,9 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() { "idea_context_propose", "idea_memory_read", "idea_memory_write", + // Live-state tools (programme live-state, lot LS4). + "idea_workstate_read", + "idea_workstate_set", ] { assert!( names.contains(&expected), @@ -521,8 +524,8 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() { } assert_eq!( tools.len(), - 12, - "exactly the twelve idea_* tools (7 base + idea_skill_read + 4 FileGuard C7); got {names:?}" + 14, + "exactly the fourteen idea_* tools (7 base + idea_skill_read + 4 FileGuard C7 + 2 live-state LS4); got {names:?}" ); // Every tool advertises an object input schema.