mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 03:08:10 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Managed workspaces run local web services and embedded PostgreSQL
databases
> - A listening process could return an unhealthy response and still be
reused
> - Embedded PostgreSQL failures had no bounded restart owner
> - Cleanup inferred ownership from a branch slug instead of exact
persisted instance data
> - This pull request validates runtime health, supervises database
recovery, and uses exact cleanup ownership
> - The benefit is reliable replacement of degraded services without
deleting active instances
## Linked Issues or Issue Description
**What happened?**
Workspace reconciliation could reuse a degraded Paperclip process after
any successful HTTP response. Embedded PostgreSQL could stop without
bounded recovery. Cleanup could infer database ownership from a branch
slug and select the wrong instance.
**Expected behavior**
Paperclip must require a semantic healthy response from the assigned
loopback listener. It must replace degraded processes. It must supervise
embedded PostgreSQL with bounded restarts. Cleanup must use exact
persisted worktree and instance-root ownership.
**Steps to reproduce**
1. Start a managed workspace runtime.
2. Make its health endpoint return HTTP 200 with an unhealthy status, or
stop its embedded PostgreSQL process.
3. Reconcile the workspace or run instance cleanup.
4. Observe that the old implementation can reuse the degraded runtime or
infer ownership from its branch slug.
**Paperclip version or commit**
Current `master` before this pull request.
**Deployment mode**
Local dev with managed workspace services.
## What Changed
- Require `{ "status": "ok" }` from the assigned loopback health
endpoint before runtime reuse or adoption.
- Refresh persisted runtime health and replace degraded managed
processes.
- Add bounded embedded PostgreSQL restart supervision with coordinated
shutdown and hot-restart support.
- Stop the unhealthy web process when PostgreSQL recovery is exhausted
so reconciliation can replace it.
- Require exact persisted instance-root ownership before cleanup can
reclaim an embedded database.
- Add focused regression tests for degraded HTTP responses, ownership
mismatches, bounded recovery, active instance preservation, and
confirmed orphan reclamation.
## Verification
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/server test --
src/embedded-postgres-supervisor.test.ts
src/services/workspace-instance-cleanup.test.ts
src/services/workspace-runtime.test.ts
src/services/execution-workspaces-service.test.ts`
- `pnpm -r typecheck`
- `pnpm build`
- `git diff --check`
- The full local stable test runner also found host-owned listeners on
ports 42000 and 52000. Those listeners conflict with the exposure test
fixture. The focused changed suites pass, and CI runs on a clean host.
## Risks
- A custom process that returns HTTP 2xx without the Paperclip health
contract is now degraded by design.
- Restart exhaustion terminates the managed web process. The runtime
reconciler then starts a clean process.
- Cleanup now fails closed when persisted ownership is missing. This can
retain an ambiguous orphan for manual review.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI GPT-5 Codex. The exact serving revision and context-window size
are not exposed. The model used agentic reasoning, repository tools,
code execution, and test execution.
## 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)
- [ ] 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
- [ ] All Paperclip CI gates are green
- [ ] 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>
358 lines
16 KiB
TypeScript
358 lines
16 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
cleanupWorktreeInstanceArtifacts,
|
|
deriveWorktreeInstanceId,
|
|
readManagedWorktreeInstanceOwnership,
|
|
readWorktreeInstancePointer,
|
|
stopEmbeddedPostgresIfRunning,
|
|
} from "../services/workspace-instance-cleanup.js";
|
|
import type { WorkspaceOperation } from "@paperclipai/shared";
|
|
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.js";
|
|
|
|
const tempRoots = new Set<string>();
|
|
|
|
async function makeTempRoot(prefix: string): Promise<string> {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
tempRoots.add(root);
|
|
return root;
|
|
}
|
|
|
|
async function writeWorkspaceEnv(workspacePath: string, homeDir: string, instanceId: string): Promise<void> {
|
|
const envDir = path.join(workspacePath, ".paperclip");
|
|
await fs.mkdir(envDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(envDir, ".env"),
|
|
`PAPERCLIP_HOME=${JSON.stringify(homeDir)}\nPAPERCLIP_INSTANCE_ID=${JSON.stringify(instanceId)}\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
function createRecorderDouble() {
|
|
const operations: Array<{
|
|
status: string;
|
|
metadata: Record<string, unknown> | null;
|
|
system: string | null;
|
|
}> = [];
|
|
const recorder: WorkspaceOperationRecorder = {
|
|
attachExecutionWorkspaceId: async () => {},
|
|
recordOperation: async (input) => {
|
|
const result = await input.run();
|
|
operations.push({
|
|
status: result.status ?? "succeeded",
|
|
metadata: input.metadata ?? null,
|
|
system: result.system ?? null,
|
|
});
|
|
return {
|
|
id: `op-${operations.length}`,
|
|
companyId: "company-1",
|
|
executionWorkspaceId: null,
|
|
heartbeatRunId: null,
|
|
issueId: null,
|
|
phase: input.phase,
|
|
command: input.command ?? null,
|
|
cwd: input.cwd ?? null,
|
|
status: (result.status ?? "succeeded") as WorkspaceOperation["status"],
|
|
exitCode: result.exitCode ?? null,
|
|
logStore: null,
|
|
logRef: null,
|
|
logBytes: null,
|
|
logSha256: null,
|
|
logCompressed: false,
|
|
stdoutExcerpt: result.stdout ?? null,
|
|
stderrExcerpt: result.stderr ?? null,
|
|
metadata: input.metadata ?? null,
|
|
startedAt: new Date(),
|
|
finishedAt: new Date(),
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
},
|
|
};
|
|
return { recorder, operations };
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(Array.from(tempRoots, (root) => fs.rm(root, { recursive: true, force: true })));
|
|
tempRoots.clear();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("worktree instance cleanup", () => {
|
|
it("derives different instance IDs for distinct paths with the same normalized name", () => {
|
|
const parent = path.join(os.tmpdir(), "paperclip-instance-id-collision");
|
|
const plusPath = path.join(parent, "feature+cleanup");
|
|
const dashPath = path.join(parent, "feature-cleanup");
|
|
|
|
expect(deriveWorktreeInstanceId(plusPath)).not.toBe(deriveWorktreeInstanceId(dashPath));
|
|
expect(deriveWorktreeInstanceId(plusPath)).toMatch(/^feature-cleanup-[a-f0-9]{12}$/);
|
|
expect(deriveWorktreeInstanceId(dashPath)).toMatch(/^feature-cleanup-[a-f0-9]{12}$/);
|
|
});
|
|
it("removes an instance directory inside the managed worktree instances root", async () => {
|
|
const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-");
|
|
const workspacePath = await makeTempRoot("paperclip-cleanup-workspace-");
|
|
const instanceId = "pap-16075";
|
|
const instanceRoot = path.join(worktreesDir, "instances", instanceId);
|
|
await fs.mkdir(path.join(instanceRoot, "db"), { recursive: true });
|
|
await fs.writeFile(path.join(instanceRoot, "marker"), "remove me", "utf8");
|
|
await writeWorkspaceEnv(workspacePath, worktreesDir, instanceId);
|
|
|
|
const pointer = await readWorktreeInstancePointer(workspacePath);
|
|
expect(pointer).not.toBeNull();
|
|
const result = await cleanupWorktreeInstanceArtifacts({
|
|
pointer: pointer!,
|
|
workspaceId: "workspace-1",
|
|
workspacePath,
|
|
expectedInstanceId: instanceId,
|
|
expectedInstanceRoot: instanceRoot,
|
|
worktreesDir,
|
|
});
|
|
|
|
expect(result).toMatchObject({ status: "removed", postgresStopped: false });
|
|
await expect(fs.stat(instanceRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it("preserves a collision-resistant active instance when persisted ownership is absent", async () => {
|
|
const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-");
|
|
const workspacePath = await makeTempRoot("paperclip-cleanup-workspace-");
|
|
const instanceId = deriveWorktreeInstanceId(workspacePath);
|
|
const instanceRoot = path.join(worktreesDir, "instances", instanceId);
|
|
await fs.mkdir(path.join(instanceRoot, "db"), { recursive: true });
|
|
await fs.writeFile(path.join(instanceRoot, "marker"), "remove me", "utf8");
|
|
await writeWorkspaceEnv(workspacePath, worktreesDir, instanceId);
|
|
|
|
const pointer = await readWorktreeInstancePointer(workspacePath);
|
|
const result = await cleanupWorktreeInstanceArtifacts({
|
|
pointer: pointer!,
|
|
workspaceId: "workspace-1",
|
|
workspacePath,
|
|
expectedInstanceId: instanceId,
|
|
expectedInstanceRoot: null,
|
|
worktreesDir,
|
|
});
|
|
|
|
expect(result).toMatchObject({ status: "refused", instanceRoot });
|
|
expect((result as { warning: string }).warning).toContain("no persisted instance root");
|
|
await expect(fs.readFile(path.join(instanceRoot, "marker"), "utf8")).resolves.toBe("remove me");
|
|
});
|
|
|
|
it("refuses and logs an instance pointer outside the managed worktree root", async () => {
|
|
const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-");
|
|
const liveHome = await makeTempRoot("paperclip-live-home-");
|
|
const workspacePath = await makeTempRoot("paperclip-cleanup-workspace-");
|
|
const liveInstanceRoot = path.join(liveHome, "instances", "default");
|
|
await fs.mkdir(liveInstanceRoot, { recursive: true });
|
|
await fs.writeFile(path.join(liveInstanceRoot, "marker"), "keep me", "utf8");
|
|
await writeWorkspaceEnv(workspacePath, liveHome, "default");
|
|
const { recorder, operations } = createRecorderDouble();
|
|
const stopEmbeddedPostgres = vi.fn(async () => false);
|
|
const removeInstanceRoot = vi.fn(async () => {});
|
|
|
|
const pointer = await readWorktreeInstancePointer(workspacePath);
|
|
const result = await cleanupWorktreeInstanceArtifacts({
|
|
pointer: pointer!,
|
|
workspaceId: "workspace-1",
|
|
workspacePath,
|
|
expectedInstanceId: "default",
|
|
expectedInstanceRoot: path.join(worktreesDir, "instances", "default"),
|
|
worktreesDir,
|
|
recorder,
|
|
dependencies: { stopEmbeddedPostgres, removeInstanceRoot },
|
|
});
|
|
|
|
expect(result).toMatchObject({ status: "refused", instanceRoot: liveInstanceRoot });
|
|
expect(stopEmbeddedPostgres).not.toHaveBeenCalled();
|
|
expect(removeInstanceRoot).not.toHaveBeenCalled();
|
|
expect(await fs.readFile(path.join(liveInstanceRoot, "marker"), "utf8")).toBe("keep me");
|
|
expect(operations).toEqual([
|
|
expect.objectContaining({
|
|
status: "skipped",
|
|
metadata: expect.objectContaining({
|
|
cleanupAction: "remove_worktree_instance",
|
|
refusalReason: "outside_managed_instances_dir",
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("treats a missing repo-local env file as a no-op", async () => {
|
|
const workspacePath = await makeTempRoot("paperclip-cleanup-workspace-");
|
|
await expect(readWorktreeInstancePointer(workspacePath)).resolves.toBeNull();
|
|
});
|
|
|
|
it("captures the managed instance root for persisted workspace ownership", async () => {
|
|
const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-");
|
|
const workspacePath = await makeTempRoot("paperclip-cleanup-workspace-");
|
|
const instanceRoot = path.join(worktreesDir, "instances", "owned-instance");
|
|
await fs.mkdir(instanceRoot, { recursive: true });
|
|
await writeWorkspaceEnv(workspacePath, worktreesDir, "owned-instance");
|
|
|
|
await expect(
|
|
readManagedWorktreeInstanceOwnership(workspacePath, worktreesDir),
|
|
).resolves.toEqual({ instanceId: "owned-instance", instanceRoot });
|
|
});
|
|
it("stops embedded Postgres before deleting the instance root", async () => {
|
|
const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-");
|
|
const workspacePath = await makeTempRoot("paperclip-cleanup-workspace-");
|
|
const instanceRoot = path.join(worktreesDir, "instances", "ordered-cleanup");
|
|
await fs.mkdir(path.join(instanceRoot, "db"), { recursive: true });
|
|
await writeWorkspaceEnv(workspacePath, worktreesDir, "ordered-cleanup");
|
|
const calls: string[] = [];
|
|
|
|
const pointer = await readWorktreeInstancePointer(workspacePath);
|
|
const result = await cleanupWorktreeInstanceArtifacts({
|
|
pointer: pointer!,
|
|
workspaceId: "workspace-1",
|
|
workspacePath,
|
|
expectedInstanceId: "ordered-cleanup",
|
|
expectedInstanceRoot: instanceRoot,
|
|
worktreesDir,
|
|
dependencies: {
|
|
stopEmbeddedPostgres: async (dataDir) => {
|
|
expect(dataDir).toBe(path.join(instanceRoot, "db"));
|
|
expect(await fs.stat(instanceRoot)).toBeDefined();
|
|
calls.push("stop");
|
|
return true;
|
|
},
|
|
removeInstanceRoot: async (target) => {
|
|
calls.push("remove");
|
|
await fs.rm(target, { recursive: true, force: true });
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result).toMatchObject({ status: "removed", postgresStopped: true });
|
|
expect(calls).toEqual(["stop", "remove"]);
|
|
});
|
|
|
|
it("refuses a pointer rewritten to another workspace's managed instance", async () => {
|
|
const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-");
|
|
const workspacePath = await makeTempRoot("paperclip-cleanup-workspace-");
|
|
const siblingInstanceRoot = path.join(worktreesDir, "instances", "feature-sibling");
|
|
await fs.mkdir(path.join(siblingInstanceRoot, "db"), { recursive: true });
|
|
await fs.writeFile(path.join(siblingInstanceRoot, "marker"), "keep me", "utf8");
|
|
await writeWorkspaceEnv(workspacePath, worktreesDir, "feature-sibling");
|
|
const { recorder, operations } = createRecorderDouble();
|
|
const stopEmbeddedPostgres = vi.fn(async () => false);
|
|
const removeInstanceRoot = vi.fn(async () => {});
|
|
|
|
const pointer = await readWorktreeInstancePointer(workspacePath);
|
|
const result = await cleanupWorktreeInstanceArtifacts({
|
|
pointer: pointer!,
|
|
workspaceId: "workspace-1",
|
|
workspacePath,
|
|
expectedInstanceId: "feature-sibling",
|
|
expectedInstanceRoot: path.join(worktreesDir, "instances", "feature-owner"),
|
|
worktreesDir,
|
|
recorder,
|
|
dependencies: { stopEmbeddedPostgres, removeInstanceRoot },
|
|
});
|
|
|
|
expect(result).toMatchObject({ status: "refused", instanceRoot: siblingInstanceRoot });
|
|
expect((result as { warning: string }).warning).toContain("persisted instance root");
|
|
expect(stopEmbeddedPostgres).not.toHaveBeenCalled();
|
|
expect(removeInstanceRoot).not.toHaveBeenCalled();
|
|
expect(await fs.readFile(path.join(siblingInstanceRoot, "marker"), "utf8")).toBe("keep me");
|
|
expect(operations).toEqual([
|
|
expect.objectContaining({
|
|
status: "skipped",
|
|
metadata: expect.objectContaining({
|
|
expectedInstanceRoot: path.join(worktreesDir, "instances", "feature-owner"),
|
|
refusalReason: "instance_root_workspace_mismatch",
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
it("refuses a managed-root symlink that canonically escapes the guard", async () => {
|
|
const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-");
|
|
const outsideRoot = await makeTempRoot("paperclip-outside-instance-");
|
|
const workspacePath = await makeTempRoot("paperclip-cleanup-workspace-");
|
|
const instancesDir = path.join(worktreesDir, "instances");
|
|
await fs.mkdir(instancesDir, { recursive: true });
|
|
await fs.symlink(outsideRoot, path.join(instancesDir, "escaped"), "dir");
|
|
await writeWorkspaceEnv(workspacePath, worktreesDir, "escaped");
|
|
|
|
const pointer = await readWorktreeInstancePointer(workspacePath);
|
|
const result = await cleanupWorktreeInstanceArtifacts({
|
|
pointer: pointer!,
|
|
workspaceId: "workspace-1",
|
|
workspacePath,
|
|
expectedInstanceId: "escaped",
|
|
expectedInstanceRoot: path.join(worktreesDir, "instances", "escaped"),
|
|
worktreesDir,
|
|
});
|
|
|
|
expect(result).toMatchObject({ status: "refused" });
|
|
expect((result as { warning: string }).warning).toContain("canonical path");
|
|
await expect(fs.stat(outsideRoot)).resolves.toBeDefined();
|
|
});
|
|
|
|
it("refuses when the managed instances directory itself is a symlink", async () => {
|
|
const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-");
|
|
const liveInstancesDir = await makeTempRoot("paperclip-live-instances-");
|
|
const workspacePath = await makeTempRoot("paperclip-cleanup-workspace-");
|
|
const liveInstanceRoot = path.join(liveInstancesDir, "default");
|
|
await fs.mkdir(liveInstanceRoot, { recursive: true });
|
|
await fs.symlink(liveInstancesDir, path.join(worktreesDir, "instances"), "dir");
|
|
await writeWorkspaceEnv(workspacePath, worktreesDir, "default");
|
|
|
|
const pointer = await readWorktreeInstancePointer(workspacePath);
|
|
const result = await cleanupWorktreeInstanceArtifacts({
|
|
pointer: pointer!,
|
|
workspaceId: "workspace-1",
|
|
workspacePath,
|
|
expectedInstanceId: "default",
|
|
expectedInstanceRoot: path.join(worktreesDir, "instances", "default"),
|
|
worktreesDir,
|
|
});
|
|
|
|
expect(result).toMatchObject({ status: "refused" });
|
|
expect((result as { warning: string }).warning).toContain("managed instances directory");
|
|
await expect(fs.stat(liveInstanceRoot)).resolves.toBeDefined();
|
|
});
|
|
it("refuses an instance pointer that belongs to a sibling worktree", async () => {
|
|
const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-");
|
|
const workspacePath = await makeTempRoot("paperclip-cleanup-workspace-");
|
|
const siblingRoot = path.join(worktreesDir, "instances", "sibling-worktree");
|
|
await fs.mkdir(siblingRoot, { recursive: true });
|
|
await fs.writeFile(path.join(siblingRoot, "marker"), "keep me", "utf8");
|
|
await writeWorkspaceEnv(workspacePath, worktreesDir, "sibling-worktree");
|
|
const stopEmbeddedPostgres = vi.fn(async () => false);
|
|
const removeInstanceRoot = vi.fn(async () => {});
|
|
|
|
const pointer = await readWorktreeInstancePointer(workspacePath);
|
|
const result = await cleanupWorktreeInstanceArtifacts({
|
|
pointer: pointer!,
|
|
workspaceId: "workspace-1",
|
|
workspacePath,
|
|
expectedInstanceId: "owned-worktree",
|
|
expectedInstanceRoot: null,
|
|
worktreesDir,
|
|
dependencies: { stopEmbeddedPostgres, removeInstanceRoot },
|
|
});
|
|
|
|
expect(result).toMatchObject({ status: "refused", instanceRoot: siblingRoot });
|
|
expect((result as { warning: string }).warning).toContain("expected workspace instance");
|
|
expect(stopEmbeddedPostgres).not.toHaveBeenCalled();
|
|
expect(removeInstanceRoot).not.toHaveBeenCalled();
|
|
expect(await fs.readFile(path.join(siblingRoot, "marker"), "utf8")).toBe("keep me");
|
|
});
|
|
|
|
it("treats an ESRCH signal race as an already-stopped PostgreSQL process", async () => {
|
|
const instanceRoot = await makeTempRoot("paperclip-postgres-exit-race-");
|
|
const dataDir = path.join(instanceRoot, "db");
|
|
await fs.mkdir(dataDir, { recursive: true });
|
|
await fs.writeFile(path.join(dataDir, "postmaster.pid"), `4242\n${dataDir}\n`, "utf8");
|
|
const signalError = Object.assign(new Error("process exited"), { code: "ESRCH" });
|
|
|
|
await expect(stopEmbeddedPostgresIfRunning(dataDir, {
|
|
processIsAlive: () => true,
|
|
readVerifiedPostgresCommand: async () => `postgres -D ${dataDir}`,
|
|
signalProcess: () => { throw signalError; },
|
|
wait: async () => {},
|
|
})).resolves.toBe(false);
|
|
});
|
|
});
|