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

@ -38,7 +38,7 @@ use domain::{PtySize, SessionId, SkillId, SkillRef};
use uuid::Uuid;
use application::{
CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
AppError, CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
LaunchAgentInput, ListAgents, ListAgentsInput, ReadAgentContext, ReadAgentContextInput,
TerminalSessions, UpdateAgentContext, UpdateAgentContextInput,
};
@ -835,6 +835,137 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
);
}
/// Inserts a live agent session into the registry, simulating an already-running
/// agent pinned on `node`. Returns the session id.
fn seed_live_agent_session(
sessions: &TerminalSessions,
agent_id: AgentId,
node: domain::NodeId,
session_id: SessionId,
) {
let size = PtySize::new(24, 80).unwrap();
let mut session = domain::TerminalSession::starting(
session_id,
node,
ProjectPath::new("/home/me/proj/.ideai/run/x").unwrap(),
domain::SessionKind::Agent { agent_id },
size,
);
session.status = domain::SessionStatus::Running;
sessions.insert(PtyHandle { session_id }, session);
}
fn nid(n: u128) -> domain::NodeId {
domain::NodeId::from_uuid(Uuid::from_u128(n))
}
/// **Singleton invariant (T1)**: launching an agent that already owns a live
/// session in *another* cell is refused with `AgentAlreadyRunning`, pointing at
/// the existing host node. The registry is left untouched and the PTY is never
/// spawned.
#[tokio::test]
async fn launch_refuses_agent_already_running_elsewhere() {
let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture(
ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
);
// The agent is already live in cell A.
let host = nid(1);
seed_live_agent_session(&sessions, agent.id, host, sid(42));
let before = sessions.len();
// Attempt to launch the same agent into a *different* cell B.
let mut input = launch_input(agent.id);
input.node_id = Some(nid(2));
let err = launch.execute(input).await.expect_err("must be refused");
match err {
AppError::AgentAlreadyRunning { agent_id, node_id } => {
assert_eq!(agent_id, agent.id);
assert_eq!(node_id, host, "reports the existing host cell");
}
other => panic!("expected AgentAlreadyRunning, got {other:?}"),
}
assert_eq!(err.code(), "AGENT_ALREADY_RUNNING");
// Invariant preserved: registry unchanged, no spawn happened.
assert_eq!(sessions.len(), before, "registry must be unchanged");
assert!(pty.spawns().is_empty(), "no PTY spawn on a refused launch");
}
/// A launch with an unspecified node (`node_id: None`) for an already-live agent
/// is also refused — it cannot be the same cell.
#[tokio::test]
async fn launch_refuses_already_running_agent_with_no_node() {
let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture(
ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
);
seed_live_agent_session(&sessions, agent.id, nid(1), sid(42));
// node_id defaults to None in launch_input.
let err = launch
.execute(launch_input(agent.id))
.await
.expect_err("must be refused");
assert!(matches!(err, AppError::AgentAlreadyRunning { .. }));
assert!(pty.spawns().is_empty());
}
/// Idempotence on the SAME node: a double-launch of the very same cell returns
/// the already-registered session without respawning, and assigns no new
/// conversation id.
#[tokio::test]
async fn launch_same_node_is_idempotent_no_respawn() {
let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture(
ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
);
let host = nid(7);
seed_live_agent_session(&sessions, agent.id, host, sid(42));
let before = sessions.len();
let mut input = launch_input(agent.id);
input.node_id = Some(host);
let out = launch.execute(input).await.expect("idempotent launch");
assert_eq!(out.session.id, sid(42), "returns the existing session");
assert!(out.assigned_conversation_id.is_none(), "nothing new assigned");
assert_eq!(sessions.len(), before, "no new session registered");
assert!(pty.spawns().is_empty(), "no respawn on same-node relaunch");
}
/// After the live session is removed (cell closed / agent exited), a fresh launch
/// succeeds — the guard only blocks while the agent is actually live.
#[tokio::test]
async fn launch_succeeds_after_session_removed() {
let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture(
ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
);
// Live, then removed (close/exit).
seed_live_agent_session(&sessions, agent.id, nid(1), sid(42));
sessions.remove(&sid(42));
assert!(sessions.session_for_agent(&agent.id).is_none());
let mut input = launch_input(agent.id);
input.node_id = Some(nid(2));
let out = launch.execute(input).await.expect("relaunch must succeed");
assert_eq!(out.session.id, sid(777), "spawned a fresh PTY session");
assert_eq!(pty.spawns().len(), 1, "exactly one spawn after the agent died");
}
/// **Anti-collision (ARCHITECTURE §14.1)**: two distinct agents of the *same*
/// profile on the *same* project root must launch into two **distinct** cwd —
/// each its own `.ideai/run/<agent-id>/` — and each writes its convention file