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:
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user