fix(agents): enforcer l'invariant « 1 session vivante par agent » (singleton)

Un agent ne peut tourner que dans une seule cellule à la fois. La garde dans
LaunchAgent refuse le spawn si l'agent est déjà vivant dans un autre node
(AGENT_ALREADY_RUNNING) ; idempotent sur le même node ; le chemin resume
(agent mort) reste inchangé. Le node_id est désormais plombé jusqu'au use case.

Corrige le reset asymétrique d'une cellule au changement d'onglet : deux leaves
partageant le même agent id rendaient session_for_agent/is_agent_live/stop_agent
ambigus (cible arbitraire). Le churn reset/reattach déclenchait aussi les accents
mélangés (FIFO intact, non touché).

- snapshot agentWasRunning calculé par node (is_node_live) et non par agent
- commande list_live_agents + live_agents()/node_for_agent()/is_node_live()
- UI : dropdown grise les agents déjà placés ailleurs ; 2e cellule en doublon
  affiche « disponible » au lieu d'une relance fantôme

Tests : cargo test (application + app-tauri) vert ; tsc + vitest vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 14:43:48 +02:00
parent f3bc3f20d8
commit b39c11a64d
17 changed files with 830 additions and 29 deletions

View File

@ -121,23 +121,37 @@ impl ProjectStore for FakeStore {
}
}
/// A controllable liveness registry: an agent is "live" iff it is in the set.
/// A controllable liveness registry. Liveness is keyed on the hosting **node**
/// (the leaf), matching the snapshot's per-cell query: a leaf is "live" iff its
/// node id is in the set. An optional agent set backs the legacy
/// `is_agent_live` query (unused by the snapshot now, kept for the trait).
#[derive(Default, Clone)]
struct FakeLive(Arc<Mutex<HashSet<AgentId>>>);
struct FakeLive {
nodes: Arc<Mutex<HashSet<NodeId>>>,
agents: Arc<Mutex<HashSet<AgentId>>>,
}
impl FakeLive {
fn with(agents: &[AgentId]) -> Self {
Self(Arc::new(Mutex::new(agents.iter().copied().collect())))
/// Seeds the set of live **nodes** (the cells whose PTY is alive).
fn with_nodes(nodes: &[NodeId]) -> Self {
Self {
nodes: Arc::new(Mutex::new(nodes.iter().copied().collect())),
agents: Arc::new(Mutex::new(HashSet::new())),
}
}
/// Simulates a PTY kill: forget every live agent.
/// Simulates a PTY kill: forget every live node/agent.
fn kill_all(&self) {
self.0.lock().unwrap().clear();
self.nodes.lock().unwrap().clear();
self.agents.lock().unwrap().clear();
}
}
impl LiveAgentRegistry for FakeLive {
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
self.0.lock().unwrap().contains(agent_id)
self.agents.lock().unwrap().contains(agent_id)
}
fn is_node_live(&self, node_id: &NodeId) -> bool {
self.nodes.lock().unwrap().contains(node_id)
}
}
@ -235,7 +249,7 @@ async fn live_agent_is_marked_running() {
let agent = aid(100);
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
let live = FakeLive::with(&[agent]);
let live = FakeLive::with_nodes(&[leaf]);
let uc = make_use_case(&store, &fs, &live);
let out = uc
@ -307,7 +321,7 @@ async fn mixed_tree_flags_each_agent_independently() {
}));
seed_layouts(&fs, lid(1), &tree);
let live = FakeLive::with(&[live_agent]);
let live = FakeLive::with_nodes(&[live_leaf]);
let uc = make_use_case(&store, &fs, &live);
let out = uc
@ -335,7 +349,7 @@ async fn snapshot_reads_liveness_before_kill() {
let agent = aid(100);
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
let live = FakeLive::with(&[agent]);
let live = FakeLive::with_nodes(&[leaf]);
let uc = make_use_case(&store, &fs, &live);
// Correct order (composition root contract): snapshot THEN kill.
@ -387,3 +401,53 @@ async fn no_agent_leaf_is_noop() {
assert_eq!(fs.writes(), writes_before);
assert_eq!(was_running(&fs, leaf), Some(false));
}
/// Duplicate leaves for the **same** agent (the singleton-invariant case): only
/// the cell whose PTY is actually live is flagged running. The other leaf —
/// pinning the same agent but never launched (or refused by the guard) — stays
/// `false`. This is exactly why liveness is keyed on the node, not the agent:
/// an agent-keyed query would wrongly mark BOTH leaves as running.
#[tokio::test]
async fn duplicate_leaves_same_agent_only_live_node_is_running() {
let store = FakeStore::default();
let fs = FakeFs::default();
register_project(&store, pid(1)).await;
let leaf_a = nid(10);
let leaf_b = nid(11);
let agent = aid(100); // SAME agent on both leaves
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
id: nid(1),
direction: Direction::Row,
children: vec![
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(leaf_a, Some(agent))),
weight: 1.0,
},
WeightedChild {
node: LayoutNode::Leaf(agent_leaf(leaf_b, Some(agent))),
weight: 1.0,
},
],
}));
seed_layouts(&fs, lid(1), &tree);
// Only node A hosts a live session.
let live = FakeLive::with_nodes(&[leaf_a]);
let uc = make_use_case(&store, &fs, &live);
let out = uc
.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
.await
.unwrap();
assert_eq!(out.running, 1, "only the live cell counts as running");
assert_eq!(out.stopped, 1, "the duplicate (dead) cell counts as stopped");
assert_eq!(was_running(&fs, leaf_a), Some(true));
assert_eq!(
was_running(&fs, leaf_b),
Some(false),
"the duplicate leaf for the same agent must NOT be marked running"
);
}