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

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