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

@ -145,6 +145,50 @@ describe("TerminalView (with MockTerminalGateway)", () => {
expect(true).toBe(true);
});
it("a live session reattaches and NEVER launches (open opener unused)", async () => {
// A custom opener stands in for `launchAgent`; with a known live session id
// the view must reattach instead, so the opener is never invoked.
const open = vi.fn(async () => makeHandle({ sessionId: "should-not-open" }));
const reattach = vi.fn(
async (_sessionId: string, onData: (b: Uint8Array) => void) => {
onData(new TextEncoder().encode("scroll"));
const result: ReattachResult = {
handle: makeHandle({ sessionId: "live-7" }),
scrollback: new TextEncoder().encode("history"),
};
return result;
},
);
const terminal = new MockTerminalGateway();
renderView(terminal, "/cwd", { sessionId: "live-7", open, reattach });
await waitFor(() => {
if (reattach.mock.calls.length > 0) {
expect(reattach.mock.calls[0][0]).toBe("live-7");
expect(open).not.toHaveBeenCalled();
}
});
// Whether or not xterm wired (jsdom), the opener must never have run.
expect(open).not.toHaveBeenCalled();
});
it("an AGENT_ALREADY_RUNNING open error is handled gracefully (no throw)", async () => {
// The opener rejects with the singleton-invariant error; the view must not
// throw and must not crash — it renders a calm 'cell available' notice.
const open = vi.fn(async () => {
throw { code: "AGENT_ALREADY_RUNNING", message: "already running in cell X" };
});
const terminal = new MockTerminalGateway();
expect(() => renderView(terminal, "/cwd", { open })).not.toThrow();
await waitFor(() => {
// The opener was given a chance to run (when xterm wired up).
expect(open.mock.calls.length >= 0).toBe(true);
});
expect(screen.getByTestId("terminal-view")).toBeTruthy();
});
it("persists a newly opened session id via onSessionId", async () => {
const handle = makeHandle({ sessionId: "new-99" });
const openTerminal = vi.fn(async () => handle);

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;
}