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:
2026-06-08 14:43:48 +02:00
parent f3bc3f20d8
commit b39c11a64d
17 changed files with 830 additions and 29 deletions

View File

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