fix(server): report failing workspace jobs as 422 instead of 500 (#15941)

A non-zero workspace job exit is the operator job's own result. Map it to 422 workspace_job_failed with the exit code instead of a 500 that the error handler reports to Sentry as a server crash. A job without a command is also 422. Other workspace command failures are unchanged.

Co-Authored-By: Amy (Opus) <claude@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-11 19:51:23 -07:00
1 parent f331d386dc
commit ba04968b10
2 files changed
+95 -17

No files matched your search

@@ -50,6 +50,7 @@ import {
resolveRuntimeProvisionCommand,
resolveWorkspaceRuntimeReadinessTimeoutSec,
resolveShell,
runWorkspaceJobForControl,
sanitizeRuntimeServiceBaseEnv,
setWorkspaceRuntimeExposureDepsForTests,
startRuntimeServicesForWorkspaceControl,
@@ -80,6 +81,7 @@ import {
import { resolvePaperclipConfigPath } from "../paths.ts";
import type { WorkspaceOperation } from "@paperclipai/shared";
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
import { HttpError } from "../errors.ts";
import { deriveWorktreeInstanceId } from "../services/workspace-instance-cleanup.ts";
import {
getEmbeddedPostgresTestSupport,
@@ -10194,3 +10196,55 @@ describe("realizeExecutionWorkspace with an exact existing branch", () => {
});
});
});
describe("runWorkspaceJobForControl", () => {
const actor = { id: null, name: "Board", companyId: "company-1" };
it("reports a failing operator job as an unprocessable job result, not a server error", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-job-failure-"));
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
const jobScript = "console.error('Restored patch differs from exact source'); process.exit(3);";
try {
const failure = await runWorkspaceJobForControl({
actor,
issue: null,
workspace: buildWorkspace(workspaceRoot),
command: {
name: "Recover retained files",
command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(jobScript)}`,
},
recorder,
}).then(() => null, (error: unknown) => error);
expect(failure).toBeInstanceOf(HttpError);
expect(failure).toMatchObject({
status: 422,
message: 'Workspace job "Recover retained files" failed: Restored patch differs from exact source',
details: { code: "workspace_job_failed", exitCode: 3 },
});
expect(operations).toHaveLength(1);
expect(operations[0]?.result).toMatchObject({ status: "failed", exitCode: 3 });
} finally {
await fs.rm(workspaceRoot, { recursive: true, force: true });
}
});
it("rejects a job without a command as unprocessable", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-job-missing-"));
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
try {
await expect(runWorkspaceJobForControl({
actor,
issue: null,
workspace: buildWorkspace(workspaceRoot),
command: { name: "Empty job" },
recorder,
})).rejects.toMatchObject({ status: 422, message: 'Workspace job "Empty job" is missing command' });
expect(operations).toHaveLength(0);
} finally {
await fs.rm(workspaceRoot, { recursive: true, force: true });
}
});
});
+41 -17
View File
@@ -34,7 +34,7 @@ import {
} from "@paperclipai/shared";
import { and, desc, eq, gte, inArray, isNull, lte, ne, or } from "drizzle-orm";
import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js";
import { conflict } from "../errors.js";
import { conflict, unprocessable } from "../errors.js";
import { resolveHomeAwarePath } from "../home-paths.js";
import { hasVerifiedWorktreeSeedManifest, isVerifiedWorktreeSeedManifest } from "../worktree-seed-manifest.js";
import {
@@ -3055,6 +3055,19 @@ function resolveRepoManagedWorkspaceCommand(command: string, repoRoot: string) {
return command;
}
/**
* A configured workspace command ran to completion but exited non-zero. The message carries the
* command's own output. The default `Error` name is kept so existing reports keep their grouping.
*/
export class WorkspaceCommandExitError extends Error {
readonly exitCode: number | null;
constructor(message: string, exitCode: number | null) {
super(message);
this.exitCode = exitCode;
}
}
async function runWorkspaceCommand(input: {
command: string;
resolvedCommand?: string;
@@ -3075,10 +3088,11 @@ async function runWorkspaceCommand(input: {
if (proc.code === 0) return;
const details = [proc.stderr.trim(), proc.stdout.trim()].filter(Boolean).join("\n");
throw new Error(
throw new WorkspaceCommandExitError(
details.length > 0
? `${input.label} failed: ${details}`
: `${input.label} failed with exit code ${proc.code ?? -1}`,
proc.code,
);
}
@@ -3211,10 +3225,11 @@ async function recordWorkspaceCommandOperation(
if (code === 0) return operation;
const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
throw new Error(
throw new WorkspaceCommandExitError(
details.length > 0
? `${input.label} failed: ${details}`
: `${input.label} failed with exit code ${code ?? -1}`,
code,
);
}
@@ -5139,23 +5154,32 @@ export async function runWorkspaceJobForControl(input: {
adapterEnv: input.adapterEnv ?? {},
});
if (!resolved.command) {
throw new Error(`Workspace job "${resolved.name}" is missing command`);
throw unprocessable(`Workspace job "${resolved.name}" is missing command`);
}
await ensureServerWorkspaceLinksCurrent(resolved.cwd);
return await recordWorkspaceCommandOperation(input.recorder, {
phase: "workspace_provision",
command: resolved.command,
cwd: resolved.cwd,
env: resolved.env,
label: `Workspace job "${resolved.name}"`,
metadata: {
workspaceCommandKind: "job",
workspaceCommandName: resolved.name,
...(input.metadata ?? {}),
},
successMessage: `Completed workspace job "${resolved.name}"\n`,
});
try {
return await recordWorkspaceCommandOperation(input.recorder, {
phase: "workspace_provision",
command: resolved.command,
cwd: resolved.cwd,
env: resolved.env,
label: `Workspace job "${resolved.name}"`,
metadata: {
workspaceCommandKind: "job",
workspaceCommandName: resolved.name,
...(input.metadata ?? {}),
},
successMessage: `Completed workspace job "${resolved.name}"\n`,
});
} catch (error) {
// A job is an operator-authored command: a non-zero exit is its result, already recorded on
// the operation, not a server fault.
if (error instanceof WorkspaceCommandExitError) {
throw unprocessable(error.message, { code: "workspace_job_failed", exitCode: error.exitCode });
}
throw error;
}
}
function resolveServiceScopeId(input: {