Stop runtime services during workspace cleanup

This commit is contained in:
Dotta
2026-03-14 09:41:13 -05:00
parent 3b25268c0b
commit 9ed7092aab
2 changed files with 88 additions and 14 deletions

View File

@@ -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", () => {

View File

@@ -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) {