diff --git a/server/src/__tests__/execution-workspace-runtime-control-conflict.test.ts b/server/src/__tests__/execution-workspace-runtime-control-conflict.test.ts index 8290e4eddfa..c4487b69b55 100644 --- a/server/src/__tests__/execution-workspace-runtime-control-conflict.test.ts +++ b/server/src/__tests__/execution-workspace-runtime-control-conflict.test.ts @@ -29,6 +29,7 @@ const mockStartRuntimeServices = vi.hoisted(() => vi.fn()); const mockStopRuntimeServicesForExecutionWorkspace = vi.hoisted(() => vi.fn()); const mockEnsurePersistedExecutionWorkspaceAvailable = vi.hoisted(() => vi.fn()); const mockBuildWorkspaceRuntimeDesiredStatePatch = vi.hoisted(() => vi.fn()); +const mockListConfiguredRuntimeServiceEntries = vi.hoisted(() => vi.fn()); // The integrated control path also takes the durable runtime-control lease (PAP-17205). This // suite covers the in-flight guard and failed-start reconciliation, so the lease always grants; // `execution-workspace-runtime-lease-route.test.ts` exercises the lease itself against a real db. @@ -63,7 +64,7 @@ vi.mock("../services/workspace-runtime.js", () => ({ buildWorkspaceRuntimeDesiredStatePatch: mockBuildWorkspaceRuntimeDesiredStatePatch, cleanupExecutionWorkspaceArtifacts: vi.fn(), ensurePersistedExecutionWorkspaceAvailable: mockEnsurePersistedExecutionWorkspaceAvailable, - listConfiguredRuntimeServiceEntries: vi.fn(() => []), + listConfiguredRuntimeServiceEntries: mockListConfiguredRuntimeServiceEntries, runWorkspaceJobForControl: vi.fn(), startRuntimeServicesForWorkspaceControl: mockStartRuntimeServices, stopRuntimeServicesForExecutionWorkspace: mockStopRuntimeServicesForExecutionWorkspace, @@ -171,6 +172,7 @@ describe.sequential("execution workspace runtime control conflict and failure re repoRef: "main", }); mockStopRuntimeServicesForExecutionWorkspace.mockResolvedValue(undefined); + mockListConfiguredRuntimeServiceEntries.mockReturnValue([{ name: "app" }]); mockWorkspaceOperationService.createRecorder.mockReturnValue({ attachExecutionWorkspaceId: vi.fn(), recordOperation: async (input: any) => { @@ -340,6 +342,78 @@ describe.sequential("execution workspace runtime control conflict and failure re } }); + it("completes database-only repair when no managed runtime service is configured", async () => { + const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-route-repair-db-only-")); + try { + const configDir = path.join(workspaceCwd, ".paperclip"); + const sourceConfigPath = path.join(workspaceCwd, "source", "config.json"); + const cliRunner = path.join(workspaceCwd, "cli", "node_modules", "tsx", "dist", "cli.mjs"); + const cliEntry = path.join(workspaceCwd, "cli", "src", "index.ts"); + for (const dir of [configDir, path.dirname(sourceConfigPath), path.dirname(cliRunner), path.dirname(cliEntry)]) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(path.join(configDir, "config.json"), "{}\n"); + fs.writeFileSync(sourceConfigPath, "{}\n"); + fs.writeFileSync(cliRunner, "// test runner\n"); + fs.writeFileSync(cliEntry, "// test entry\n"); + fs.writeFileSync(path.join(configDir, "seed-manifest.json"), JSON.stringify({ + version: 2, + source: { instanceId: "source", configPath: sourceConfigPath }, + state: "failed", + attemptId: "previous", + })); + + const { deriveWorktreeInstanceId } = await import("../services/workspace-instance-cleanup.js"); + mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ + cwd: workspaceCwd, + config: { desiredState: "stopped" }, + })); + mockListConfiguredRuntimeServiceEntries.mockReturnValue([]); + mockSpawn.mockImplementation(() => { + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough; + stderr: PassThrough; + kill: ReturnType; + }; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = vi.fn(); + queueMicrotask(() => { + fs.writeFileSync(path.join(configDir, "seed-manifest.json"), JSON.stringify({ + version: 2, + source: { instanceId: "source", configPath: sourceConfigPath }, + snapshotAt: "2026-08-18T00:00:00.000Z", + seedMode: "full", + migrationRevision: "0142_test.sql", + targetInstanceId: deriveWorktreeInstanceId(workspaceCwd), + phase: "complete", + state: "verified", + attemptId: "repair-attempt", + startedAt: "2026-08-18T00:00:00.000Z", + finishedAt: "2026-08-18T00:01:00.000Z", + diagnostics: [{ phase: "complete", status: "succeeded", at: "2026-08-18T00:01:00.000Z" }], + })); + child.emit("exit", 0); + }); + return child; + }); + + const app = await createApp(); + const res = await request(app) + .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`) + .send({}); + + expect(res.status).toBe(200); + expect(mockStopRuntimeServicesForExecutionWorkspace).toHaveBeenCalledTimes(1); + expect(mockStartRuntimeServices).not.toHaveBeenCalled(); + expect(mockBuildWorkspaceRuntimeDesiredStatePatch).toHaveBeenCalledWith( + expect.objectContaining({ action: "stop" }), + ); + } finally { + fs.rmSync(workspaceCwd, { recursive: true, force: true }); + } + }); + it("fails repair at the exact seed phase and leaves managed services stopped", async () => { const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-route-repair-failure-")); try { diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index e195ad12433..59f027047cf 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -226,6 +226,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P const configuredServices = effectiveRuntimeConfig ? listConfiguredRuntimeServiceEntries({ workspaceRuntime: effectiveRuntimeConfig }) : []; + const repairRestartsRuntimeServices = action === "repair" && configuredServices.length > 0; const workspaceCommand = effectiveRuntimeConfig ? findWorkspaceCommandDefinition(effectiveRuntimeConfig, target.workspaceCommandId ?? null) : null; @@ -267,7 +268,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P return; } - if ((action === "start" || action === "restart" || action === "repair") && !effectiveRuntimeConfig) { + if ((action === "start" || action === "restart") && !effectiveRuntimeConfig) { res.status(422).json({ error: "Execution workspace has no workspace command configuration or inherited project workspace default" }); return; } @@ -623,40 +624,46 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P await reportRepairPhase("full_reseed", "succeeded"); await reportRepairPhase("managed_restart", "started"); - const availableWorkspace = await ensureWorkspaceAvailable(); - if (!availableWorkspace) { - throw new Error("Execution workspace needs a local path before Paperclip can restart it."); + let startedServices: Awaited> = []; + if (repairRestartsRuntimeServices) { + const availableWorkspace = await ensureWorkspaceAvailable(); + if (!availableWorkspace) { + throw new Error("Execution workspace needs a local path before Paperclip can restart it."); + } + startedServices = await startRuntimeServicesForWorkspaceControl({ + db, + actor: { + id: actor.agentId ?? null, + name: actor.actorType === "user" ? "Board" : "Agent", + companyId: existing.companyId, + }, + issue: existing.sourceIssueId + ? { id: existing.sourceIssueId, identifier: null, title: existing.name } + : null, + workspace: availableWorkspace, + executionWorkspaceId: existing.id, + config: { + workspaceRuntime: effectiveRuntimeConfig, + runtimeProvisionCommand: + existing.config?.runtimeProvisionCommand + ?? projectPolicy?.workspaceStrategy?.runtimeProvisionCommand + ?? null, + }, + adapterEnv: {}, + onLog, + recorder, + }); } - const startedServices = await startRuntimeServicesForWorkspaceControl({ - db, - actor: { - id: actor.agentId ?? null, - name: actor.actorType === "user" ? "Board" : "Agent", - companyId: existing.companyId, - }, - issue: existing.sourceIssueId - ? { id: existing.sourceIssueId, identifier: null, title: existing.name } - : null, - workspace: availableWorkspace, - executionWorkspaceId: existing.id, - config: { - workspaceRuntime: effectiveRuntimeConfig, - runtimeProvisionCommand: - existing.config?.runtimeProvisionCommand - ?? projectPolicy?.workspaceStrategy?.runtimeProvisionCommand - ?? null, - }, - adapterEnv: {}, - onLog, - recorder, - }); runtimeServiceCount = startedServices.length; await reportRepairPhase("managed_restart", "succeeded"); await reportRepairPhase("readiness_validation", "started"); if ( - startedServices.length === 0 - || startedServices.some((service) => service.status !== "running" || service.healthStatus !== "healthy") + repairRestartsRuntimeServices + && ( + startedServices.length === 0 + || startedServices.some((service) => service.status !== "running" || service.healthStatus !== "healthy") + ) ) { throw new Error("Managed restart did not produce healthy runtime services."); } @@ -740,7 +747,9 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P config: { workspaceRuntime: effectiveRuntimeConfig }, currentDesiredState, currentServiceStates: existing.config?.serviceStates ?? null, - action: action === "repair" ? "start" : action, + action: action === "repair" + ? repairRestartsRuntimeServices ? "start" : "stop" + : action, serviceIndex: selectedServiceIndex, }); const metadata = mergeExecutionWorkspaceConfig(existing.metadata as Record | null, { @@ -759,13 +768,16 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P : action === "restart" ? "Restarted execution workspace runtime services.\n" : action === "repair" - ? "Repaired the isolated workspace database and restarted healthy runtime services.\n" - : "Started execution workspace runtime services.\n", + ? repairRestartsRuntimeServices + ? "Repaired the isolated workspace database and restarted healthy runtime services.\n" + : "Repaired the isolated workspace database; no managed runtime services were configured to restart.\n" + : "Started execution workspace runtime services.\n", metadata: { runtimeServiceCount, workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, runtimeServiceId: selectedRuntimeServiceId, serviceIndex: selectedServiceIndex, + runtimeRestarted: action === "repair" ? repairRestartsRuntimeServices : undefined, }, }; },