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:
3
.gitignore
vendored
3
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
|
||||
343
crates/application/src/workstate/live.rs
Normal file
343
crates/application/src/workstate/live.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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}");
|
||||
}
|
||||
|
||||
@ -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,28 +1281,43 @@ 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(
|
||||
create,
|
||||
launch,
|
||||
list,
|
||||
close,
|
||||
update,
|
||||
create_skill,
|
||||
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
||||
Arc::clone(&sessions),
|
||||
)
|
||||
.with_input_mediator(
|
||||
Arc::clone(&mediator) as Arc<dyn InputMediator>,
|
||||
Arc::clone(&mailbox) as Arc<dyn AgentMailbox>,
|
||||
)
|
||||
.with_conversations(conversations)
|
||||
.with_events(Arc::new(bus.clone()))
|
||||
.with_memory_harvest(Arc::new(HarvestMemoryFromTurn::new(
|
||||
Arc::clone(&memories) as Arc<dyn MemoryStore>,
|
||||
Arc::new(bus.clone()),
|
||||
))),
|
||||
);
|
||||
let mut builder = OrchestratorService::new(
|
||||
create,
|
||||
launch,
|
||||
list,
|
||||
close,
|
||||
update,
|
||||
create_skill,
|
||||
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
||||
Arc::clone(&sessions),
|
||||
)
|
||||
.with_input_mediator(
|
||||
Arc::clone(&mediator) as Arc<dyn InputMediator>,
|
||||
Arc::clone(&mailbox) as Arc<dyn AgentMailbox>,
|
||||
)
|
||||
.with_conversations(conversations)
|
||||
.with_events(Arc::new(bus.clone()))
|
||||
.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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
391
crates/domain/src/live_state.rs
Normal file
391
crates/domain/src/live_state.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -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" }"#);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
128
crates/infrastructure/src/store/live_state.rs
Normal file
128
crates/infrastructure/src/store/live_state.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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};
|
||||
|
||||
157
crates/infrastructure/tests/live_state_store.rs
Normal file
157
crates/infrastructure/tests/live_state_store.rs
Normal 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);
|
||||
}
|
||||
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user