diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts index df72db38439..d8273d84984 100644 --- a/server/src/__tests__/environment-run-orchestrator.test.ts +++ b/server/src/__tests__/environment-run-orchestrator.test.ts @@ -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); diff --git a/server/src/services/computer-environment-driver.test.ts b/server/src/services/computer-environment-driver.test.ts index f3c3dd6cd2f..816c0ce3123 100644 --- a/server/src/services/computer-environment-driver.test.ts +++ b/server/src/services/computer-environment-driver.test.ts @@ -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); diff --git a/server/src/services/computer-environment-driver.ts b/server/src/services/computer-environment-driver.ts index fba7139f1ba..b89e70dffc1 100644 --- a/server/src/services/computer-environment-driver.ts +++ b/server/src/services/computer-environment-driver.ts @@ -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 { diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index 37ad6e59704..82d7997efc7 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -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; + 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, diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index b4812ff2fdf..94558bb4dd4 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -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, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 75c9ffda65a..b70e34e6a61 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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, diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 79c6e7b0efe..5807a868630 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -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 },