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:
@ -185,6 +185,7 @@ 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,
|
||||
freshStart: options.freshStart ?? false,
|
||||
// Hosting cell: lets the backend enforce one-live-session-per-agent.
|
||||
nodeId: options.nodeId ?? null,
|
||||
},
|
||||
@ -220,6 +221,7 @@ export class TauriAgentGateway implements AgentGateway {
|
||||
cols: options.cols,
|
||||
cellKind: "chat",
|
||||
conversationId: options.conversationId ?? null,
|
||||
freshStart: options.freshStart ?? false,
|
||||
nodeId: options.nodeId ?? null,
|
||||
},
|
||||
onOutput: channel,
|
||||
|
||||
@ -288,6 +288,7 @@ export class HttpAgentGateway implements AgentGateway {
|
||||
rows: options.rows,
|
||||
cols: options.cols,
|
||||
conversationId: options.conversationId ?? null,
|
||||
freshStart: options.freshStart ?? false,
|
||||
onData,
|
||||
});
|
||||
return makeWsTerminalHandle(res.sessionId, this.ws, res.assignedConversationId);
|
||||
|
||||
@ -382,6 +382,7 @@ export class WsLiveClient {
|
||||
cols: number;
|
||||
nodeId?: string | null;
|
||||
conversationId?: string | null;
|
||||
freshStart?: boolean;
|
||||
onData: (bytes: Uint8Array) => void;
|
||||
onStatus?: (status: string, exitCode?: number | null) => void;
|
||||
}): Promise<TerminalAttachResult> {
|
||||
@ -394,6 +395,7 @@ export class WsLiveClient {
|
||||
rows: params.rows,
|
||||
cols: params.cols,
|
||||
conversationId: params.conversationId ?? null,
|
||||
freshStart: params.freshStart ?? false,
|
||||
},
|
||||
{ onData: params.onData, onStatus: params.onStatus, lastSeq: 0 },
|
||||
);
|
||||
|
||||
@ -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}` }
|
||||
: {}),
|
||||
};
|
||||
|
||||
@ -150,11 +150,19 @@ function normalizeAgent(value: unknown, index: number): AgentWorkState {
|
||||
const agent = isRecord(value) ? value : {};
|
||||
const liveKind: "pty" | "structured" =
|
||||
isRecord(agent.live) && agent.live.kind === "structured" ? "structured" : "pty";
|
||||
const runtimeState = normalizeRuntimeState(agent.runtimeState ?? agent.runtime_state);
|
||||
const live = isRecord(agent.live)
|
||||
? {
|
||||
nodeId: stringValue(agent.live.nodeId),
|
||||
sessionId: stringValue(agent.live.sessionId),
|
||||
kind: liveKind,
|
||||
...(normalizeRuntimeState(agent.live.runtimeState ?? agent.live.runtime_state)
|
||||
? {
|
||||
runtimeState: normalizeRuntimeState(
|
||||
agent.live.runtimeState ?? agent.live.runtime_state,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@ -173,6 +181,7 @@ function normalizeAgent(value: unknown, index: number): AgentWorkState {
|
||||
name: stringValue(agent.name, "Unnamed agent"),
|
||||
profileId: stringValue(agent.profileId, "unknown"),
|
||||
...(live ? { live } : {}),
|
||||
...(runtimeState ? { runtimeState } : {}),
|
||||
busy: normalizeBusy(agent.busy),
|
||||
tickets: arrayValue(agent.tickets).map(normalizeTicket),
|
||||
inboxDepth: numberValue(agent.inboxDepth, inbox.length),
|
||||
@ -181,6 +190,18 @@ function normalizeAgent(value: unknown, index: number): AgentWorkState {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRuntimeState(value: unknown): AgentWorkState["runtimeState"] {
|
||||
if (
|
||||
value === "running" ||
|
||||
value === "stopping" ||
|
||||
value === "stopped" ||
|
||||
value === "failedStart"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeSummaryStatus(value: unknown): ConversationPreviewStatus {
|
||||
if (
|
||||
value === "ready" ||
|
||||
|
||||
@ -572,8 +572,15 @@ export interface LiveWorkSession {
|
||||
nodeId: string;
|
||||
sessionId: string;
|
||||
kind: "pty" | "structured";
|
||||
runtimeState?: AgentRuntimeState;
|
||||
}
|
||||
|
||||
export type AgentRuntimeState =
|
||||
| "running"
|
||||
| "stopping"
|
||||
| "stopped"
|
||||
| "failedStart";
|
||||
|
||||
/** Busy/idle status for an agent in the work-state read-model. */
|
||||
export type WorkBusyState =
|
||||
| { state: "idle" }
|
||||
@ -663,6 +670,7 @@ export interface AgentWorkState {
|
||||
name: string;
|
||||
profileId: string;
|
||||
live?: LiveWorkSession;
|
||||
runtimeState?: AgentRuntimeState;
|
||||
busy: WorkBusyState;
|
||||
tickets: AgentTicketState[];
|
||||
inboxDepth?: number;
|
||||
|
||||
@ -378,6 +378,8 @@ export interface OpenTerminalOptions {
|
||||
* meaningful for {@link AgentGateway.launchAgent}; ignored for plain terminals.
|
||||
*/
|
||||
conversationId?: string;
|
||||
/** Ignore a previous conversation id and request a cold, clean launch. */
|
||||
freshStart?: boolean;
|
||||
/**
|
||||
* The layout leaf (node) hosting this launch. Drives the "one live session per
|
||||
* agent" invariant backend-side: launching an agent already running in a
|
||||
@ -400,6 +402,8 @@ export interface LiveAgent {
|
||||
sessionId: string;
|
||||
/** Runtime kind backing the live agent. */
|
||||
kind: "pty" | "structured";
|
||||
/** Runtime lifecycle state. Live agents are always running. */
|
||||
runtimeState?: AgentRuntimeState;
|
||||
}
|
||||
|
||||
/** Result returned when the backend stops a live agent. */
|
||||
@ -407,8 +411,15 @@ export interface StoppedLiveAgent {
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
kind: "pty" | "structured";
|
||||
runtimeState?: AgentRuntimeState;
|
||||
}
|
||||
|
||||
export type AgentRuntimeState =
|
||||
| "running"
|
||||
| "stopping"
|
||||
| "stopped"
|
||||
| "failedStart";
|
||||
|
||||
/**
|
||||
* A live terminal handle returned by {@link TerminalGateway.openTerminal}.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user