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

@ -259,6 +259,60 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
expect(screen.queryByText("Idle")).toBeNull();
});
it("removes the orange busy veil when backend live-state reports stopping", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const workState = new MockWorkStateGateway();
const created = await agent.createAgent("p1", {
name: "StoppingAgent",
profileId: "prof-1",
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
await layout.mutateLayout("p1", {
type: "setSession",
target: leafId,
session: "stopping-session",
});
workState._setProjectWorkState("p1", {
agents: [
{
agentId: created.id,
name: "StoppingAgent",
profileId: "prof-1",
runtimeState: "stopping",
live: {
nodeId: leafId,
sessionId: "stopping-session",
kind: "pty",
runtimeState: "stopping",
},
busy: { state: "busy", ticket: "stale-ticket", sinceMs: 1 },
tickets: [],
},
],
conversations: [],
});
render(
<DIProvider gateways={{ layout, agent, workState } as unknown as Gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
await waitFor(() =>
expect(screen.queryByTestId("agent-cell-runtime-loading")).toBeNull(),
);
expect(screen.queryByTestId("target-announcements-overlay")).toBeNull();
expect(screen.queryByLabelText("StoppingAgent arrêté")).toBeNull();
expect(screen.getByTestId("terminal-view")).toBeTruthy();
});
it("relaunches a stopped agent from scratch in the same cell", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();

View File

@ -847,6 +847,9 @@ function LeafView({
const agentWork = agentId
? workState?.agents.find((row) => row.agentId === agentId)
: undefined;
const runtimeState = agentWork?.runtimeState;
const stoppingOrStopped =
runtimeState === "stopping" || runtimeState === "stopped";
const activeTicket = agentWork?.tickets.find((ticket) => ticket.status === "inProgress");
const queuedTickets = agentWork?.tickets.filter((ticket) => ticket.status === "queued") ?? [];
const inboxDepth = Math.max(
@ -858,9 +861,12 @@ function LeafView({
agentWork?.backgroundTasks?.filter((task) =>
task.status === "completed" || task.status === "failed" || task.status === "cancelled",
) ?? [];
const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket);
const busyByWorkState =
!stoppingOrStopped &&
(agentWork?.busy.state === "busy" || Boolean(activeTicket));
const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined;
const busyActive = announcementBusyActive || busyByWorkState;
const busyActive =
!stoppingOrStopped && (announcementBusyActive || busyByWorkState);
const [cellRestart, setCellRestart] = useState<CellRestartState | null>(null);
const cellRestartRef = useRef<CellRestartState | null>(null);
const cellRestartRequestRef = useRef(0);
@ -938,7 +944,6 @@ function LeafView({
const [restartError, setRestartError] = useState(false);
const [freshRestartedSession, setFreshRestartedSession] =
useState<FreshRestartedSession | null>(null);
const runtimeState = agentWork?.runtimeState;
const livePinnedAgent = agentId
? liveAgents.find((la) => la.agentId === agentId)
: undefined;
@ -1762,7 +1767,7 @@ function LeafView({
an availability veil showing its live réflexions. Native TUI cells
need the full-cell guard; custom CLI cells keep the canonical busy
badge and composer lock only, matching their normal chat turn flow. */}
{!cellRestart && !otherRestartActive && !modelServerOverlay && cellMode !== "custom" && agentId != null && (
{!cellRestart && !otherRestartActive && !modelServerOverlay && !stoppingOrStopped && cellMode !== "custom" && agentId != null && (
<TargetAnnouncementsOverlay
projectId={projectId}
agentId={agentId}