mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Heartbeat runs start workspace services and receive service reports from adapters. > - Service context updates and readiness notices are spread through the executor. > - These steps share a clear responsibility, while process and lease ownership already live elsewhere. > - This pull request puts service coordination in one module. > - The executor retains the same dispatch and cleanup order. ## Linked Issues or Issue Description **What existing behavior does this improve?** Maintenance of heartbeat execution. This is the first of two final focused extractions after #15973. Related #15653 changes runtime callback origins; this PR preserves that policy. **Current behavior** `heartbeat.ts` has 4,372 lines and 178,459 bytes. It validates service intents, starts services, persists adapter reports and primary URLs, and posts readiness notices inline. **Proposed behavior** `heartbeat/runtime-services.ts` owns those steps in 212 lines. `heartbeat.ts` becomes 4,215 lines and 173,657 bytes. Process registries and leases stay in `workspace-runtime.ts`. The executor still releases services in its original cleanup path. **Reason and benefit** Service coordination has named preparation and result phases. The execution path is easier to read without changing service lifetime or moving cleanup policy. **Breaking changes** None intended. All 140 named public heartbeat exports remain available, including `postWorkspaceReadyComment`. ## What Changed - Move service-intent validation, startup, adapter-result persistence, and readiness notices into one module. - Preserve mutable run context, primary URL selection, notice error handling, and release ordering. - Add four public-path tests using disposable PostgreSQL, real HTTP service processes, and a fixture adapter. - Extend browser recovery acceptance to fetch the actual service URL, check the readiness notice, and verify listener cleanup. - Update the module map and acceptance instructions. ## Verification - Before extraction: all five tests pass, including the four new characterization cases and existing notice formatting test. - After extraction: all 17 tests pass across service coordination, notice formatting, output recording, and runtime authorization. - Structural comparison confirms that all moved bodies and the reconstructed executor match the baseline. Module boundary checks pass. - `pnpm -r typecheck` and `pnpm build` pass. - Extended isolated browser acceptance: **2/2 pass** (3.7 minutes), covering actual service readiness, recovery, bounded failure, and cleanup. At `8e33e2032de681082f128470eb8ab0711b382afe`, all 55 current-head checks pass (two configured skips), including hosted full-suite tests. Greptile is 5/5 with no findings or review threads. ## Risks - Service context must be persisted before adapter dispatch and before run completion. The tests inspect both phases and preserve the first service URL when adapter reports arrive. - Notice failures must remain nonfatal. The public-path test checks both startup and result notices. - Shared services must survive run cleanup; ephemeral services must stop after success or failure. Tests use actual processes and HTTP requests. - The fixture supplies UUID service report IDs, as required by the database. The existing generated-ID fallback for reports without IDs is outside this extraction. - Test adapters and services are local fixtures; these tests make no live provider calls. ## Model Used OpenAI Codex, based on GPT-6, assisted with reasoning, code edits, tool execution, and verification. The exact deployment model ID 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>
98 lines
6.8 KiB
TypeScript
98 lines
6.8 KiB
TypeScript
import { test, expect } from "@playwright/test";
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { seedWorkspaceBootstrap } from "./seed.mjs";
|
|
|
|
// Opt-in: requires the isolated test-drive launched with this suite's Git shim.
|
|
// Never attach these fault-injection tests to a regular development instance.
|
|
const base = process.env.WORKSPACE_BOOTSTRAP_TEST_URL;
|
|
test.skip(!base, "Launch the disposable workspace-bootstrap test-drive; see README.md");
|
|
|
|
for (const persistent of [false, true]) {
|
|
test(persistent ? "exhausts the shared retry budget with an actionable stop" : "recovers and completes through the task UI without manual Retry", async ({ page }, info) => {
|
|
const fixture = await seedWorkspaceBootstrap(base!, persistent);
|
|
const api = async (route: string) => {
|
|
const response = await page.request.get(`${base}/api${route}`);
|
|
expect(response.ok(), await response.text()).toBeTruthy();
|
|
return response.json();
|
|
};
|
|
const title = `${persistent ? "Bound persistent failure" : "Recover and preserve work"} ${Date.now()}`;
|
|
await page.goto(`${base}/${fixture.prefix}/dashboard`);
|
|
const announcement = page.getByRole("button", { name: "Dismiss announcement" });
|
|
if (await announcement.isVisible()) await announcement.click();
|
|
await page.getByRole("link", { name: "Tasks", exact: true }).click();
|
|
await page.getByRole("button", { name: "New Task", exact: true }).last().click();
|
|
await page.getByRole("textbox", { name: "editable markdown" }).fill(title);
|
|
await page.getByRole("button", { name: "Select assignee", exact: true }).click();
|
|
await page.getByRole("option").filter({ hasText: fixture.agentName }).click();
|
|
// Let the closing popover unmount before clicking another popover trigger.
|
|
await expect(page.getByRole("searchbox", { name: "Search assignees", includeHidden: true })).toHaveCount(0);
|
|
// The preceding popover's focus restoration can consume the first click.
|
|
await expect(async () => {
|
|
if (!await page.getByRole("textbox", { name: "Search projects..." }).isVisible()) {
|
|
await page.getByRole("button", { name: "Project", exact: true }).click();
|
|
}
|
|
await expect(page.getByRole("textbox", { name: "Search projects..." })).toBeVisible({ timeout: 1_000 });
|
|
}).toPass({ timeout: 10_000, intervals: [1_000] });
|
|
await page.getByRole("button", { name: fixture.projectName, exact: true }).click();
|
|
await expect(page.getByRole("textbox", { name: "Search projects...", includeHidden: true })).toHaveCount(0);
|
|
const created = page.waitForResponse(response =>
|
|
response.request().method() === "POST"
|
|
&& new URL(response.url()).pathname === `/api/companies/${fixture.companyId}/issues`,
|
|
);
|
|
await page.getByRole("button", { name: "Create task", exact: true }).click();
|
|
const response = await created;
|
|
expect(response.ok(), await response.text()).toBeTruthy();
|
|
const task = await response.json();
|
|
expect(task.description).toBe(title);
|
|
const taskLink = page.getByRole("complementary").getByRole("link", { name: task.title, exact: true });
|
|
await expect(taskLink).toBeVisible();
|
|
// Follow the UI's actual persisted link, including across task-tab state.
|
|
const href = await taskLink.getAttribute("href");
|
|
expect(href).toBeTruthy();
|
|
await page.goto(new URL(href!, base).href);
|
|
const runs = async () => (await api(`/companies/${fixture.companyId}/heartbeat-runs`)).filter((row: { agentId: string }) => row.agentId === fixture.agentId);
|
|
await expect.poll(async () => (await runs()).some((row: { errorCode: string }) => row.errorCode === "workspace_git_scan_timeout"), { timeout: 30_000 }).toBe(true);
|
|
await expect(page.getByText(/Agent resumes in/)).toBeVisible();
|
|
await page.screenshot({ path: info.outputPath("scheduled-retry.png"), fullPage: true });
|
|
await expect.poll(async () => (await api(`/issues/${task.id}`)).status, { timeout: 150_000 }).toBe(persistent ? "blocked" : "done");
|
|
// The worker updates the task before its process exit is persisted.
|
|
await expect.poll(async () => (await runs()).filter((row: { status: string }) => ["running", "queued", "scheduled_retry"].includes(row.status)).length).toBe(0);
|
|
const history = await runs();
|
|
expect(history).toHaveLength(persistent ? 3 : 2);
|
|
expect(history.filter((row: { status: string }) => row.status === "succeeded")).toHaveLength(persistent ? 0 : 1);
|
|
if (persistent) {
|
|
await expect(page.getByText("Workspace scan timed out", { exact: true })).toBeVisible();
|
|
await expect(page.getByText("No live execution path", { exact: true })).toHaveCount(0);
|
|
await page.waitForTimeout(35_000);
|
|
expect(await runs()).toHaveLength(3);
|
|
} else {
|
|
await expect(page.getByText(/Workspace recovered automatically\./)).toBeVisible();
|
|
const project = await api(`/projects/${fixture.projectId}`);
|
|
const sourceCopy = project.workspaces.find((row: { name: string }) => row.name === "Source copy");
|
|
const completed = history.find((row: { status: string }) => row.status === "succeeded");
|
|
// The worker already verified the managed copy with its run-scoped API.
|
|
// Independently confirm the configured source was never overwritten.
|
|
expect(sourceCopy).toBeTruthy();
|
|
expect(completed.scheduledRetryAttempt).toBe(1);
|
|
const completedRun = await api(`/heartbeat-runs/${completed.id}`);
|
|
const completedLog = await api(`/heartbeat-runs/${completed.id}/log`);
|
|
expect(completedRun.stdoutExcerpt).toContain("Workspace recovery verification complete.");
|
|
expect(completedRun.lastOutputSeq).toBeGreaterThan(0);
|
|
expect(completedRun.lastOutputBytes).toBe(completedRun.logBytes);
|
|
expect(completedLog.content).toContain("Workspace recovery verification complete.");
|
|
const [runtimeService] = completedRun.contextSnapshot.paperclipRuntimeServices;
|
|
expect(runtimeService).toMatchObject({ serviceName: "fixture-web", companyId: fixture.companyId, provider: "local_process" });
|
|
const comments = await api(`/issues/${task.id}/comments`);
|
|
expect(comments.some((comment: { body: string }) => comment.body.includes("## Workspace Ready") && comment.body.includes(runtimeService.url))).toBe(true);
|
|
// Run status precedes final cleanup; wait for the owned ephemeral listener to stop.
|
|
await expect.poll(async () => fetch(runtimeService.url, { signal: AbortSignal.timeout(2_000) }).then(() => true, () => false)).toBe(false);
|
|
expect(await readFile(path.join(fixture.source, "README.md"), "utf8")).toBe("Existing uncommitted work\n");
|
|
}
|
|
await page.reload();
|
|
await expect(page.getByRole("button", { name: new RegExp(`^Change status \\(current: ${persistent ? "Blocked" : "Done"}`) }).first()).toBeVisible();
|
|
await expect(page.getByText(/Agent resumes in/)).toHaveCount(0);
|
|
await page.screenshot({ path: info.outputPath("settled-task.png"), fullPage: true });
|
|
});
|
|
}
|