feat(app): auto-update live-state depuis le cycle de délégation (LS3)
Branche la mise à jour automatique du live-state sur le cycle de délégation de l'orchestrateur, en best-effort non bloquant. - Trait `LiveStateProvider` (résolution par root) + champ `Option` dans l'`OrchestratorService`, injecté via le builder `with_live_state` ; `None` ⇒ aucun effet (zéro régression). - Transitions dérivées du cycle : `ask` → entrée `Working`, `reply` → `Done` + `last_delegation`. - Helpers best-effort : un échec de mise à jour du live-state ne transforme jamais un succès de délégation en erreur. - `AppLiveStateProvider` + wiring côté app-tauri (provider par root). cargo test -p application : 0 échec ; --test orchestrator_service : 55/0 (dont 4 nouveaux tests LS3) ; cargo fmt --all --check : exit 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -40,8 +40,11 @@ use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
CloseTerminal, CreateAgentFromScratch, CreateSkill, HarvestMemoryFromTurn, LaunchAgent,
|
||||
ListAgents, OrchestratorService, TerminalSessions, UpdateAgentContext,
|
||||
ListAgents, LiveStateProvider, OrchestratorService, TerminalSessions, UpdateAgentContext,
|
||||
UpdateLiveState,
|
||||
};
|
||||
use domain::live_state::{LiveEntry, LiveState, WorkStatus};
|
||||
use domain::ports::LiveStateStore;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes (mirror agent_lifecycle.rs)
|
||||
@ -431,6 +434,73 @@ impl MemoryStore for HarvestMemories {
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory [`LiveStateStore`] for the live-state auto-update hook (lot LS3): keeps a
|
||||
/// keyed last-writer-wins [`LiveState`] and counts upserts (to prove no write-storm),
|
||||
/// with an optional forced failure to prove the auto-update stays best-effort.
|
||||
#[derive(Default)]
|
||||
struct RecordingLiveState {
|
||||
state: Mutex<LiveState>,
|
||||
upserts: Mutex<usize>,
|
||||
fail: bool,
|
||||
}
|
||||
impl RecordingLiveState {
|
||||
fn entry_for(&self, agent: AgentId) -> Option<LiveEntry> {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.agent_id == agent)
|
||||
.cloned()
|
||||
}
|
||||
fn upsert_count(&self) -> usize {
|
||||
*self.upserts.lock().unwrap()
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl LiveStateStore for RecordingLiveState {
|
||||
async fn load(&self) -> Result<LiveState, StoreError> {
|
||||
if self.fail {
|
||||
return Err(StoreError::Io("forced failure".to_owned()));
|
||||
}
|
||||
Ok(self.state.lock().unwrap().clone())
|
||||
}
|
||||
async fn upsert(&self, entry: LiveEntry) -> Result<(), StoreError> {
|
||||
if self.fail {
|
||||
return Err(StoreError::Io("forced failure".to_owned()));
|
||||
}
|
||||
*self.upserts.lock().unwrap() += 1;
|
||||
self.state.lock().unwrap().upsert(entry);
|
||||
Ok(())
|
||||
}
|
||||
async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError> {
|
||||
if self.fail {
|
||||
return Err(StoreError::Io("forced failure".to_owned()));
|
||||
}
|
||||
self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`LiveStateProvider`] that always yields the **same** [`UpdateLiveState`] over a
|
||||
/// shared recording store (root-agnostic for the test).
|
||||
struct TestLiveStateProvider {
|
||||
update: Arc<UpdateLiveState>,
|
||||
}
|
||||
impl LiveStateProvider for TestLiveStateProvider {
|
||||
fn live_state_for(&self, _root: &ProjectPath) -> Option<Arc<UpdateLiveState>> {
|
||||
Some(Arc::clone(&self.update))
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed millis clock for deterministic `updated_at_ms`.
|
||||
struct FixedMillisClock(i64);
|
||||
impl Clock for FixedMillisClock {
|
||||
fn now_millis(&self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
struct SeqIds(Mutex<u128>);
|
||||
impl SeqIds {
|
||||
fn new() -> Self {
|
||||
@ -1114,18 +1184,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 +1231,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 +1270,35 @@ 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>);
|
||||
}
|
||||
let service = Arc::new(builder);
|
||||
|
||||
AskFixture {
|
||||
service,
|
||||
@ -1201,6 +1308,7 @@ fn ask_fixture_with_memory(contexts: FakeContexts, fail_memory: bool) -> AskFixt
|
||||
pty,
|
||||
mediator,
|
||||
memories,
|
||||
live,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1302,6 +1410,147 @@ async fn ask_live_pty_target_writes_task_and_returns_reply() {
|
||||
assert_eq!(fx.mailbox.pending(&aid(1)), 0, "ticket drained on reply");
|
||||
}
|
||||
|
||||
// --- LS3: live-state auto-update from the delegation cycle ------------------
|
||||
|
||||
/// A successful `ask` flips the **target** to `Working` with the distilled intent and
|
||||
/// the current ticket — derived from the existing delegation transition (zéro token).
|
||||
#[tokio::test]
|
||||
async fn ask_sets_target_working_with_intent_and_ticket() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
|
||||
// The Working transition is posted right after the enqueue, before any reply.
|
||||
await_until(|| fx.live.entry_for(aid(1)).is_some()).await;
|
||||
let entry = fx
|
||||
.live
|
||||
.entry_for(aid(1))
|
||||
.expect("target live entry present");
|
||||
assert_eq!(entry.status, WorkStatus::Working);
|
||||
assert_eq!(
|
||||
entry.intent, "Analyse §17",
|
||||
"intent distilled from the task"
|
||||
);
|
||||
assert!(entry.ticket.is_some(), "current ticket recorded");
|
||||
assert!(
|
||||
entry.last_delegation.is_none(),
|
||||
"no delegation resolved yet"
|
||||
);
|
||||
// The live-state path does not touch the memory store (canonical effects separate).
|
||||
assert!(
|
||||
fx.memories.slugs().is_empty(),
|
||||
"no memory write on this path"
|
||||
);
|
||||
|
||||
// Drain the ask so the spawned task completes cleanly.
|
||||
fx.service
|
||||
.dispatch(&project(), reply_cmd(aid(1), "done"))
|
||||
.await
|
||||
.expect("reply ok");
|
||||
timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
.expect("ask completes")
|
||||
.expect("join ok")
|
||||
.expect("ask ok");
|
||||
}
|
||||
|
||||
/// A successful `reply` flips the target to `Done` and records `last_delegation` = the
|
||||
/// resolved ticket.
|
||||
#[tokio::test]
|
||||
async fn reply_sets_target_done_with_last_delegation() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
|
||||
fx.service
|
||||
.dispatch(&project(), reply_cmd(aid(1), "the answer"))
|
||||
.await
|
||||
.expect("reply ok");
|
||||
timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
.expect("ask completes")
|
||||
.expect("join ok")
|
||||
.expect("ask ok");
|
||||
|
||||
let entry = fx
|
||||
.live
|
||||
.entry_for(aid(1))
|
||||
.expect("target live entry present");
|
||||
assert_eq!(
|
||||
entry.status,
|
||||
WorkStatus::Done,
|
||||
"target marked Done on reply"
|
||||
);
|
||||
assert!(
|
||||
entry.last_delegation.is_some(),
|
||||
"last_delegation set to the resolved ticket"
|
||||
);
|
||||
// Working then Done ⇒ exactly two upserts for one delegation (no write-storm).
|
||||
assert_eq!(
|
||||
fx.live.upsert_count(),
|
||||
2,
|
||||
"one Working + one Done write per delegation"
|
||||
);
|
||||
}
|
||||
|
||||
/// A live-state store that fails must NOT fail the `ask`/`reply`: the delegation
|
||||
/// still returns its reply (best-effort hook, exactly like the memory harvest).
|
||||
#[tokio::test]
|
||||
async fn live_state_failure_does_not_break_delegation() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture_with_live(FakeContexts::with_agent(&agent, "# persona"), true);
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
|
||||
fx.service
|
||||
.dispatch(&project(), reply_cmd(aid(1), "still works"))
|
||||
.await
|
||||
.expect("reply ok despite failing live-state");
|
||||
let out = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
.expect("ask completes")
|
||||
.expect("join ok")
|
||||
.expect("ask ok despite failing live-state");
|
||||
assert_eq!(out.reply.as_deref(), Some("still works"));
|
||||
// The failing store recorded nothing.
|
||||
assert!(fx.live.entry_for(aid(1)).is_none());
|
||||
}
|
||||
|
||||
/// When the live-state hook is **unwired** (`None`), the delegation behaves exactly as
|
||||
/// before — no live-state write at all (zéro régression).
|
||||
#[tokio::test]
|
||||
async fn no_live_state_wired_means_no_write() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = ask_fixture_without_live(FakeContexts::with_agent(&agent, "# persona"));
|
||||
seed_live_pty(&fx.sessions, aid(1), sid(800));
|
||||
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
fx.service
|
||||
.dispatch(&project(), reply_cmd(aid(1), "ok"))
|
||||
.await
|
||||
.expect("reply ok");
|
||||
timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
.expect("ask completes")
|
||||
.expect("join ok")
|
||||
.expect("ask ok");
|
||||
|
||||
assert_eq!(fx.live.upsert_count(), 0, "no write when hook is None");
|
||||
assert!(fx.live.entry_for(aid(1)).is_none());
|
||||
}
|
||||
|
||||
/// The target returned to its prompt **without** `idea_reply`: the mediator's grace
|
||||
/// 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 +3249,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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user