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:
@ -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
|
||||
|
||||
@ -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" });
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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