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:
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user