Files
PaperClipAI/tests/e2e/composer-stop.spec.ts
T
DottaandPaperclip 60e03c002d fix(grok): hold new runs while connection needs recovery (#16445)
## Thinking Path

> - Paperclip manages agents and their work.
> - Grok Bot assignments use a managed Runner and a verified connection.
> - Stop can leave the connection held for remote-work inspection.
> - The queue started new runs before it checked that connection.
> - Those runs allocated Runners, failed, and caused more preparation
attempts.
> - This pull request checks readiness at queue admission.
> - Pending input waits for recovery without using managed compute.

## Linked Issues or Issue Description

Refs #16427 and #16422. The open Grok changes cover the coding adapter
and do not duplicate this fix.

**What happened?**

A real Stop test held the Grok connection for inspection. A second task
was pending. Four new preparation attempts acquired managed Runners,
then failed because the connection was held. No new external assignment
or webhook was sent. Pause stopped the loop, and all leases completed
cleanup.

**Expected behavior**

Keep fresh input in the durable queue until its connection is ready. Do
not allocate a Runner to discover a known connection hold. Keep existing
session reconciliation under its recorded authority.

**Steps to reproduce**

1. Connect Grok Bot and start an assignment.
2. Queue another task for that agent.
3. Stop the first assignment and leave the inspection hold in place.
4. Run queue recovery. The pending task repeatedly starts preparation
and fails.

**Paperclip version or commit**

Observed on packaged Core 5a9e6b6315 in
Cloud staging. This branch includes current master
afe5b3418d.

## What Changed

- Reuse the broker's current connection and authority checks before
fresh Grok run claims.
- Let valid connection-pool routing select a native provider before
applying any unused Grok connection hold.
- Leave pending input queued while readiness is denied. Recheck at the
claim boundary if Stop races the queue scan.
- Preserve already-started reconciliation and all later
runtime-selection and dispatch checks.
- Propagate database failures. Treat only explicit connection-authority
errors as a hold.
- Add queue, OAuth, broker and assembled heartbeat regressions.
- Update the Grok guide and existing release evidence plan.

## Verification

- Two queue admission assertions fail before the correction.
- Six focused suites pass all 813 checks, including current master's
native ownership coverage and a real healthy Codex pool member with a
held Grok connection. The routing test reaches native runtime selection
and then injects a controlled stop before provider start.
- The assembled test uses real OAuth and broker state, repeats public
queue recovery, and checks zero leases, assignments and extra webhooks.
- Repository typecheck and build pass on final head
83664a91ce. Its full local suite is still
running. The earlier full attempt was stopped as superseded after the
valid router review.
- The full suite uses a clean tool PATH. On the earlier baseline, mocked
Gemini tests reached the installed host CLI and exceeded their
five-second deadline. Both tests pass with that host CLI absent. Test
source and deadlines are unchanged.
- The existing native cleanup and queue claim-isolation tests pass. The
process-agent Stop browser journey passes locally. Its first CI attempt
queried the queue 67 ms after comment creation, before asynchronous
publication. The test now polls for the same durable entry with its
existing deadline.
- One server CI worker shut down after 1,047 tests passed. The failed
attempt remains recorded. No routing flags or gates were changed. No UI
component or schema changes.
- The final head passes 55 CI checks, with two optional skips. Greptile
reviewed that exact head at 5/5, with the earlier thread resolved and no
new actionable finding.
- Actual packaged recovery admission still needs a live repeat. The
provider currently rejects routine starts with an external rate-limit
error. This patch does not remove that provider limit.

## Risks

Readiness can change after preflight. Runtime selection and dispatch
retain their current authority checks. Already-started runs bypass this
fresh-work check so they can reconcile their original session. Other
providers keep their current queue behavior. Expected connection denials
hold input; unexpected database errors still propagate. No migration is
needed.

## Model Used

OpenAI GPT-6 in Codex, with reasoning, repository editing, shell tools
and computer use. The exact serving model ID, snapshot and context
window are not exposed by this session.

## 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>
2026-10-15 11:32:46 -05:00

494 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");
await page.getByRole("dialog").getByRole("checkbox").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",
});
}
});
}