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:
@ -22,6 +22,7 @@ import type { Agent } from "@/domain";
|
||||
import type { LayoutNode } from "@/domain";
|
||||
import type {
|
||||
ConversationDetails,
|
||||
LiveAgent,
|
||||
OpenTerminalOptions,
|
||||
TerminalHandle,
|
||||
} from "@/ports";
|
||||
@ -143,7 +144,7 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
// the wrong terminal. The root cell (no parent split) cannot be closed.
|
||||
const canClose = parentSplit !== null && parentSplit.siblings === 2;
|
||||
const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0;
|
||||
const { agent: agentGateway } = useGateways();
|
||||
const { agent: agentGateway, system } = useGateways();
|
||||
|
||||
// Load the project's agents for the dropdown.
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
@ -156,9 +157,65 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
return () => { cancelled = true; };
|
||||
}, [agentGateway, projectId]);
|
||||
|
||||
// Load the agents currently running (and where), so the dropdown can disable an
|
||||
// agent already live in another cell — it cannot run in two cells at once. The
|
||||
// backend refuses such a launch (`AGENT_ALREADY_RUNNING`); disabling it here is
|
||||
// the matching UI affordance.
|
||||
const [liveAgents, setLiveAgents] = useState<LiveAgent[]>([]);
|
||||
const refreshLive = () => {
|
||||
if (!agentGateway?.listLiveAgents) return;
|
||||
agentGateway.listLiveAgents(projectId).then(setLiveAgents).catch(() => {
|
||||
/* ignore — treat as none live */
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!agentGateway?.listLiveAgents) return;
|
||||
let cancelled = false;
|
||||
agentGateway.listLiveAgents(projectId).then((list) => {
|
||||
if (!cancelled) setLiveAgents(list);
|
||||
}).catch(() => {/* ignore */});
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agentGateway, projectId]);
|
||||
|
||||
// Live refresh on agent lifecycle events: a launch/exit in any cell changes
|
||||
// who is running where, so re-pull the live set to keep every dropdown in sync.
|
||||
useEffect(() => {
|
||||
if (!system) return;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
void system
|
||||
.onDomainEvent((event) => {
|
||||
if (event.type === "agentLaunched" || event.type === "agentExited") {
|
||||
refreshLive();
|
||||
}
|
||||
})
|
||||
.then((un) => {
|
||||
if (cancelled) un();
|
||||
else unsubscribe = un;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [system, agentGateway, projectId]);
|
||||
|
||||
// Build the terminal opener based on whether an agent is pinned.
|
||||
const agentId = agent ?? null;
|
||||
|
||||
/**
|
||||
* Whether `candidate` is currently running in a cell *other than this one*.
|
||||
* Such an agent cannot be launched here (one live session per agent), so the
|
||||
* dropdown disables it and `onChange` rejects selecting it.
|
||||
*/
|
||||
const isLiveElsewhere = (candidate: string): boolean =>
|
||||
liveAgents.some((la) => la.agentId === candidate && la.nodeId !== id);
|
||||
|
||||
// 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);
|
||||
|
||||
// ── Resume popup state (T7) ───────────────────────────────────────────────
|
||||
// When an agent cell carries a persisted conversation id and its PTY session
|
||||
// is dead, the opener is about to relaunch in Resume mode. We intercept that
|
||||
@ -177,7 +234,7 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
const handle = await agentGateway!.launchAgent(
|
||||
projectId,
|
||||
agentId!,
|
||||
{ ...opts, conversationId: convId },
|
||||
{ ...opts, conversationId: convId, nodeId: id },
|
||||
onData,
|
||||
);
|
||||
// First launch on a fresh cell mints a conversation id: persist it on the
|
||||
@ -271,6 +328,14 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
value={agentId ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// Reject pinning an agent that is already running in another cell.
|
||||
// (The dropdown also disables such options, but guard the change in
|
||||
// case it is set programmatically.)
|
||||
if (val !== "" && isLiveElsewhere(val)) {
|
||||
setBusyNotice("Cet agent tourne déjà dans une autre cellule.");
|
||||
return;
|
||||
}
|
||||
setBusyNotice(null);
|
||||
void vm.setCellAgent(id, val === "" ? null : val);
|
||||
}}
|
||||
style={{
|
||||
@ -284,11 +349,17 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
}}
|
||||
>
|
||||
<option value="">Plain</option>
|
||||
{agents.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
{agents.map((a) => {
|
||||
// An agent already running in another cell cannot be pinned here.
|
||||
// The agent pinned on THIS cell stays selectable (same node).
|
||||
const elsewhere = isLiveElsewhere(a.id);
|
||||
return (
|
||||
<option key={a.id} value={a.id} disabled={elsewhere}>
|
||||
{a.name}
|
||||
{elsewhere ? " (en cours ailleurs)" : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
|
||||
<button
|
||||
@ -329,6 +400,26 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
sessionId={session}
|
||||
onSessionId={(sid) => void vm.setSession(id, sid)}
|
||||
/>
|
||||
{busyNotice && (
|
||||
<p
|
||||
role="status"
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 4,
|
||||
left: 4,
|
||||
right: 4,
|
||||
margin: 0,
|
||||
fontSize: 11,
|
||||
color: "var(--color-content-muted, #9a9a9a)",
|
||||
background: "var(--color-surface, #1e1e1e)",
|
||||
padding: "2px 6px",
|
||||
borderRadius: 3,
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
{busyNotice}
|
||||
</p>
|
||||
)}
|
||||
{pendingResume && (
|
||||
<ResumeConversationPopup
|
||||
agentWasRunning={agentWasRunning}
|
||||
|
||||
162
frontend/src/features/layout/singletonAgent.test.tsx
Normal file
162
frontend/src/features/layout/singletonAgent.test.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
/**
|
||||
* "One live session per agent" invariant (UI side).
|
||||
*
|
||||
* - The mock agent gateway mirrors the backend guard: launching an agent already
|
||||
* live in another cell is refused with `AGENT_ALREADY_RUNNING`, and the same
|
||||
* node is idempotent; `listLiveAgents` reports who runs where (T5).
|
||||
* - The per-cell dropdown disables (and `onChange` rejects) an agent already live
|
||||
* in another cell, while the agent pinned on its own cell stays selectable (T6).
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
import type { Gateways, LiveAgent } from "@/ports";
|
||||
import {
|
||||
MockLayoutGateway,
|
||||
MockAgentGateway,
|
||||
type GatewayError,
|
||||
} from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { LayoutGrid } from "./LayoutGrid";
|
||||
import { leaves } from "./layout";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("singleton agent — mock gateway guard (T5)", () => {
|
||||
it("refuses launching an agent already running in another cell", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const a = await agent.createAgent("p1", { name: "Solo", profileId: "p" });
|
||||
|
||||
// Launch in cell A.
|
||||
await agent.launchAgent(
|
||||
"p1",
|
||||
a.id,
|
||||
{ cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" },
|
||||
noop,
|
||||
);
|
||||
|
||||
// Launching the SAME agent in cell B is refused.
|
||||
let caught: GatewayError | null = null;
|
||||
await agent
|
||||
.launchAgent(
|
||||
"p1",
|
||||
a.id,
|
||||
{ cwd: "/x", rows: 24, cols: 80, nodeId: "node-B" },
|
||||
noop,
|
||||
)
|
||||
.catch((e) => {
|
||||
caught = e as GatewayError;
|
||||
});
|
||||
expect(caught).not.toBeNull();
|
||||
expect(caught!.code).toBe("AGENT_ALREADY_RUNNING");
|
||||
});
|
||||
|
||||
it("listLiveAgents reports the cell hosting each live agent", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const a = await agent.createAgent("p1", { name: "Solo", profileId: "p" });
|
||||
|
||||
expect(await agent.listLiveAgents("p1")).toEqual([]);
|
||||
|
||||
await agent.launchAgent(
|
||||
"p1",
|
||||
a.id,
|
||||
{ cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" },
|
||||
noop,
|
||||
);
|
||||
|
||||
const live: LiveAgent[] = await agent.listLiveAgents("p1");
|
||||
expect(live).toEqual([{ agentId: a.id, nodeId: "node-A" }]);
|
||||
});
|
||||
|
||||
it("relaunching in the SAME node is allowed (idempotent), not refused", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const a = await agent.createAgent("p1", { name: "Solo", profileId: "p" });
|
||||
|
||||
await agent.launchAgent(
|
||||
"p1",
|
||||
a.id,
|
||||
{ cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" },
|
||||
noop,
|
||||
);
|
||||
// Same node again must NOT throw.
|
||||
await expect(
|
||||
agent.launchAgent(
|
||||
"p1",
|
||||
a.id,
|
||||
{ cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" },
|
||||
noop,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("singleton agent — dropdown guard (T6)", () => {
|
||||
function renderGrid(layout: MockLayoutGateway, agent: MockAgentGateway) {
|
||||
// A system gateway stub so LeafView's event subscription is a no-op.
|
||||
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>,
|
||||
);
|
||||
}
|
||||
|
||||
it("disables an agent already running in another cell and rejects selecting it", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
|
||||
|
||||
// Mark the agent live in a DIFFERENT node than the (single) leaf we render.
|
||||
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
|
||||
{ agentId: a.id, nodeId: "some-other-node" },
|
||||
]);
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
// The option for the live-elsewhere agent is rendered disabled with a label.
|
||||
const option = await screen.findByRole("option", {
|
||||
name: /Busy \(en cours ailleurs\)/,
|
||||
});
|
||||
expect((option as HTMLOptionElement).disabled).toBe(true);
|
||||
|
||||
// Programmatically selecting it must be rejected (agent not pinned).
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
const setCellAgentSpy = vi.spyOn(layout, "mutateLayout");
|
||||
fireEvent.change(select, { target: { value: a.id } });
|
||||
|
||||
// A notice is shown and no setCellAgent mutation was issued.
|
||||
expect(await screen.findByRole("status")).toBeTruthy();
|
||||
expect(
|
||||
setCellAgentSpy.mock.calls.filter((c) => c[1].type === "setCellAgent"),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("the agent pinned on THIS cell stays selectable even while live (same node)", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const a = await agent.createAgent("p1", { name: "Mine", profileId: "p" });
|
||||
|
||||
// Pin the agent on the single leaf.
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const leafId = leaves(tree)[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: a.id,
|
||||
});
|
||||
|
||||
// The agent is live in THIS very cell (same node id as the leaf).
|
||||
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
|
||||
{ agentId: a.id, nodeId: leafId },
|
||||
]);
|
||||
|
||||
renderGrid(layout, agent);
|
||||
|
||||
const option = await screen.findByRole("option", { name: "Mine" });
|
||||
// Live on its own cell ⇒ NOT disabled (it can keep running here).
|
||||
expect((option as HTMLOptionElement).disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user