fix(application,domain,infrastructure,web-server,app-tauri,frontend): arrêt d'agent fiable avec cascade sur l'arbre de propriété et statut cohérent — #320 (QA verte)
Symptôme: stopper un agent (ex Main sur UnityPlugin) laissait des travaux poursuivre, la cascade n'atteignait pas les agents lancés et la pastille restait orange pendant que l'agent continuait à en appeler d'autres. - barrière d'admission par génération d'exécution (ExecutionControl): un stop ferme l'admission de tout le sous-arbre propriété/attente AVANT toute annulation asynchrone — plus aucune nouvelle action inter-agents après stop - arbre de propriété durable en-process (launched_by) + dépendances d'attente actives: la cascade couvre transitivement agents et travaux lancés - commandes d'un agent arrêté rejetées (AppError::AgentStopped); stop lui-même admis pour rester idempotent; réactivation sur relance humaine (génération +1) et enregistrement du propriétaire au spawn - admission exposée aux adaptateurs hors commandes cœur (tickets, templates, plugins) via ensure_agent_execution_active; MCP server/tools, web-server et commandes Tauri alignés - cohérence live-state: statut Idle avec intent « Arrêté » ou « Arrêt incomplet — Certains travaux sont encore actifs » selon l'issue de la cascade; workstate et surfaces UI (AgentsPanel, LayoutGrid, ProjectWorkStatePanel) reflètent l'état réel, plus de pastille orange fantôme - tests: orchestrator_service (cascade/générations/idempotence), workstate_actions, background_task_cancel, error_codes, agents, layout, workstate QA verte sur fix/320-agent-stop-cascade. Aucun push.
This commit is contained in:
@ -17,6 +17,16 @@ import {
|
||||
|
||||
const PROJECT_ID = "project-work-state-test";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((done, fail) => {
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function renderPanel(
|
||||
workState: MockWorkStateGateway = new MockWorkStateGateway(),
|
||||
system: MockSystemGateway = new MockSystemGateway(),
|
||||
@ -107,6 +117,43 @@ describe("ProjectWorkStatePanel", () => {
|
||||
expect(screen.getByText("Ticket 4c65d981")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("projects stopping and stopped backend states for parent and descendant without orange", async () => {
|
||||
const workState = new MockWorkStateGateway();
|
||||
workState._setProjectWorkState(PROJECT_ID, {
|
||||
agents: [
|
||||
{
|
||||
agentId: "agent-parent",
|
||||
name: "Main",
|
||||
profileId: "codex",
|
||||
runtimeState: "stopping",
|
||||
live: { nodeId: "node-main", sessionId: "session-main", kind: "pty" },
|
||||
busy: { state: "busy", ticket: "stale-parent", sinceMs: 1 },
|
||||
tickets: [],
|
||||
},
|
||||
{
|
||||
agentId: "agent-child",
|
||||
name: "Child",
|
||||
profileId: "codex",
|
||||
runtimeState: "stopped",
|
||||
busy: { state: "busy", ticket: "stale-child", sinceMs: 1 },
|
||||
tickets: [],
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
|
||||
renderPanel(workState);
|
||||
|
||||
const mainRow = (await screen.findByText("Main")).closest("li")!;
|
||||
const childRow = screen.getByText("Child").closest("li")!;
|
||||
const stopping = within(mainRow).getByText("Arrêt en cours…");
|
||||
const stopped = within(childRow).getByText("Arrêté");
|
||||
expect(stopping.className).not.toContain("text-warning");
|
||||
expect(stopped.className).not.toContain("text-warning");
|
||||
expect(within(mainRow).queryByText("Busy")).toBeNull();
|
||||
expect(within(childRow).queryByText("Busy")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders an in-progress human ticket", async () => {
|
||||
const workState = new MockWorkStateGateway();
|
||||
workState._setProjectWorkState(PROJECT_ID, {
|
||||
@ -992,14 +1039,18 @@ describe("ProjectWorkStatePanel", () => {
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
const stopSpy = vi.spyOn(agent, "stopLiveAgent");
|
||||
const pendingStop = deferred<Awaited<ReturnType<typeof agent.stopLiveAgent>>>();
|
||||
const stopSpy = vi
|
||||
.spyOn(agent, "stopLiveAgent")
|
||||
.mockReturnValue(pendingStop.promise);
|
||||
const refreshSpy = vi.spyOn(workState, "getProjectWorkState");
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
renderPanel(workState, new MockSystemGateway(), { layout, agent });
|
||||
|
||||
await openAgentDetails("Runner");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
|
||||
const row = await openAgentDetails("Runner");
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Stop" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(confirmSpy).toHaveBeenCalledWith(
|
||||
@ -1013,7 +1064,39 @@ describe("ProjectWorkStatePanel", () => {
|
||||
"Arrêt demandé depuis Travail",
|
||||
),
|
||||
);
|
||||
const stopping = within(row).getByRole("button", { name: "Arrêt en cours…" });
|
||||
expect((stopping as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(within(row).getAllByText("Arrêt en cours…")).toHaveLength(2);
|
||||
expect(within(row).queryByText("Busy")).toBeNull();
|
||||
|
||||
workState._setProjectWorkState(PROJECT_ID, {
|
||||
agents: [
|
||||
{
|
||||
agentId: created.id,
|
||||
name: "Runner",
|
||||
profileId: "codex",
|
||||
runtimeState: "stopped",
|
||||
busy: { state: "idle" },
|
||||
tickets: [],
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
pendingStop.resolve({
|
||||
agentId: created.id,
|
||||
alreadyStopped: false,
|
||||
stopped: [
|
||||
{
|
||||
sessionId: "mock-agent-session-1",
|
||||
nodeId: leafId,
|
||||
kind: "pty",
|
||||
runtimeState: "stopped",
|
||||
},
|
||||
],
|
||||
});
|
||||
await waitFor(() => expect(refreshSpy).toHaveBeenCalled());
|
||||
await waitFor(() => expect(within(row).getAllByText("Arrêté")).toHaveLength(1));
|
||||
expect(within(row).queryByText("Busy")).toBeNull();
|
||||
await waitFor(async () => {
|
||||
const leaf = leaves(await layout.loadLayout(PROJECT_ID))[0];
|
||||
expect(leaf.session).toBeNull();
|
||||
@ -1021,6 +1104,52 @@ describe("ProjectWorkStatePanel", () => {
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("surfaces an incomplete stop and retries without hiding the backend state", async () => {
|
||||
const workState = new MockWorkStateGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const pendingRetry = deferred<Awaited<ReturnType<typeof agent.stopLiveAgent>>>();
|
||||
workState._setProjectWorkState(PROJECT_ID, {
|
||||
agents: [
|
||||
{
|
||||
agentId: "agent-partial",
|
||||
name: "Main",
|
||||
profileId: "codex",
|
||||
live: { nodeId: "node-main", sessionId: "session-main", kind: "pty" },
|
||||
busy: { state: "busy", ticket: "ticket-main", sinceMs: 1 },
|
||||
tickets: [],
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
const stopSpy = vi
|
||||
.spyOn(agent, "stopLiveAgent")
|
||||
.mockRejectedValueOnce(new Error("cascade failed"))
|
||||
.mockReturnValueOnce(pendingRetry.promise);
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
renderPanel(workState, new MockSystemGateway(), { agent });
|
||||
|
||||
const row = await openAgentDetails("Main");
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Stop" }));
|
||||
|
||||
expect(await within(row).findByText("Arrêt incomplet")).toBeTruthy();
|
||||
expect(within(row).getByRole("alert").textContent).toBe(
|
||||
"Certains travaux sont encore actifs.",
|
||||
);
|
||||
fireEvent.click(within(row).getByRole("button", { name: "Réessayer l’arrêt" }));
|
||||
|
||||
await waitFor(() => expect(stopSpy).toHaveBeenCalledTimes(2));
|
||||
expect(confirmSpy).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
(within(row).getByRole("button", { name: "Arrêt en cours…" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
|
||||
pendingRetry.resolve({ agentId: "agent-partial", alreadyStopped: true, stopped: [] });
|
||||
await waitFor(() => expect(within(row).getAllByText("Arrêté")).toHaveLength(2));
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("View conversation exposes only Lot C preview fields", async () => {
|
||||
const workState = new MockWorkStateGateway();
|
||||
workState._setProjectWorkState(PROJECT_ID, {
|
||||
|
||||
Reference in New Issue
Block a user