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:
@ -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