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

@ -85,6 +85,9 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
* set by the hook once launchAgent resolves.
*/
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
const [stopStateByAgent, setStopStateByAgent] = useState<
Record<string, "stopping" | "incomplete">
>({});
const [panelNodeIds, setPanelNodeIds] = useState<Record<string, string>>({});
/**
@ -228,9 +231,19 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
setActiveAgentId(agentId);
}
function handleStop(agentId?: string) {
void vm.stopAgent(agentId);
setActiveAgentId(null);
async function handleStop(agentId: string) {
setStopStateByAgent((current) => ({ ...current, [agentId]: "stopping" }));
const stopped = await vm.stopAgent(agentId);
if (stopped) {
setActiveAgentId((current) => (current === agentId ? null : current));
setStopStateByAgent((current) => {
const next = { ...current };
delete next[agentId];
return next;
});
} else {
setStopStateByAgent((current) => ({ ...current, [agentId]: "incomplete" }));
}
}
// Determine if a template is chosen → profile selector is hidden (template imposes it).
@ -386,6 +399,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
{vm.agents.map((a) => {
const isSelected = a.id === vm.selectedAgentId;
const isRunning = a.id === activeAgentId;
const stopState = stopStateByAgent[a.id];
const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id);
const agentProfile = vm.profiles.find((p) => p.id === a.profileId) ?? null;
const profileName =
@ -561,11 +575,22 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
<Button
size="sm"
variant="ghost"
aria-label={`stop ${a.name}`}
onClick={() => handleStop(a.id)}
aria-label={
stopState === "stopping"
? `Arrêt en cours… — ${a.name}`
: stopState === "incomplete"
? `Réessayer l’arrêt — ${a.name}`
: `stop ${a.name}`
}
disabled={stopState === "stopping"}
onClick={() => void handleStop(a.id)}
className="text-danger hover:text-danger"
>
Stop
{stopState === "stopping"
? "Arrêt en cours…"
: stopState === "incomplete"
? "Réessayer l’arrêt"
: "Stop"}
</Button>
) : (
<Button

View File

@ -36,6 +36,14 @@ import { AgentsPanel } from "./AgentsPanel";
const PROJECT_ID = "proj-test-001";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
/**
* Renders `AgentsPanel` behind a `DIProvider` with an isolated
* `MockAgentGateway` (and optionally a seeded `MockProfileGateway`).
@ -451,7 +459,8 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
it("stopping an agent unmounts the terminal view", async () => {
const agent = new MockAgentGateway();
const created = await agent.createAgent(PROJECT_ID, { name: "Echo", profileId: "p1" });
const stop = vi.spyOn(agent, "stopLiveAgent");
const pendingStop = deferred<Awaited<ReturnType<typeof agent.stopLiveAgent>>>();
const stop = vi.spyOn(agent, "stopLiveAgent").mockReturnValue(pendingStop.promise);
renderPanel(agent);
await waitForIdle();
@ -464,6 +473,11 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
// Stop
fireEvent.click(screen.getByRole("button", { name: "stop Echo" }));
const stopping = screen.getByRole("button", { name: "Arrêt en cours… — Echo" });
expect((stopping as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByTestId("terminal-view")).toBeTruthy();
pendingStop.resolve({ agentId: created.id, alreadyStopped: true, stopped: [] });
await waitFor(() => {
expect(screen.queryByTestId("terminal-view")).toBeNull();
});
@ -474,6 +488,30 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
);
});
it("keeps the agent visible after an incomplete stop and exposes retry", async () => {
const agent = new MockAgentGateway();
const created = await agent.createAgent(PROJECT_ID, { name: "Main", profileId: "p1" });
const stop = vi
.spyOn(agent, "stopLiveAgent")
.mockRejectedValueOnce(new Error("cascade failed"))
.mockResolvedValueOnce({ agentId: created.id, alreadyStopped: true, stopped: [] });
renderPanel(agent);
await waitForIdle();
fireEvent.click(screen.getByRole("button", { name: "launch Main" }));
await screen.findByTestId("terminal-view");
fireEvent.click(screen.getByRole("button", { name: "stop Main" }));
expect(
await screen.findByText("Arrêt incomplet — Certains travaux sont encore actifs."),
).toBeTruthy();
expect(screen.getByTestId("terminal-view")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Réessayer l’arrêt — Main" }));
await waitFor(() => expect(stop).toHaveBeenCalledTimes(2));
await waitFor(() => expect(screen.queryByTestId("terminal-view")).toBeNull());
});
it("stops a live agent even when its TerminalView was never mounted", async () => {
const agent = new MockAgentGateway();
const created = await agent.createAgent(PROJECT_ID, { name: "Headless", profileId: "p1" });

View File

@ -145,7 +145,7 @@ export interface AgentsViewModel {
onData: (bytes: Uint8Array) => void,
) => Promise<TerminalHandle>;
/** Stops an agent PTY explicitly by closing its live session id. */
stopAgent: (agentId?: string) => Promise<void>;
stopAgent: (agentId?: string) => Promise<boolean>;
/**
* Cancels the auto-resume armed for a rate-limited agent (ARCHITECTURE §21):
* the "Annuler la reprise" action. Optimistically drops the countdown; the
@ -513,20 +513,23 @@ export function useAgents(projectId: string): AgentsViewModel {
);
const stopAgent = useCallback(
async (agentId?: string) => {
async (agentId?: string): Promise<boolean> => {
const targetAgentId = agentId ?? runningAgentId;
if (!targetAgentId) return;
setRunningAgentId((current) =>
current === targetAgentId || !targetAgentId ? null : current,
);
if (!targetAgentId) return false;
setError(null);
try {
await agent.stopLiveAgent(projectId, targetAgentId, "Arrêt demandé depuis Agents");
setRunningAgentId((current) =>
current === targetAgentId ? null : current,
);
setLiveAgents((current) =>
current.filter((candidate) => candidate.agentId !== targetAgentId),
);
} catch (e) {
setError(describe(e));
return true;
} catch {
setError("Arrêt incomplet — Certains travaux sont encore actifs.");
await refreshLiveAgents();
return false;
}
},
[agent, projectId, refreshLiveAgents, runningAgentId],