merge(live-state): intègre LS1→LS4 (modèle, store, auto-update, injection + outils MCP)

Incrément cohérent du programme live-state qui clôt le cold-start de coordination :
LS1 modèle+port, LS2 store FS+use cases, LS3 auto-update depuis le cycle de délégation,
LS4 injection « État du projet » bornée + outils MCP idea_workstate_read/set.
Tests verts sur domain/application/infrastructure/app-tauri. LS5+ continue sur la branche feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 08:10:25 +02:00
21 changed files with 2959 additions and 94 deletions

3
.gitignore vendored
View File

@ -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/

View File

@ -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**
/// (`<root>/.ideai/live-state.json`) et le store fixe sa racine à la construction. On
/// construit donc un `UpdateLiveState` frais par transition, ciblant le bon dossier.
/// Porte l'horloge (port [`Clock`]) injectée au composition root — `UpdateLiveState`
/// l'utilise pour estampiller `updated_at_ms`.
struct AppLiveStateProvider {
clock: Arc<dyn Clock>,
}
impl LiveStateProvider for AppLiveStateProvider {
fn live_state_for(&self, root: &domain::project::ProjectPath) -> Option<Arc<UpdateLiveState>> {
let store = Arc::new(FsLiveStateStore::new(root));
Some(Arc::new(UpdateLiveState::new(
store,
Arc::clone(&self.clock),
)))
}
}
/// Implémente [`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** (`<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<dyn Clock>,
}
impl AppLiveStateLeanProvider {
fn getter(&self, root: &domain::project::ProjectPath) -> Arc<GetLiveStateLean> {
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<Arc<GetLiveStateLean>> {
Some(self.getter(root))
}
}
impl LiveStateReadProvider for AppLiveStateLeanProvider {
fn live_state_lean_for(
&self,
root: &domain::project::ProjectPath,
) -> Option<Arc<GetLiveStateLean>> {
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<dyn Clock>,
}) as Arc<dyn LiveStateLeanProvider>),
);
// 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 (`<root>/.ideai/live-state.json`) car le port fixe sa
// racine à la construction. Best-effort strict : un échec n'altère jamais la
// délégation.
.with_live_state(Arc::new(AppLiveStateProvider {
clock: Arc::clone(&clock) as Arc<dyn Clock>,
}) as Arc<dyn LiveStateProvider>)
// 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<dyn Clock>,
}) as Arc<dyn LiveStateReadProvider>),
// 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<domain::live_state::LiveState>,
}
#[async_trait]
impl domain::ports::LiveStateStore for InMemLiveStore {
async fn load(&self) -> Result<domain::live_state::LiveState, StoreError> {
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<application::UpdateLiveState>,
}
impl application::LiveStateProvider for WsWriteProvider {
fn live_state_for(&self, _root: &ProjectPath) -> Option<Arc<application::UpdateLiveState>> {
Some(Arc::clone(&self.update))
}
}
struct WsReadProvider {
getter: Arc<application::GetLiveStateLean>,
}
impl application::LiveStateReadProvider for WsReadProvider {
fn live_state_lean_for(
&self,
_root: &ProjectPath,
) -> Option<Arc<application::GetLiveStateLean>> {
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<OrchestratorService> {
let store = Arc::new(InMemLiveStore::default()) as Arc<dyn domain::ports::LiveStateStore>;
let clock = Arc::new(FixedClock) as Arc<dyn Clock>;
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<dyn application::LiveStateProvider>)
.with_live_state_read(read as Arc<dyn application::LiveStateReadProvider>);
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.

View File

@ -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<Arc<dyn ProviderSessionStore>>;
}
/// 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**
/// (`<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<Arc<GetLiveStateLean>>;
}
// ---------------------------------------------------------------------------
// 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<Arc<PermissionProjectorRegistry>>,
/// 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<Arc<dyn LiveStateLeanProvider>>,
}
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<dyn LiveStateLeanProvider>) -> 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<InjectedLiveRow> {
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<AgentId, &crate::workstate::LeanLiveEntry> =
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

View File

@ -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,

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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<Arc<RecordTurn>>;
}
/// Provider **par project root** d'un [`UpdateLiveState`] (lot LS3), calqué sur
/// [`RecordTurnProvider`].
///
/// Même tension résolue de la même façon : l'[`OrchestratorService`] est **unique**
/// pour tous les projets, mais le live-state est **par project root**
/// (`<root>/.ideai/live-state.json`) — et le port [`domain::ports::LiveStateStore`]
/// fixe son root à la construction (pas de paramètre `root` par appel, contrairement
/// au `MemoryStore` du harvest). `ask_agent` connaît le `project.root` du tour et
/// demande au provider de matérialiser un `UpdateLiveState` ciblant le **bon** dossier
/// (les adapters `Fs*` ne font que des jointures de chemin, leur construction est
/// triviale).
///
/// `None` ⇒ aucun auto-update : zéro régression. Implémenté dans app-tauri (seul
/// détenteur des adapters `Fs*` et de l'horloge).
pub trait LiveStateProvider: Send + Sync {
/// Construit l'[`UpdateLiveState`] dont le store cible `root`. Appelé une fois par
/// transition best-effort ; `None` ⇒ on saute silencieusement l'auto-update.
fn live_state_for(&self, root: &ProjectPath) -> Option<Arc<UpdateLiveState>>;
}
/// 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<Arc<GetLiveStateLean>>;
}
/// Dispatches validated orchestrator commands to the agent/terminal use cases.
pub struct OrchestratorService {
create_agent: Arc<CreateAgentFromScratch>,
@ -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<Arc<ReadSkill>>,
/// Auto-update du **live-state** (programme live-state, lot LS3), dérivé des
/// transitions de délégation déjà existantes (zéro token agent, zéro appel
/// modèle) : la cible passe `Working` quand un `ask` est accepté, puis `Done`
/// (+ `last_delegation`) quand elle rend son résultat. Injecté via
/// [`Self::with_live_state`] ; `None` (défaut) ⇒ aucun auto-update (zéro
/// régression). **Best-effort strict** : un échec de persistance ne transforme
/// **jamais** un succès de délégation/reply en erreur.
live_state: Option<Arc<dyn LiveStateProvider>>,
/// 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<Arc<dyn LiveStateReadProvider>>,
}
/// 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<dyn LiveStateProvider>) -> 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<dyn LiveStateReadProvider>) -> 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::<Vec<_>>()
.join(" ")
.chars()
.take(domain::live_state::FIELD_PREVIEW_MAX_CHARS)
.collect()
}
/// Pose **best-effort** la cible d'une délégation acceptée en `Working` sur le
/// live-state (lot LS3). No-op silencieux quand l'auto-update n'est pas câblé.
/// Tout échec est avalé (un diag, jamais une erreur propagée) : l'auto-update
/// dérivé ne doit **jamais** faire échouer une délégation. N'écrit **que** sur
/// cette transition d'`ask` (jamais par tour/outil) ⇒ pas de write-storm.
async fn mark_target_working_best_effort(
&self,
root: &ProjectPath,
target: AgentId,
ticket: TicketId,
intent: String,
) {
let Some(provider) = &self.live_state else {
return;
};
let Some(update) = provider.live_state_for(root) else {
return;
};
let outcome = update
.execute(UpdateLiveStateInput {
agent_id: target,
ticket: Some(ticket),
intent,
status: domain::live_state::WorkStatus::Working,
progress: None,
last_delegation: None,
})
.await;
if let Err(e) = outcome {
crate::diag!("[live-state] working auto-update failed: target={target} err={e}");
}
}
/// Pose **best-effort** la cible en `Done` (+ `last_delegation` = ticket résolu)
/// sur le live-state (lot LS3), quand elle vient de rendre son résultat. Mêmes
/// garanties best-effort/no-storm que [`Self::mark_target_working_best_effort`].
async fn mark_target_done_best_effort(
&self,
root: &ProjectPath,
target: AgentId,
ticket: TicketId,
) {
let Some(provider) = &self.live_state else {
return;
};
let Some(update) = provider.live_state_for(root) else {
return;
};
let outcome = update
.execute(UpdateLiveStateInput {
agent_id: target,
ticket: Some(ticket),
intent: String::new(),
status: domain::live_state::WorkStatus::Done,
progress: None,
last_delegation: Some(ticket),
})
.await;
if let Err(e) = outcome {
crate::diag!("[live-state] done auto-update failed: target={target} err={e}");
}
}
/// Persiste **best-effort** un tour (`Prompt`/`Response`) dans `conversation`.
///
/// 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<OrchestratorOutcome, AppError> {
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<AgentId, String> =
listed.agents.into_iter().map(|a| (a.id, a.name)).collect();
let rows: Vec<serde_json::Value> = 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<String>,
progress: Option<String>,
ticket: Option<TicketId>,
last_delegation: Option<TicketId>,
) -> Result<OrchestratorOutcome, AppError> {
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))
}

View File

@ -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<TicketId>,
/// 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<String>,
/// The ticket of the most recent delegation this agent issued, if any.
pub last_delegation: Option<TicketId>,
}
/// Publishes (inserts or replaces) an agent's live-state row.
pub struct UpdateLiveState {
store: Arc<dyn LiveStateStore>,
clock: Arc<dyn Clock>,
}
impl UpdateLiveState {
/// Builds the use case from the injected store and clock ports.
#[must_use]
pub fn new(store: Arc<dyn LiveStateStore>, clock: Arc<dyn Clock>) -> 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<TicketId>,
/// The ticket of the most recent delegation it issued, if any.
pub last_delegation: Option<TicketId>,
}
impl From<LiveEntry> 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<LeanLiveEntry>,
}
impl From<LiveState> 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<dyn LiveStateStore>,
clock: Arc<dyn Clock>,
}
impl GetLiveStateLean {
/// Builds the use case from the injected store and clock ports.
#[must_use]
pub fn new(store: Arc<dyn LiveStateStore>, clock: Arc<dyn Clock>) -> 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<LeanLiveState, AppError> {
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<LiveState>,
}
#[async_trait::async_trait]
impl LiveStateStore for InMemoryLiveStateStore {
async fn load(&self) -> Result<LiveState, StoreError> {
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);
}
}

View File

@ -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;

View File

@ -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<domain::live_state::LiveState>,
}
#[async_trait]
impl domain::ports::LiveStateStore for SeededLiveStore {
async fn load(&self) -> Result<domain::live_state::LiveState, domain::ports::StoreError> {
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<application::GetLiveStateLean>,
}
impl application::LiveStateLeanProvider for SeededLeanProvider {
fn live_state_lean_for(
&self,
_root: &ProjectPath,
) -> Option<Arc<application::GetLiveStateLean>> {
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<dyn domain::ports::LiveStateStore>,
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<dyn application::LiveStateLeanProvider>);
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}");
}

View File

@ -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<LiveState>,
upserts: Mutex<usize>,
fail: bool,
}
impl RecordingLiveState {
fn entry_for(&self, agent: AgentId) -> Option<LiveEntry> {
self.state
.lock()
.unwrap()
.entries
.iter()
.find(|e| e.agent_id == agent)
.cloned()
}
fn upsert_count(&self) -> usize {
*self.upserts.lock().unwrap()
}
}
#[async_trait]
impl LiveStateStore for RecordingLiveState {
async fn load(&self) -> Result<LiveState, StoreError> {
if self.fail {
return Err(StoreError::Io("forced failure".to_owned()));
}
Ok(self.state.lock().unwrap().clone())
}
async fn upsert(&self, entry: LiveEntry) -> Result<(), StoreError> {
if self.fail {
return Err(StoreError::Io("forced failure".to_owned()));
}
*self.upserts.lock().unwrap() += 1;
self.state.lock().unwrap().upsert(entry);
Ok(())
}
async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError> {
if self.fail {
return Err(StoreError::Io("forced failure".to_owned()));
}
self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n);
Ok(())
}
}
/// A [`LiveStateProvider`] that always yields the **same** [`UpdateLiveState`] over a
/// shared recording store (root-agnostic for the test).
struct TestLiveStateProvider {
update: Arc<UpdateLiveState>,
}
impl LiveStateProvider for TestLiveStateProvider {
fn live_state_for(&self, _root: &ProjectPath) -> Option<Arc<UpdateLiveState>> {
Some(Arc::clone(&self.update))
}
}
/// 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<GetLiveStateLean>,
}
impl LiveStateReadProvider for TestLiveStateReadProvider {
fn live_state_lean_for(&self, _root: &ProjectPath) -> Option<Arc<GetLiveStateLean>> {
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<u128>);
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<HarvestMemories>,
/// The live-state store wired into the auto-update hook (lot LS3): lets a test
/// assert the target's Working/Done transitions and the upsert count.
live: Arc<RecordingLiveState>,
}
/// Builds an orchestrator wired with a real [`InMemoryMailbox`] + PTY (B-3) and a
/// 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,8 +1281,7 @@ fn ask_fixture_with_memory(contexts: FakeContexts, fail_memory: bool) -> AskFixt
Arc::new(pty.clone()) as Arc<dyn PtyPort>,
));
let conversations = Arc::new(TestConversations::new()) as Arc<dyn ConversationRegistry>;
let service = Arc::new(
OrchestratorService::new(
let mut builder = OrchestratorService::new(
create,
launch,
list,
@ -1190,8 +1300,24 @@ fn ask_fixture_with_memory(contexts: FakeContexts, fail_memory: bool) -> AskFixt
.with_memory_harvest(Arc::new(HarvestMemoryFromTurn::new(
Arc::clone(&memories) as Arc<dyn MemoryStore>,
Arc::new(bus.clone()),
))),
);
)));
if wire_live {
builder = builder.with_live_state(Arc::new(TestLiveStateProvider {
update: Arc::new(UpdateLiveState::new(
Arc::clone(&live) as Arc<dyn LiveStateStore>,
Arc::new(FixedMillisClock(1_234)) as Arc<dyn Clock>,
)),
}) as Arc<dyn LiveStateProvider>);
// 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<dyn LiveStateStore>,
Arc::new(FixedMillisClock(1_234)) as Arc<dyn Clock>,
)),
}) as Arc<dyn LiveStateReadProvider>);
}
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()),
}
}

View File

@ -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,
};

View File

@ -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<Self> {
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<TicketId>,
/// 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<String>,
/// The ticket of the most recent delegation this agent issued, if any.
pub last_delegation: Option<TicketId>,
/// 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<TicketId>,
intent: impl Into<String>,
status: WorkStatus,
progress: Option<String>,
last_delegation: Option<TicketId>,
updated_at_ms: u64,
) -> Result<Self, DomainError> {
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<String, DomainError> {
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<LiveEntry>,
}
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<u64> = 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");
}
}

View File

@ -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<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
}
/// 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<String>,
/// Optional finer-grained progress note (domain-bounded on write).
progress: Option<String>,
/// The ticket the agent is currently handling, if any.
ticket: Option<TicketId>,
/// The ticket of the most recent delegation it issued, if any.
last_delegation: Option<TicketId>,
},
}
/// 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<Option<TicketId>, 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<Option<TicketId>, 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<WorkStatus, OrchestratorError> {
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<String> {
@ -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" }"#);

View File

@ -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<crate::live_state::LiveState, StoreError>;
/// 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.

View File

@ -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,
};

View File

@ -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<ToolDef> {
"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"
);
}
}

View File

@ -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
//! <project_root>/.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 `<root>/.ideai` en
/// dérive, et le fichier est créé paresseusement à la première écriture.
pub struct FsLiveStateStore {
/// Racine `<project_root>/.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 }
}
/// `<root>/.ideai/live-state.json` — le fichier cible.
fn path(&self) -> PathBuf {
self.dir.join(LIVE_STATE_FILE)
}
/// `<root>/.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<LiveState, StoreError> {
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<LiveState, StoreError> {
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
}
}

View File

@ -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};

View File

@ -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);
}

View File

@ -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.