Fix execution workspace runtime control reuse

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-28 21:04:56 -05:00
parent 2fea39b814
commit e3f07aad55
2 changed files with 347 additions and 33 deletions

View file

@ -10,7 +10,9 @@ import {
agents, agents,
companies, companies,
createDb, createDb,
executionWorkspaces,
heartbeatRuns, heartbeatRuns,
projects,
workspaceRuntimeServices, workspaceRuntimeServices,
} from "@paperclipai/db"; } from "@paperclipai/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@ -880,6 +882,101 @@ describe("ensureRuntimeServicesForRun", () => {
expect(third[0]?.id).not.toBe(first[0]?.id); expect(third[0]?.id).not.toBe(first[0]?.id);
}); });
it("does not reuse project-scoped shared services across different workspace launch contexts", async () => {
const primaryWorkspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-primary-"));
const worktreeWorkspaceRoot = path.join(primaryWorkspaceRoot, ".paperclip", "worktrees", "PAP-874-chat-speed-issues");
await fs.mkdir(worktreeWorkspaceRoot, { recursive: true });
const primaryWorkspace = buildWorkspace(primaryWorkspaceRoot);
const executionWorkspace: RealizedExecutionWorkspace = {
...buildWorkspace(worktreeWorkspaceRoot),
source: "task_session",
strategy: "git_worktree",
cwd: worktreeWorkspaceRoot,
branchName: "PAP-874-chat-speed-issues",
worktreePath: worktreeWorkspaceRoot,
};
const serviceCommand =
"node -e \"require('node:http').createServer((req,res)=>res.end(process.env.PAPERCLIP_HOME)).listen(Number(process.env.PORT), '127.0.0.1')\"";
const config = {
workspaceRuntime: {
services: [
{
name: "paperclip-dev",
command: serviceCommand,
cwd: ".",
env: {
PAPERCLIP_HOME: "{{workspace.cwd}}/.paperclip/runtime-services",
},
port: { type: "auto" },
readiness: {
type: "http",
urlTemplate: "http://127.0.0.1:{{port}}",
timeoutSec: 10,
intervalMs: 100,
},
expose: {
type: "url",
urlTemplate: "http://127.0.0.1:{{port}}",
},
lifecycle: "shared",
reuseScope: "project_workspace",
stopPolicy: {
type: "on_run_finish",
},
},
],
},
};
const primaryRunId = "run-project-workspace";
const executionRunId = "run-execution-workspace";
leasedRunIds.add(primaryRunId);
leasedRunIds.add(executionRunId);
const primaryServices = await ensureRuntimeServicesForRun({
runId: primaryRunId,
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
issue: null,
workspace: primaryWorkspace,
config,
adapterEnv: {},
});
const executionServices = await ensureRuntimeServicesForRun({
runId: executionRunId,
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
issue: null,
workspace: executionWorkspace,
executionWorkspaceId: "execution-workspace-1",
config,
adapterEnv: {},
});
expect(primaryServices).toHaveLength(1);
expect(executionServices).toHaveLength(1);
expect(primaryServices[0]?.reused).toBe(false);
expect(executionServices[0]?.reused).toBe(false);
expect(executionServices[0]?.id).not.toBe(primaryServices[0]?.id);
expect(executionServices[0]?.executionWorkspaceId).toBe("execution-workspace-1");
expect(executionServices[0]?.cwd).toBe(worktreeWorkspaceRoot);
expect(executionServices[0]?.url).not.toBe(primaryServices[0]?.url);
const primaryResponse = await fetch(primaryServices[0]!.url!);
expect(await primaryResponse.text()).toBe(path.join(primaryWorkspaceRoot, ".paperclip", "runtime-services"));
const executionResponse = await fetch(executionServices[0]!.url!);
expect(await executionResponse.text()).toBe(path.join(worktreeWorkspaceRoot, ".paperclip", "runtime-services"));
});
it("does not leak parent Paperclip instance env into runtime service commands", async () => { it("does not leak parent Paperclip instance env into runtime service commands", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-env-")); const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-env-"));
const workspace = buildWorkspace(workspaceRoot); const workspace = buildWorkspace(workspaceRoot);
@ -1089,6 +1186,8 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
afterEach(async () => { afterEach(async () => {
await db.delete(workspaceRuntimeServices); await db.delete(workspaceRuntimeServices);
await db.delete(executionWorkspaces);
await db.delete(projects);
await db.delete(heartbeatRuns); await db.delete(heartbeatRuns);
await db.delete(agents); await db.delete(agents);
await db.delete(companies); await db.delete(companies);
@ -1201,6 +1300,127 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
await expect(fetch(service!.url!)).rejects.toThrow(); await expect(fetch(service!.url!)).rejects.toThrow();
}); });
it("persists controlled execution workspace stops as stopped", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-"));
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const runId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Codex Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Runtime stop test",
status: "active",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Execution workspace stop test",
status: "active",
cwd: workspaceRoot,
providerType: "local_fs",
providerRef: workspaceRoot,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "manual",
status: "running",
startedAt: new Date(),
updatedAt: new Date(),
});
const workspace = {
...buildWorkspace(workspaceRoot),
projectId: null,
workspaceId: null,
};
leasedRunIds.add(runId);
const services = await ensureRuntimeServicesForRun({
db,
runId,
agent: {
id: agentId,
name: "Codex Coder",
companyId,
},
issue: null,
workspace,
executionWorkspaceId,
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({
db,
executionWorkspaceId,
workspaceCwd: workspace.cwd,
});
await releaseRuntimeServicesForRun(runId);
leasedRunIds.delete(runId);
await new Promise((resolve) => setTimeout(resolve, 250));
await expect(fetch(services[0]!.url!)).rejects.toThrow();
const persisted = await db
.select()
.from(workspaceRuntimeServices)
.where(eq(workspaceRuntimeServices.id, services[0]!.id))
.then((rows) => rows[0] ?? null);
expect(persisted?.status).toBe("stopped");
expect(persisted?.healthStatus).toBe("unknown");
expect(persisted?.stoppedAt).toBeTruthy();
});
}); });
describe("normalizeAdapterManagedRuntimeServices", () => { describe("normalizeAdapterManagedRuntimeServices", () => {

View file

@ -914,6 +914,88 @@ function buildTemplateData(input: {
}; };
} }
function renderRuntimeServiceEnv(input: {
envConfig: Record<string, unknown>;
templateData: ReturnType<typeof buildTemplateData>;
}) {
const rendered: Record<string, string> = {};
for (const [key, value] of Object.entries(input.envConfig)) {
if (typeof value !== "string") continue;
rendered[key] = renderTemplate(value, input.templateData);
}
return rendered;
}
function resolveRuntimeServiceReuseIdentity(input: {
service: Record<string, unknown>;
workspace: RealizedExecutionWorkspace;
agent: ExecutionWorkspaceAgentRef;
issue: ExecutionWorkspaceIssueRef | null;
adapterEnv: Record<string, string>;
scopeType: RuntimeServiceRef["scopeType"];
scopeId: string | null;
}): {
serviceName: string;
lifecycle: RuntimeServiceRef["lifecycle"];
command: string;
serviceCwd: string;
envConfig: Record<string, unknown>;
envFingerprint: string;
explicitPort: number;
identityPort: number | null;
reuseKey: string | null;
} {
const serviceName = asString(input.service.name, "service");
const lifecycle = asString(input.service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared";
const command = asString(input.service.command, "");
const serviceCwdTemplate = asString(input.service.cwd, ".");
const portConfig = parseObject(input.service.port);
const envConfig = parseObject(input.service.env);
const explicitPort = asNumber(portConfig.value, asNumber(input.service.port, 0));
const identityPort = explicitPort > 0 ? explicitPort : null;
const templateData = buildTemplateData({
workspace: input.workspace,
agent: input.agent,
issue: input.issue,
adapterEnv: input.adapterEnv,
port: identityPort,
});
const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd);
const renderedEnv = renderRuntimeServiceEnv({
envConfig,
templateData,
});
const envFingerprint = createHash("sha256").update(stableStringify(renderedEnv)).digest("hex");
const reuseKey =
lifecycle === "shared"
? createHash("sha256")
.update(
stableStringify({
scopeType: input.scopeType,
scopeId: input.scopeId,
serviceName,
command,
cwd: serviceCwd,
port: identityPort,
env: renderedEnv,
}),
)
.digest("hex")
: null;
return {
serviceName,
lifecycle,
command,
serviceCwd,
envConfig,
envFingerprint,
explicitPort,
identityPort,
reuseKey,
};
}
function resolveServiceScopeId(input: { function resolveServiceScopeId(input: {
service: Record<string, unknown>; service: Record<string, unknown>;
workspace: RealizedExecutionWorkspace; workspace: RealizedExecutionWorkspace;
@ -1121,17 +1203,25 @@ async function startLocalRuntimeService(input: {
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent"; scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
scopeId: string | null; scopeId: string | null;
}): Promise<RuntimeServiceRecord> { }): Promise<RuntimeServiceRecord> {
const serviceName = asString(input.service.name, "service"); const identity = resolveRuntimeServiceReuseIdentity({
const lifecycle = asString(input.service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared"; service: input.service,
const command = asString(input.service.command, ""); workspace: input.workspace,
agent: input.agent,
issue: input.issue,
adapterEnv: input.adapterEnv,
scopeType: input.scopeType,
scopeId: input.scopeId,
});
const serviceName = identity.serviceName;
const lifecycle = identity.lifecycle;
const command = identity.command;
if (!command) throw new Error(`Runtime service "${serviceName}" is missing command`); if (!command) throw new Error(`Runtime service "${serviceName}" is missing command`);
const serviceCwdTemplate = asString(input.service.cwd, ".");
const portConfig = parseObject(input.service.port); const portConfig = parseObject(input.service.port);
const envConfig = parseObject(input.service.env); const envConfig = identity.envConfig;
const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex"); const envFingerprint = identity.envFingerprint;
const serviceIdentityFingerprint = input.reuseKey ?? envFingerprint; const serviceIdentityFingerprint = input.reuseKey ?? envFingerprint;
const explicitPort = asNumber(portConfig.value, asNumber(input.service.port, 0)); const explicitPort = identity.explicitPort;
const identityPort = explicitPort > 0 ? explicitPort : null; const identityPort = identity.identityPort;
const port = const port =
asString(portConfig.type, "") === "auto" asString(portConfig.type, "") === "auto"
? await allocatePort() ? await allocatePort()
@ -1145,15 +1235,16 @@ async function startLocalRuntimeService(input: {
adapterEnv: input.adapterEnv, adapterEnv: input.adapterEnv,
port, port,
}); });
const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd); const serviceCwd =
port === identityPort
? identity.serviceCwd
: resolveConfiguredPath(renderTemplate(asString(input.service.cwd, "."), templateData), input.workspace.cwd);
const env: Record<string, string> = { const env: Record<string, string> = {
...sanitizeRuntimeServiceBaseEnv(process.env), ...sanitizeRuntimeServiceBaseEnv(process.env),
...input.adapterEnv, ...input.adapterEnv,
} as Record<string, string>; } as Record<string, string>;
for (const [key, value] of Object.entries(envConfig)) { for (const [key, value] of Object.entries(renderRuntimeServiceEnv({ envConfig, templateData }))) {
if (typeof value === "string") { env[key] = value;
env[key] = renderTemplate(value, templateData);
}
} }
if (port) { if (port) {
const portEnvKey = asString(portConfig.envKey, "PORT"); const portEnvKey = asString(portConfig.envKey, "PORT");
@ -1346,8 +1437,13 @@ async function stopRuntimeService(serviceId: string) {
if (!record) return; if (!record) return;
clearIdleTimer(record); clearIdleTimer(record);
record.status = "stopped"; record.status = "stopped";
record.healthStatus = "unknown";
record.lastUsedAt = new Date().toISOString(); record.lastUsedAt = new Date().toISOString();
record.stoppedAt = new Date().toISOString(); record.stoppedAt = new Date().toISOString();
runtimeServicesById.delete(serviceId);
if (record.reuseKey && runtimeServicesByReuseKey.get(record.reuseKey) === record.id) {
runtimeServicesByReuseKey.delete(record.reuseKey);
}
if (record.child && record.child.pid) { if (record.child && record.child.pid) {
await terminateLocalService({ await terminateLocalService({
pid: record.child.pid, pid: record.child.pid,
@ -1362,10 +1458,6 @@ async function stopRuntimeService(serviceId: string) {
}); });
} }
} }
runtimeServicesById.delete(serviceId);
if (record.reuseKey) {
runtimeServicesByReuseKey.delete(record.reuseKey);
}
await removeLocalServiceRegistryRecord(record.serviceKey); await removeLocalServiceRegistryRecord(record.serviceKey);
await persistRuntimeServiceRecord(record.db, record); await persistRuntimeServiceRecord(record.db, record);
} }
@ -1441,7 +1533,6 @@ export async function ensureRuntimeServicesForRun(input: {
try { try {
for (const service of rawServices) { for (const service of rawServices) {
const lifecycle = asString(service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared";
const { scopeType, scopeId } = resolveServiceScopeId({ const { scopeType, scopeId } = resolveServiceScopeId({
service, service,
workspace: input.workspace, workspace: input.workspace,
@ -1450,13 +1541,15 @@ export async function ensureRuntimeServicesForRun(input: {
runId: input.runId, runId: input.runId,
agent: input.agent, agent: input.agent,
}); });
const envConfig = parseObject(service.env); const reuseKey = resolveRuntimeServiceReuseIdentity({
const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex"); service,
const serviceName = asString(service.name, "service"); workspace: input.workspace,
const reuseKey = agent: input.agent,
lifecycle === "shared" issue: input.issue,
? [scopeType, scopeId ?? "", serviceName, envFingerprint].join(":") adapterEnv: input.adapterEnv,
: null; scopeType,
scopeId,
}).reuseKey;
if (reuseKey) { if (reuseKey) {
const existingId = runtimeServicesByReuseKey.get(reuseKey); const existingId = runtimeServicesByReuseKey.get(reuseKey);
@ -1520,7 +1613,6 @@ export async function startRuntimeServicesForWorkspaceControl(input: {
const invocationId = input.invocationId ?? randomUUID(); const invocationId = input.invocationId ?? randomUUID();
for (const service of rawServices) { for (const service of rawServices) {
const lifecycle = asString(service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared";
const { scopeType, scopeId } = resolveServiceScopeId({ const { scopeType, scopeId } = resolveServiceScopeId({
service, service,
workspace: input.workspace, workspace: input.workspace,
@ -1529,13 +1621,15 @@ export async function startRuntimeServicesForWorkspaceControl(input: {
runId: invocationId, runId: invocationId,
agent: input.actor, agent: input.actor,
}); });
const envConfig = parseObject(service.env); const reuseKey = resolveRuntimeServiceReuseIdentity({
const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex"); service,
const serviceName = asString(service.name, "service"); workspace: input.workspace,
const reuseKey = agent: input.actor,
lifecycle === "shared" issue: input.issue,
? [scopeType, scopeId ?? "", serviceName, envFingerprint].join(":") adapterEnv: input.adapterEnv,
: null; scopeType,
scopeId,
}).reuseKey;
if (reuseKey) { if (reuseKey) {
const existingId = runtimeServicesByReuseKey.get(reuseKey); const existingId = runtimeServicesByReuseKey.get(reuseKey);