Files
PaperClipAI/server/src/__tests__/execution-workspaces-service.test.ts
T
DottaandPaperclip 72b509c895 Recognize delivered workspaces and reap terminal worktrees (#10908)
## Thinking Path

> - Paperclip separates workspace provisioning lifecycle from whether
the work was actually delivered.
> - Git ancestry alone cannot recognize squash merges or deliveries into
a branch other than the workspace base.
> - A merged pull request linked from a terminal issue is stronger
delivery evidence for those cases.
> - The read contract should expose that evidence without changing
persisted workspace schema.
> - Cleanup must remain conservative: terminal descendants, delivered
work, and no active run checkout are all required.
> - Reusing the existing cleanup primitives keeps service shutdown,
lease cleanup, activity logging, and archival behavior consistent.
> - Focused regression coverage locks in both the honest read signal and
the fail-closed reaper guards.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Execution workspace close-readiness payloads and terminal workspace
cleanup.

**Current behavior**

Delivered squash-merged or cross-branch workspaces can remain `active`
and report a permanent “not merged” warning because git ancestry does
not contain their original commits.

**Proposed behavior**

Read payloads distinguish PR-confirmed delivery, ancestry delivery,
unmerged work, and unknown state. Fully terminal delivered workspace
trees are archived only when no active run holds the checkout.

**Reason and benefit**

Operators and automation receive an honest delivery signal, while
shipped worktrees stop looking active forever and genuinely unmerged
work retains its warning.

**Breaking changes**

The workspace payload gains a derived field. Existing fields and
persistence remain unchanged; no database migration is required.

**What happened?**

A delivered workspace can remain `active` and warn that it is not merged
forever after its issue ships through a squash or cross-branch pull
request.

**Expected behavior**

Pull-request delivery should be represented honestly, and a fully
terminal delivered workspace should become cleanup-eligible when no run
holds its checkout.

**Steps to reproduce**

1. Create an issue workspace with commits ahead of its configured base.
2. Deliver those commits with a squash merge or into a different target
branch.
3. Mark the source issue and descendants done, then read workspace close
readiness.

Before this change, the workspace remains active with a “not merged”
warning indefinitely.

## What Changed

- Added the derived `deliveryState` workspace contract: `merged_via_pr`,
`merged_by_ancestry`, `unmerged`, or `unknown`.
- Extracted a shared GitHub pull-request merge classifier and reused it
for merge confirmations and workspace delivery checks.
- Suppressed false ancestry warnings when a terminal issue has
ground-truth merged-PR evidence.
- Added an idempotent terminality reaper with descendant-terminal,
active-run, and delivered-work guards.
- Restricted PR delivery evidence to the source issue, then required
live merged state plus matching GitHub repository, head branch, and
current workspace HEAD; persisted status, stale PRs, lexical mentions,
inbound references, and descendant PRs cannot authorize cleanup.
- Preserved workspaces with modified or untracked files even when their
committed HEAD was delivered.
- Bounded both long-lived pull-request state caches to 1,000 entries
with oldest-entry eviction.
- Routed eligible workspaces through existing runtime shutdown, lease
cleanup, activity logging, and archival machinery with exclusive Git
index, HEAD, and branch-ref locks plus non-forced removal.
- Added regression coverage for delivery derivation, warning behavior,
reaper guards, scheduler wiring, and squash/cross-branch delivery.

## Verification

- `pnpm -r typecheck`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/execution-workspaces-service.test.ts
src/__tests__/merged-pr-confirmation-sweep.test.ts
src/__tests__/server-startup-feedback-export.test.ts --reporter=verbose`
— 63 passed
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/execution-workspaces-service.test.ts --reporter=verbose`
after review hardening — 43 passed
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/execution-workspaces-service.test.ts
src/__tests__/merged-pr-confirmation-sweep.test.ts
src/__tests__/external-objects-service.test.ts --reporter=dot` on the
final local head — 73 passed
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-workspace-busy.test.ts --reporter=verbose` — 15
passed
- `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm test:run` —
server 3,662 passed (4 skipped), UI 3,599 passed, CLI 327 passed, shared
415 passed, and skills catalog 20 passed; the aggregate DB stage ran
both source and built copies of one unrelated embedded-Postgres
migration test and both reached its 5-second timeout
- `pnpm --filter @paperclipai/db exec vitest run
src/status-card-migrations.test.ts --reporter=verbose` — isolated
aggregate-timeout verification passed in 3.99 seconds
- `NODE_ENV=production pnpm build`
- `pnpm check:token-gates`

## Risks

The reaper intentionally fails closed when issue terminality,
pull-request state, git ancestry, or checkout ownership cannot be
proven. GitHub lookups can delay classification and cleanup but cannot
cause an unproven workspace to be archived. Automated terminal archival
holds exclusive Git index, HEAD, and branch-ref locks across validation
and removal, skips configured destructive hooks, and uses non-forced
removal so dirty writes fail closed. Reopening a source issue does not
restore an archived workspace; it emits an audit event so a human or
agent can re-provision explicitly.

## Model Used

OpenAI Codex, GPT-5. The runtime did not expose a more specific model ID
or context-window size. Reasoning, tool use, repository editing, test
execution, and GitHub CLI access were enabled.

## 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 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>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-05 16:34:04 -05:00

3425 lines
113 KiB
TypeScript

import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { promisify } from "node:util";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq, inArray, sql } from "drizzle-orm";
import {
activityLog,
agents,
companies,
createDb,
executionWorkspaces,
heartbeatRuns,
issueComments,
issueReferenceMentions,
issueRecoveryActions,
issueWorkProducts,
issues,
projectWorkspaces,
projects,
workspaceRuntimeServices,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
executionWorkspaceService,
deriveExecutionWorkspaceDeliveryState,
mergeExecutionWorkspaceConfig,
readExecutionWorkspaceConfig,
} from "../services/execution-workspaces.ts";
import { issueService } from "../services/issues.ts";
import {
startRuntimeServicesForWorkspaceControl,
stopRuntimeServicesForExecutionWorkspace,
} from "../services/workspace-runtime.ts";
const execFileAsync = promisify(execFile);
describe("execution workspace delivery state", () => {
it.each([
[{ sourceIssueTerminal: true, mergedPullRequest: true, pullRequestStateUnknown: false, isMergedIntoBase: false }, "merged_via_pr"],
[{ sourceIssueTerminal: false, mergedPullRequest: false, pullRequestStateUnknown: false, isMergedIntoBase: true }, "merged_by_ancestry"],
[{ sourceIssueTerminal: true, mergedPullRequest: false, pullRequestStateUnknown: false, isMergedIntoBase: false }, "unmerged"],
[{ sourceIssueTerminal: true, mergedPullRequest: false, pullRequestStateUnknown: true, isMergedIntoBase: false }, "unknown"],
] as const)("derives %s as %s", (input, expected) => {
expect(deriveExecutionWorkspaceDeliveryState(input)).toBe(expected);
});
});
describe("execution workspace config helpers", () => {
it("reads typed config from persisted metadata", () => {
expect(readExecutionWorkspaceConfig({
source: "project_primary",
config: {
environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b",
provisionCommand: "bash ./scripts/provision-worktree.sh",
runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
cleanupCommand: "pkill -f vite || true",
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev", port: 3100 }],
},
},
})).toEqual({
environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b",
provisionCommand: "bash ./scripts/provision-worktree.sh",
runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
cleanupCommand: "pkill -f vite || true",
desiredState: null,
serviceStates: null,
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev", port: 3100 }],
},
});
});
it("merges config patches without dropping unrelated metadata", () => {
expect(mergeExecutionWorkspaceConfig(
{
source: "project_primary",
createdByRuntime: false,
config: {
environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b",
provisionCommand: "bash ./scripts/provision-worktree.sh",
runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh",
cleanupCommand: "pkill -f vite || true",
},
},
{
environmentId: "6286d5a9-9ea7-42b9-98b3-18ee904c26d7",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev" }],
},
},
)).toEqual({
source: "project_primary",
createdByRuntime: false,
config: {
environmentId: "6286d5a9-9ea7-42b9-98b3-18ee904c26d7",
provisionCommand: "bash ./scripts/provision-worktree.sh",
runtimeProvisionCommand: "bash ./scripts/provision-runtime.sh",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
cleanupCommand: "pkill -f vite || true",
desiredState: null,
serviceStates: null,
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev" }],
},
},
});
});
it("clears a persisted environment selection when patching it to null", () => {
expect(mergeExecutionWorkspaceConfig(
{
source: "project_primary",
config: {
environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b",
},
},
{
environmentId: null,
},
)).toEqual({
source: "project_primary",
});
});
it("clears the nested config block when requested", () => {
expect(mergeExecutionWorkspaceConfig(
{
source: "project_primary",
config: {
provisionCommand: "bash ./scripts/provision-worktree.sh",
},
},
null,
)).toEqual({
source: "project_primary",
});
});
});
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres execution workspace service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
async function runGit(cwd: string, args: string[]) {
await execFileAsync("git", ["-C", cwd, ...args], { cwd });
}
async function readGit(cwd: string, args: string[]) {
const output = await execFileAsync("git", ["-C", cwd, ...args], { cwd });
return output.stdout.trim() || null;
}
async function createTempRepo() {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-execution-workspace-"));
await runGit(repoRoot, ["init"]);
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
await runGit(repoRoot, ["config", "user.email", "test@paperclip.local"]);
await fs.writeFile(path.join(repoRoot, "README.md"), "# Test repo\n", "utf8");
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
await runGit(repoRoot, ["branch", "-M", "main"]);
return repoRoot;
}
async function waitForPath(filePath: string, timeoutMs = 5_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fs.access(filePath);
return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
throw new Error(`Timed out waiting for ${filePath}`);
}
function stableStringifyForTest(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringifyForTest(entry)).join(",")}]`;
}
if (value && typeof value === "object") {
const rec = value as Record<string, unknown>;
return `{${Object.keys(rec).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyForTest(rec[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
async function fingerprintWorkspaceBranchIncoherenceForTest(input: {
repoRoot: string;
worktreePath: string;
sourceIssueId: string;
executionWorkspaceId: string;
expectedBranch: string;
actualBranch: string | null;
}) {
const status = await execFileAsync("git", ["-C", input.worktreePath, "status", "--porcelain", "--untracked-files=all"], {
cwd: input.worktreePath,
}).then((output) => output.stdout).catch(() => null);
const expectedHeadSha = await readGit(input.repoRoot, ["rev-parse", "--verify", `refs/heads/${input.expectedBranch}^{commit}`])
.catch(() => null);
const actualHeadSha = await readGit(input.worktreePath, ["rev-parse", "HEAD"]).catch(() => null);
const cleanliness = status === null ? "unknown" : status.trim().length > 0 ? "dirty" : "clean";
const digest = createHash("sha256")
.update(stableStringifyForTest({
version: 1,
reason: "git_worktree_branch_incoherence",
sourceIssueId: input.sourceIssueId,
executionWorkspaceId: input.executionWorkspaceId,
worktreePath: path.resolve(input.worktreePath),
expectedBranch: input.expectedBranch,
actualBranch: input.actualBranch,
cleanliness,
expectedHeadSha,
actualHeadSha,
}))
.digest("hex");
return `workspace_incoherence:v1:sha256:${digest}`;
}
describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof executionWorkspaceService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const tempDirs = new Set<string>();
const pullRequestDetailsByKey = new Map<string, {
state: "merged" | "open" | "unknown";
headRef: string | null;
headSha: string | null;
}>();
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-execution-workspaces-service-");
db = createDb(tempDb.connectionString);
svc = executionWorkspaceService(db, {
resolvePullRequestDetails: vi.fn(async (companyId, reference) =>
pullRequestDetailsByKey.get(`${companyId}:${reference.number}`)
?? { state: "unknown", headRef: null, headSha: null }
),
});
}, 20_000);
afterEach(async () => {
await db.delete(workspaceRuntimeServices);
await db.delete(activityLog);
await db.delete(issueRecoveryActions);
await db.delete(issueWorkProducts);
await db.delete(issueReferenceMentions);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
pullRequestDetailsByKey.clear();
for (const dir of tempDirs) {
await fs.rm(dir, { recursive: true, force: true });
}
tempDirs.clear();
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedTerminalWorkspace(options: {
mergedPr?: boolean;
activeRun?: boolean;
childStatus?: "done" | "todo";
} = {}) {
const companyId = randomUUID();
const projectId = randomUUID();
const executionWorkspaceId = randomUUID();
const sourceIssueId = randomUUID();
const issuePrefix = `P${companyId.slice(0, 8).toUpperCase()}`;
const identifier = `${issuePrefix}-1`;
const repoRoot = await createTempRepo();
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-terminal-${randomUUID()}`);
tempDirs.add(repoRoot);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "PAP-16015-delivery"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "PAP-16015-delivery"]);
await fs.writeFile(path.join(worktreePath, "delivered.txt"), "delivered\n", "utf8");
await runGit(worktreePath, ["add", "delivered.txt"]);
await runGit(worktreePath, ["commit", "-m", "Delivered change"]);
const headSha = await readGit(worktreePath, ["rev-parse", "HEAD"]);
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Terminal workspaces",
status: "in_progress",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: identifier,
status: "active",
cwd: worktreePath,
providerRef: worktreePath,
providerType: "git_worktree",
repoUrl: "https://github.com/paperclipai/paperclip.git",
baseRef: "main",
branchName: "PAP-16015-delivery",
});
await db.insert(issues).values({
id: sourceIssueId,
companyId,
projectId,
identifier,
title: "Delivered source issue",
status: "done",
priority: "medium",
executionWorkspaceId,
});
await db
.update(executionWorkspaces)
.set({ sourceIssueId })
.where(eq(executionWorkspaces.id, executionWorkspaceId));
if (options.childStatus) {
await db.insert(issues).values({
id: randomUUID(),
companyId,
projectId,
parentId: sourceIssueId,
title: "Descendant",
status: options.childStatus,
priority: "medium",
});
}
if (options.mergedPr) {
await db.insert(issueWorkProducts).values({
companyId,
issueId: sourceIssueId,
executionWorkspaceId,
type: "pull_request",
provider: "github",
title: "Delivered PR",
url: "https://github.com/paperclipai/paperclip/pull/10623",
status: "merged",
});
}
pullRequestDetailsByKey.set(`${companyId}:10623`, {
state: "merged",
headRef: "PAP-16015-delivery",
headSha,
});
pullRequestDetailsByKey.set(`${companyId}:10624`, {
state: "merged",
headRef: "unrelated-delivery",
headSha,
});
pullRequestDetailsByKey.set(`${companyId}:10625`, {
state: "merged",
headRef: "descendant-delivery",
headSha,
});
if (options.activeRun) {
const agentId = randomUUID();
const runId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId,
name: "Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
});
await db.update(issues).set({ checkoutRunId: runId }).where(eq(issues.id, sourceIssueId));
}
return { companyId, projectId, executionWorkspaceId, sourceIssueId, identifier, repoRoot, worktreePath, headSha };
}
it("reports a squash cross-branch delivery as merged_via_pr and suppresses the ancestry warning", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-delivery-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "PAP-16015-delivery"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "PAP-16015-delivery"]);
await fs.writeFile(path.join(worktreePath, "delivered.txt"), "delivered\n", "utf8");
await runGit(worktreePath, ["add", "delivered.txt"]);
await runGit(worktreePath, ["commit", "-m", "Delivered change"]);
const seeded = await seedTerminalWorkspace();
pullRequestDetailsByKey.set(`${seeded.companyId}:10623`, {
state: "merged",
headRef: "PAP-16015-delivery",
headSha: await readGit(worktreePath, ["rev-parse", "HEAD"]),
});
await db.update(executionWorkspaces).set({
cwd: worktreePath,
providerRef: worktreePath,
providerType: "git_worktree",
baseRef: "main",
repoUrl: "https://github.com/paperclipai/paperclip.git",
branchName: "PAP-16015-delivery",
}).where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
await db.insert(issueWorkProducts).values({
companyId: seeded.companyId,
issueId: seeded.sourceIssueId,
type: "pull_request",
provider: "github",
title: "Cross-branch delivery",
url: "https://github.com/paperclipai/paperclip/pull/10623",
status: "merged",
});
const readiness = await svc.getCloseReadiness(seeded.executionWorkspaceId);
expect(readiness?.deliveryState).toBe("merged_via_pr");
expect(readiness?.git?.isMergedIntoBase).toBe(false);
expect(readiness?.warnings).not.toContain(
"This workspace is 1 commit ahead of main and is not merged.",
);
const sweep = await svc.sweepTerminalWorkspaces();
const archived = await db
.select({
status: executionWorkspaces.status,
cleanupEligibleAt: executionWorkspaces.cleanupEligibleAt,
cleanupReason: executionWorkspaces.cleanupReason,
})
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, seeded.executionWorkspaceId))
.then((rows) => rows[0]);
expect(sweep.archived).toBe(1);
expect(archived).toMatchObject({ status: "archived", cleanupReason: "issue_terminal" });
expect(archived?.cleanupEligibleAt).toBeInstanceOf(Date);
}, 20_000);
it("does not treat an unrelated inbound issue mention as delivery evidence", async () => {
const seeded = await seedTerminalWorkspace();
const unrelatedIssueId = randomUUID();
await db.insert(issues).values({
id: unrelatedIssueId,
companyId: seeded.companyId,
projectId: seeded.projectId,
title: `Investigate ${seeded.identifier} follow-up`,
status: "done",
priority: "medium",
});
await db.insert(issueReferenceMentions).values({
companyId: seeded.companyId,
sourceIssueId: unrelatedIssueId,
targetIssueId: seeded.sourceIssueId,
sourceKind: "title",
sourceRecordId: null,
documentKey: null,
matchedText: seeded.identifier,
});
await db.insert(issueWorkProducts).values({
companyId: seeded.companyId,
issueId: unrelatedIssueId,
type: "pull_request",
provider: "github",
title: "Unrelated merged PR",
url: "https://github.com/paperclipai/paperclip/pull/10624",
status: "merged",
});
const readiness = await svc.getCloseReadiness(seeded.executionWorkspaceId);
const sweep = await svc.sweepTerminalWorkspaces();
const [workspace] = await db
.select({
status: executionWorkspaces.status,
cleanupEligibleAt: executionWorkspaces.cleanupEligibleAt,
cleanupReason: executionWorkspaces.cleanupReason,
})
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
expect(readiness?.deliveryState).toBe("unmerged");
expect(sweep).toMatchObject({ archived: 0, skippedUndelivered: 1 });
expect(workspace).toMatchObject({
status: "active",
cleanupEligibleAt: null,
cleanupReason: null,
});
});
it("does not trust directly linked merged products for a different repository or branch", async () => {
const seeded = await seedTerminalWorkspace();
await db.insert(issueWorkProducts).values([
{
companyId: seeded.companyId,
issueId: seeded.sourceIssueId,
executionWorkspaceId: seeded.executionWorkspaceId,
type: "pull_request",
provider: "github",
title: "Wrong branch merged PR",
url: "https://github.com/paperclipai/paperclip/pull/10624",
status: "merged",
},
{
companyId: seeded.companyId,
issueId: seeded.sourceIssueId,
executionWorkspaceId: seeded.executionWorkspaceId,
type: "pull_request",
provider: "github",
title: "Wrong repository merged PR",
url: "https://github.com/unrelated/paperclip/pull/10623",
status: "merged",
},
]);
const readiness = await svc.getCloseReadiness(seeded.executionWorkspaceId);
const sweep = await svc.sweepTerminalWorkspaces();
const [workspace] = await db
.select({ status: executionWorkspaces.status })
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
expect(readiness?.deliveryState).toBe("unmerged");
expect(sweep).toMatchObject({ archived: 0, skippedUndelivered: 1 });
expect(workspace?.status).toBe("active");
});
it("does not trust a previously merged PR after new workspace commits", async () => {
const seeded = await seedTerminalWorkspace({ mergedPr: true });
await fs.writeFile(path.join(seeded.worktreePath, "new-work.txt"), "not delivered\n", "utf8");
await runGit(seeded.worktreePath, ["add", "new-work.txt"]);
await runGit(seeded.worktreePath, ["commit", "-m", "New undelivered work"]);
const readiness = await svc.getCloseReadiness(seeded.executionWorkspaceId);
const sweep = await svc.sweepTerminalWorkspaces();
const [workspace] = await db
.select({ status: executionWorkspaces.status })
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
expect(readiness?.deliveryState).toBe("unmerged");
expect(readiness?.warnings).toContain(
"This workspace is 2 commits ahead of main and is not merged.",
);
expect(sweep).toMatchObject({ archived: 0, skippedUndelivered: 1 });
expect(workspace?.status).toBe("active");
});
it("does not reap uncommitted work after the workspace HEAD was delivered", async () => {
const seeded = await seedTerminalWorkspace({ mergedPr: true });
await fs.writeFile(path.join(seeded.worktreePath, "uncommitted.txt"), "not delivered\n", "utf8");
const readiness = await svc.getCloseReadiness(seeded.executionWorkspaceId);
const sweep = await svc.sweepTerminalWorkspaces();
const [workspace] = await db
.select({ status: executionWorkspaces.status })
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
expect(readiness?.deliveryState).toBe("merged_via_pr");
expect(readiness?.warnings).toContain("The workspace has 1 untracked file.");
expect(sweep).toMatchObject({ archived: 0, skippedUndelivered: 1 });
expect(workspace?.status).toBe("active");
});
it("refuses cleanup when the worktree changes after delivery assessment", async () => {
const seeded = await seedTerminalWorkspace({ mergedPr: true });
await db.update(executionWorkspaces).set({
metadata: {
createdByRuntime: true,
config: { cleanupCommand: "rm -f late-work.txt" },
},
}).where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
const racingService = executionWorkspaceService(db, {
resolvePullRequestDetails: async (_companyId, reference) =>
pullRequestDetailsByKey.get(`${seeded.companyId}:${reference.number}`) ?? { state: "unknown" },
beforeTerminalWorkspaceCleanup: async () => {
await fs.writeFile(path.join(seeded.worktreePath, "late-work.txt"), "not delivered\n", "utf8");
},
});
const sweep = await racingService.sweepTerminalWorkspaces();
const [workspace] = await db
.select({ status: executionWorkspaces.status, cleanupReason: executionWorkspaces.cleanupReason })
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
expect(sweep).toMatchObject({ archived: 0, cleanupFailed: 1 });
expect(workspace?.status).toBe("cleanup_failed");
expect(workspace?.cleanupReason).toContain("git worktree changed after delivery was verified");
await expect(fs.readFile(path.join(seeded.worktreePath, "late-work.txt"), "utf8"))
.resolves.toBe("not delivered\n");
});
it("archives terminal workspaces without running configured cleanup hooks", async () => {
const seeded = await seedTerminalWorkspace({ mergedPr: true });
const cleanupMarker = path.join(path.dirname(seeded.worktreePath), `cleanup-marker-${randomUUID()}`);
tempDirs.add(cleanupMarker);
await db.update(executionWorkspaces).set({
metadata: {
createdByRuntime: true,
config: { cleanupCommand: `touch ${cleanupMarker}` },
},
}).where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
const sweep = await svc.sweepTerminalWorkspaces();
const [workspace] = await db
.select({ status: executionWorkspaces.status })
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
expect(sweep).toMatchObject({ archived: 1, cleanupFailed: 0 });
expect(workspace?.status).toBe("archived");
await expect(fs.access(seeded.worktreePath)).rejects.toThrow();
await expect(fs.access(cleanupMarker)).rejects.toThrow();
});
it("holds Git index and ref locks across terminal cleanup", async () => {
const seeded = await seedTerminalWorkspace({ mergedPr: true });
await db.update(executionWorkspaces).set({
metadata: { createdByRuntime: true },
}).where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
let commitFailure = "";
let refUpdateFailure = "";
const lockingService = executionWorkspaceService(db, {
resolvePullRequestDetails: async (_companyId, reference) =>
pullRequestDetailsByKey.get(`${seeded.companyId}:${reference.number}`) ?? { state: "unknown" },
beforeTerminalWorkspaceCleanup: async () => {
try {
await runGit(seeded.worktreePath, ["commit", "--allow-empty", "-m", "Late commit"]);
} catch (error) {
commitFailure = error instanceof Error ? error.message : String(error);
}
try {
await runGit(seeded.worktreePath, ["update-ref", "HEAD", seeded.headSha]);
} catch (error) {
refUpdateFailure = error instanceof Error ? error.message : String(error);
}
},
});
const sweep = await lockingService.sweepTerminalWorkspaces();
const [workspace] = await db
.select({ status: executionWorkspaces.status, cleanupReason: executionWorkspaces.cleanupReason })
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
expect(commitFailure).toContain("index.lock");
expect(refUpdateFailure).toMatch(/HEAD\.lock|refs\/heads\/PAP-16015-delivery\.lock/);
expect(sweep).toMatchObject({ archived: 1, cleanupFailed: 0 });
expect(workspace).toMatchObject({ status: "archived", cleanupReason: "issue_terminal" });
await expect(fs.access(seeded.worktreePath)).rejects.toThrow();
expect(await readGit(seeded.repoRoot, ["branch", "--list", "PAP-16015-delivery"])).toBeNull();
});
it("does not treat a descendant PR on the shared workspace as parent delivery evidence", async () => {
const seeded = await seedTerminalWorkspace();
const descendantIssueId = randomUUID();
await db.insert(issues).values({
id: descendantIssueId,
companyId: seeded.companyId,
projectId: seeded.projectId,
parentId: seeded.sourceIssueId,
title: "Delivered descendant",
status: "done",
priority: "medium",
executionWorkspaceId: seeded.executionWorkspaceId,
});
await db.insert(issueWorkProducts).values({
companyId: seeded.companyId,
issueId: descendantIssueId,
executionWorkspaceId: seeded.executionWorkspaceId,
type: "pull_request",
provider: "github",
title: "Descendant delivery",
url: "https://github.com/paperclipai/paperclip/pull/10625",
status: "merged",
});
const readiness = await svc.getCloseReadiness(seeded.executionWorkspaceId);
const sweep = await svc.sweepTerminalWorkspaces();
const [parentWorkspace] = await db
.select({ status: executionWorkspaces.status })
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
expect(readiness?.deliveryState).toBe("unmerged");
expect(sweep).toMatchObject({ archived: 0, skippedUndelivered: 1 });
expect(parentWorkspace?.status).toBe("active");
});
it("reaps only fully-terminal delivered workspaces without active checkout runs", async () => {
const eligible = await seedTerminalWorkspace({ mergedPr: true, childStatus: "done" });
const activeRun = await seedTerminalWorkspace({ mergedPr: true, activeRun: true });
const openDescendant = await seedTerminalWorkspace({ mergedPr: true, childStatus: "todo" });
const undelivered = await seedTerminalWorkspace();
const result = await svc.sweepTerminalWorkspaces();
const rows = await db
.select({ id: executionWorkspaces.id, status: executionWorkspaces.status, cleanupEligibleAt: executionWorkspaces.cleanupEligibleAt, cleanupReason: executionWorkspaces.cleanupReason })
.from(executionWorkspaces)
.where(inArray(executionWorkspaces.id, [
eligible.executionWorkspaceId,
activeRun.executionWorkspaceId,
openDescendant.executionWorkspaceId,
undelivered.executionWorkspaceId,
]));
const byId = new Map(rows.map((row) => [row.id, row]));
expect(result).toMatchObject({ archived: 1, skippedActiveRun: 1, skippedNonTerminalTree: 1, skippedUndelivered: 1 });
expect(byId.get(eligible.executionWorkspaceId)).toMatchObject({ status: "archived", cleanupReason: "issue_terminal" });
expect(byId.get(eligible.executionWorkspaceId)?.cleanupEligibleAt).toBeInstanceOf(Date);
expect(byId.get(activeRun.executionWorkspaceId)?.status).toBe("active");
expect(byId.get(openDescendant.executionWorkspaceId)?.status).toBe("active");
expect(byId.get(undelivered.executionWorkspaceId)?.status).toBe("active");
const second = await svc.sweepTerminalWorkspaces();
expect(second.archived).toBe(0);
expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, eligible.executionWorkspaceId)))[0]?.status).toBe("archived");
await issueService(db).update(eligible.sourceIssueId, {
status: "todo",
actorUserId: "local-board",
});
const reopenedWorkspace = await db
.select({ status: executionWorkspaces.status })
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, eligible.executionWorkspaceId))
.then((rows) => rows[0]);
const reopenActivities = await db
.select({ action: activityLog.action })
.from(activityLog)
.where(eq(activityLog.entityId, eligible.executionWorkspaceId));
expect(reopenedWorkspace?.status).toBe("archived");
expect(reopenActivities).toContainEqual({ action: "execution_workspace.source_issue_reopened" });
}, 20_000);
it("allows archiving shared workspace sessions with warnings even when issues are still open", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspaces",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
},
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
sourceType: "local_path",
isPrimary: true,
cwd: "/tmp/paperclip-primary",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Shared workspace",
status: "active",
providerType: "local_fs",
cwd: "/tmp/paperclip-primary",
metadata: {
config: {
teardownCommand: "bash ./scripts/teardown.sh",
},
},
});
await db.insert(issues).values({
id: randomUUID(),
companyId,
projectId,
title: "Still working",
status: "todo",
priority: "medium",
executionWorkspaceId,
});
const readiness = await svc.getCloseReadiness(executionWorkspaceId);
expect(readiness).toMatchObject({
workspaceId: executionWorkspaceId,
deliveryState: "unknown",
state: "ready_with_warnings",
isSharedWorkspace: true,
isProjectPrimaryWorkspace: true,
isDestructiveCloseAllowed: true,
});
expect(readiness?.blockingReasons).toEqual([]);
expect(readiness?.warnings).toEqual(expect.arrayContaining([
"This workspace is still linked to an open issue. Archiving it will detach this shared workspace session from those issues, but keep the underlying project workspace available.",
"This shared workspace session points at project workspace infrastructure. Archiving it only removes the session record.",
]));
});
it("clears matching environment selections transactionally without touching other workspaces", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const matchingWorkspaceId = randomUUID();
const otherWorkspaceId = randomUUID();
const untouchedWorkspaceId = randomUUID();
const environmentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace cleanup",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
},
});
await db.insert(executionWorkspaces).values([
{
id: matchingWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "directory",
name: "Matching workspace",
status: "active",
providerType: "local_fs",
cwd: "/tmp/workspace-a",
metadata: {
source: "manual",
config: {
environmentId,
cleanupCommand: "echo clean",
},
},
},
{
id: otherWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "directory",
name: "Different environment",
status: "active",
providerType: "local_fs",
cwd: "/tmp/workspace-b",
metadata: {
source: "manual",
config: {
environmentId: randomUUID(),
},
},
},
{
id: untouchedWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "directory",
name: "No environment",
status: "active",
providerType: "local_fs",
cwd: "/tmp/workspace-c",
metadata: {
source: "manual",
},
},
]);
const cleared = await svc.clearEnvironmentSelection(companyId, environmentId);
expect(cleared).toBe(1);
const rows = await db
.select({
id: executionWorkspaces.id,
metadata: executionWorkspaces.metadata,
})
.from(executionWorkspaces)
.where(inArray(executionWorkspaces.id, [matchingWorkspaceId, otherWorkspaceId, untouchedWorkspaceId]));
const byId = new Map(rows.map((row) => [row.id, row.metadata as Record<string, unknown> | null]));
expect(readExecutionWorkspaceConfig(byId.get(matchingWorkspaceId) ?? null)).toMatchObject({
environmentId: null,
cleanupCommand: "echo clean",
});
expect(readExecutionWorkspaceConfig(byId.get(otherWorkspaceId) ?? null)).toMatchObject({
environmentId: expect.any(String),
});
expect(readExecutionWorkspaceConfig(byId.get(untouchedWorkspaceId) ?? null)).toBeNull();
});
it("limits reusable summaries to open non-shared execution workspaces", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const openWorkspaceId = randomUUID();
const sharedWorkspaceId = randomUUID();
const closedWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Reusable workspaces",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
},
});
await db.insert(executionWorkspaces).values([
{
id: openWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Open isolated workspace",
status: "idle",
providerType: "git_worktree",
cwd: "/tmp/open-workspace",
branchName: "paperclip/open",
},
{
id: sharedWorkspaceId,
companyId,
projectId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Shared session",
status: "active",
providerType: "local_fs",
cwd: "/tmp/project-primary",
},
{
id: closedWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Closed isolated workspace",
status: "active",
providerType: "git_worktree",
cwd: "/tmp/closed-workspace",
closedAt: new Date("2026-05-23T20:00:00.000Z"),
},
]);
const summaries = await svc.listSummaries(companyId, {
projectId,
reuseEligible: true,
});
expect(summaries).toEqual([
expect.objectContaining({
id: openWorkspaceId,
name: "Open isolated workspace",
mode: "isolated_workspace",
status: "idle",
cwd: "/tmp/open-workspace",
branchName: "paperclip/open",
}),
]);
});
it("reconciles a forward branch record, comments on the source issue, and resolves matching workspace recovery", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-reconcile-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["branch", "feature/current", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "feature/current"]);
await fs.writeFile(path.join(worktreePath, "feature.txt"), "current branch\n", "utf8");
await runGit(worktreePath, ["add", "feature.txt"]);
await runGit(worktreePath, ["commit", "-m", "Current branch work"]);
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
const actualBranch = await readGit(worktreePath, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
const fingerprint = await fingerprintWorkspaceBranchIncoherenceForTest({
repoRoot,
worktreePath,
sourceIssueId: issueId,
executionWorkspaceId,
expectedBranch: "feature/recorded",
actualBranch,
});
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
identifier: "PAP-123",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "feature/recorded",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
await db.insert(issueRecoveryActions).values({
companyId,
sourceIssueId: issueId,
kind: "workspace_validation",
status: "active",
ownerType: "board",
cause: "workspace_validation_failed",
fingerprint,
evidence: {
workspaceValidation: {
fingerprint,
},
},
nextAction: "Repair the source issue workspace link.",
});
const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "forward",
reason: null,
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
});
expect(result.workspace.branchName).toBe("feature/current");
expect(result.workspace.name).toBe("feature/current");
expect(result.inspection).toMatchObject({
fromBranch: "feature/recorded",
toBranch: "feature/current",
ancestryVerdict: "ancestor",
fingerprint,
});
expect(result.recoveryAction).toMatchObject({
kind: "workspace_validation",
status: "resolved",
outcome: "restored",
fingerprint,
});
const [comment] = await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issueId));
expect(comment).toMatchObject({
companyId,
issueId,
authorType: "user",
authorUserId: "local-board",
});
expect(comment?.body).toContain("Execution workspace branch reconciled.");
expect(comment?.body).toContain("- Mode: `forward`");
expect(comment?.body).toContain("- From branch: `feature/recorded`");
expect(comment?.body).toContain("- To branch: `feature/current`");
expect(comment?.body).toContain(`- Fingerprint: \`${fingerprint}\``);
expect(comment?.body).toContain(`- Recovery action: \`${result.recoveryAction?.id}\``);
const [recoveryAction] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.sourceIssueId, issueId));
expect(recoveryAction).toMatchObject({
status: "resolved",
outcome: "restored",
resolutionNote: "Execution workspace branch record reconciled from \"feature/recorded\" to \"feature/current\".",
});
}, 20_000);
it("reconciles forward when the recorded branch has no resolvable commit and the worktree is clean", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-missing-recorded-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["worktree", "add", "-b", "feature/current", worktreePath, "HEAD"]);
await fs.writeFile(path.join(worktreePath, "feature.txt"), "current branch\n", "utf8");
await runGit(worktreePath, ["add", "feature.txt"]);
await runGit(worktreePath, ["commit", "-m", "Current branch work"]);
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Missing recorded branch",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
identifier: "PAP-124",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "feature/never-created",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/never-created",
baseRef: "main",
});
const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "forward",
reason: null,
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
});
expect(result.workspace.branchName).toBe("feature/current");
expect(result.workspace.name).toBe("feature/current");
expect(result.inspection).toMatchObject({
fromBranch: "feature/never-created",
toBranch: "feature/current",
fromSha: null,
ancestryVerdict: "unknown",
cleanliness: "clean",
});
const [comment] = await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issueId));
expect(comment?.body).toContain("Execution workspace branch reconciled.");
expect(comment?.body).toContain("- From branch: `feature/never-created`");
expect(comment?.body).toContain("- To branch: `feature/current`");
}, 20_000);
it("keeps forward reconciliation fail-closed when the recorded branch is missing but the worktree is dirty", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-missing-recorded-dirty-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["worktree", "add", "-b", "feature/current", worktreePath, "HEAD"]);
await fs.writeFile(path.join(worktreePath, "uncommitted.txt"), "dirty work\n", "utf8");
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Missing recorded branch dirty",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
identifier: "PAP-125",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "feature/never-created",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/never-created",
baseRef: "main",
});
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "forward",
reason: null,
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
message: expect.stringContaining("requires the recorded branch to be an ancestor"),
});
}, 20_000);
it("keeps forward reconciliation fail-closed when the checked-out branch ref does not resolve either", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-missing-both-refs-${randomUUID()}`);
tempDirs.add(worktreePath);
// An empty tree keeps the worktree clean even after its branch ref is
// deleted, so this exercises the adoption gate rather than cleanliness.
const emptyTreeSha = (await readGit(repoRoot, ["hash-object", "-t", "tree", "/dev/null"]))!;
const emptyCommitSha = (await readGit(repoRoot, ["commit-tree", emptyTreeSha, "-m", "empty base"]))!;
await runGit(repoRoot, ["branch", "empty-base", emptyCommitSha]);
await runGit(repoRoot, ["worktree", "add", "-b", "feature/current", worktreePath, "empty-base"]);
// Deleting the local ref while it is checked out leaves symbolic-ref still
// reporting the branch name even though nothing resolves to a commit.
await runGit(repoRoot, ["update-ref", "-d", "refs/heads/feature/current"]);
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Missing both branch refs",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
identifier: "PAP-126",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "feature/never-created",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/never-created",
baseRef: "main",
});
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "forward",
reason: null,
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
message: expect.stringContaining("requires the recorded branch to be an ancestor"),
});
}, 20_000);
it("quarantine_restore rescues dirty live-branch work, resolves recovery, and returns the source issue to todo", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-quarantine-restore-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", "-b", "feature/live", worktreePath, "feature/recorded"]);
await fs.appendFile(path.join(worktreePath, "README.md"), "dirty tracked work\n", "utf8");
await fs.writeFile(path.join(worktreePath, "untracked.txt"), "dirty untracked work\n", "utf8");
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
const actualBranch = await readGit(worktreePath, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
const fingerprint = await fingerprintWorkspaceBranchIncoherenceForTest({
repoRoot,
worktreePath,
sourceIssueId: issueId,
executionWorkspaceId,
expectedBranch: "feature/recorded",
actualBranch,
});
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Codex Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
cwd: repoRoot,
isPrimary: true,
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
projectWorkspaceId,
title: "Source task",
identifier: "PAP-124",
status: "blocked",
priority: "medium",
assigneeAgentId: agentId,
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "feature/recorded",
status: "active",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
await db.insert(issueRecoveryActions).values({
companyId,
sourceIssueId: issueId,
kind: "workspace_validation",
status: "active",
ownerType: "board",
cause: "workspace_validation_failed",
fingerprint,
evidence: {
workspaceValidation: {
fingerprint,
},
},
nextAction: "Repair the source issue workspace link.",
});
const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "quarantine_restore",
reason: "rescue dirty work and restore recorded branch",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
});
expect(result.workspace.branchName).toBe("feature/recorded");
expect(result.inspection).toMatchObject({
fromBranch: "feature/recorded",
toBranch: "feature/live",
cleanliness: "dirty",
fingerprint,
});
expect(result.rescueRef).toMatchObject({
branchName: expect.stringMatching(/^paperclip\/rescue\/PAP-124\/\d{8}T\d{6}Z$/),
fileCount: 2,
});
expect(result.restoredSourceIssue).toMatchObject({
id: issueId,
status: "todo",
assigneeAgentId: agentId,
});
expect(result.sourceIssueStatusChanged).toBe(true);
expect(result.recoveryAction).toMatchObject({
kind: "workspace_validation",
status: "resolved",
outcome: "restored",
fingerprint,
});
const rescueRef = result.rescueRef!.branchName;
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe("feature/recorded");
await expect(readGit(worktreePath, ["status", "--porcelain", "--untracked-files=all"])).resolves.toBeNull();
await expect(readGit(repoRoot, ["show", `${rescueRef}:untracked.txt`])).resolves.toBe("dirty untracked work");
const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(sourceIssue).toMatchObject({
status: "todo",
checkoutRunId: null,
executionRunId: null,
});
const [recoveryAction] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.sourceIssueId, issueId));
expect(recoveryAction).toMatchObject({
status: "resolved",
outcome: "restored",
resolutionNote: `Execution workspace dirty worktree quarantined on "${rescueRef}" and restored recorded branch "feature/recorded".`,
});
const comments = await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issueId))
.orderBy(issueComments.createdAt);
expect(comments).toHaveLength(2);
expect(comments[0]?.body).toContain("Execution workspace dirty worktree quarantined before restore.");
expect(comments[0]?.body).toContain(`Rescue branch: \`${rescueRef}\``);
expect(comments[1]?.body).toContain("Execution workspace branch reconciled.");
expect(comments[1]?.body).toContain("- Mode: `quarantine_restore`");
expect(comments[1]?.body).toContain(`- Rescue ref: \`${rescueRef}\``);
}, 20_000);
it("quarantine_restore rejects active runtime services before creating a rescue branch", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-quarantine-running-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", "-b", "feature/live", worktreePath, "feature/recorded"]);
await fs.appendFile(path.join(worktreePath, "README.md"), "dirty tracked work\n", "utf8");
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
const runtimeServiceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
identifier: "PAP-125",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "feature/recorded",
status: "active",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
await db.insert(workspaceRuntimeServices).values({
id: runtimeServiceId,
companyId,
projectId,
executionWorkspaceId,
issueId,
scopeType: "execution_workspace",
serviceName: "web",
status: "running",
lifecycle: "shared",
command: "pnpm dev",
cwd: worktreePath,
provider: "local_process",
healthStatus: "healthy",
});
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "quarantine_restore",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
message: "Execution workspace branch reconciliation requires all runtime services to be stopped",
details: {
inspection: expect.objectContaining({
cleanliness: "dirty",
fromBranch: "feature/recorded",
toBranch: "feature/live",
}),
runtimeServices: [
{
id: runtimeServiceId,
serviceName: "web",
status: "running",
},
],
},
});
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe("feature/live");
await expect(readGit(
repoRoot,
["for-each-ref", "--format=%(refname:short)", "refs/heads/paperclip/rescue"],
)).resolves.toBeNull();
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
}, 20_000);
it.each(["review", "approval"] as const)(
"quarantine_restore preserves pending execution-%s semantics on the source issue",
async (stageType) => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-quarantine-${stageType}-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", "-b", "feature/live", worktreePath, "feature/recorded"]);
await fs.appendFile(path.join(worktreePath, "README.md"), "dirty tracked review work\n", "utf8");
const companyId = randomUUID();
const coderAgentId = randomUUID();
const reviewerAgentId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
const reviewStageId = randomUUID();
const actualBranch = await readGit(worktreePath, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
const fingerprint = await fingerprintWorkspaceBranchIncoherenceForTest({
repoRoot,
worktreePath,
sourceIssueId: issueId,
executionWorkspaceId,
expectedBranch: "feature/recorded",
actualBranch,
});
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
{
id: coderAgentId,
companyId,
name: "Codex Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: reviewerAgentId,
companyId,
name: "QA Reviewer",
role: "qa",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
cwd: repoRoot,
isPrimary: true,
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
projectWorkspaceId,
title: "Source task awaiting review",
identifier: "PAP-125",
status: "blocked",
priority: "medium",
assigneeAgentId: coderAgentId,
executionPolicy: {
stages: [
{
id: reviewStageId,
type: stageType,
participants: [{ type: "agent", agentId: reviewerAgentId }],
},
],
},
executionState: {
status: "pending",
currentStageId: reviewStageId,
currentStageIndex: 0,
currentStageType: stageType,
currentParticipant: { type: "agent", agentId: reviewerAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "feature/recorded",
status: "active",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
await db.insert(issueRecoveryActions).values({
companyId,
sourceIssueId: issueId,
kind: "workspace_validation",
status: "active",
ownerType: "board",
cause: "workspace_validation_failed",
fingerprint,
evidence: {
workspaceValidation: {
fingerprint,
},
},
nextAction: "Repair the source issue workspace link.",
});
const result = await svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "quarantine_restore",
reason: "rescue dirty work and restore recorded branch",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
});
expect(result.restoredSourceIssue).toMatchObject({
id: issueId,
status: "in_review",
assigneeAgentId: reviewerAgentId,
});
expect(result.sourceIssueStatusChanged).toBe(true);
const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(sourceIssue).toMatchObject({
status: "in_review",
assigneeAgentId: reviewerAgentId,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
});
expect(sourceIssue?.executionState).toMatchObject({
status: "pending",
currentStageId: reviewStageId,
currentStageType: stageType,
currentParticipant: { type: "agent", agentId: reviewerAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
});
}, 20_000);
it.each([
{
claimantLabel: "active",
claimantIssueIdentifier: "PAP-126",
claimantHasActiveRun: true,
expectedReason: "active run",
},
{
claimantLabel: "idle",
claimantIssueIdentifier: "PAP-127",
claimantHasActiveRun: false,
expectedReason: "no active run",
},
])(
"quarantine_restore refuses dirty repair when the live branch has a $claimantLabel claimant",
async ({ claimantIssueIdentifier, claimantHasActiveRun, expectedReason }) => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-quarantine-claimant-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", "-b", "feature/live", worktreePath, "feature/recorded"]);
await fs.appendFile(path.join(worktreePath, "README.md"), "dirty tracked work\n", "utf8");
await fs.writeFile(path.join(worktreePath, "untracked.txt"), "dirty untracked work\n", "utf8");
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const issueId = randomUUID();
const claimantIssueId = randomUUID();
const executionWorkspaceId = randomUUID();
const claimantWorkspaceId = randomUUID();
const claimantRunId = claimantHasActiveRun ? randomUUID() : null;
const claimantWorkspacePath = path.join(path.dirname(repoRoot), `paperclip-claimant-${randomUUID()}`);
const now = new Date();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Codex Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
cwd: repoRoot,
isPrimary: true,
});
if (claimantRunId) {
await db.insert(heartbeatRuns).values({
id: claimantRunId,
companyId,
agentId,
invocationSource: "manual",
status: "running",
startedAt: now,
updatedAt: now,
});
}
await db.insert(issues).values([
{
id: issueId,
companyId,
projectId,
projectWorkspaceId,
title: "Source task",
identifier: "PAP-125",
status: "blocked",
priority: "medium",
assigneeAgentId: agentId,
},
{
id: claimantIssueId,
companyId,
projectId,
projectWorkspaceId,
title: claimantHasActiveRun ? "Active claimant" : "Idle claimant",
identifier: claimantIssueIdentifier,
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
executionRunId: claimantRunId,
},
]);
await db.insert(executionWorkspaces).values([
{
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "feature/recorded",
status: "active",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
},
{
id: claimantWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
sourceIssueId: claimantIssueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "feature/live",
status: "active",
providerType: "git_worktree",
cwd: claimantWorkspacePath,
providerRef: claimantWorkspacePath,
branchName: "feature/live",
baseRef: "main",
lastUsedAt: new Date(now.getTime() + 1_000),
updatedAt: new Date(now.getTime() + 1_000),
},
]);
await db
.update(issues)
.set({ executionWorkspaceId: claimantWorkspaceId })
.where(eq(issues.id, claimantIssueId));
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "quarantine_restore",
reason: "should refuse branch claimant",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
details: {
code: "workspace_validation_failed",
workspaceValidation: expect.objectContaining({
cleanliness: "dirty",
contention: expect.objectContaining({
claimedByWorkspaceId: claimantWorkspaceId,
claimedByIssueIdentifier: claimantIssueIdentifier,
activeRun: claimantRunId
? expect.objectContaining({
id: claimantRunId,
status: "running",
})
: null,
}),
safeRepair: expect.objectContaining({
eligible: false,
succeeded: false,
reason: expect.stringContaining(expectedReason),
}),
}),
},
});
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe("feature/live");
await expect(readGit(worktreePath, ["status", "--porcelain", "--untracked-files=all"])).resolves.not.toBeNull();
},
20_000,
);
it("rejects branch reconciliation when the worktree is dirty", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-dirty-reconcile-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["branch", "feature/current", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "feature/current"]);
await fs.writeFile(path.join(worktreePath, "feature.txt"), "current branch\n", "utf8");
await runGit(worktreePath, ["add", "feature.txt"]);
await runGit(worktreePath, ["commit", "-m", "Current branch work"]);
await fs.writeFile(path.join(worktreePath, "dirty.txt"), "not safe to mutate\n", "utf8");
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Dirty workspace",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "override",
reason: "operator override still requires idle clean workspace",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
message: "Execution workspace branch reconciliation requires a clean worktree",
details: {
inspection: expect.objectContaining({
cleanliness: "dirty",
statusEntryCount: 1,
fromBranch: "feature/recorded",
toBranch: "feature/current",
}),
},
});
const [workspace] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId));
expect(workspace?.branchName).toBe("feature/recorded");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
}, 20_000);
it("rejects branch reconciliation while the workspace lifecycle is active", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-active-reconcile-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["branch", "feature/current", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "feature/current"]);
await fs.writeFile(path.join(worktreePath, "feature.txt"), "current branch\n", "utf8");
await runGit(worktreePath, ["add", "feature.txt"]);
await runGit(worktreePath, ["commit", "-m", "Current branch work"]);
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Active workspace",
status: "active",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "override",
reason: "operator override still requires idle workspace",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
message: "Execution workspace branch reconciliation requires the workspace to be idle",
details: {
workspaceStatus: "active",
inspection: expect.objectContaining({
cleanliness: "clean",
fromBranch: "feature/recorded",
toBranch: "feature/current",
}),
},
});
const [workspace] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId));
expect(workspace?.branchName).toBe("feature/recorded");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
}, 20_000);
it("rejects branch reconciliation if the workspace becomes active before the branch record update", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-race-reconcile-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["branch", "feature/current", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "feature/current"]);
await fs.writeFile(path.join(worktreePath, "feature.txt"), "current branch\n", "utf8");
await runGit(worktreePath, ["add", "feature.txt"]);
await runGit(worktreePath, ["commit", "-m", "Current branch work"]);
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Race workspace",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
const originalTransaction = db.transaction.bind(db);
const transactionSpy = vi.spyOn(db, "transaction").mockImplementation(
(async (...args: Parameters<typeof db.transaction>) => {
await db
.update(executionWorkspaces)
.set({ status: "active", updatedAt: new Date() })
.where(eq(executionWorkspaces.id, executionWorkspaceId));
return originalTransaction(...args);
}) as typeof db.transaction,
);
try {
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "override",
reason: "operator override still requires idle workspace at write time",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
message: "Execution workspace branch reconciliation requires the workspace to be idle",
details: {
workspaceStatus: "active",
inspection: expect.objectContaining({
cleanliness: "clean",
fromBranch: "feature/recorded",
toBranch: "feature/current",
}),
},
});
} finally {
transactionSpy.mockRestore();
}
const [workspace] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId));
expect(workspace).toMatchObject({
status: "active",
branchName: "feature/recorded",
});
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
}, 20_000);
it("rejects branch reconciliation while runtime services are active", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-running-reconcile-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["branch", "feature/current", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "feature/current"]);
await fs.writeFile(path.join(worktreePath, "feature.txt"), "current branch\n", "utf8");
await runGit(worktreePath, ["add", "feature.txt"]);
await runGit(worktreePath, ["commit", "-m", "Current branch work"]);
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
const runtimeServiceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Runtime workspace",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
await db.insert(workspaceRuntimeServices).values({
id: runtimeServiceId,
companyId,
projectId,
executionWorkspaceId,
issueId,
scopeType: "execution_workspace",
serviceName: "web",
status: "running",
lifecycle: "shared",
command: "pnpm dev",
cwd: worktreePath,
provider: "local_process",
healthStatus: "healthy",
});
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "override",
reason: "operator override still requires stopped services",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
message: "Execution workspace branch reconciliation requires all runtime services to be stopped",
details: {
inspection: expect.objectContaining({
cleanliness: "clean",
fromBranch: "feature/recorded",
toBranch: "feature/current",
}),
runtimeServices: [
{
id: runtimeServiceId,
serviceName: "web",
status: "running",
},
],
},
});
const [workspace] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId));
expect(workspace?.branchName).toBe("feature/recorded");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
}, 20_000);
it("rejects branch reconciliation when a runtime service starts before the locked update", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-raced-service-reconcile-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["branch", "feature/current", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "feature/current"]);
await fs.writeFile(path.join(worktreePath, "feature.txt"), "current branch\n", "utf8");
await runGit(worktreePath, ["add", "feature.txt"]);
await runGit(worktreePath, ["commit", "-m", "Current branch work"]);
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
const runtimeServiceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Runtime race workspace",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
let releaseBlockingTransaction: (() => void) | null = null;
const releaseBlockingTransactionPromise = new Promise<void>((resolve) => {
releaseBlockingTransaction = resolve;
});
let blockingTransactionReadyResolve: (() => void) | null = null;
const blockingTransactionReady = new Promise<void>((resolve) => {
blockingTransactionReadyResolve = resolve;
});
const blockingTransaction = db.transaction(async (tx) => {
await tx.execute(sql`select id from ${executionWorkspaces} where ${executionWorkspaces.id} = ${executionWorkspaceId} for update`);
await tx.insert(workspaceRuntimeServices).values({
id: runtimeServiceId,
companyId,
projectId,
executionWorkspaceId,
issueId,
scopeType: "execution_workspace",
serviceName: "web",
status: "running",
lifecycle: "shared",
command: "pnpm dev",
cwd: worktreePath,
provider: "local_process",
healthStatus: "healthy",
});
blockingTransactionReadyResolve?.();
await releaseBlockingTransactionPromise;
});
await blockingTransactionReady;
const reconcileExpectation = expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "override",
reason: "operator override still requires stopped services",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
message: "Execution workspace branch reconciliation requires all runtime services to be stopped",
details: {
inspection: expect.objectContaining({
cleanliness: "clean",
fromBranch: "feature/recorded",
toBranch: "feature/current",
}),
runtimeServices: [
{
id: runtimeServiceId,
serviceName: "web",
status: "running",
},
],
},
});
await new Promise((resolve) => setTimeout(resolve, 250));
releaseBlockingTransaction?.();
await reconcileExpectation;
await blockingTransaction;
const [workspace] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId));
expect(workspace?.branchName).toBe("feature/recorded");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
}, 20_000);
it("rejects branch reconciliation when runtime service activation is already spawning", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-spawning-service-reconcile-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "feature/recorded"]);
await runGit(repoRoot, ["branch", "feature/current", "feature/recorded"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "feature/current"]);
await fs.writeFile(path.join(worktreePath, "feature.txt"), "current branch\n", "utf8");
await runGit(worktreePath, ["add", "feature.txt"]);
await runGit(worktreePath, ["commit", "-m", "Current branch work"]);
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
const runtimeStartedMarker = path.join(os.tmpdir(), `paperclip-runtime-started-${randomUUID()}.marker`);
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Runtime activation race workspace",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
const serverScript = [
`require("node:fs").writeFileSync(${JSON.stringify(runtimeStartedMarker)}, "started");`,
"setTimeout(() => {",
" require(\"node:http\")",
" .createServer((_req, res) => { res.end(\"ok\"); })",
" .listen(Number(process.env.PORT), \"127.0.0.1\");",
"}, 600);",
"setInterval(() => {}, 1000);",
].join(" ");
const command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(serverScript)}`;
let startedServices: Awaited<ReturnType<typeof startRuntimeServicesForWorkspaceControl>> = [];
try {
const startPromise = startRuntimeServicesForWorkspaceControl({
db,
invocationId: randomUUID(),
actor: {
id: null,
name: "Board",
companyId,
},
issue: {
id: issueId,
identifier: null,
title: "Source task",
},
workspace: {
baseCwd: worktreePath,
source: "task_session",
projectId,
workspaceId: null,
repoUrl: null,
repoRef: "main",
strategy: "git_worktree",
cwd: worktreePath,
branchName: "feature/current",
worktreePath,
warnings: [],
created: false,
},
executionWorkspaceId,
config: {
workspaceRuntime: {
services: [
{
name: "web",
command,
lifecycle: "shared",
reuseScope: "execution_workspace",
port: { type: "auto", envKey: "PORT" },
expose: { urlTemplate: "http://127.0.0.1:{{port}}" },
readiness: { type: "http", intervalMs: 50, timeoutSec: 10 },
},
],
},
},
adapterEnv: {},
});
await Promise.race([
waitForPath(runtimeStartedMarker),
startPromise.then(
() => {
throw new Error("Runtime service activation finished before the process-start marker was observed");
},
(error) => {
throw error;
},
),
]);
const reconcileErrorPromise = svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "override",
reason: "operator override still requires stopped services",
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
}).then(
() => {
throw new Error("Branch reconciliation unexpectedly succeeded while a runtime service was starting");
},
(error) => error,
);
startedServices = await startPromise;
await expect(reconcileErrorPromise).resolves.toMatchObject({
status: 422,
message: "Execution workspace branch reconciliation requires all runtime services to be stopped",
details: {
inspection: expect.objectContaining({
cleanliness: "clean",
fromBranch: "feature/recorded",
toBranch: "feature/current",
}),
runtimeServices: [
expect.objectContaining({
id: startedServices[0]?.id,
serviceName: "web",
status: "starting",
}),
],
},
});
} finally {
await stopRuntimeServicesForExecutionWorkspace({
db,
executionWorkspaceId,
workspaceCwd: worktreePath,
});
await fs.rm(runtimeStartedMarker, { force: true });
}
const [workspace] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId));
expect(workspace?.branchName).toBe("feature/recorded");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
}, 20_000);
it("rejects forward branch reconciliation for diverged branches", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-diverged-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["checkout", "-b", "feature/recorded"]);
await fs.writeFile(path.join(repoRoot, "recorded.txt"), "recorded branch\n", "utf8");
await runGit(repoRoot, ["add", "recorded.txt"]);
await runGit(repoRoot, ["commit", "-m", "Recorded branch work"]);
await runGit(repoRoot, ["checkout", "main"]);
await runGit(repoRoot, ["checkout", "-b", "feature/current"]);
await fs.writeFile(path.join(repoRoot, "current.txt"), "current branch\n", "utf8");
await runGit(repoRoot, ["add", "current.txt"]);
await runGit(repoRoot, ["commit", "-m", "Current branch work"]);
await runGit(repoRoot, ["checkout", "main"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "feature/current"]);
const companyId = randomUUID();
const projectId = randomUUID();
const issueId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Branch reconcile",
status: "in_progress",
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
title: "Source task",
status: "blocked",
priority: "medium",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
sourceIssueId: issueId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Diverged workspace",
status: "idle",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "feature/recorded",
baseRef: "main",
});
await expect(svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, {
mode: "forward",
reason: null,
actor: {
actorType: "user",
actorId: "local-board",
agentId: null,
runId: null,
},
})).rejects.toMatchObject({
status: 422,
details: {
inspection: expect.objectContaining({
ancestryVerdict: "diverged",
fromBranch: "feature/recorded",
toBranch: "feature/current",
}),
},
});
const [workspace] = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId));
expect(workspace?.branchName).toBe("feature/recorded");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(0);
}, 20_000);
it("returns full details at the observed volume without multiplying unconfigured shared service history", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const workspaceCount = 6_176;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace scale regression",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
sourceType: "local_path",
isPrimary: true,
cwd: "/tmp/workspace-scale-regression",
});
const workspaceRows = Array.from({ length: workspaceCount }, (_, index) => ({
id: randomUUID(),
companyId,
projectId,
projectWorkspaceId,
mode: "shared_workspace" as const,
strategyType: "project_primary" as const,
name: `Shared workspace ${index + 1}`,
status: "idle" as const,
providerType: "local_fs" as const,
cwd: "/tmp/workspace-scale-regression",
}));
for (let offset = 0; offset < workspaceRows.length; offset += 400) {
await db.insert(executionWorkspaces).values(workspaceRows.slice(offset, offset + 400));
}
await db.insert(workspaceRuntimeServices).values(
Array.from({ length: 163 }, (_, index) => ({
id: randomUUID(),
companyId,
projectId,
projectWorkspaceId,
scopeType: "project_workspace",
scopeId: projectWorkspaceId,
serviceName: `historical-service-${index + 1}`,
status: "stopped",
lifecycle: "shared",
reuseKey: `historical-service-${index + 1}`,
command: `pnpm historical:${index + 1}`,
cwd: "/tmp/workspace-scale-regression",
provider: "local_process",
healthStatus: "unknown",
})),
);
const workspaces = await svc.list(companyId);
expect(workspaces).toHaveLength(workspaceCount);
expect(workspaces.reduce((count, workspace) => count + (workspace.runtimeServices?.length ?? 0), 0)).toBe(0);
expect(JSON.stringify(workspaces).length).toBeLessThan(12_000_000);
const overview = await svc.listOverview(companyId, { limit: 1, offset: 0 });
expect(overview.total).toBe(workspaceCount);
expect(overview.items[0]).toMatchObject({
serviceCount: 0,
runningServiceCount: 0,
primaryService: null,
hasRuntimeConfig: false,
});
}, 30_000);
it("inherits only runtime-service rows matching the current project workspace configuration and reuse scopes", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const currentWebServiceId = randomUUID();
const currentWorkerServiceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Configured runtime selection",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
sourceType: "local_path",
isPrimary: true,
cwd: "/tmp/configured-runtime-selection",
metadata: {
runtimeConfig: {
workspaceRuntime: {
services: [
{ name: "web", command: "pnpm dev" },
{
name: "worker",
command: "pnpm worker",
reuseScope: "execution_workspace",
},
],
},
desiredState: "stopped",
},
},
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Shared configured workspace",
status: "idle",
providerType: "local_fs",
cwd: "/tmp/configured-runtime-selection",
});
await db.insert(workspaceRuntimeServices).values([
{
id: randomUUID(),
companyId,
projectId,
projectWorkspaceId,
scopeType: "project_workspace",
scopeId: projectWorkspaceId,
serviceName: "web",
status: "stopped",
lifecycle: "shared",
reuseKey: "old-web",
command: "pnpm dev",
cwd: "/tmp/configured-runtime-selection",
provider: "local_process",
healthStatus: "unknown",
updatedAt: new Date("2026-07-29T10:00:00.000Z"),
},
{
id: currentWebServiceId,
companyId,
projectId,
projectWorkspaceId,
scopeType: "project_workspace",
scopeId: projectWorkspaceId,
serviceName: "web",
status: "stopped",
lifecycle: "shared",
reuseKey: "current-web",
command: "pnpm dev",
cwd: "/tmp/configured-runtime-selection",
provider: "local_process",
healthStatus: "unknown",
updatedAt: new Date("2026-07-30T10:00:00.000Z"),
},
{
id: randomUUID(),
companyId,
projectId,
projectWorkspaceId,
scopeType: "project_workspace",
scopeId: projectWorkspaceId,
serviceName: "removed-worker",
status: "stopped",
lifecycle: "shared",
reuseKey: "removed-worker",
command: "pnpm worker",
cwd: "/tmp/configured-runtime-selection",
provider: "local_process",
healthStatus: "unknown",
updatedAt: new Date("2026-07-31T10:00:00.000Z"),
},
{
id: currentWorkerServiceId,
companyId,
projectId,
projectWorkspaceId,
executionWorkspaceId,
scopeType: "execution_workspace",
scopeId: executionWorkspaceId,
serviceName: "worker",
status: "running",
lifecycle: "shared",
reuseKey: "current-worker",
command: "pnpm worker",
cwd: "/tmp/configured-runtime-selection",
provider: "local_process",
healthStatus: "healthy",
updatedAt: new Date("2026-07-31T11:00:00.000Z"),
},
]);
const [workspace] = await svc.list(companyId);
expect(workspace?.runtimeServices).toEqual([
expect.objectContaining({
id: currentWebServiceId,
serviceName: "web",
configIndex: 0,
}),
expect.objectContaining({
id: currentWorkerServiceId,
serviceName: "worker",
configIndex: 1,
}),
]);
const overview = await svc.listOverview(companyId, { limit: 10, offset: 0 });
expect(overview.items[0]).toMatchObject({
serviceCount: 2,
runningServiceCount: 1,
hasRuntimeConfig: true,
primaryService: {
id: currentWorkerServiceId,
serviceName: "worker",
status: "running",
},
});
});
it("returns a bounded company-scoped workspace overview with service and linked issue summaries", async () => {
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const projectId = randomUUID();
const workspaceAId = "11111111-1111-4111-8111-111111111111";
const workspaceBId = "22222222-2222-4222-8222-222222222222";
const archivedWorkspaceId = "33333333-3333-4333-8333-333333333333";
const otherWorkspaceId = "44444444-4444-4444-8444-444444444444";
const crossCompanyProjectWorkspaceId = "55555555-5555-4555-8555-555555555555";
await db.insert(companies).values([
{
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
},
{
id: otherCompanyId,
name: "OtherCo",
issuePrefix: "OTH",
requireBoardApprovalForNewAgents: false,
},
]);
await db.insert(projects).values([
{
id: projectId,
companyId,
name: "Workspaces",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
},
},
{
id: randomUUID(),
companyId: otherCompanyId,
name: "Other project",
status: "in_progress",
},
]);
const otherProject = await db
.select({ id: projects.id })
.from(projects)
.where(inArray(projects.companyId, [otherCompanyId]))
.then((rows) => rows[0]!.id);
await db.insert(executionWorkspaces).values([
{
id: workspaceAId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Active A",
status: "active",
providerType: "git_worktree",
cwd: "/tmp/workspace-a",
branchName: "paperclip/a",
lastUsedAt: new Date("2026-06-03T10:00:00.000Z"),
updatedAt: new Date("2026-06-03T10:05:00.000Z"),
metadata: {
config: {
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev" }],
},
},
},
},
{
id: workspaceBId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Active B",
status: "idle",
providerType: "git_worktree",
cwd: "/tmp/workspace-b",
branchName: "paperclip/b",
lastUsedAt: new Date("2026-06-02T10:00:00.000Z"),
updatedAt: new Date("2026-06-02T10:05:00.000Z"),
},
{
id: archivedWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Archived",
status: "archived",
providerType: "git_worktree",
cwd: "/tmp/workspace-archived",
lastUsedAt: new Date("2026-06-04T10:00:00.000Z"),
},
{
id: otherWorkspaceId,
companyId: otherCompanyId,
projectId: otherProject,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Other company",
status: "active",
providerType: "git_worktree",
cwd: "/tmp/workspace-other",
lastUsedAt: new Date("2026-06-05T10:00:00.000Z"),
},
{
id: crossCompanyProjectWorkspaceId,
companyId,
projectId: otherProject,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Cross-company project mismatch",
status: "active",
providerType: "git_worktree",
cwd: "/tmp/workspace-cross-company-project",
lastUsedAt: new Date("2026-06-06T10:00:00.000Z"),
},
]);
await db.insert(workspaceRuntimeServices).values([
{
id: randomUUID(),
companyId,
projectId,
executionWorkspaceId: workspaceAId,
issueId: null,
scopeType: "execution_workspace",
serviceName: "web",
status: "running",
lifecycle: "shared",
command: "pnpm dev",
cwd: "/tmp/workspace-a",
port: 3100,
url: "http://localhost:3100",
provider: "local_process",
healthStatus: "healthy",
updatedAt: new Date("2026-06-03T10:06:00.000Z"),
},
{
id: randomUUID(),
companyId,
projectId,
executionWorkspaceId: workspaceAId,
issueId: null,
scopeType: "execution_workspace",
serviceName: "worker",
status: "stopped",
lifecycle: "shared",
command: "pnpm worker",
cwd: "/tmp/workspace-a",
provider: "local_process",
healthStatus: "unknown",
},
]);
await db.insert(issues).values(
Array.from({ length: 5 }, (_, index) => ({
id: randomUUID(),
companyId,
projectId,
title: `Linked issue ${index + 1}`,
status: "todo",
priority: "medium",
identifier: `PAP-${index + 1}`,
executionWorkspaceId: workspaceAId,
updatedAt: new Date(`2026-06-03T09:0${index}:00.000Z`),
})),
);
await db.insert(issues).values({
id: randomUUID(),
companyId,
projectId,
title: "Hidden linked issue",
status: "todo",
priority: "medium",
executionWorkspaceId: workspaceAId,
hiddenAt: new Date("2026-06-03T11:00:00.000Z"),
});
const overview = await svc.listOverview(companyId, {
limit: 10,
offset: 0,
});
expect(overview.total).toBe(2);
expect(overview.items.map((item) => item.workspaceId)).toEqual([workspaceAId, workspaceBId]);
expect(overview.items.map((item) => item.workspaceId)).not.toContain(archivedWorkspaceId);
expect(overview.items.map((item) => item.workspaceId)).not.toContain(otherWorkspaceId);
expect(overview.items.map((item) => item.workspaceId)).not.toContain(crossCompanyProjectWorkspaceId);
expect(overview.hasMore).toBe(false);
const activeA = overview.items[0]!;
expect(activeA).toMatchObject({
key: `execution:${workspaceAId}`,
kind: "execution_workspace",
workspaceName: "Active A",
projectId,
projectUrlKey: "workspaces",
projectName: "Workspaces",
branchName: "paperclip/a",
serviceCount: 2,
runningServiceCount: 1,
primaryServiceUrl: "http://localhost:3100",
primaryServiceUrlRunning: true,
hasRuntimeConfig: true,
linkedIssueCount: 5,
});
expect(activeA.primaryService).toMatchObject({
serviceName: "web",
status: "running",
url: "http://localhost:3100",
port: 3100,
healthStatus: "healthy",
});
expect(activeA.linkedIssues).toHaveLength(4);
expect(activeA.linkedIssues.map((issue) => issue.title)).toEqual([
"Linked issue 5",
"Linked issue 4",
"Linked issue 3",
"Linked issue 2",
]);
});
it("supports status and project filters with stable limit/offset pagination", async () => {
const companyId = randomUUID();
const projectAId = randomUUID();
const projectBId = randomUUID();
const activeWorkspaceId = "55555555-5555-4555-8555-555555555555";
const idleWorkspaceId = "66666666-6666-4666-8666-666666666666";
const archivedWorkspaceId = "77777777-7777-4777-8777-777777777777";
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values([
{
id: projectAId,
companyId,
name: "Project A",
status: "in_progress",
},
{
id: projectBId,
companyId,
name: "Project B",
status: "in_progress",
},
]);
await db.insert(executionWorkspaces).values([
{
id: activeWorkspaceId,
companyId,
projectId: projectAId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Newest active",
status: "active",
providerType: "git_worktree",
lastUsedAt: new Date("2026-06-03T10:00:00.000Z"),
},
{
id: idleWorkspaceId,
companyId,
projectId: projectAId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Older idle",
status: "idle",
providerType: "git_worktree",
lastUsedAt: new Date("2026-06-02T10:00:00.000Z"),
},
{
id: archivedWorkspaceId,
companyId,
projectId: projectBId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Archived",
status: "archived",
providerType: "git_worktree",
lastUsedAt: new Date("2026-06-04T10:00:00.000Z"),
},
]);
const secondPage = await svc.listOverview(companyId, {
projectId: projectAId,
limit: 1,
offset: 1,
});
expect(secondPage.total).toBe(2);
expect(secondPage.items.map((item) => item.workspaceId)).toEqual([idleWorkspaceId]);
expect(secondPage.hasMore).toBe(false);
expect(secondPage.nextOffset).toBeNull();
const archivedOnly = await svc.listOverview(companyId, {
status: ["archived"],
limit: 10,
offset: 0,
});
expect(archivedOnly.total).toBe(1);
expect(archivedOnly.items.map((item) => item.workspaceId)).toEqual([archivedWorkspaceId]);
});
it("warns about dirty and unmerged git worktrees and reports cleanup actions", async () => {
const repoRoot = await createTempRepo();
tempDirs.add(repoRoot);
const worktreePath = path.join(path.dirname(repoRoot), `paperclip-worktree-${randomUUID()}`);
tempDirs.add(worktreePath);
await runGit(repoRoot, ["branch", "paperclip-close-check"]);
await runGit(repoRoot, ["worktree", "add", worktreePath, "paperclip-close-check"]);
await fs.writeFile(path.join(worktreePath, "feature.txt"), "hello\n", "utf8");
await runGit(worktreePath, ["add", "feature.txt"]);
await runGit(worktreePath, ["commit", "-m", "Feature commit"]);
await fs.writeFile(path.join(worktreePath, "untracked.txt"), "left behind\n", "utf8");
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspaces",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
workspaceStrategy: {
type: "git_worktree",
teardownCommand: "bash ./scripts/project-teardown.sh",
},
},
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
sourceType: "git_repo",
isPrimary: true,
cwd: repoRoot,
cleanupCommand: "printf 'project cleanup\\n'",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Feature workspace",
status: "active",
providerType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
branchName: "paperclip-close-check",
baseRef: "main",
metadata: {
createdByRuntime: true,
config: {
cleanupCommand: "printf 'workspace cleanup\\n'",
},
},
});
const readiness = await svc.getCloseReadiness(executionWorkspaceId);
expect(readiness).toMatchObject({
workspaceId: executionWorkspaceId,
deliveryState: "unmerged",
state: "ready_with_warnings",
isSharedWorkspace: false,
isProjectPrimaryWorkspace: false,
isDestructiveCloseAllowed: true,
git: {
workspacePath: worktreePath,
branchName: "paperclip-close-check",
baseRef: "main",
createdByRuntime: true,
hasDirtyTrackedFiles: false,
hasUntrackedFiles: true,
aheadCount: 1,
behindCount: 0,
isMergedIntoBase: false,
},
});
expect(readiness?.warnings).toEqual(expect.arrayContaining([
"The workspace has 1 untracked file.",
"This workspace is 1 commit ahead of main and is not merged.",
]));
expect(readiness?.plannedActions.map((action) => action.kind)).toEqual(expect.arrayContaining([
"archive_record",
"cleanup_command",
"teardown_command",
"git_worktree_remove",
"git_branch_delete",
]));
}, 20_000);
});