Admit fresh computer owners when execution configuration changes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-10 08:41:04 -05:00
1 parent 5b72e2f050
commit 2bc6fc84fc
7 files changed
+74 -3

No files matched your search

@@ -749,6 +749,20 @@ describe("environmentRunOrchestrator — realizeForRun", () => {
describe("native runner lifecycle changes before lease acquisition", () => {
it("fences computer warm admission when the selected model changes", async () => {
const environment = { ...makeEnvironment("computer"), config: { provider: "boat" } };
mockGetEnvironment.mockResolvedValue(environment);
const acquireRunLease = vi.fn(async (input) => ({ environment: input.environment, lease: makeLease(), leaseContext: {} }));
const orchestrator = environmentRunOrchestrator({} as never, { environmentRuntime: makeMockRuntime({ acquireRunLease }) });
const input = { companyId: "company-1", selectedEnvironmentId: "env-1", localEnvironmentId: "local", adapterType: "paperclip_runner",
issueId: "task", heartbeatRunId: "run-1", agentId: "agent-1", persistedExecutionWorkspace: null, executionWorkspaceSettings: null };
for (const model of ["model-a", "model-a", "model-b"]) await orchestrator.acquireForRun({ ...input, adapterConfig: { provider: "codex", model } });
const keys = acquireRunLease.mock.calls.map(([input]) => input.executionConfigurationKey);
expect(keys[0]).toMatch(/^[a-f0-9]{64}$/);
expect(keys[1]).toBe(keys[0]);
expect(keys[2]).not.toBe(keys[0]);
});
it("requests a reusable lease when an existing task switches from per-turn to warm", async () => {
const environment = { ...makeEnvironment("sandbox"), config: { provider: "daytona", reuseLease: false } };
mockGetEnvironment.mockResolvedValue(environment);
@@ -4,7 +4,7 @@ import { createComputerEnvironmentDriver } from "./computer-environment-driver.j
const state = vi.hoisted(() => {
const binding = { owner: { computerId: "computer", ownerId: "owner", generation: 1 },
listenerPort: 43127, remoteCwd: "/remote/workspace", placementId: "placement", agentHome: "/remote/home" };
return { admit: vi.fn(async () => binding), admitProbe: vi.fn(async () => binding),
return { admit: vi.fn(async (_input: { sessionKey: string }) => binding), admitProbe: vi.fn(async () => binding),
retire: vi.fn(async () => ({ retired: true })), acquireLease: vi.fn(async (input: unknown) => input) };
});
vi.mock("../modules/computers/index.js", () => ({ computerService: () => state }));
@@ -12,6 +12,20 @@ vi.mock("./environments.js", () => ({ environmentService: () => state }));
vi.mock("./instance-settings.js", () => ({ instanceSettingsService: () => ({ getExperimental: async () => ({ enableBoatEnvironments: true }) }) }));
describe("computer environment probe ownership", () => {
it("keeps stable configuration on one session key and fences a changed model", async () => {
state.admit.mockClear();
const db = { update: () => ({ set: () => ({ where: async () => undefined }) }) };
const driver = createComputerEnvironmentDriver(db as never);
for (const executionConfigurationKey of ["model-a", "model-a", "model-b"]) {
await driver.acquireRunLease({ companyId: "company", agentId: "agent", heartbeatRunId: "run",
environment: { id: "environment", config: {} }, adapterType: "paperclip_runner", executionConfigurationKey } as never);
}
const keys = state.admit.mock.calls.map(([input]) => input.sessionKey);
expect(keys[0]).toBe(keys[1]);
expect(keys[0]).not.toBe(keys[2]);
state.admit.mockClear();
});
it("admits a bounded probe without fabricating a heartbeat run", async () => {
const db = { update: () => ({ set: () => ({ where: async () => undefined }) }) };
const driver = createComputerEnvironmentDriver(db as never);
@@ -49,7 +49,7 @@ export function createComputerEnvironmentDriver(db: Db): EnvironmentRuntimeDrive
const idleTimeoutMs = resolvePaperclipRunnerIdleTimeoutMs(input.environment.config.runnerIdleTimeoutMs);
const binding = input.heartbeatRunId ? await computers.admit({ companyId: input.companyId, environmentId: input.environment.id,
agentId: input.agentId, runId: input.heartbeatRunId, idleTimeoutMs,
sessionKey: createHash("sha256").update(JSON.stringify([input.agentId, input.executionWorkspaceId ?? input.issueId, input.adapterType])).digest("hex") })
sessionKey: createHash("sha256").update(JSON.stringify([input.agentId, input.executionWorkspaceId ?? input.issueId, input.adapterType, input.executionConfigurationKey])).digest("hex") })
: await computers.admitProbe({ companyId: input.companyId, environmentId: input.environment.id,
agentId: input.agentId, probeId: randomUUID(), idleTimeoutMs });
try {
@@ -15,6 +15,7 @@
* and transport logic.
*/
import { createHash } from "node:crypto";
import type { Db } from "@paperclipai/db";
import type {
Environment,
@@ -203,6 +204,7 @@ export function environmentRunOrchestrator(
* Wraps the runtime driver's acquire call with standardized error handling.
*/
async function acquireLease(input: {
executionConfigurationKey?: string;
companyId: string;
environment: Environment;
issueId: string | null;
@@ -267,6 +269,7 @@ export function environmentRunOrchestrator(
localEnvironmentId: string;
adapterType: string;
adapterConfig?: Record<string, unknown>;
executionConfigurationKey?: string;
admittedLifecycleMode?: "warm" | "per_turn";
issueId: string | null;
heartbeatRunId: string;
@@ -289,6 +292,9 @@ export function environmentRunOrchestrator(
// Step 2: Acquire lease
const acquisitionInput = {
executionConfigurationKey: environment.driver === "computer" ? createHash("sha256").update(JSON.stringify([
input.adapterConfig, input.executionConfigurationKey, environment.config, environment.envVars,
])).digest("hex") : undefined,
companyId: input.companyId,
environment,
issueId: input.issueId,
@@ -433,6 +433,8 @@ function stripSecretRefValuesFromPluginLeaseMetadata(input: {
}
export interface EnvironmentDriverAcquireInput {
/** Stable selected execution configuration; command-backed warm owners fence changes. */
executionConfigurationKey?: string;
companyId: string;
environment: Environment;
issueId: string | null;
@@ -3979,6 +3981,7 @@ export function environmentRuntimeService(
},
async acquireRunLease(input: {
executionConfigurationKey?: string;
companyId: string;
environment: Environment;
issueId: string | null;
@@ -4017,6 +4020,7 @@ export function environmentRuntimeService(
});
const driver = requireDriver(input.environment);
const lease = await driver.acquireRunLease({
executionConfigurationKey: input.executionConfigurationKey,
companyId: input.companyId,
environment: input.environment,
issueId: input.issueId,
+4 -1
View File
@@ -4443,7 +4443,7 @@ export function heartbeatService(
}
await bindIssueToPersistedExecutionWorkspace(persistedExecutionWorkspace);
const projectRepositoryPaths: string[] = [];
if (executionWorkspace.projectId && resolvedWorkspace.source === "project_primary" && !resolvedWorkspace.baseCwdFallback) {
if (selectedEnvironmentForConfig?.driver !== "computer" && executionWorkspace.projectId && resolvedWorkspace.source === "project_primary" && !resolvedWorkspace.baseCwdFallback) {
const repositoryRows = await db.select().from(projectWorkspaces).where(and(
eq(projectWorkspaces.companyId, agent.companyId),
eq(projectWorkspaces.projectId, executionWorkspace.projectId),
@@ -4492,6 +4492,9 @@ export function heartbeatService(
localEnvironmentId: localEnvironment.id,
adapterType: agent.adapterType,
adapterConfig: parseObject(agent.adapterConfig),
executionConfigurationKey: createHash("sha256").update(JSON.stringify([
config, agent.runtimeConfig, agent.permissions, managedAiRuntime?.sessionIdentity,
])).digest("hex"),
admittedLifecycleMode: persistedNativeExecutionInput?.session.lifecyclePolicy.mode,
issueId: issueId ?? null,
heartbeatRunId: run.id,
@@ -7433,6 +7433,36 @@ describe("native warm session supervision", () => {
await closeWarmNativeSessionsForRun({ runId: owners[1]!.runId, reason: "fixture cleanup" });
});
it("replaces a computer warm runner after model configuration admits a fresh owner", async () => {
const name = "computer-model-change";
const current = { ...execution, binding: { ...execution.binding, runId: `${name}-one`, agentId: name, executionWorkspaceId: name },
session: { ...execution.session, normalizedSessionId: name, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 } } } as NativeExecutionInputV1;
const firstClose = vi.fn(async () => undefined), secondClose = vi.fn(async () => undefined);
const firstRetire = vi.fn(async () => true), secondRetire = vi.fn(async () => true);
const target = { kind: "remote", transport: "computer", environmentId: name, remoteCwd: `/home/user/${name}`,
resourceAuthority: { kind: "computer-owner", computerId: name, ownerId: "prior-owner", generation: 1 },
retainWarm: vi.fn(async () => undefined), retire: firstRetire };
const result = { result: { summary: "completed" }, terminal: { runTerminalState: "succeeded" }, turnId: name,
normalizedSessionId: name, providerSessionId: name, driverKind: "test", driverVersion: "1", nativeEventCount: 1,
highestContiguousSourceSeq: 1, usage: null };
state.execute.mockReset().mockImplementationOnce(async options => {
await options.onSession?.({ close: firstClose }); return result;
}).mockImplementationOnce(async options => {
expect(options.existingSession).toBeUndefined();
expect(secondRetire).not.toHaveBeenCalled();
await options.onSession?.({ close: secondClose }); return result;
});
await executePaperclipNativeSession({ db: leaseDb(current), execution: current, runnerInstanceId: name, runnerExecutionTarget: target as never });
const next = { ...current, binding: { ...current.binding, runId: `${name}-two` }, provider: { ...current.provider, model: "changed-model" } } as NativeExecutionInputV1;
await executePaperclipNativeSession({ db: leaseDb(next), execution: next, runnerInstanceId: name,
runnerExecutionTarget: { ...target, resourceAuthority: { ...target.resourceAuthority, ownerId: "fresh-owner" }, retire: secondRetire } as never });
expect(firstRetire).toHaveBeenCalledOnce();
expect(firstClose).toHaveBeenCalledOnce();
expect(secondRetire).not.toHaveBeenCalled();
await closeWarmNativeSessionsForRun({ runId: next.binding.runId, reason: "fixture cleanup" });
expect(secondRetire).toHaveBeenCalledOnce();
});
it("keeps a computer transport alive when its old idle timer loses to warm admission", async () => {
const name = "computer-idle-admission-race";
const current = { ...execution, binding: { ...execution.binding, runId: `${name}-one`, agentId: name, executionWorkspaceId: name },