diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index e6527f48..20dcf838 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -10,6 +10,7 @@ import { normalizeAdapterManagedRuntimeServices, realizeExecutionWorkspace, releaseRuntimeServicesForRun, + stopRuntimeServicesForExecutionWorkspace, type RealizedExecutionWorkspace, } from "../services/workspace-runtime.ts"; @@ -457,6 +458,60 @@ describe("ensureRuntimeServicesForRun", () => { expect(captured.port).toMatch(/^\d+$/); expect(services[0]?.executionWorkspaceId).toBe("execution-workspace-1"); }); + + it("stops execution workspace runtime services by executionWorkspaceId", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-")); + const workspace = buildWorkspace(workspaceRoot); + const runId = "run-stop"; + leasedRunIds.add(runId); + + const services = await ensureRuntimeServicesForRun({ + runId, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + issue: null, + workspace, + executionWorkspaceId: "execution-workspace-stop", + config: { + workspaceRuntime: { + services: [ + { + name: "web", + command: + "node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"", + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + lifecycle: "shared", + reuseScope: "execution_workspace", + stopPolicy: { + type: "manual", + }, + }, + ], + }, + }, + adapterEnv: {}, + }); + + expect(services[0]?.url).toBeTruthy(); + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-stop", + workspaceCwd: workspace.cwd, + }); + await releaseRuntimeServicesForRun(runId); + leasedRunIds.delete(runId); + await new Promise((resolve) => setTimeout(resolve, 250)); + + await expect(fetch(services[0]!.url!)).rejects.toThrow(); + }); }); describe("normalizeAdapterManagedRuntimeServices", () => { diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 172d1757..87de0f08 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -249,6 +249,21 @@ async function directoryExists(value: string) { return fs.stat(value).then((stats) => stats.isDirectory()).catch(() => false); } +function terminateChildProcess(child: ChildProcess) { + if (!child.pid) return; + if (process.platform !== "win32") { + try { + process.kill(-child.pid, "SIGTERM"); + return; + } catch { + // Fall through to the direct child kill. + } + } + if (!child.killed) { + child.kill("SIGTERM"); + } +} + function buildWorkspaceCommandEnv(input: { base: ExecutionWorkspaceInput; repoRoot: string; @@ -528,12 +543,12 @@ export async function cleanupExecutionWorkspaceArtifacts(input: { } if (input.workspace.providerType === "git_worktree" && workspacePath) { + const repoRoot = await resolveGitRepoRootForWorkspaceCleanup( + workspacePath, + input.projectWorkspace?.cwd ?? null, + ); const worktreeExists = await directoryExists(workspacePath); if (worktreeExists) { - const repoRoot = await resolveGitRepoRootForWorkspaceCleanup( - workspacePath, - input.projectWorkspace?.cwd ?? null, - ); if (!repoRoot) { warnings.push(`Could not resolve git repo root for "${workspacePath}".`); } else { @@ -542,12 +557,16 @@ export async function cleanupExecutionWorkspaceArtifacts(input: { } catch (err) { warnings.push(err instanceof Error ? err.message : String(err)); } - if (createdByRuntime && input.workspace.branchName) { - try { - await runGit(["branch", "-D", input.workspace.branchName], repoRoot); - } catch (err) { - warnings.push(err instanceof Error ? err.message : String(err)); - } + } + } + if (createdByRuntime && input.workspace.branchName) { + if (!repoRoot) { + warnings.push(`Could not resolve git repo root to delete branch "${input.workspace.branchName}".`); + } else { + try { + await runGit(["branch", "-D", input.workspace.branchName], repoRoot); + } catch (err) { + warnings.push(err instanceof Error ? err.message : String(err)); } } } @@ -859,7 +878,7 @@ async function startLocalRuntimeService(input: { const child = spawn(shell, ["-lc", command], { cwd: serviceCwd, env, - detached: false, + detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], }); let stderrExcerpt = ""; @@ -885,7 +904,7 @@ async function startLocalRuntimeService(input: { try { await waitForReadiness({ service: input.service, url }); } catch (err) { - child.kill("SIGTERM"); + terminateChildProcess(child); throw new Error( `Failed to start runtime service "${serviceName}": ${err instanceof Error ? err.message : String(err)}${stderrExcerpt ? ` | stderr: ${stderrExcerpt.trim()}` : ""}`, ); @@ -944,8 +963,8 @@ async function stopRuntimeService(serviceId: string) { record.status = "stopped"; record.lastUsedAt = new Date().toISOString(); record.stoppedAt = new Date().toISOString(); - if (record.child && !record.child.killed) { - record.child.kill("SIGTERM"); + if (record.child && record.child.pid) { + terminateChildProcess(record.child); } runtimeServicesById.delete(serviceId); if (record.reuseKey) {