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:
2026-09-11 16:42:29 +02:00
parent 1dc892d80b
commit 1316e87010
20 changed files with 1048 additions and 154 deletions

View File

@ -14,7 +14,7 @@ import type {
InboxItem,
LeafCell,
} from "@/domain";
import type { BackgroundTaskAttachment } from "@/ports";
import type { BackgroundTaskAttachment, StoppedLiveAgent } from "@/ports";
import { useGateways } from "@/app/di";
import { leaves } from "@/features/layout/layout";
import { useLayout } from "@/features/layout/useLayout";
@ -138,12 +138,38 @@ function canRetryTask(status: BackgroundCompletion["status"]): boolean {
return status === "failed" || status === "cancelFailed" || status === "cancelled";
}
function agentStatusLabel(live: boolean, busy: boolean): string {
type AgentStopUiState = "idle" | "stopping" | "stopped" | "incomplete";
function agentStatusLabel(
live: boolean,
busy: boolean,
runtimeState: AgentWorkState["runtimeState"],
stopState: AgentStopUiState,
): string {
if (stopState === "stopping" || runtimeState === "stopping") {
return "Arrêt en cours…";
}
if (stopState === "incomplete") return "Arrêt incomplet";
if (stopState === "stopped" || runtimeState === "stopped") return "Arrêté";
if (busy) return "Busy";
return live ? "Idle" : "Offline";
}
function agentStatusClass(live: boolean, busy: boolean): string {
function agentStatusClass(
live: boolean,
busy: boolean,
runtimeState: AgentWorkState["runtimeState"],
stopState: AgentStopUiState,
): string {
if (stopState === "incomplete") return "bg-danger/10 text-danger";
if (
stopState === "stopping" ||
stopState === "stopped" ||
runtimeState === "stopping" ||
runtimeState === "stopped"
) {
return "bg-raised text-muted";
}
if (busy) return "bg-warning/15 text-warning";
if (live) return "bg-success/10 text-success";
return "bg-raised text-muted";
@ -600,6 +626,7 @@ function AgentRow({
const { agent: agentGateway } = useGateways();
const [message, setMessage] = useState<string | null>(null);
const [actionBusy, setActionBusy] = useState(false);
const [stopState, setStopState] = useState<AgentStopUiState>("idle");
const [detailsOpen, setDetailsOpen] = useState(false);
const [backgroundOpen, setBackgroundOpen] = useState(false);
const live = agent.live !== undefined;
@ -617,7 +644,15 @@ function AgentRow({
const currentTurn =
busy || tickets.some((ticket) => ticket.status === "inProgress");
const activeTicket = tickets.find((ticket) => ticket.status === "inProgress");
const statusLabel = agentStatusLabel(live, busy);
const statusLabel = agentStatusLabel(live, busy, agent.runtimeState, stopState);
const stopLabel =
stopState === "stopping" || agent.runtimeState === "stopping"
? "Arrêt en cours…"
: stopState === "incomplete"
? "Réessayer l’arrêt"
: stopState === "stopped" || agent.runtimeState === "stopped"
? "Arrêté"
: "Stop";
const taskSummary = backgroundTaskSummary(backgroundTasks);
const activityLine = compactIntent(
activeTicket?.taskPreview ??
@ -661,20 +696,33 @@ function AgentRow({
}
}
async function stop(): Promise<void> {
async function stop(confirm = true): Promise<void> {
if (!agent.live || !agentGateway) return;
const warning = currentTurn
? " The current turn will be interrupted."
: "";
if (!window.confirm(`Stop ${agent.name}?${warning}`)) return;
if (confirm && !window.confirm(`Stop ${agent.name}?${warning}`)) return;
setActionBusy(true);
setStopState("stopping");
setMessage(null);
let stopped: StoppedLiveAgent;
try {
const stopped = await agentGateway.stopLiveAgent(
stopped = await agentGateway.stopLiveAgent(
projectId,
agent.agentId,
"Arrêt demandé depuis Travail",
);
} catch {
setStopState("incomplete");
setMessage("Certains travaux sont encore actifs.");
setActionBusy(false);
return;
}
// The stop response is the backend confirmation. Keep the row non-busy
// while its authoritative live-state snapshot is being refreshed.
setStopState("stopped");
try {
if (visibleNodeIds.has(agent.live.nodeId)) {
const leaf = visibleLeaves.find((candidate) => candidate.id === agent.live?.nodeId);
const stoppedSessionIds = new Set(stopped.stopped.map((runtime) => runtime.sessionId));
@ -684,7 +732,11 @@ function AgentRow({
}
await refreshAfterAction();
} catch (e) {
setMessage(e instanceof Error ? e.message : String(e));
setMessage(
`Arrêt confirmé. Impossible d’actualiser l’affichage : ${
e instanceof Error ? e.message : String(e)
}`,
);
} finally {
setActionBusy(false);
}
@ -715,7 +767,7 @@ function AgentRow({
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
agentStatusClass(live, busy),
agentStatusClass(live, busy, agent.runtimeState, stopState),
)}
>
{statusLabel}
@ -770,11 +822,18 @@ function AgentRow({
<Button
size="sm"
variant="danger"
loading={actionBusy}
disabled={!agentGateway}
onClick={() => void stop()}
aria-label={stopLabel}
loading={stopState === "stopping"}
disabled={
!agentGateway ||
actionBusy ||
stopState === "stopped" ||
agent.runtimeState === "stopping" ||
agent.runtimeState === "stopped"
}
onClick={() => void stop(stopState !== "incomplete")}
>
Stop
{stopLabel}
</Button>
)}
</div>

View File

@ -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, {