feat(domain,app,infra): injection « État du projet » + outils MCP workstate (LS4)

Clôt le cold-start de coordination du programme live-state :
- domain : WorkStatus::parse/label, commands ReadWorkState/SetWorkState
  (request status/intent/progress/lastDelegation), erreur UnknownWorkStatus + validate.
- application : section bornée « # État du projet » injectée au lancement
  (port LiveStateLeanProvider, InjectedLiveRow, LIVE_STATE_INJECT_MAX, resolve_live_state) ;
  LiveStateReadProvider + read_workstate/set_workstate câblés au dispatch.
- infrastructure : 2 ToolDef idea_workstate_read / idea_workstate_set (mapping,
  tool_returns_reply), compteur d'outils MCP 12→14.
- app-tauri : providers câblés, garde du nombre d'outils 12→14.
- tests (QA, verts) : domain/orchestrator, application lifecycle + orchestrator_service,
  infrastructure mcp_server, app-tauri state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 08:09:57 +02:00
parent 9d46e6cd21
commit 533a9c57f3
12 changed files with 1503 additions and 68 deletions

View File

@ -39,9 +39,9 @@ use domain::{OrchestratorCommand, OrchestratorRequest, PtySize, SessionId};
use uuid::Uuid;
use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, HarvestMemoryFromTurn, LaunchAgent,
ListAgents, LiveStateProvider, OrchestratorService, TerminalSessions, UpdateAgentContext,
UpdateLiveState,
CloseTerminal, CreateAgentFromScratch, CreateSkill, GetLiveStateLean, HarvestMemoryFromTurn,
LaunchAgent, ListAgents, LiveStateProvider, LiveStateReadProvider, OrchestratorService,
TerminalSessions, UpdateAgentContext, UpdateLiveState,
};
use domain::live_state::{LiveEntry, LiveState, WorkStatus};
use domain::ports::LiveStateStore;
@ -493,6 +493,17 @@ impl LiveStateProvider for TestLiveStateProvider {
}
}
/// 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 {
@ -1297,6 +1308,14 @@ fn ask_fixture_full(
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);
@ -1551,6 +1570,178 @@ async fn no_live_state_wired_means_no_write() {
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