feat(agent): robustesse routage ask — fix registre session + concurrence (R0+A0) — §14.3/§15
Stabilise le routage de `ask` avant le bind transport MCP (v5). Invariant « 1 agent = 1 employé » durci ; un agent traite un tour à la fois. - R0a garde LaunchAgent : lève AgentAlreadyRunning pour un lancement neuf ciblant un agent déjà vivant sur un autre node (PTY + structuré) ; rebind seulement même-node ou réattache explicite (conversation_id). Idem spawn_agent. - R0b list_live_agents agrège PTY + structuré (LiveSessions) + dédup. - R0c réconciliation des layouts.json à doublons à l'ouverture (host déterministe, idempotent) — corrige « une cellule reset au retour d'onglet ». - R0d UI : option agent désactivée si vivant ailleurs + « aller à la cellule », mapping AGENT_ALREADY_RUNNING. - A0 sérialisation FIFO des tours par agent_id dans ask_agent (verrou tokio par agent ; agents différents en parallèle ; timeout tour 300s, cap attente 600s). Cadrage : .ideai/briefs/orchestration-v5-transport-bind-cadrage.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
140
frontend/src/features/layout/agentAlreadyRunning.test.tsx
Normal file
140
frontend/src/features/layout/agentAlreadyRunning.test.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
/**
|
||||
* R0d — launch-path mapping of the backend singleton refusal.
|
||||
*
|
||||
* When `launch_agent` is refused with `AGENT_ALREADY_RUNNING` (the agent is a
|
||||
* singleton already alive in another cell), the hosting leaf must surface a
|
||||
* clear notice — never a raw error — together with an "aller à la cellule"
|
||||
* action that focuses the live host. The host cell is resolved from the live
|
||||
* agents set (the R0b source of truth: PTY *and* chat), not by parsing the
|
||||
* error message.
|
||||
*
|
||||
* The terminal opener that drives the launch only runs once xterm's `open`
|
||||
* succeeds; under jsdom the real xterm bails, so — exactly like
|
||||
* `LayoutGrid.chat.test.tsx` — we stub *only* xterm (the headless obstacle),
|
||||
* leaving the leaf's launch + error-mapping logic untouched.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
|
||||
// Minimal xterm stub so `term.open` succeeds and the opener (→ launchAgent) runs.
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
Terminal: class {
|
||||
loadAddon() {}
|
||||
open() {}
|
||||
onData() {
|
||||
return { dispose() {} };
|
||||
}
|
||||
onResize() {
|
||||
return { dispose() {} };
|
||||
}
|
||||
write() {}
|
||||
dispose() {}
|
||||
get cols() {
|
||||
return 80;
|
||||
}
|
||||
get rows() {
|
||||
return 24;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: class {
|
||||
fit() {}
|
||||
},
|
||||
}));
|
||||
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
|
||||
|
||||
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
}
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockLayoutGateway,
|
||||
MockSystemGateway,
|
||||
MockTerminalGateway,
|
||||
} from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { leaves } from "./layout";
|
||||
import { LayoutGrid } from "./LayoutGrid";
|
||||
|
||||
function renderGrid(gateways: Gateways) {
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("R0d — launch refused with AGENT_ALREADY_RUNNING", () => {
|
||||
it("maps the refusal to a clear notice + 'aller à la cellule' on the host", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const terminal = new MockTerminalGateway();
|
||||
const system = new MockSystemGateway();
|
||||
|
||||
const agent = await agentGateway.createAgent("p1", {
|
||||
name: "Solo",
|
||||
profileId: "claude",
|
||||
});
|
||||
|
||||
// Two visible leaves; pin the agent on cell A.
|
||||
const initial = await layout.loadLayout("p1");
|
||||
const aId = leaves(initial)[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "split",
|
||||
target: aId,
|
||||
direction: "row",
|
||||
newLeaf: "b",
|
||||
container: "c",
|
||||
});
|
||||
const after = leaves(await layout.loadLayout("p1"));
|
||||
const bId = after.find((l) => l.id !== aId)!.id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: aId,
|
||||
agent: agent.id,
|
||||
});
|
||||
|
||||
// The live set reports the agent already hosted in cell B (the host).
|
||||
vi.spyOn(agentGateway, "listLiveAgents").mockResolvedValue([
|
||||
{ agentId: agent.id, nodeId: bId, sessionId: "s-1" },
|
||||
]);
|
||||
// The launch from cell A is refused by the backend (singleton invariant).
|
||||
vi.spyOn(agentGateway, "launchAgent").mockRejectedValue({
|
||||
code: "AGENT_ALREADY_RUNNING",
|
||||
message: `agent ${agent.id} is already running in cell ${bId}`,
|
||||
});
|
||||
|
||||
const gateways = {
|
||||
layout,
|
||||
agent: agentGateway,
|
||||
terminal,
|
||||
system,
|
||||
} as unknown as Gateways;
|
||||
renderGrid(gateways);
|
||||
|
||||
// The leaf's terminal opener runs the launch → refused → mapped to a notice.
|
||||
const status = await screen.findByRole("status");
|
||||
expect(status.textContent).toMatch(/déjà actif dans une autre cellule/);
|
||||
// And NOT a raw error dump.
|
||||
expect(status.textContent).not.toMatch(/AGENT_ALREADY_RUNNING/);
|
||||
|
||||
// The go-to action focuses the live host cell (B): it gets an outline flash.
|
||||
const goTo = await screen.findByRole("button", {
|
||||
name: "aller à la cellule",
|
||||
});
|
||||
fireEvent.click(goTo);
|
||||
await waitFor(() => {
|
||||
const host = document.querySelector<HTMLElement>(
|
||||
`[data-node-id="${bId}"]`,
|
||||
)!;
|
||||
expect(host.style.outline).not.toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user