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:
2026-06-21 19:28:02 +02:00
parent 18401116aa
commit 9d46e6cd21
5 changed files with 467 additions and 42 deletions

View File

@ -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()),
}
}