Files
PaperClipAI/tests/e2e/composer-stop.spec.ts
DottaandPaperclip 07fee85eaf refactor(heartbeat): extract workspace preparation (#15807)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The heartbeat service prepares the workspace and dispatches each
agent run.
> - After the runtime extraction, `heartbeat.ts` still had 6,417 lines.
> - Workspace setup occupied one contiguous 857-line block inside
`executeRun`.
> - This pull request moves that block into one workspace preparation
module.
> - The executor keeps dispatch gates and terminal cleanup. Reviewers
can inspect workspace setup as one phase.

## Linked Issues or Issue Description

Refs #15803 and #15578. This continues the heartbeat refactor series.
The search found no open duplicate workspace preparation extraction.

**What existing behavior does this improve?**

It makes heartbeat workspace preparation easier to maintain. It does not
change the workspace policy or execution behavior.

**Current behavior**

`executeRun` contains workspace resolution, freshness decisions,
provisioning, persistence, issue binding, environment acquisition, and
realization inline.

**Proposed behavior**

`prepareHeartbeatWorkspace` owns that sequence in
`server/src/services/heartbeat/workspace-preparation.ts`. `heartbeat.ts`
calls it at the same point and uses its returned workspace, lease,
target, and metadata.

**Reason and benefit**

The main file falls from 6,417 to 5,597 lines. The workspace phase has a
typed boundary and direct regression tests.

**Breaking changes**

None. There are no schema, API, feature-flag, or execution-policy
changes.

## What Changed

- Extract the 857-line workspace preparation block without changing its
operations or their order.
- Preserve native recovery bindings, workspace persistence failure
cleanup, issue rebinding after realization, and the cloned restore
lease.
- Keep environment selection, dispatch admission, terminalization, and
final lease release in `heartbeat.ts`.
- Add 12 boundary tests with disposable PostgreSQL, real Git
repositories, and real local environment leases.
- Make the existing Stop and plan-only recovery tests await executor
completion instead of short polling deadlines. Assert exactly one
continuation for the specific plan-only source run.
- Keep server-only Node preload hooks out of the deterministic process
adapter used by Stop browser acceptance.
- Refresh workspace recovery browser acceptance for the current task
composer, and give its disposable test-drive an explicit 10-second
snapshot deadline. Production snapshot settings stay unchanged.
- Document the workspace preparation boundary in `doc/DEVELOPING.md`.

## Verification

- AST comparison: all 857 moved lines match the original block. The
remaining module body differs only at the extraction call.
- `pnpm build`: passed.
- `pnpm -r typecheck`: passed.
- Focused regression coverage: 651 distinct cases across eight suites
passed across targeted runs, including all 12 new boundary tests. The
complete 400-test recovery suite passed together on the final commit.
- Workspace bootstrap browser acceptance: both journeys passed (4.2
minutes), including preserved source edits, ignored-file exclusion,
bounded retries, and persistence across reload.
- Stop/queue browser acceptance: both process and native runner journeys
passed (2.2 minutes), covering queueing, Stop, subtree pause/cancel,
resume, and unrelated-task isolation.
- [Full repository
CI](https://github.com/paperclipai/paperclip/actions/runs/38062408589):
all 47 jobs passed, including the general and serialized test suites,
eight browser shards, build, typecheck, native runner checks, and canary
dry run.
- The local `pnpm test:run` was interrupted to prioritize the recovery
regression; the complete repository suite was verified in CI.
- Greptile: 5/5 on `c28e698b885cf5fd12f36f7b4052e7a2e6f879d7`, with no
open review threads.

## Risks

- This code writes durable workspace bindings and acquires environment
leases. A changed operation order could break recovery or cleanup after
partial setup. The extraction preserves the original statements and
exception boundaries.
- The new tests cover creation, reuse, immutable recovery bindings,
persistence failure, acquisition and realization failure, controller
ownership loss, isolation, execution policy, and lease snapshot
independence.
- Browser acceptance uses real application services with deterministic
local workers and a fake Codex protocol provider. It does not qualify
live cloud providers.

## Model Used

OpenAI GPT-6 through Codex, with code editing, shell execution, and test
tools. The exact serving model identifier and context-window size are
not exposed in 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-10 10:30:17 -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.");
const queue = await json(
await request.get(`/api/issues/${parent.id}/queued-comments`),
);
expect(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",
});
}
});
}