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:
2026-06-10 17:34:57 +02:00
parent 37e72747d3
commit 6ca519b815
21 changed files with 2402 additions and 85 deletions

View File

@ -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"
);
}