mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
Admit fresh computer owners when execution configuration changes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user