fix(runtime): stop/restart d'agent visible robuste — kill réel, état stopped, relance froide (#196, QA verte)

Solidifie le cycle stop/restart d'un agent visible en cellule:

- terminal/registry: kill effectif de la TUI native et de la CLI custom, arrêt idempotent du live runtime (PTY + structuré), distinction nette « agent stoppé » vs « pas de session ».

- workstate + lifecycle: projection d'un état runtime explicite « stopped » (AgentRuntimeState: running/stopping/stopped/failedStart), réconcilié avec le live state; traitement des courses « agent not found ».

- relance froide via freshStart: repart de zéro sans réattacher une session morte et recharge la configuration courante de l'agent (y compris changement de modèle).

- contrat projeté côté adapters frontend (ports/domain/normalization/mock) pour la consommation UI future (#197/#198), sans surface UI ici.

Tests QA verts: workstate_actions, orchestrator_service stop_agent, agent_lifecycle (reopen/fresh_start), workstate stopped runtime state, vitest adapter stop_live_agent.
This commit is contained in:
2026-08-12 14:56:02 +02:00
parent 4e93fa2f20
commit bad7b4c2f3
23 changed files with 440 additions and 46 deletions

View File

@ -401,6 +401,8 @@ export class MockAgentGateway implements AgentGateway {
private liveSessionByAgent = new Map<string, string>();
/** Runtime kind per live agent (`agentId → kind`). */
private liveKindByAgent = new Map<string, "pty" | "structured">();
/** Last stopped runtime per agent for idempotent stop. */
private stoppedByAgent = new Map<string, StoppedLiveAgent>();
/** Retained structured reply chunks per live chat session. */
private chatScrollback = new Map<string, ReplyChunk[]>();
/** Persisted custom chat drafts keyed by project/layout/node. */
@ -460,6 +462,7 @@ export class MockAgentGateway implements AgentGateway {
nodeId,
sessionId: this.liveSessionByAgent.get(agentId)!,
kind: this.liveKindByAgent.get(agentId) ?? "pty",
runtimeState: "running",
}));
}
@ -509,6 +512,7 @@ export class MockAgentGateway implements AgentGateway {
nodeId,
sessionId,
kind: this.liveKindByAgent.get(agentId) ?? "pty",
runtimeState: "running",
};
}
@ -523,6 +527,8 @@ export class MockAgentGateway implements AgentGateway {
}
const sessionId = this.liveSessionByAgent.get(agentId);
if (!sessionId) {
const stopped = this.stoppedByAgent.get(agentId);
if (stopped) return stopped;
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} has no live session`,
@ -537,7 +543,14 @@ export class MockAgentGateway implements AgentGateway {
this.liveByAgent.delete(agentId);
const kind = this.liveKindByAgent.get(agentId) ?? "pty";
this.liveKindByAgent.delete(agentId);
return { agentId, sessionId, kind };
const stopped: StoppedLiveAgent = {
agentId,
sessionId,
kind,
runtimeState: "stopped",
};
this.stoppedByAgent.set(agentId, stopped);
return stopped;
}
async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
@ -781,6 +794,7 @@ export class MockAgentGateway implements AgentGateway {
this.liveByAgent.set(agentId, options.nodeId);
this.liveSessionByAgent.set(agentId, sessionId);
this.liveKindByAgent.set(agentId, "pty");
this.stoppedByAgent.delete(agentId);
}
const clearLive = () => {
if (this.liveByAgent.get(agentId) === options.nodeId) {
@ -807,7 +821,7 @@ export class MockAgentGateway implements AgentGateway {
// 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.
if (!options.conversationId) {
if (options.freshStart || !options.conversationId) {
return {
...handle,
assignedConversationId: `mock-conversation-${sessionId}`,
@ -848,11 +862,12 @@ export class MockAgentGateway implements AgentGateway {
this.liveByAgent.set(agentId, options.nodeId);
this.liveSessionByAgent.set(agentId, sessionId);
this.liveKindByAgent.set(agentId, "structured");
this.stoppedByAgent.delete(agentId);
}
return {
sessionId,
cellKind: "chat",
...(!options.conversationId
...(options.freshStart || !options.conversationId
? { assignedConversationId: `mock-conversation-${sessionId}` }
: {}),
};