Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions server/src/__tests__/heartbeat-process-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
issueTreeHolds,
issueWorkProducts,
issues,
projects,
projectWorkspaces,
workspaceOperations,
} from "@paperclipai/db";
import {
Expand Down Expand Up @@ -369,6 +371,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
}
await db.delete(agentWakeupRequests);
await db.delete(budgetPolicies);
await db.delete(projectWorkspaces);
await db.delete(projects);
for (let attempt = 0; attempt < 5; attempt += 1) {
await db.delete(agentRuntimeState);
try {
Expand Down Expand Up @@ -924,6 +928,118 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
return { companyId, agentId, runId, wakeupRequestId, issueId };
}

it("blocks git-backed project workspace routing failures without invoking the adapter", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const issueId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;

await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "paperclip",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "primary",
sourceType: "git_repo",
cwd: null,
repoUrl: `file:///tmp/paperclip-missing-${randomUUID()}`,
repoRef: "master",
isPrimary: true,
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
projectWorkspaceId,
title: "Project-bound implementation",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
assigneeUserId: null,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});

const heartbeat = heartbeatService(db);
const run = await heartbeat.invoke(agentId, "assignment", {
issueId,
taskId: issueId,
wakeReason: "issue_assigned",
}, "system", { actorType: "system", actorId: "test" });
expect(run).toBeTruthy();

await waitForHeartbeatIdle(db, 10_000);

expect(mockAdapterExecute).not.toHaveBeenCalled();
const failedRun = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, run!.id))
.then((rows) => rows[0] ?? null);
expect(failedRun).toMatchObject({
status: "failed",
errorCode: "workspace_routing_failed",
});

const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(sourceIssue).toMatchObject({
status: "blocked",
executionRunId: null,
assigneeAgentId: agentId,
});

const [action] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.sourceIssueId, issueId));
expect(action).toMatchObject({
kind: "stranded_assigned_issue",
status: "active",
ownerAgentId: agentId,
cause: "workspace_routing_failed",
attemptCount: 1,
wakePolicy: {
type: "manual_unblock",
reason: "workspace_routing_failed",
},
});
expect(action?.evidence).toMatchObject({
latestRunId: run!.id,
latestRunErrorCode: "workspace_routing_failed",
workspaceId: projectWorkspaceId,
});

const recoveryWakeups = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.reason, "source_scoped_recovery_action"));
expect(recoveryWakeups).toHaveLength(0);
});

it("keeps a local run active when the recorded pid is still alive", async () => {
const child = spawnAliveProcess();
childProcesses.add(child);
Expand Down
82 changes: 82 additions & 0 deletions server/src/__tests__/heartbeat-workspace-session.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import type { agents } from "@paperclipai/db";
import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-local/server";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
import {
assertGitBackedProjectWorkspace,
applyPersistedExecutionWorkspaceConfig,
buildRealizedExecutionWorkspaceFromPersisted,
buildExplicitResumeSessionOverride,
Expand All @@ -16,9 +22,12 @@ import {
resolveRuntimeSessionParamsForWorkspace,
stripWorkspaceRuntimeFromExecutionRunConfig,
shouldResetTaskSessionForWake,
WorkspaceRoutingError,
type ResolvedWorkspaceForRun,
} from "../services/heartbeat.ts";

const execFile = promisify(execFileCallback);

function buildResolvedWorkspace(overrides: Partial<ResolvedWorkspaceForRun> = {}): ResolvedWorkspaceForRun {
return {
cwd: "/tmp/project",
Expand Down Expand Up @@ -59,6 +68,79 @@ function buildAgent(adapterType: string, runtimeConfig: Record<string, unknown>
} as unknown as typeof agents.$inferSelect;
}

describe("assertGitBackedProjectWorkspace", () => {
it("accepts a git-backed project workspace when cwd is a git checkout", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-git-workspace-"));
await execFile("git", ["init"], { cwd: dir });

await expect(assertGitBackedProjectWorkspace({
workspace: {
id: "workspace-1",
name: "Paperclip",
sourceType: "git_repo",
repoUrl: "https://github.com/paperclipai/paperclip",
},
cwd: dir,
})).resolves.toBeUndefined();
});

it("rejects a git-backed project workspace when cwd is missing", async () => {
await expect(assertGitBackedProjectWorkspace({
workspace: {
id: "workspace-1",
sourceType: "git_repo",
repoUrl: "https://github.com/paperclipai/paperclip",
},
cwd: path.join(os.tmpdir(), "paperclip-missing-workspace"),
})).rejects.toThrow(/checkout path is not available/);
await expect(assertGitBackedProjectWorkspace({
workspace: {
id: "workspace-1",
sourceType: "git_repo",
repoUrl: "https://github.com/paperclipai/paperclip",
},
cwd: path.join(os.tmpdir(), "paperclip-missing-workspace"),
})).rejects.toMatchObject({
code: "workspace_routing_failed",
workspaceId: "workspace-1",
});
});

it("rejects a repoUrl-backed project workspace when cwd is not a git checkout", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-non-git-workspace-"));

await expect(assertGitBackedProjectWorkspace({
workspace: {
id: "workspace-1",
sourceType: "local_path",
repoUrl: "https://github.com/paperclipai/paperclip",
},
cwd: dir,
})).rejects.toThrow(/not a git repository/);
await expect(assertGitBackedProjectWorkspace({
workspace: {
id: "workspace-1",
sourceType: "local_path",
repoUrl: "https://github.com/paperclipai/paperclip",
},
cwd: dir,
})).rejects.toBeInstanceOf(WorkspaceRoutingError);
});

it("allows non-git local_path project workspaces", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-local-workspace-"));

await expect(assertGitBackedProjectWorkspace({
workspace: {
id: "workspace-1",
sourceType: "local_path",
repoUrl: null,
},
cwd: dir,
})).resolves.toBeUndefined();
});
});

describe("resolveRuntimeSessionParamsForWorkspace", () => {
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
const agentId = "agent-123";
Expand Down
Loading
Loading