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:
@ -168,6 +168,37 @@ interface PendingResume {
|
||||
reject: (e: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A transient in-cell notice. `goToNodeId`, when present, identifies the layout
|
||||
* leaf that already hosts the conflicting live agent; the notice then renders an
|
||||
* "aller à la cellule" button that focuses that cell. An agent is a singleton
|
||||
* ("1 agent = 1 employé"): we surface where it already lives instead of cloning.
|
||||
*/
|
||||
interface CellNotice {
|
||||
message: string;
|
||||
goToNodeId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses the layout leaf with the given node id: scrolls it into view and
|
||||
* flashes a brief outline so the user sees where the agent already lives. Works
|
||||
* off the `data-node-id` attribute each {@link LeafView} renders. Best-effort —
|
||||
* a no-op when the node is not currently mounted (e.g. on another tab).
|
||||
*/
|
||||
function goToCell(nodeId: string): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const el = document.querySelector<HTMLElement>(`[data-node-id="${nodeId}"]`);
|
||||
if (!el) return;
|
||||
// `scrollIntoView` is absent in some headless DOMs (jsdom); the highlight is
|
||||
// the essential affordance, so guard the scroll and still flash the outline.
|
||||
el.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
||||
const previous = el.style.outline;
|
||||
el.style.outline = "2px solid var(--color-primary, #5b9bd5)";
|
||||
window.setTimeout(() => {
|
||||
el.style.outline = previous;
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm, parentSplit, projectId, visibleNodeIds }: LeafViewProps) {
|
||||
// A cell can be closed only when it lives inside a (binary) split: closing it
|
||||
// collapses the parent split, keeping the *sibling*. Splits are always binary
|
||||
@ -241,6 +272,32 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
const liveFor = (candidate: string): LiveAgent | undefined =>
|
||||
liveAgents.find((la) => la.agentId === candidate);
|
||||
|
||||
/**
|
||||
* Maps an error into a {@link CellNotice}. For the singleton-invariant refusal
|
||||
* (`AGENT_ALREADY_RUNNING`) it resolves the host cell from the live-agents set
|
||||
* (the source of truth exposed by R0b — PTY *and* chat) so the notice can
|
||||
* offer "aller à la cellule" rather than relaunch a clone. The lookup is done
|
||||
* against a *fresh* `listLiveAgents` call (the refusal often races the launch
|
||||
* on mount, before the cached `liveAgents` state has settled), falling back to
|
||||
* the cached set. The error message embeds the host node id too, but the
|
||||
* structured live-agents lookup is preferred.
|
||||
*/
|
||||
const noticeFromError = async (
|
||||
err: unknown,
|
||||
candidate: string,
|
||||
): Promise<CellNotice> => {
|
||||
const message = describeNotice(err);
|
||||
if (!isAlreadyRunning(err)) return { message };
|
||||
const fresh = agentGateway?.listLiveAgents
|
||||
? await agentGateway.listLiveAgents(projectId).catch(() => liveAgents)
|
||||
: liveAgents;
|
||||
const host = fresh.find((la) => la.agentId === candidate);
|
||||
return {
|
||||
message: "Cet agent est déjà actif dans une autre cellule.",
|
||||
goToNodeId: host && host.nodeId !== id ? host.nodeId : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/** True when the live session is already displayed by another visible cell. */
|
||||
const visibleElsewhere = (candidate: string): LiveAgent | undefined => {
|
||||
const live = liveFor(candidate);
|
||||
@ -259,7 +316,10 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
|
||||
// A transient notice shown when an action is blocked by the singleton
|
||||
// invariant (selecting / launching an agent already live in another cell).
|
||||
const [busyNotice, setBusyNotice] = useState<string | null>(null);
|
||||
// `goToNodeId`, when set, is the host cell of the conflicting live agent: the
|
||||
// notice then offers an "aller à la cellule" action that focuses that cell —
|
||||
// we never silently relaunch a clone (product rule "1 agent = 1 employé").
|
||||
const [busyNotice, setBusyNotice] = useState<CellNotice | null>(null);
|
||||
|
||||
// ── Resume popup state (T7) ───────────────────────────────────────────────
|
||||
// When an agent cell carries a persisted conversation id and its PTY session
|
||||
@ -316,12 +376,23 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
return result.handle;
|
||||
}
|
||||
|
||||
const handle = await agentGateway!.launchAgent(
|
||||
projectId,
|
||||
agentId!,
|
||||
{ ...opts, conversationId: convId, nodeId: id },
|
||||
onData,
|
||||
);
|
||||
const handle = await agentGateway!
|
||||
.launchAgent(
|
||||
projectId,
|
||||
agentId!,
|
||||
{ ...opts, conversationId: convId, nodeId: id },
|
||||
onData,
|
||||
)
|
||||
.catch(async (err: unknown) => {
|
||||
// A neat backend refusal (`AGENT_ALREADY_RUNNING`) means the agent is a
|
||||
// singleton already alive in another cell: surface a clear notice with
|
||||
// an "aller à la cellule" action (host resolved from the live set), then
|
||||
// re-throw so the view layer does not treat the launch as succeeded.
|
||||
if (isAlreadyRunning(err)) {
|
||||
setBusyNotice(await noticeFromError(err, agentId!));
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
// First launch on a fresh cell mints a conversation id: persist it on the
|
||||
// leaf so the next open resumes (T4b — closes the persistence loop).
|
||||
if (handle.assignedConversationId) {
|
||||
@ -448,14 +519,19 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
const isVisible =
|
||||
live && live.nodeId !== id && visibleNodeIds.has(live.nodeId);
|
||||
if (isVisible) {
|
||||
setBusyNotice("Cet agent est déjà visible dans une autre cellule.");
|
||||
setBusyNotice({
|
||||
message: "Cet agent est déjà actif dans une autre cellule.",
|
||||
goToNodeId: live!.nodeId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const isBackground =
|
||||
live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId);
|
||||
if (isBackground) {
|
||||
if (!agentGateway?.attachLiveAgent || !live.sessionId) {
|
||||
setBusyNotice("Session active introuvable pour cet agent.");
|
||||
setBusyNotice({
|
||||
message: "Session active introuvable pour cet agent.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setBusyNotice(null);
|
||||
@ -466,7 +542,9 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
}
|
||||
setBusyNotice(null);
|
||||
await vm.setCellAgent(id, val);
|
||||
})().catch((err: unknown) => setBusyNotice(describeNotice(err)));
|
||||
})().catch(async (err: unknown) =>
|
||||
setBusyNotice(await noticeFromError(err, val)),
|
||||
);
|
||||
}}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
@ -548,7 +626,7 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
/>
|
||||
)}
|
||||
{busyNotice && (
|
||||
<p
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
position: "absolute",
|
||||
@ -562,10 +640,36 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
padding: "2px 6px",
|
||||
borderRadius: 3,
|
||||
zIndex: 3,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{busyNotice}
|
||||
</p>
|
||||
<span style={{ minWidth: 0 }}>{busyNotice.message}</span>
|
||||
{busyNotice.goToNodeId && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="aller à la cellule"
|
||||
onClick={() => {
|
||||
goToCell(busyNotice.goToNodeId!);
|
||||
setBusyNotice(null);
|
||||
}}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
fontSize: 11,
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
color: "var(--color-primary, #5b9bd5)",
|
||||
border: "1px solid var(--color-primary, #5b9bd5)",
|
||||
borderRadius: 3,
|
||||
padding: "1px 6px",
|
||||
}}
|
||||
>
|
||||
Aller à la cellule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{pendingResume && (
|
||||
<ResumeConversationPopup
|
||||
@ -779,3 +883,13 @@ function describeNotice(e: unknown): string {
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
/** True when `e` is the backend singleton-invariant refusal (`GatewayError`). */
|
||||
function isAlreadyRunning(e: unknown): boolean {
|
||||
return (
|
||||
typeof e === "object" &&
|
||||
e !== null &&
|
||||
"code" in e &&
|
||||
(e as { code: unknown }).code === "AGENT_ALREADY_RUNNING"
|
||||
);
|
||||
}
|
||||
|
||||
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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -200,3 +200,94 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("R0d — dropdown disables agents live elsewhere + go-to-cell", () => {
|
||||
function renderGrid(layout: MockLayoutGateway, agent: MockAgentGateway) {
|
||||
const system = {
|
||||
onDomainEvent: vi.fn().mockResolvedValue(() => {}),
|
||||
};
|
||||
const gateways = { layout, agent, system } as unknown as Gateways;
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Splits the project's single leaf into two visible leaves; returns both ids. */
|
||||
async function splitInTwo(
|
||||
layout: MockLayoutGateway,
|
||||
): Promise<{ a: string; b: string }> {
|
||||
const initial = await layout.loadLayout("p1");
|
||||
const a = leaves(initial)[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "split",
|
||||
target: a,
|
||||
direction: "row",
|
||||
newLeaf: "b",
|
||||
container: "c",
|
||||
});
|
||||
const after = leaves(await layout.loadLayout("p1"));
|
||||
return { a, b: after.find((l) => l.id !== a)!.id };
|
||||
}
|
||||
|
||||
it("an agent live in another VISIBLE cell ⇒ option disabled + 'aller à la cellule'", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const ag = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
|
||||
const { a, b } = await splitInTwo(layout);
|
||||
|
||||
// The agent is live in cell B (a currently-visible leaf).
|
||||
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
|
||||
{ agentId: ag.id, nodeId: b, sessionId: "s-1" },
|
||||
]);
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
// In cell A's dropdown the option is DISABLED (cannot run in two cells).
|
||||
const selectA = (await screen.findByLabelText(
|
||||
`agent selector ${a}`,
|
||||
)) as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
const opt = Array.from(selectA.options).find((o) => o.value === ag.id)!;
|
||||
expect(opt.disabled).toBe(true);
|
||||
expect(opt.textContent).toMatch(/visible ailleurs/);
|
||||
});
|
||||
|
||||
// Selecting it surfaces a clear notice + an "aller à la cellule" action.
|
||||
fireEvent.change(selectA, { target: { value: ag.id } });
|
||||
expect((await screen.findByRole("status")).textContent).toMatch(
|
||||
/déjà actif dans une autre cellule/,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "aller à la cellule" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("an agent NOT live anywhere stays selectable (no regression)", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const ag = await agent.createAgent("p1", { name: "Free", profileId: "p" });
|
||||
|
||||
// No live agents at all.
|
||||
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([]);
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
const option = await screen.findByRole("option", { name: "Free" });
|
||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
||||
|
||||
// And selecting it pins the agent on the cell (no notice, no block).
|
||||
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: ag.id } });
|
||||
await waitFor(async () => {
|
||||
const leaf = leaves(await layout.loadLayout("p1")).find(
|
||||
(l) => l.id === leafId,
|
||||
)!;
|
||||
expect(leaf.agent).toBe(ag.id);
|
||||
});
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user