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

@ -162,11 +162,19 @@ export function TerminalView({
};
const onOpenError = (e: unknown) => {
if (!disposed) {
if (disposed) return;
// The agent is already running in another cell (singleton invariant): this
// is NOT an error — the cell is simply available. Show a calm, muted notice
// (not the red failure line) so the user can pick another agent.
if (errorCode(e) === "AGENT_ALREADY_RUNNING") {
term.write(
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
`\r\n\x1b[2mCellule disponible — l'agent est en cours dans une autre cellule.\x1b[0m\r\n`,
);
return;
}
term.write(
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
);
};
// Re-attach to an existing live PTY when this cell already has a session;
@ -265,3 +273,11 @@ function describe(e: unknown): string {
}
return String(e);
}
/** Extracts a gateway error `code` when present (the Tauri/mock error shape). */
function errorCode(e: unknown): string | undefined {
if (e && typeof e === "object" && "code" in e) {
return String((e as { code: unknown }).code);
}
return undefined;
}