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

@ -19,6 +19,7 @@ import type {
AgentGateway,
ConversationDetails,
CreateAgentInput,
LiveAgent,
OpenTerminalOptions,
ReattachResult,
TerminalHandle,
@ -40,6 +41,10 @@ export class TauriAgentGateway implements AgentGateway {
return invoke<Agent[]>("list_agents", { projectId });
}
listLiveAgents(projectId: string): Promise<LiveAgent[]> {
return invoke<LiveAgent[]>("list_live_agents", { projectId });
}
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
// The `create_agent` command takes a single `request` DTO; `projectId` must
// live *inside* it (camelCase), not at the top level.
@ -91,6 +96,8 @@ export class TauriAgentGateway implements AgentGateway {
// Resume id: the leaf's persisted conversation id, when any (T4b). The
// backend resumes it; absent ⇒ a fresh cell that may get a new id.
conversationId: options.conversationId ?? null,
// Hosting cell: lets the backend enforce one-live-session-per-agent.
nodeId: options.nodeId ?? null,
},
onOutput: channel,
});

View File

@ -40,6 +40,7 @@ import type {
CreateSkillInput,
CreateTemplateInput,
Gateways,
LiveAgent,
MemoryGateway,
GitGateway,
LayoutGateway,
@ -170,6 +171,13 @@ export class MockAgentGateway implements AgentGateway {
private sessionSeq = 0;
/** Live agent PTY sessions, kept across detach so reattach can find them. */
private sessions = new Map<string, MockPtySession>();
/**
* The cell each live agent currently runs in (`agentId → nodeId`). Mirrors the
* backend "one live session per agent" invariant: launching an agent already
* present here in a *different* node is refused; closing its session clears it.
* Only populated when the caller supplies a `nodeId`.
*/
private liveByAgent = new Map<string, string>();
private getAgents(projectId: string): Agent[] {
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
@ -184,6 +192,15 @@ export class MockAgentGateway implements AgentGateway {
return structuredClone(this.getAgents(projectId));
}
async listLiveAgents(projectId: string): Promise<LiveAgent[]> {
// Only report agents that belong to this project (ids are disjoint across
// projects in the mock, mirroring the backend's per-project agent ids).
const known = new Set(this.getAgents(projectId).map((a) => a.id));
return [...this.liveByAgent.entries()]
.filter(([agentId]) => known.has(agentId))
.map(([agentId, nodeId]) => ({ agentId, nodeId }));
}
async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
const list = this.getAgents(projectId);
const slug = slugify(input.name) || "agent";
@ -320,17 +337,36 @@ export class MockAgentGateway implements AgentGateway {
};
throw err;
}
// Singleton invariant: refuse a launch when the agent is already live in a
// *different* cell (mirrors the backend `AGENT_ALREADY_RUNNING`). The same
// node is allowed (idempotent re-launch of the very same cell).
const liveNode = this.liveByAgent.get(agentId);
if (liveNode !== undefined && options.nodeId && liveNode !== options.nodeId) {
const err: GatewayError = {
code: "AGENT_ALREADY_RUNNING",
message: `agent ${agentId} is already running in cell ${liveNode}`,
};
throw err;
}
this.sessionSeq += 1;
const sessionId = `mock-agent-session-${this.sessionSeq}`;
const cwd = options.cwd;
const enc = new TextEncoder();
const session = new MockPtySession(sessionId, onData);
this.sessions.set(sessionId, session);
// Record liveness for `listLiveAgents` + the guard above (only when the
// caller pins a node).
if (options.nodeId) this.liveByAgent.set(agentId, options.nodeId);
// Greet so something is visible immediately (mirrors MockTerminalGateway).
queueMicrotask(() =>
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
);
const handle = makeMockHandle(session, () => this.sessions.delete(sessionId));
const handle = makeMockHandle(session, () => {
this.sessions.delete(sessionId);
if (this.liveByAgent.get(agentId) === options.nodeId) {
this.liveByAgent.delete(agentId);
}
});
// Simulate session assignment (T4b): a fresh cell (no conversation id) gets a
// newly-minted id surfaced on the handle so the caller persists it; a cell
// that already carries an id resumes it and nothing new is assigned.