mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
## Thinking Path > - Paperclip manages agents and their assigned tasks. > - A task pause controls execution without changing task status. > - Resume can remove that pause and request work from eligible assignees. > - The preview counted blocked tasks and former executors as wake targets. > - The release route correctly skipped those targets, so the dialog promised work that did not start. > - Resume also trusted stale pause snapshots after tasks were reopened. > - This PR aligns preview with execution eligibility and rechecks current task state when releasing a pause. ## Linked Issues or Issue Description **What happened?** Pause an assigned blocked task, then open Resume. The dialog says that one task will resume and offers to wake one agent. Applying it only removes the pause. The route skips blocked and backlog tasks. A former executor can also inflate the wake count after reassignment. **Expected behavior** The dialog must distinguish removing a pause from starting work. Its wake count must use the current assignees and statuses accepted by the release route. Blocked work must stay blocked. **Steps to reproduce** 1. Open an assigned blocked task. 2. Choose Pause work, then Resume task. 3. Inspect the wake count and apply Resume work. Related runtime recovery work is tracked separately in #17097 and #17439. This PR does not change remote execution recovery. ## What Changed - Share the resume status list between preview and release. Count only current assignees on todo, in-progress, and in-review tasks for resume wakes. - Explain that Resume removes the pause. Show the existing preview warnings, including tasks that remain idle. - Disable and uncheck the wake option when no agents qualify. Send the same choice to the server. - Reevaluate previously skipped pause members after reopening or releasing another hold. Retain active-pause and unsafe-execution guards. - Add real PostgreSQL and HTTP-route regressions plus browser coverage for both task interfaces. Keep draft, ancestor-pause, and Stop/reconciliation coverage. - Update the task coordination maps. Add an opt-in dedicated test database URL for hosts where embedded PostgreSQL cannot start. ## Verification - Final head `9548eb16736f632cc111a5c13a9a994ec1d95a13`: all 55 completed checks pass, with two optional Storybook checks skipped. [CI 40188094904](https://github.com/paperclipai/paperclip/actions/runs/40188094904) passes, including typecheck, build, all general/serialized suites, and all eight E2E shards. Greptile is 5/5 on this commit; the reopened-task finding is fixed and resolved. - Red: the real PostgreSQL regression receives three agents instead of the one eligible current assignee on the original code. - Green: all 27 tree-control service, route, and unit tests pass. They cover current assignees, blocked/backlog exclusions, reopened tasks, company boundaries, remaining pause holds, and execution blockers. - The reopened-task route regression fails on the previous implementation: skipped members never reach preflight/wake, and an unsafe replay releases the hold instead of returning 409. All cases pass with the fix. - Browser: all five applicable cases pass together (90 seconds): blocked-work resume in both interfaces, draft/ancestor-pause cases in both interfaces, and the process Stop/pause/reconciliation/restart/cancel lifecycle. The optional native-runner fixture is skipped locally because its fixture binary is not configured. - All 157 task-page component tests pass on the prior commit; subsequent changes affect the server route and Stop fixture only. - `pnpm -r typecheck` and `pnpm build` pass before rebase, and CI typecheck/build passed on the previous PR commit. Server typecheck passes again after the route fix. The final-head CI run also passes these full checks. - `node scripts/check-maps.mjs`, `node scripts/check-token-gates.mjs`, and `git diff --check` pass. - Full `pnpm test:run` was attempted but did not complete: this host cannot initialize embedded PostgreSQL, and unrelated workspace-runtime tests timed out. The focused database tests and E2E suite use disposable PostgreSQL 18 instead. All general-test and serialized-test CI shards passed on the previous PR commit; the final-head CI run also passes every test shard. - The first CI run caught the existing Stop test trying to check a disabled wake option for blocked legacy work. It now asserts zero wake targets and retains the no-execution-before-reconciliation checks. - Earlier E2E attempts exposed fixture problems: assignment started the fixture before it was paused, and the classic interface had two matching editor nodes. The fixture now enables dispatch after the hold exists and scopes the final assertion to the task composer. No product assertion was removed. ## Risks Resume preview is a snapshot. The release route still checks current status, assignment, and execution blockers. It does not bypass approval, budget, or recovery gates. Pause, cancel, and restore keep their existing affected-agent semantics. The test database override only accepts a dedicated database name ending in `_test`; it runs migrations before the suite and closes its clients afterward. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, browser control, and code execution. The runtime does not expose a more specific model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
503 lines
18 KiB
TypeScript
503 lines
18 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import {
|
|
test,
|
|
expect,
|
|
type APIRequestContext,
|
|
type APIResponse,
|
|
type Page,
|
|
} from "@playwright/test";
|
|
|
|
async function json(response: APIResponse) {
|
|
const text = await response.text();
|
|
expect(response.ok(), `${response.url()}: ${response.status()} ${text}`).toBe(
|
|
true,
|
|
);
|
|
return JSON.parse(text);
|
|
}
|
|
async function task(
|
|
request: APIRequestContext,
|
|
companyId: string,
|
|
data: Record<string, unknown>,
|
|
) {
|
|
return json(
|
|
await request.post(`/api/companies/${companyId}/issues`, {
|
|
data: { title: "Composer stop acceptance", status: "backlog", ...data },
|
|
}),
|
|
);
|
|
}
|
|
async function running(
|
|
request: APIRequestContext,
|
|
issueId: string,
|
|
adapter: "process" | "paperclip_runner",
|
|
) {
|
|
let run:
|
|
| { id: string; status: string; runtimeMode?: string; processPid?: number }
|
|
| undefined;
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const runs = await json(
|
|
await request.get(`/api/issues/${issueId}/live-runs`),
|
|
);
|
|
run = runs.find(
|
|
(candidate: { status: string }) => candidate.status === "running",
|
|
);
|
|
return !!run;
|
|
},
|
|
{ timeout: 30_000 },
|
|
)
|
|
.toBe(true);
|
|
let fullRun = await json(await request.get(`/api/heartbeat-runs/${run!.id}`));
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
fullRun = await json(
|
|
await request.get(`/api/heartbeat-runs/${run!.id}`),
|
|
);
|
|
return fullRun.runtimeMode;
|
|
},
|
|
{ timeout: 15_000 },
|
|
)
|
|
.toBe(adapter === "process" ? "legacy" : "native");
|
|
if (adapter === "process") {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
fullRun = await json(
|
|
await request.get(`/api/heartbeat-runs/${run!.id}`),
|
|
);
|
|
return fullRun.processPid;
|
|
},
|
|
{ timeout: 15_000 },
|
|
)
|
|
.toBeTruthy();
|
|
}
|
|
return fullRun;
|
|
}
|
|
async function reconcileDemoExecution(
|
|
request: APIRequestContext,
|
|
issueId: string,
|
|
runId: string,
|
|
) {
|
|
// These deterministic fixtures only print output. No external action occurred.
|
|
// Master requires recorded outcomes before a cancelled provider can restart.
|
|
const activity = await json(
|
|
await request.get(`/api/issues/${issueId}/activity`),
|
|
);
|
|
const settled = activity.find(
|
|
(entry: { action: string; runId: string }) =>
|
|
entry.action === "issue.execution_recovery_settled" &&
|
|
entry.runId === runId,
|
|
);
|
|
const recovery = await json(
|
|
await request.get(`/api/issues/${issueId}/recovery-actions`),
|
|
);
|
|
const actionId = recovery.active?.id ?? settled?.details?.recoveryActionId;
|
|
expect(actionId).toBeTruthy();
|
|
await json(
|
|
await request.post(`/api/issues/${issueId}/recovery-actions/resolve`, {
|
|
data: {
|
|
actionId,
|
|
outcome: "restored",
|
|
sourceIssueStatus: "todo",
|
|
executionReconciliation: {
|
|
runId,
|
|
providerStopped: true,
|
|
actionOutcome: "not_performed",
|
|
outcomeEvidence:
|
|
"The deterministic acceptance fixture only emits console/protocol output. The verified stopped process performed no external actions.",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function menu(page: Page, action: string) {
|
|
await page
|
|
.getByRole("button", { name: "More task actions", exact: true })
|
|
.click();
|
|
await page
|
|
.locator('[data-slot="popover-content"]')
|
|
.getByRole("button", { name: action, exact: true })
|
|
.click();
|
|
}
|
|
function processAlive(pid: number) {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
test.setTimeout(120_000);
|
|
|
|
for (const adapter of ["process", "paperclip_runner"] as const) {
|
|
test(`${adapter}: queue, composer Stop, subtree pause/cancel, and resume`, async ({
|
|
page,
|
|
request,
|
|
}, testInfo) => {
|
|
test.skip(
|
|
adapter === "paperclip_runner" && !process.env.PAPERCLIP_STOP_FAKE_CODEX,
|
|
"Set PAPERCLIP_STOP_FAKE_CODEX and PAPERCLIP_RUNNER_BINARY for real runnerd with the deterministic provider.",
|
|
);
|
|
const company = await json(
|
|
await request.post("/api/companies", {
|
|
data: { name: `Composer Stop ${adapter} ${Date.now()}` },
|
|
}),
|
|
);
|
|
const originalSettings = await json(
|
|
await request.get("/api/instance/settings/experimental"),
|
|
);
|
|
const statusMetadata: Record<string, string | null>[] = [];
|
|
page.on("websocket", (socket) => {
|
|
socket.on("framereceived", ({ payload: frame }) => {
|
|
try {
|
|
const event = JSON.parse(
|
|
typeof frame === "string" ? frame : frame.toString("utf8"),
|
|
);
|
|
if (
|
|
event.companyId !== company.id ||
|
|
event.type !== "heartbeat.run.status"
|
|
)
|
|
return;
|
|
// Retain only scalar status routing evidence for this owned company,
|
|
// never raw frames, provider output, errors, or tool payloads.
|
|
const entry: Record<string, string | null> = {};
|
|
for (const key of [
|
|
"runId",
|
|
"agentId",
|
|
"status",
|
|
"issueId",
|
|
"deliveryId",
|
|
"startedAt",
|
|
"finishedAt",
|
|
] as const) {
|
|
const value = event.payload?.[key];
|
|
if (value === null || typeof value === "string") entry[key] = value;
|
|
}
|
|
if (typeof event.createdAt === "string")
|
|
entry.eventCreatedAt = event.createdAt;
|
|
statusMetadata.push(entry);
|
|
} catch {
|
|
// Non-JSON frames are irrelevant and are not retained.
|
|
}
|
|
});
|
|
});
|
|
try {
|
|
await json(
|
|
await request.patch("/api/instance/settings/experimental", {
|
|
data: { enableClassicTaskInterface: false, enableNativeRunner: true },
|
|
}),
|
|
);
|
|
async function agent(name: string) {
|
|
return json(
|
|
await request.post(`/api/companies/${company.id}/agents`, {
|
|
data: {
|
|
name,
|
|
role: "engineer",
|
|
adapterType: adapter,
|
|
adapterConfig:
|
|
adapter === "process"
|
|
? {
|
|
command: process.execPath,
|
|
args: [
|
|
"-e",
|
|
"console.log('stop fixture ready'); setInterval(() => console.log('working'), 200);",
|
|
],
|
|
// The child only runs this script; server-only fixture
|
|
// loaders must not follow NODE_OPTIONS into the adapter.
|
|
env: { NODE_OPTIONS: "" },
|
|
graceSec: 1,
|
|
}
|
|
: { provider: "codex", model: "gpt-5.1-codex-mini" },
|
|
runtimeConfig: {
|
|
heartbeat: { enabled: false, wakeOnDemand: true },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
const owner = await agent("Stop fixture parent");
|
|
const childOwner = await agent("Stop fixture child");
|
|
const otherOwner = await agent("Stop fixture unrelated");
|
|
const parent = await task(request, company.id, {
|
|
assigneeAgentId: owner.id,
|
|
});
|
|
const child = await task(request, company.id, {
|
|
title: "Child work",
|
|
parentId: parent.id,
|
|
assigneeAgentId: childOwner.id,
|
|
});
|
|
const completed = await task(request, company.id, {
|
|
title: "Finished child",
|
|
parentId: parent.id,
|
|
status: "done",
|
|
});
|
|
const other = await task(request, company.id, {
|
|
title: "Unrelated work",
|
|
assigneeAgentId: otherOwner.id,
|
|
});
|
|
for (const issue of [parent, child, other])
|
|
await json(
|
|
await request.patch(`/api/issues/${issue.id}`, {
|
|
data: { status: "todo" },
|
|
}),
|
|
);
|
|
const parentRun = await running(request, parent.id, adapter);
|
|
const childRun = await running(request, child.id, adapter);
|
|
const otherRun = await running(request, other.id, adapter);
|
|
if (adapter === "paperclip_runner") {
|
|
// A run row becomes live before its provider turn starts. Prove that the
|
|
// deterministic provider is active before attempting interruption.
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const calls = await readFile(
|
|
process.env.PAPERCLIP_STOP_CODEX_LOG!,
|
|
"utf8",
|
|
).catch(() => "");
|
|
return calls.split("turn/start").length - 1;
|
|
},
|
|
{ timeout: 30_000 },
|
|
)
|
|
.toBeGreaterThanOrEqual(3);
|
|
}
|
|
expect(parentRun.runtimeMode).toBe(
|
|
adapter === "process" ? "legacy" : "native",
|
|
);
|
|
await page.goto(`/${company.issuePrefix}/issues/${parent.identifier}`);
|
|
const stop = page.getByRole("button", { name: "Stop", exact: true });
|
|
await expect(stop).toBeVisible({ timeout: 30_000 });
|
|
const editor = page.getByRole("textbox", { name: "editable markdown" });
|
|
await editor.fill("Please check mobile too.");
|
|
await expect(stop).toHaveCount(0);
|
|
await page.getByRole("button", { name: "Send", exact: true }).click();
|
|
await expect(stop).toBeVisible();
|
|
const comments = await json(
|
|
await request.get(`/api/issues/${parent.id}/comments`),
|
|
);
|
|
expect(JSON.stringify(comments)).toContain("Please check mobile too.");
|
|
await expect.poll(async () => {
|
|
const queue = await json(
|
|
await request.get(`/api/issues/${parent.id}/queued-comments`),
|
|
);
|
|
return JSON.stringify(queue.entries);
|
|
}).toContain("Please check mobile too.");
|
|
|
|
let dispatchedAt = 0;
|
|
page.on("request", (req) => {
|
|
if (req.method() === "POST" && req.url().endsWith(`/heartbeat-runs/${parentRun.id}/cancel`))
|
|
dispatchedAt = Date.now();
|
|
});
|
|
const clickedAt = Date.now();
|
|
await stop.click();
|
|
await expect(page.getByRole("dialog")).toHaveCount(0);
|
|
await expect(
|
|
page.getByRole("button", { name: "Dismiss notification" }),
|
|
).toHaveCount(0);
|
|
await expect.poll(async () =>
|
|
(await json(await request.get(`/api/heartbeat-runs/${parentRun.id}`))).status,
|
|
{ timeout: 35_000 },
|
|
).toBe("cancelled");
|
|
const stoppedAt = Date.now();
|
|
expect(dispatchedAt - clickedAt).toBeLessThan(2000);
|
|
expect(dispatchedAt).toBeGreaterThan(0);
|
|
if (adapter === "process") {
|
|
expect(parentRun.processPid).toBeTruthy();
|
|
await expect
|
|
.poll(() => processAlive(parentRun.processPid), { timeout: 3000 })
|
|
.toBe(false);
|
|
await expect
|
|
.poll(() => processAlive(childRun.processPid), { timeout: 3000 })
|
|
.toBe(true);
|
|
} else {
|
|
const finalRun = await json(
|
|
await request.get(`/api/heartbeat-runs/${parentRun.id}`),
|
|
);
|
|
expect(finalRun.resultJson?.nativeCancellation?.dispatchState).toBe(
|
|
"acknowledged",
|
|
);
|
|
expect(
|
|
await readFile(process.env.PAPERCLIP_STOP_CODEX_LOG!, "utf8"),
|
|
).toContain("turn/interrupt");
|
|
}
|
|
await testInfo.attach(`${adapter}-timing`, {
|
|
body: JSON.stringify({
|
|
clickToRequestMs: dispatchedAt - clickedAt,
|
|
requestToStoppedMs: stoppedAt - dispatchedAt,
|
|
}),
|
|
contentType: "application/json",
|
|
});
|
|
expect(
|
|
(await json(await request.get(`/api/issues/${parent.id}/tree-control/state`))).activePauseHold,
|
|
).toBeNull();
|
|
expect((await json(await request.get(`/api/heartbeat-runs/${childRun.id}`))).status).toBe("running");
|
|
await expect(editor).toBeVisible();
|
|
await expect(page.getByText("Subtree is paused.", { exact: true })).toHaveCount(0);
|
|
// Pausing future work is a separate, explicit subtree action.
|
|
await menu(page, "Pause subtree");
|
|
await expect(page.getByRole("dialog")).toHaveCount(0);
|
|
await expect.poll(async () => (await json(await request.get(`/api/heartbeat-runs/${childRun.id}`))).status,
|
|
{ timeout: 35_000 }).toBe("cancelled");
|
|
if (adapter === "process") {
|
|
await expect.poll(() => processAlive(childRun.processPid), { timeout: 3000 }).toBe(false);
|
|
}
|
|
expect(
|
|
(
|
|
await json(
|
|
await request.get(`/api/issues/${parent.id}/tree-control/state`),
|
|
)
|
|
).activePauseHold,
|
|
).toBeTruthy();
|
|
expect(
|
|
(await json(await request.get(`/api/heartbeat-runs/${otherRun.id}`)))
|
|
.status,
|
|
).toBe("running");
|
|
await expect(
|
|
page.getByText("Subtree is paused.", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("button", { name: "Dismiss notification" }),
|
|
).toHaveCount(0);
|
|
if (adapter === "paperclip_runner") {
|
|
await expect(
|
|
page.getByRole("button", { name: /^Run cancelled/ }),
|
|
).toHaveClass(/text-muted-foreground/);
|
|
}
|
|
await page.reload();
|
|
await expect(
|
|
page.getByText("Subtree is paused.", { exact: true }),
|
|
).toBeVisible();
|
|
// Cross the isolated server's ten-second scheduler interval repeatedly.
|
|
for (let i = 0; i < 3; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 10_000));
|
|
expect(
|
|
await json(await request.get(`/api/issues/${parent.id}/live-runs`)),
|
|
).toEqual([]);
|
|
expect(
|
|
await json(await request.get(`/api/issues/${child.id}/live-runs`)),
|
|
).toEqual([]);
|
|
}
|
|
await menu(page, "Resume subtree");
|
|
const resumeWake = page.getByRole("dialog").getByRole("checkbox");
|
|
if (adapter === "process") {
|
|
// Stopped legacy work is blocked pending reconciliation. Removing the
|
|
// pause must not promise or request a wake through that recovery gate.
|
|
await expect(resumeWake).toHaveAccessibleName("Wake affected agents (0)");
|
|
await expect(resumeWake).toBeDisabled();
|
|
await expect(resumeWake).not.toBeChecked();
|
|
} else {
|
|
await resumeWake.check();
|
|
}
|
|
await page
|
|
.getByRole("dialog")
|
|
.getByRole("button", { name: "Resume subtree", exact: true })
|
|
.click();
|
|
await expect(page.getByRole("dialog")).toHaveCount(0);
|
|
if (adapter === "process") {
|
|
// Legacy processes lack runner stop/action proof, so releasing the hold
|
|
// preserves their recovery gate until the fixture reconciles them.
|
|
expect(await json(await request.get(`/api/issues/${parent.id}/live-runs`))).toEqual([]);
|
|
expect(await json(await request.get(`/api/issues/${child.id}/live-runs`))).toEqual([]);
|
|
await reconcileDemoExecution(request, parent.id, parentRun.id);
|
|
await reconcileDemoExecution(request, child.id, childRun.id);
|
|
}
|
|
// A verified stopped native runner can honor the explicitly selected
|
|
// Wake agents option without another manual reconciliation step.
|
|
const resumedParentRun = await running(request, parent.id, adapter);
|
|
const resumedChildRun = await running(request, child.id, adapter);
|
|
expect(resumedParentRun.id).not.toBe(parentRun.id);
|
|
expect(resumedChildRun.id).not.toBe(childRun.id);
|
|
if (adapter === "paperclip_runner") {
|
|
await expect.poll(async () => {
|
|
const calls = await readFile(process.env.PAPERCLIP_STOP_CODEX_LOG!, "utf8");
|
|
return calls.split("turn/start").length - 1;
|
|
}, { timeout: 30_000 }).toBeGreaterThanOrEqual(5);
|
|
await page.screenshot({ path: testInfo.outputPath("native-resumed.png"), fullPage: true });
|
|
}
|
|
await menu(page, "Pause subtree");
|
|
await expect(page.getByRole("dialog")).toHaveCount(0);
|
|
await expect(
|
|
page.getByText("Subtree is paused.", { exact: true }),
|
|
).toBeVisible();
|
|
await menu(page, "Cancel subtree...");
|
|
const dialog = page.getByRole("dialog");
|
|
await expect(
|
|
dialog.getByRole("heading", { name: "Cancel subtree?" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
dialog.locator('textarea, input[type="checkbox"]'),
|
|
).toHaveCount(0);
|
|
await dialog.getByRole("button", { name: "Keep tasks" }).click();
|
|
expect(
|
|
(await json(await request.get(`/api/issues/${parent.id}`))).status,
|
|
).not.toBe("cancelled");
|
|
await menu(page, "Cancel subtree...");
|
|
await dialog
|
|
.getByRole("button", { name: "Cancel 2 tasks", exact: true })
|
|
.click();
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await json(await request.get(`/api/issues/${child.id}`))).status,
|
|
)
|
|
.toBe("cancelled");
|
|
expect(
|
|
(await json(await request.get(`/api/issues/${parent.id}`))).status,
|
|
).toBe("cancelled");
|
|
expect(
|
|
(await json(await request.get(`/api/issues/${completed.id}`))).status,
|
|
).toBe("done");
|
|
expect(
|
|
(await json(await request.get(`/api/heartbeat-runs/${otherRun.id}`)))
|
|
.status,
|
|
).toBe("running");
|
|
// The child was never opened, so no child run-history cache can hide a
|
|
// missing task association. Observe this new run's retryable terminal
|
|
// delivery before judging the final notification state.
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
statusMetadata.find(
|
|
(entry) =>
|
|
entry.runId === resumedChildRun.id &&
|
|
entry.status === "cancelled" &&
|
|
typeof entry.deliveryId === "string" &&
|
|
entry.deliveryId.length > 0,
|
|
),
|
|
// The real status-delivery sweep runs every 15 seconds.
|
|
{ timeout: 20_000 },
|
|
)
|
|
.toMatchObject({
|
|
runId: resumedChildRun.id,
|
|
issueId: child.id,
|
|
status: "cancelled",
|
|
});
|
|
await expect(
|
|
page.getByRole("button", { name: "Dismiss notification" }),
|
|
).toHaveCount(0);
|
|
await page.screenshot({
|
|
path: testInfo.outputPath(`${adapter}-cancelled.png`),
|
|
});
|
|
} finally {
|
|
const statusEvidence = JSON.stringify(statusMetadata, null, 2);
|
|
// The company is disposable and scoped to this test invocation.
|
|
await request.patch(`/api/companies/${company.id}`, {
|
|
data: { status: "archived" },
|
|
});
|
|
await request.patch("/api/instance/settings/experimental", {
|
|
data: {
|
|
enableClassicTaskInterface:
|
|
originalSettings.enableClassicTaskInterface,
|
|
enableNativeRunner: originalSettings.enableNativeRunner,
|
|
},
|
|
});
|
|
await testInfo.attach("owned-company-status-metadata", {
|
|
body: statusEvidence,
|
|
contentType: "application/json",
|
|
});
|
|
}
|
|
});
|
|
}
|