Archive finished subagents across the whole track (#3368)

* feat(app): archive finished subagents together

Make the track action clean up finished managed and provider-owned children as one operation while preserving running work. Managed archives remain sequential and provider-owned dismissal stays client-local.

* test(app): clarify subagent archive assertions
This commit is contained in:
Mohamed Boudra
2026-08-14 18:16:22 +02:00
committed by GitHub
parent 23853a1515
commit 75bbca4a1b
24 changed files with 998 additions and 42 deletions
@@ -0,0 +1,79 @@
import { test } from "../support/fixtures";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
archiveFinishedSubagents,
expectArchiveFinishedInProgress,
expectArchiveFinishedRetry,
expectManagedSubagentArchived,
expectManagedSubagentUnarchived,
expectSubagentRowGone,
expectSubagentRowVisible,
holdManagedSubagentArchiveRequest,
openSubagentsTrack,
rejectNextManagedSubagentArchiveRequest,
seedParentWithSubagent,
} from "../support/helpers/subagents";
test.describe("Archive finished subagents", () => {
let workspace: SeededWorkspace;
test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "archive-finished-subagents-" });
});
test.afterAll(async () => {
await workspace?.cleanup();
});
test("archives a finished managed child from the track", async ({ page }) => {
const agents = await seedParentWithSubagent(workspace, {
parentTitle: "Archive parent",
childTitle: "Finished child",
});
await workspace.client.waitForAgentUpsert(
agents.child.id,
(snapshot) => snapshot.status === "idle",
);
const archiveGate = await holdManagedSubagentArchiveRequest(page, agents.child.id);
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
await openSubagentsTrack(page);
await expectSubagentRowVisible(page, agents.child.id);
await archiveFinishedSubagents(page);
await archiveGate.waitForRequest();
await expectSubagentRowGone(page, agents.child.id);
await expectArchiveFinishedInProgress(page, 0, 1);
await expectManagedSubagentUnarchived(workspace, agents.child.id);
archiveGate.release();
await expectManagedSubagentArchived(workspace, agents.child.id);
});
test("restores a failed child and retries the archive", async ({ page }) => {
const agents = await seedParentWithSubagent(workspace, {
parentTitle: "Retry parent",
childTitle: "Retry child",
});
await workspace.client.waitForAgentUpsert(
agents.child.id,
(snapshot) => snapshot.status === "idle",
);
const rejection = await rejectNextManagedSubagentArchiveRequest(page, agents.child.id);
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
await openSubagentsTrack(page);
await expectSubagentRowVisible(page, agents.child.id);
await archiveFinishedSubagents(page);
await rejection.waitForRejection();
await expectSubagentRowVisible(page, agents.child.id);
await expectArchiveFinishedRetry(page, 1, 1);
await archiveFinishedSubagents(page);
await expectSubagentRowGone(page, agents.child.id);
await expectManagedSubagentArchived(workspace, agents.child.id);
});
});
@@ -1,7 +1,19 @@
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
import type { SeededWorkspace } from "./seed-client";
type WebSocketMessage = string | Buffer;
export interface ManagedSubagentArchiveGate {
release(): void;
waitForRequest(): Promise<void>;
}
export interface ManagedSubagentArchiveRejection {
waitForRejection(): Promise<void>;
}
export interface SeededSubagentPair {
parent: {
id: string;
@@ -132,6 +144,147 @@ export async function expectSubagentRowGone(page: Page, childId: string): Promis
});
}
export async function expectManagedSubagentUnarchived(
workspace: Pick<SeededWorkspace, "client">,
childId: string,
): Promise<void> {
await expect(workspace.client.fetchAgent({ agentId: childId })).resolves.toMatchObject({
agent: { id: childId, archivedAt: null },
});
}
export async function expectManagedSubagentArchived(
workspace: Pick<SeededWorkspace, "client">,
childId: string,
): Promise<void> {
await expect
.poll(() => workspace.client.fetchAgent({ agentId: childId }), { timeout: 30_000 })
.toMatchObject({ agent: { id: childId, archivedAt: expect.any(String) } });
}
export async function archiveFinishedSubagents(page: Page): Promise<void> {
await page.getByRole("button", { name: "Archive finished subagents" }).click();
}
export async function expectArchiveFinishedInProgress(
page: Page,
completed: number,
total: number,
): Promise<void> {
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeDisabled();
await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible();
}
export async function expectArchiveFinishedRetry(
page: Page,
failed: number,
total: number,
): Promise<void> {
await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeEnabled();
}
export async function holdManagedSubagentArchiveRequest(
page: Page,
subagentId: string,
): Promise<ManagedSubagentArchiveGate> {
let released = false;
let resolveRequest: (() => void) | undefined;
const delayedForwards: Array<() => void> = [];
const request = new Promise<void>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
if (isArchiveRequestForSubagent(message, subagentId) && !released) {
delayedForwards.push(() => server.send(message));
resolveRequest?.();
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
return {
release() {
released = true;
for (const forward of delayedForwards.splice(0)) forward();
},
waitForRequest: () => request,
};
}
export async function rejectNextManagedSubagentArchiveRequest(
page: Page,
subagentId: string,
): Promise<ManagedSubagentArchiveRejection> {
let resolveRejection: (() => void) | undefined;
const rejection = new Promise<void>((resolve) => {
resolveRejection = resolve;
});
let rejected = false;
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const requestId = archiveRequestId(message, subagentId);
if (requestId && !rejected) {
rejected = true;
// A daemon-rejected archive RPC: the request never reaches the daemon, and its normal
// correlated error travels back through the real browser client and mutation rollback.
ws.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId,
requestType: "archive_agent_request",
error: "Archive rejected for test",
code: "archive_rejected",
},
},
}),
);
resolveRejection?.();
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
return { waitForRejection: () => rejection };
}
function isArchiveRequestForSubagent(message: WebSocketMessage, subagentId: string): boolean {
return archiveRequestId(message, subagentId) !== null;
}
function archiveRequestId(message: WebSocketMessage, subagentId: string): string | null {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(rawMessage) as {
type?: unknown;
message?: { type?: unknown; agentId?: unknown; requestId?: unknown };
};
if (
envelope.type === "session" &&
envelope.message?.type === "archive_agent_request" &&
envelope.message.agentId === subagentId &&
typeof envelope.message.requestId === "string"
) {
return envelope.message.requestId;
}
return null;
} catch {
return null;
}
}
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
const row = page.getByTestId(`subagents-track-row-${childId}`);
await expect(row).toBeVisible({ timeout: 30_000 });