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

@ -72,6 +72,12 @@ export interface ConversationDetails {
export interface AgentGateway {
/** Lists all agents belonging to the given project. */
listAgents(projectId: string): Promise<Agent[]>;
/**
* Lists the agents that currently own a live session and the cell hosting
* each. Used to disable an agent already running in another cell (it cannot be
* launched a second time — one live session per agent).
*/
listLiveAgents(projectId: string): Promise<LiveAgent[]>;
/** Creates a new agent from scratch; returns the created agent. */
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent>;
/** Reads an agent's `.md` context by agent id. */
@ -135,6 +141,21 @@ export interface OpenTerminalOptions {
* meaningful for {@link AgentGateway.launchAgent}; ignored for plain terminals.
*/
conversationId?: string;
/**
* The layout leaf (node) hosting this launch. Drives the "one live session per
* agent" invariant backend-side: launching an agent already running in a
* *different* node is refused (`AGENT_ALREADY_RUNNING`); the same node is
* idempotent. Only meaningful for {@link AgentGateway.launchAgent}.
*/
nodeId?: string;
}
/** One currently-live agent and the cell hosting it (see {@link AgentGateway.listLiveAgents}). */
export interface LiveAgent {
/** The live agent's id. */
agentId: string;
/** The node (layout leaf) hosting the agent's live session. */
nodeId: string;
}
/**