mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Plugin environment drivers with `supportsTasks` run agents as provider tasks, and warm runs keep a parked task for the next run > - A follow-up run can find that its parked task already stopped. Core then retires that task before it acquires a new one > - Core sent `stop`, `status`, and `complete` in the settle step. Then the plugin release hook sent `stop`, `status`, and `complete` again for the same task. Every call was on the critical path of the new run > - This pull request removes the repeated calls and keeps every call that does work no other call does > - The benefit is a faster start for a run whose parked task already stopped ## Linked Issues or Issue Description No issue exists. This follows the enhancement template. **What existing behavior does this improve?** The release of a plugin task lease that has already stopped. This occurs when a follow-up run finds that its parked warm task stopped. **Subsystem affected** Server: plugin environment drivers and the plugin Runner task lifecycle. **Current behavior** Core sends `stop`, `status`, and `complete` in the settle step. Then the plugin release hook sends `stop`, `status`, and `complete` again for the same task. Core also sends `stop` after resume already saw that the task stopped. On a hosted test deployment, 8 of 40 follow-up runs took this path. From the resume to the new acquire, these runs spent P50 1014 ms and P90 1080 ms. These 8 runs are the full overall P90 (1275 ms). **Proposed behavior** Core does not call the release hook when settle confirmed the stop and completed the same task. After a resume with no lease, Core sends `status` first, and sends `stop` only when `status` does not confirm the stop. **Reason and benefit** The repeated calls do no new work. Removing them saves about 355 ms, plus the `stop` share of about 187 ms, for each such run. These numbers are modeled from trace timings. **Breaking changes** None for the plugin API. A task driver must release task-scoped resources in its `status` or `complete` handling. The doc now says this. **Additional context** The settle `status` stays. Providers release task storage there, and it records the stop that gates the credential writeback and `complete`. ## What Changed - `settleDispatchedEnvironmentTask` now returns `"completed"` when it confirmed the stop and completed the task in this call, `"stopped"` when the lease was already settled, and `false` otherwise. - The plugin driver release does not call `environmentReleaseLease` when settle returned `"completed"`. In all other cases it calls the hook as before. - When `environmentResumeLease` returns no provider lease, the release sends `status` first. It sends `stop` (and a second `status`) only when that `status` does not confirm `executionStopped: true`. Startup and startup-cleanup leases keep the old `stop` first order. - The cleanup sweep (`retryPendingSandboxTeardown`) and `destroyRunLease` do not change. A settle failure still leaves the lease in `pending_cleanup`, and the sweep runs `stop`, `status`, `complete`, and the release hook. - `doc/plugins/PLUGIN_RUNNER_EXECUTION.md` tells drivers about the new order and that task-scoped resources must be released in `status` or `complete`. ### Dispatch count for a stopped parked task (resume to new acquire) | | Calls | | --- | --- | | Before | resume, stop, status, (credentials), complete, release hook (stop, status, complete), acquire: 9 provider calls (10 with Codex writeback) | | After | resume, status, (credentials), complete, acquire: 4 provider calls (5 with Codex writeback) | The modeled saving is about 540 ms for each such run: about 355 ms for the repeated release, and about 187 ms for the settle `stop` and `status`. This pull request keeps the settle `status`, so the real saving is about 355 ms plus the `stop` share of the 187 ms. These numbers are modeled from trace timings. They are not measured on this branch. ### What covers each removed call - Release hook `stop`, `status`, `complete`: settle already sent `stop` and `status` and checked `executionStopped: true` for the same task id (`lease.providerLeaseId`), then sent `complete`. The skip uses settle's success as its condition, not the code path. - Settle `stop` after a resume with no lease: the next `status` must report `executionStopped: true`. If it does not, Core sends `stop` and `status` as before. ### What each kept call protects - Settle `status`: providers release task storage and cancel preparations here. It also writes `runnerTaskExecutionStopped`, which the credential writeback and the `complete` bookkeeping need. - Credential writeback: Codex can rotate its token. The writeback must land before the new task installs credentials. It still runs inside `complete`, before the new acquire. - Settle `complete`: it retires every run lease that owned the task and clears the dispatch marker. - Release hook when settle did not complete a dispatched task in this call: the provider may still hold the lease. ## Verification - `pnpm --filter @paperclipai/shared --filter @paperclipai/plugin-sdk build` - `pnpm --filter @paperclipai/server typecheck` - `npx vitest run src/__tests__/plugin-warm-tasks.integration.test.ts src/__tests__/plugin-task-credential-writeback.test.ts src/__tests__/environment-task-runtime.test.ts` - Related suites: `environment-runtime`, `plugin-runner-task.integration`, `task-creation-recovery.integration`, `heartbeat-plugin-environment`, `native-startup-cleanup-recovery`, `plugin-environment-driver-ready-recovery`, `environment-task-authority.integration`, `native-workspace-warm-reuse`, `remote-workspaces.integration`, `legacy-workspace-restore-recovery`, `plugin-task-source-recovery.integration`, `managed-plugin-environments`. Locally, all of these pass except `heartbeat-plugin-environment` "inherits the instance default environment across companies…". That test also fails locally with the `origin/master` service files, so this change does not cause it. The existing `plugin-runner-task.integration` same-run replacement test now expects no release hook after a completed settle. New tests: - The stopped parked path sends exactly resume, `status`, `complete`, acquire. The `status` goes to the old task. - A resume with no lease for a task that still runs sends `status`, `stop`, `status`, `complete`. - A failure at `status`, `complete`, or `stop` leaves the lease in `pending_cleanup`, and the sweep then sends `stop`, `status`, `complete`, and the release hook. - With an observed stop, the Codex writeback runs before `complete`. A writeback failure leaves the lease for cleanup, and the retry writes back and completes. - A startup-cleanup lease still sends `stop` first. ## Risks - A third-party task driver that releases resources only in `onEnvironmentReleaseLease` would not get that call after a completed settle. The doc now states the requirement. The cleanup sweep still calls the hook after any failure. - A driver that returns no lease from resume while the task still runs costs one extra `status`. The behavior stays correct. - No migration. No API change for plugins. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
434 lines
37 KiB
TypeScript
434 lines
37 KiB
TypeScript
import * as pluginProbe from "../services/native-runtime/plugin-runner-probe.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { PassThrough } from "node:stream";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { agents, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, environmentLeases, environments, heartbeatRuns, plugins, projects } from "@paperclipai/db";
|
|
import { definePlugin } from "../../../packages/plugins/sdk/src/define-plugin.js";
|
|
import { startWorkerRpcHost } from "../../../packages/plugins/sdk/src/worker-rpc-host.js";
|
|
import { createRequest, parseMessage, serializeMessage, isJsonRpcResponse, isJsonRpcSuccessResponse, JsonRpcCallError } from "../../../packages/plugins/sdk/src/protocol.js";
|
|
import { environmentTaskOperationSchema, environmentTaskResultSchema, readEnvironmentTaskErrorDiagnostic, type PluginEnvironmentTaskOperation } from "@paperclipai/plugin-sdk";
|
|
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
|
import { agentEnvironmentTestService } from "../services/agent-environment-test.js";
|
|
import { heartbeatService } from "../services/heartbeat.js";
|
|
import { remoteExecutionHasStopped, remoteTerminationReceipt } from "../services/remote-execution-termination.js";
|
|
import { resolvePluginTaskResume } from "../services/environment-task-runtime.js";
|
|
import { environmentRuntimeService } from "../services/environment-runtime.js";
|
|
import { environmentRunOrchestrator } from "../services/environment-run-orchestrator.js";
|
|
import { createRunnerProviderLaunch, runnerLaunchRequirementsDigest, type RunnerProviderLaunchRequirements } from "@paperclipai/paperclip-runner";
|
|
import { createPluginRunnerTaskOwner } from "../services/native-runtime/plugin-runner-task.js";
|
|
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
|
import { recoveryPostgresSupport as support, startRecoveryPostgres } from "./helpers/recovery-postgres.js";
|
|
|
|
const ingress = vi.hoisted(() => ({ connect: vi.fn() }));
|
|
vi.mock("../realtime/runner-prp-outbound.js", () => ({ connectRunnerPrpIngress: ingress.connect }));
|
|
const databaseSuite = support.supported ? describe : describe.skip;
|
|
|
|
/** Real SDK serialization and dispatch with synthetic provider resources. */
|
|
function workerHarness(plugin: ReturnType<typeof definePlugin>, manifest: PaperclipPluginManifestV1) {
|
|
const stdin = new PassThrough(), stdout = new PassThrough();
|
|
const host = startWorkerRpcHost({ plugin, stdin, stdout });
|
|
const pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void }>();
|
|
let nextId = 0, buffer = "";
|
|
stdout.on("data", chunk => {
|
|
buffer += String(chunk);
|
|
let end: number;
|
|
while ((end = buffer.indexOf("\n")) >= 0) {
|
|
const message = parseMessage(buffer.slice(0, end));
|
|
buffer = buffer.slice(end + 1);
|
|
if (!isJsonRpcResponse(message)) continue;
|
|
const waiter = pending.get(Number(message.id));
|
|
pending.delete(Number(message.id));
|
|
if (isJsonRpcSuccessResponse(message)) waiter?.resolve(message.result);
|
|
else waiter?.reject(new JsonRpcCallError(message.error));
|
|
}
|
|
});
|
|
const call = (method: string, params: unknown) => new Promise<any>((resolve, reject) => {
|
|
const id = ++nextId;
|
|
pending.set(id, { resolve, reject });
|
|
stdin.write(serializeMessage(createRequest(method as never, params as never, id)));
|
|
});
|
|
return { call, stop: () => host.stop(), initialize: () => call("initialize", {
|
|
manifest, config: {}, instanceInfo: { instanceId: "fixture", hostVersion: "1.0.0" }, apiVersion: 1,
|
|
}) };
|
|
}
|
|
|
|
databaseSuite("Core plugin Runner task lifecycle", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let stopDb: (() => Promise<void>) | undefined;
|
|
beforeAll(async () => {
|
|
const started = await startRecoveryPostgres("plugin-runner-task");
|
|
stopDb = started.cleanup; db = createDb(started.connectionString);
|
|
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
|
afterAll(async () => { await stopDb?.(); });
|
|
|
|
it.each(["unresolved_operation", "source_state_unverified", "malformed_rpc", "extra_rpc_fields"] as const)("relays only the closed source refusal through Core (%s)", async mode => {
|
|
const reason = mode === "source_state_unverified" ? mode : "unresolved_operation";
|
|
const companyId = randomUUID(), agentId = randomUUID(), pluginId = randomUUID(), environmentId = randomUUID();
|
|
const sourceRunId = randomUUID(), runId = randomUUID(), sourceLeaseId = randomUUID(), leaseId = randomUUID();
|
|
const manifest: PaperclipPluginManifestV1 = { id: pluginId, apiVersion: 1, version: "1.0.0", displayName: "Task diagnostics",
|
|
description: "Synthetic task provider", author: "Paperclip", categories: ["automation"],
|
|
capabilities: ["environment.drivers.register"], entrypoints: { worker: "fixture" },
|
|
environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", supportsTasks: true, configSchema: { type: "object" } }] };
|
|
await db.insert(companies).values({ id: companyId, name: "Diagnostic fixture", issuePrefix: `D${companyId.slice(0, 7).replace(/-/g, "").toUpperCase()}` });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Fixture", adapterType: "paperclip_runner" });
|
|
await db.insert(environments).values({ id: environmentId, name: `Diagnostic ${environmentId}`, driver: "plugin", config: {} });
|
|
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "fixture", version: "1.0.0", status: "ready", manifestJson: manifest });
|
|
await db.insert(heartbeatRuns).values([
|
|
{ id: sourceRunId, companyId, agentId, invocationSource: "manual", status: "succeeded", resultJson: { outcome: "preserved" } },
|
|
{ id: runId, companyId, agentId, invocationSource: "manual", status: "running" },
|
|
]);
|
|
const pin = { driver: "plugin", pluginId, driverKey: "tasks", runnerTaskLease: true,
|
|
runnerTaskProjectAuthValidation: { configurationId: "fixture", provider: "openai", projectIds: [] } };
|
|
const source = { id: sourceLeaseId, companyId, environmentId, heartbeatRunId: sourceRunId, provider: "plugin:fixture:tasks", providerLeaseId: "source-task" };
|
|
await db.insert(environmentLeases).values([
|
|
{ ...source, status: "released", leasePolicy: "ephemeral", cleanupStatus: "success", releasedAt: new Date(), metadata: { ...pin,
|
|
runnerTaskSessionRetained: true, runnerTaskIdentity: { runnerId: "runner", leaseId: sourceLeaseId, runId: sourceRunId, sessionId: "session", turnId: "old-turn", itemId: "old-item" },
|
|
remoteExecutionTermination: remoteTerminationReceipt(source, { providerLeaseId: "source-task", state: "stopped" }) } },
|
|
{ id: leaseId, companyId, environmentId, heartbeatRunId: runId, provider: source.provider, providerLeaseId: "current-task", status: "active", metadata: pin },
|
|
]);
|
|
const dispatched: string[] = [];
|
|
const rpc = workerHarness(definePlugin({ async setup() {}, async onEnvironmentTask(params) {
|
|
dispatched.push(params.operation.kind);
|
|
throw Object.assign(new Error("synthetic-private-provider-text"), { status: 409, code: "SESSION_STATE_UNAVAILABLE", reason,
|
|
operationId: "forged-operation", callId: "forged-call", taskId: "foreign-task" });
|
|
} }), manifest);
|
|
const workers = { getWorker: () => ({ supportedMethods: ["environmentTask"] }),
|
|
call: (_id: string, method: string, params: unknown) => {
|
|
if (mode === "malformed_rpc" || mode === "extra_rpc_fields") throw new JsonRpcCallError({ code: -32000,
|
|
message: "synthetic-private-provider-text", data: { schema: "paperclip/environment-task-error/v1", diagnostic: {
|
|
httpStatus: 409, errorCode: "SESSION_STATE_UNAVAILABLE", reason: mode === "malformed_rpc" ? "provider text" : reason,
|
|
operationId: "forged-operation", callId: "forged-call", taskId: "foreign-task",
|
|
} } });
|
|
return rpc.call(method, params);
|
|
} } as unknown as PluginWorkerManager;
|
|
try {
|
|
const runtime = environmentRuntimeService(db, { pluginWorkerManager: workers });
|
|
const error = await runtime.task({ companyId, leaseId, operation: { kind: "submit", resumeFrom: "source-task", preparedLaunchId: "fixture",
|
|
projectIds: [], bootstrapTicket: "fixture-ticket", runner: { runnerId: "runner", leaseId: sourceLeaseId, runId, sessionId: "session",
|
|
turnId: "turn", itemId: "item", harness: "codex", protocolMin: 1, protocolMax: 3 } } }).catch(error => error);
|
|
expect(readEnvironmentTaskErrorDiagnostic(error)).toEqual(mode === "malformed_rpc" ? null : { httpStatus: 409, errorCode: "SESSION_STATE_UNAVAILABLE", reason });
|
|
expect(error.message).toBe(mode === "malformed_rpc"
|
|
? "Plugin-provided Runner execution operation unavailable; reconcile the same task before retrying" : "Plugin Runner source state unavailable");
|
|
expect(JSON.stringify(error)).not.toMatch(/synthetic-private|forged|foreign-task/);
|
|
expect(dispatched).toEqual(mode === "malformed_rpc" || mode === "extra_rpc_fields" ? [] : ["submit"]);
|
|
const [savedSource] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, sourceRunId));
|
|
expect(savedSource).toMatchObject({ status: "succeeded", resultJson: { outcome: "preserved" } });
|
|
const [current] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, leaseId));
|
|
expect(current.metadata).toMatchObject({ runnerTaskDispatched: true, runnerTaskExecutionStopped: false });
|
|
expect(JSON.stringify(current.metadata)).not.toMatch(/synthetic-private|forged|unresolved_operation|source_state_unverified/);
|
|
} finally { rpc.stop(); }
|
|
});
|
|
|
|
it("does not allocate agent-dependent setup leases before the agent is prepared", async () => {
|
|
const companyId = randomUUID(), pluginId = randomUUID(), environmentId = randomUUID(), agentId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Setup lease fixture", issuePrefix: "SETUP" });
|
|
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "@test/agent-owned-tasks", version: "1.0.0", status: "ready",
|
|
manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage", "environment.drivers.register"],
|
|
environmentDrivers: [{ driverKey: "tasks", supportsTasks: true, requiresAgent: true }] } as never });
|
|
await db.insert(environments).values({ id: environmentId, name: "Agent computer", driver: "plugin", status: "active",
|
|
config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } });
|
|
const call = vi.fn();
|
|
const setup = agentEnvironmentTestService(db, { call } as never);
|
|
const input = { companyId, environmentId, adapterType: "paperclip_runner" };
|
|
expect((await setup.resolveAdapterTestExecutionContext(input)).fallbackChecks).toMatchObject([{ code: "environment_agent_required", level: "warn" }]);
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Draft", lifecycleState: "preparing", lifecycleHolds: ["setup"], status: "paused" });
|
|
expect((await setup.resolveAdapterTestExecutionContext({ ...input, agentId })).fallbackChecks).toMatchObject([{ code: "environment_agent_preparing", level: "warn" }]);
|
|
await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId));
|
|
await db.update(agents).set({ lifecycleState: "paused", pauseReason: "setup" }).where(eq(agents.id, agentId));
|
|
expect((await setup.resolveAdapterTestExecutionContext({ ...input, agentId })).fallbackChecks).toMatchObject([{ code: "environment_agent_approval_required", level: "warn" }]);
|
|
const foreign = randomUUID();
|
|
await db.insert(companies).values({ id: foreign, name: "Other company", issuePrefix: "OTHER" });
|
|
expect((await setup.resolveAdapterTestExecutionContext({ ...input, companyId: foreign, agentId })).fallbackChecks).toMatchObject([{ code: "environment_agent_preparing" }]);
|
|
expect(call).not.toHaveBeenCalled();
|
|
expect(await db.select().from(environmentLeases).where(eq(environmentLeases.environmentId, environmentId))).toEqual([]);
|
|
});
|
|
|
|
it("uses an acquired lease through SDK RPC, native dispatch, connection, stop proof and release", async () => {
|
|
const companyId = randomUUID(), agentId = randomUUID(), runId = randomUUID();
|
|
const environmentId = randomUUID(), pluginId = randomUUID(), projectId = randomUUID();
|
|
const manifest: PaperclipPluginManifestV1 = {
|
|
id: "test.runner-tasks", apiVersion: 1, version: "1.0.0", displayName: "Runner tasks",
|
|
description: "Synthetic task provider", author: "Paperclip", categories: ["automation"],
|
|
capabilities: ["environment.drivers.register"], entrypoints: { worker: "dist/worker.js" },
|
|
environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", supportsTasks: true, configSchema: { type: "object" } }],
|
|
};
|
|
await db.insert(companies).values({ id: companyId, name: "Fixture", status: "active" });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Fixture", role: "engineer", status: "active",
|
|
adapterType: "paperclip_runner", adapterConfig: {}, runtimeConfig: {}, permissions: {} });
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "manual", status: "running" });
|
|
await db.insert(projects).values({ id: projectId, companyId, name: "Fixture", status: "active" });
|
|
const [environment] = await db.insert(environments).values({ id: environmentId, name: "Fixture", driver: "plugin", status: "active",
|
|
config: { pluginKey: manifest.id, driverKey: "tasks", driverConfig: {} } }).returning();
|
|
await db.insert(plugins).values({ id: pluginId, pluginKey: manifest.id, packageName: "@test/runner-tasks",
|
|
version: "1.0.0", apiVersion: 1, categories: ["automation"], manifestJson: manifest, status: "ready" });
|
|
const calls: PluginEnvironmentTaskOperation[] = [];
|
|
let executionStopped = false, stopRequested = false, acquisition = 0, releaseFailure = false;
|
|
let projectAuthAllowed = true;
|
|
const released = vi.fn(), complete = vi.fn();
|
|
const plugin = definePlugin({
|
|
async setup() {},
|
|
async onEnvironmentAcquireLease() { return { providerLeaseId: `attempt:fixture.${++acquisition}`, metadata: {} }; },
|
|
async onEnvironmentReleaseLease() { if (releaseFailure) throw new Error("synthetic release failure"); released(); },
|
|
async onEnvironmentTask(params) {
|
|
calls.push(params.operation);
|
|
switch (params.operation.kind) {
|
|
case "describe": return { kind: "description", launchPreparationVersion: 1, projectAuthCheckVersion: 1, taskId: params.taskId,
|
|
runner: { version: "fixture", digest: `sha256:${"a".repeat(64)}` } };
|
|
case "prepare": return { kind: "prepared", taskId: params.taskId, launch: {
|
|
configurationId: `config-${params.taskId}`, requirementsDigest: runnerLaunchRequirementsDigest(params.operation.requirements as RunnerProviderLaunchRequirements),
|
|
provider: createRunnerProviderLaunch(params.operation.requirements as RunnerProviderLaunchRequirements, {
|
|
options: { codexArgs: [] }, root: "/runner", cwd: "/workspace", codexHome: "/account",
|
|
codexCommand: "/bin/codex", providerNodeCommand: "/bin/node", opencodeProxyPath: "", acpxSidecarPath: "",
|
|
}),
|
|
} };
|
|
case "check_project_auth": return { kind: "project_auth", taskId: params.taskId,
|
|
configurationId: params.operation.configurationId, provider: params.operation.provider, allowed: projectAuthAllowed };
|
|
case "read_state": return { kind: "state", taskId: params.taskId, state: {
|
|
schema: "paperclip.runner.durable.state.v1", ...params.operation.identity, lifecycle: "suspended",
|
|
} };
|
|
case "submit": stopRequested = false; executionStopped = false; return { kind: "accepted", taskId: params.taskId };
|
|
case "status": return { kind: "status", taskId: params.taskId, phase: stopRequested ? "cancelled" : "running", executionStopped, sessionStateAvailable: executionStopped };
|
|
case "connection": return { kind: "connection", taskId: params.taskId, endpoint: {
|
|
kind: "authenticated_websocket", websocketUrl: "wss://runner.example.test/connect", generation: "fixture",
|
|
secretHeaders: [{ name: "Authorization", value: "Bearer synthetic-fixture" }],
|
|
} };
|
|
case "stop": stopRequested = true; return { kind: "accepted", taskId: params.taskId };
|
|
case "complete": complete(); return { kind: "accepted", taskId: params.taskId };
|
|
default: throw new Error(`Unexpected runner task operation: ${params.operation.kind}`);
|
|
}
|
|
},
|
|
});
|
|
const harness = workerHarness(plugin, manifest);
|
|
const { supportedMethods } = await harness.initialize();
|
|
const workers = { isRunning: () => true, getWorker: () => ({ supportedMethods }),
|
|
call: (_id: string, method: string, params: unknown) => harness.call(method, params) } as unknown as PluginWorkerManager;
|
|
const runtime = environmentRuntimeService(db, { pluginWorkerManager: workers });
|
|
const setup = agentEnvironmentTestService(db, workers);
|
|
await setup.assertAdapterTestEnvironmentForCompany(companyId, environmentId, "paperclip_runner");
|
|
const probe = await setup.resolveAdapterTestExecutionContext({ companyId, agentId, adapterType: "paperclip_runner", environmentId });
|
|
expect(probe.fallbackChecks).toEqual([]);
|
|
expect(probe.executionTarget).toMatchObject({ runnerTaskProbe: { runId: expect.any(String), sessionId: expect.any(String) } });
|
|
if (probe.executionTarget?.kind !== "remote" || probe.executionTarget.transport !== "sandbox" || !probe.executionTarget.runnerTaskProbe) throw new Error("Probe task target unavailable");
|
|
await expect(probe.executionTarget.runnerTask!({ kind: "describe" }))
|
|
.resolves.toMatchObject({ kind: "description" });
|
|
const [probeLease] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, probe.executionTarget.leaseId!));
|
|
expect(probeLease.heartbeatRunId).toBeNull();
|
|
const selected = { identity: "core-selected-revision", sessionIdentity: "core-selected-account", home: "/core-owned-home" };
|
|
const verify = vi.spyOn(pluginProbe, "probePluginRunnerEnvironment").mockResolvedValue({ adapterType: "paperclip_runner", status: "pass", testedAt: new Date().toISOString(), checks: [] });
|
|
try {
|
|
await setup.testManagedEnvironment("paperclip_runner", { companyId, adapterType: "paperclip_runner", config: {}, executionTarget: probe.executionTarget },
|
|
{ provider: "openai" } as any, selected as any);
|
|
expect(verify).toHaveBeenCalledWith(expect.anything(), { identity: selected.identity, accountIdentity: selected.sessionIdentity, home: "/core-owned-home/provider" });
|
|
verify.mockClear();
|
|
// A preconfigured Runner home does not use the selected connection, so the probe refuses it.
|
|
const operationsBefore = calls.length;
|
|
await expect(setup.testManagedEnvironment("paperclip_runner", { companyId, adapterType: "paperclip_runner", config: { homeMode: "preconfigured" }, executionTarget: probe.executionTarget },
|
|
{ provider: "openai" } as any, selected as any)).resolves.toMatchObject({ status: "fail",
|
|
checks: [{ code: "ai_connection_required", message: expect.stringContaining("isolated Runner home") }] });
|
|
expect(verify).not.toHaveBeenCalled();
|
|
expect(calls).toHaveLength(operationsBefore);
|
|
} finally { verify.mockRestore(); }
|
|
await probe.executionTarget.runnerTask!({ kind: "check_project_auth", configurationId: "probe-config", provider: "openai", projectIds: [] });
|
|
await probe.executionTarget.runnerTask!({ kind: "submit", preparedLaunchId: "probe-config", bootstrapTicket: "synthetic-probe-ticket", projectIds: [],
|
|
runner: { protocolMin: 1, protocolMax: 3, harness: "codex", runnerId: "probe-runner", leaseId: probeLease.id,
|
|
runId: probe.executionTarget.runnerTaskProbe.runId, sessionId: probe.executionTarget.runnerTaskProbe.sessionId, turnId: "probe-turn", itemId: "probe-item" },
|
|
launch: { runnerVersion: "fixture", runnerDigest: `sha256:${"a".repeat(64)}`,
|
|
maxOutboxBytes: 262144, p0ReserveBytes: 65536, maxRuntimeMs: 0,
|
|
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } },
|
|
});
|
|
const probeSubmit = calls.find((op): op is Extract<PluginEnvironmentTaskOperation, { kind: "submit" }> => op.kind === "submit")!;
|
|
expect(probeSubmit.launch?.maxRuntimeMs).toBeGreaterThan(0);
|
|
expect(probeSubmit.launch?.maxRuntimeMs).toBeLessThanOrEqual(120_000);
|
|
executionStopped = true;
|
|
await probe.release(); released.mockClear(); complete.mockClear(); calls.length = 0;
|
|
const orchestrator = environmentRunOrchestrator(db, { environmentRuntime: runtime });
|
|
const acquired = await orchestrator.acquireForRun({ companyId, selectedEnvironmentId: environmentId, localEnvironmentId: randomUUID(),
|
|
agentId, issueId: null, heartbeatRunId: runId, persistedExecutionWorkspace: null,
|
|
executionWorkspaceSettings: null, adapterType: "paperclip_runner" });
|
|
const realization = await orchestrator.realizeForRun({ companyId, environment: acquired.environment, lease: acquired.lease,
|
|
adapterType: "paperclip_runner", issueId: null, heartbeatRunId: runId,
|
|
executionWorkspace: { baseCwd: "/local/project", cwd: "/local/project", source: "project_primary", projectId,
|
|
workspaceId: null, repoUrl: null, repoRef: null, strategy: "project_primary", branchName: null, worktreePath: null,
|
|
warnings: [], created: false, branchCreatedByRuntime: false },
|
|
effectiveExecutionWorkspaceMode: null, persistedExecutionWorkspace: null });
|
|
const target = realization.executionTarget;
|
|
expect(target).toMatchObject({ kind: "remote", transport: "sandbox", remoteCwd: ".", leaseId: acquired.lease.id });
|
|
if (target?.kind !== "remote" || target.transport !== "sandbox" || !target.runnerTask) throw new Error("Missing plugin target");
|
|
expect(target.runner).toBeUndefined();
|
|
const description = environmentTaskResultSchema.parse(await target.runnerTask({ kind: "describe" }));
|
|
expect(description).toMatchObject({ kind: "description", runner: { version: "fixture" } });
|
|
const onDispatched = vi.fn(), checkpoint = vi.fn(async () => {}), close = vi.fn(async () => {});
|
|
ingress.connect.mockReturnValue({ ready: Promise.resolve(), close });
|
|
const owner = createPluginRunnerTaskOwner({ taskId: description.taskId, task: target.runnerTask, projectAuthCheckVersion: 1,
|
|
credentialIdentity: "revision", accountIdentity: "account", credential: { type: "codex-auth-json", authJson: '{"OPENAI_API_KEY":"synthetic-account-key"}' }, projectIds: target.runnerTaskProjectIds ?? [], harness: "codex",
|
|
onSessionRetained: checkpoint, onDispatched, pollMs: 1 });
|
|
const identity = { runnerInstanceId: randomUUID(), environmentLeaseId: acquired.lease.id, runId,
|
|
normalizedSessionId: randomUUID(), turnId: "turn", itemId: "item" };
|
|
const requirements: RunnerProviderLaunchRequirements = { schema: "paperclip.runner.launch-requirements.v1", identity,
|
|
provider: "codex", model: "fixture", instructions: "Core policy", runtimeContext: null, settings: {},
|
|
approvalPolicy: "never", readOnly: false, preparedConversation: false, includeCollaborationModeInstructions: false };
|
|
const prepared = await owner.driver.prepareLaunch!(requirements);
|
|
expect(calls).toContainEqual(expect.objectContaining({kind:"prepare",accountIdentity:"account"}));
|
|
// Re-preparation invalidates the old credential validation before the worker reply.
|
|
await runtime.task({ companyId, leaseId: acquired.lease.id, operation: environmentTaskOperationSchema.parse({
|
|
kind: "prepare", requirements, projectIds: [projectId],
|
|
}) });
|
|
await expect(runtime.task({ companyId, leaseId: acquired.lease.id, operation: {
|
|
kind: "submit", projectIds: [projectId], preparedLaunchId: prepared.configurationId, bootstrapTicket: "synthetic-ticket",
|
|
accountIdentity: "account", credential: { type: "codex-auth-json", authJson: '{"OPENAI_API_KEY":"never-transfer-stale-proof"}' },
|
|
runner: { protocolMin: 1, protocolMax: 3, harness: "codex", runnerId: identity.runnerInstanceId,
|
|
leaseId: identity.environmentLeaseId, runId: identity.runId, sessionId: identity.normalizedSessionId,
|
|
turnId: identity.turnId, itemId: identity.itemId },
|
|
} })).rejects.toThrow("validation unavailable");
|
|
expect(calls.some(op => op.kind === "submit")).toBe(false);
|
|
await owner.driver.prepareLaunch!(requirements);
|
|
const registration = await owner.registration({} as never, runId);
|
|
const handle = owner.launcher({ command: "ignored", args: [], cwd: "/ignored", environment: { OPENAI_API_KEY: "synthetic-launch-key" },
|
|
nativeRunner: { identity, ticket: "synthetic-bootstrap-ticket",
|
|
connection: registration.connection, stateDirectory: "/runner/state", runnerVersion: "fixture", runnerDigest: `sha256:${"a".repeat(64)}`,
|
|
maxOutboxBytes: 262144, p0ReserveBytes: 65536, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } } });
|
|
let settled = false;
|
|
void handle.completion.then(() => { settled = true; });
|
|
try {
|
|
registration.activate(); await registration.ready();
|
|
expect(onDispatched).toHaveBeenCalledOnce();
|
|
expect(ingress.connect).toHaveBeenCalledWith(expect.objectContaining({ endpoint: expect.objectContaining({ websocketUrl: "wss://runner.example.test/connect" }) }));
|
|
const submit = calls.find(operation => operation.kind === "submit");
|
|
expect(submit).toMatchObject({ kind: "submit", projectIds: [projectId], runner: { leaseId: acquired.lease.id, runId },
|
|
launch: { runnerVersion: "fixture" } });
|
|
const [saved] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, acquired.lease.id));
|
|
expect(saved.metadata?.runnerTaskDispatched).toBe(true);
|
|
expect(JSON.stringify(saved.metadata)).not.toMatch(/synthetic-account-key|synthetic-launch-key|synthetic-bootstrap-ticket|synthetic-fixture/);
|
|
const acquisitionBefore = acquisition;
|
|
const callsBefore = calls.length;
|
|
const recovered = await environmentRunOrchestrator(db, { environmentRuntime: environmentRuntimeService(db, { pluginWorkerManager: workers }) }).acquireForRun({
|
|
companyId, selectedEnvironmentId: environmentId, localEnvironmentId: randomUUID(), agentId,
|
|
issueId: null, heartbeatRunId: runId, persistedExecutionWorkspace: null,
|
|
executionWorkspaceSettings: null, adapterType: "paperclip_runner",
|
|
reattachRemoteLease: { kind: "plugin_task", leaseId: saved.id },
|
|
});
|
|
expect(recovered.lease.id).toBe(saved.id);
|
|
const adopted = createPluginRunnerTaskOwner({ taskId: description.taskId, task: target.runnerTask, projectIds: [projectId], harness: "codex",
|
|
projectAuthCheckVersion: 1, adoptIdentity: identity });
|
|
await adopted.driver.authorizeRunnerAdoption!(prepared.configurationId, identity);
|
|
const adoptedRegistration = await adopted.registration({} as never, runId);
|
|
adoptedRegistration.activate(); await adoptedRegistration.ready();
|
|
await adoptedRegistration.release("detach");
|
|
expect(acquisition).toBe(acquisitionBefore);
|
|
expect(calls.slice(callsBefore).some(op => ["prepare", "submit", "stop", "complete"].includes(op.kind))).toBe(false);
|
|
expect(executionStopped).toBe(false);
|
|
projectAuthAllowed = false;
|
|
const revoked = createPluginRunnerTaskOwner({ taskId: description.taskId, task: target.runnerTask, projectIds: [projectId], harness: "codex",
|
|
projectAuthCheckVersion: 1, adoptIdentity: identity });
|
|
const connectionsBeforeRevocation = calls.filter(op => op.kind === "connection").length;
|
|
await expect(revoked.driver.authorizeRunnerAdoption!(prepared.configurationId, identity)).rejects.toThrow("project authentication conflicts");
|
|
expect(calls.filter(op => op.kind === "connection")).toHaveLength(connectionsBeforeRevocation);
|
|
expect(calls.slice(callsBefore).filter(op => op.kind === "check_project_auth")).toEqual(Array(2).fill({
|
|
kind: "check_project_auth", configurationId: prepared.configurationId, provider: "openai", projectIds: [projectId],
|
|
}));
|
|
projectAuthAllowed = true;
|
|
close.mockClear();
|
|
await expect(runtime.task({ companyId: randomUUID(), leaseId: acquired.lease.id, operation: { kind: "stop" } })).rejects.toThrow("lease unavailable");
|
|
const before = calls.filter(value => value.kind === "submit").length;
|
|
await expect(runtime.task({ companyId, leaseId: acquired.lease.id, operation: { ...submit!, projectIds: [randomUUID()] } as never })).rejects.toThrow("project unavailable");
|
|
expect(calls.filter(value => value.kind === "submit")).toHaveLength(before);
|
|
handle.child.kill("SIGTERM");
|
|
await vi.waitFor(() => expect(stopRequested).toBe(true));
|
|
await delay(10); expect(settled).toBe(false);
|
|
executionStopped = true; await handle.completion;
|
|
await registration.release(); expect(complete).not.toHaveBeenCalled();
|
|
await registration.checkpoint("settled");
|
|
expect(checkpoint).toHaveBeenCalledOnce(); expect(complete).toHaveBeenCalledOnce();
|
|
await runtime.releaseRunLeases(runId, "released");
|
|
expect(released).toHaveBeenCalledOnce(); expect(close).toHaveBeenCalledOnce();
|
|
expect(await remoteExecutionHasStopped(db, companyId, runId)).toBe(true);
|
|
|
|
// A controller that dies after dispatch leaves only the lease marker.
|
|
const second = await runtime.acquireRunLease({ companyId, environment: environment as never, agentId,
|
|
issueId: null, heartbeatRunId: runId, persistedExecutionWorkspace: null, adapterType: "paperclip_runner" });
|
|
if (submit?.kind !== "submit") throw new Error("Missing submit fixture");
|
|
await runtime.task({ companyId, leaseId: second.lease.id, operation: { kind: "check_project_auth", configurationId: "second-config", provider: "openai", projectIds: [projectId] } });
|
|
await runtime.task({ companyId, leaseId: second.lease.id,
|
|
operation: { ...submit, credential: undefined, accountIdentity: undefined, credentialIdentity: undefined,
|
|
preparedLaunchId: "second-config", runner: { ...submit.runner, leaseId: second.lease.id } } });
|
|
await runtime.task({ companyId, leaseId: second.lease.id, operation: { kind: "complete" } });
|
|
const errors = vi.fn();
|
|
await runtime.releaseRunLeases(runId, "released", errors);
|
|
expect(errors).toHaveBeenCalledOnce();
|
|
expect(released).toHaveBeenCalledOnce(); // No release while termination is unknown.
|
|
const [pending] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, second.lease.id));
|
|
expect(pending).toMatchObject({ status: "pending_cleanup", metadata: { runnerTaskDispatched: true } });
|
|
await db.delete(environments).where(eq(environments.id, environmentId));
|
|
executionStopped = true;
|
|
releaseFailure = true;
|
|
const recovery = heartbeatService(db, { environmentRuntime: runtime, pluginWorkerManager: workers });
|
|
expect((await recovery.sweepPendingCleanupLeases()).destroyed).toBe(0);
|
|
const [retryable] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, second.lease.id));
|
|
expect(retryable.status).toBe("pending_cleanup");
|
|
releaseFailure = false;
|
|
await db.update(environmentLeases).set({ metadata: { ...retryable.metadata, pendingCleanupRetryAfterMs: 0 } }).where(eq(environmentLeases.id, second.lease.id));
|
|
const sweep = await recovery.sweepPendingCleanupLeases();
|
|
expect(released).toHaveBeenCalledTimes(2);
|
|
expect(sweep.destroyed).toBe(1);
|
|
const [cleaned] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, second.lease.id));
|
|
expect(cleaned).toMatchObject({ status: "expired", cleanupStatus: "success", environmentId: null,
|
|
metadata: { runnerTaskDispatched: false, remoteExecutionTermination: { state: "stopped" } } });
|
|
} finally { executionStopped = true; handle.child.kill(); await handle.completion.catch(() => {}); harness.stop(); }
|
|
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
|
|
|
it.each([false, true])("replaces a same-run task only after the provider confirms termination (%s)", async stopped => {
|
|
const companyId = randomUUID(), agentId = randomUUID(), runId = randomUUID(), pluginId = randomUUID(), environmentId = randomUUID(), oldLeaseId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Task recovery", issuePrefix: `R${companyId.slice(0, 7)}` });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Fixture", adapterType: "paperclip_runner" });
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "manual", status: "running" });
|
|
await db.insert(environments).values({ id: environmentId, name: `Recovery ${environmentId}`, driver: "plugin", status: "active", config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } });
|
|
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "fixture-recovery", version: "1.0.0", status: "ready", manifestJson: {
|
|
capabilities: ["environment.drivers.register"], environmentDrivers: [{ driverKey: "tasks", supportsTasks: true }],
|
|
} as never });
|
|
const priorIdentity = { runnerInstanceId: "runner", environmentLeaseId: oldLeaseId, runId, normalizedSessionId: "session" };
|
|
const runner = { runnerId: "runner", leaseId: oldLeaseId, runId, sessionId: "session", turnId: "turn", itemId: "item", harness: "codex", protocolMin: 1, protocolMax: 3 };
|
|
await db.insert(environmentLeases).values({ id: oldLeaseId, companyId, environmentId, heartbeatRunId: runId, status: "active", provider: `plugin:${pluginId}:tasks`, providerLeaseId: "old-task",
|
|
metadata: { driver: "plugin", pluginId, pluginKey: pluginId, driverKey: "tasks", agentId, runnerTaskLease: true, runnerTaskDispatched: true, runnerTaskIdentity: runner } });
|
|
const operations: string[] = [];
|
|
const workers = { isRunning: () => true, getWorker: () => ({ supportedMethods: ["environmentTask", "environmentReleaseLease", "environmentAcquireLease"] }),
|
|
call: async (_id: string, method: string, params: any) => {
|
|
operations.push(method === "environmentTask" ? params.operation.kind : method);
|
|
if (method === "environmentAcquireLease") {
|
|
expect(await remoteExecutionHasStopped(db, companyId, runId)).toBe(true);
|
|
return { providerLeaseId: "new-task", metadata: {} };
|
|
}
|
|
if (method === "environmentReleaseLease") return;
|
|
if (params.operation.kind === "check_project_auth") return { kind: "project_auth", taskId: params.taskId, configurationId: params.operation.configurationId, provider: params.operation.provider, allowed: true };
|
|
if (params.operation.kind === "status") return { kind: "status", taskId: "old-task", phase: "cancelled", executionStopped: stopped, sessionStateAvailable: stopped };
|
|
return { kind: "accepted", taskId: params.taskId };
|
|
} } as unknown as PluginWorkerManager;
|
|
const runtime = environmentRuntimeService(db, { pluginWorkerManager: workers });
|
|
const acquire = environmentRunOrchestrator(db, { environmentRuntime: runtime }).acquireForRun({
|
|
companyId, selectedEnvironmentId: environmentId, localEnvironmentId: randomUUID(), adapterType: "paperclip_runner", agentId,
|
|
issueId: null, heartbeatRunId: runId, persistedExecutionWorkspace: null, executionWorkspaceSettings: null, recoverPluginTask: true,
|
|
});
|
|
if (!stopped) {
|
|
await expect(acquire).rejects.toThrow("termination_unconfirmed");
|
|
expect(operations).not.toContain("environmentAcquireLease");
|
|
expect(operations).not.toContain("complete");
|
|
return;
|
|
}
|
|
const next = await acquire;
|
|
expect(operations).toEqual(["stop", "status", "complete", "environmentAcquireLease"]);
|
|
expect(next.lease.id).not.toBe(oldLeaseId);
|
|
await expect(resolvePluginTaskResume(db, { companyId, leaseId: next.lease.id, priorIdentity })).resolves.toBe("old-task");
|
|
await expect(runtime.task({ companyId, leaseId: next.lease.id, operation: { kind: "submit", projectIds: [], bootstrapTicket: "fixture", runner } })).rejects.toThrow("identity mismatch");
|
|
await runtime.task({ companyId, leaseId: next.lease.id, operation: { kind: "check_project_auth", configurationId: "recovery-config", provider: "openai", projectIds: [] } });
|
|
await expect(runtime.task({ companyId, leaseId: next.lease.id, operation: { kind: "submit", preparedLaunchId: "recovery-config", projectIds: [], bootstrapTicket: "fixture", runner, resumeFrom: "old-task" } })).resolves.toMatchObject({ kind: "accepted" });
|
|
const competingId = randomUUID();
|
|
await db.insert(environmentLeases).values({ id: competingId, companyId, environmentId, heartbeatRunId: runId, status: "active", provider: `plugin:${pluginId}:tasks`, providerLeaseId: "competing-task" });
|
|
await expect(resolvePluginTaskResume(db, { companyId, leaseId: next.lease.id, priorIdentity })).rejects.toThrow("not authorized");
|
|
await db.delete(environmentLeases).where(eq(environmentLeases.id, competingId));
|
|
await db.update(environmentLeases).set({ cleanupStatus: "failed" }).where(eq(environmentLeases.id, oldLeaseId));
|
|
await expect(resolvePluginTaskResume(db, { companyId, leaseId: next.lease.id, priorIdentity })).rejects.toThrow("not authorized");
|
|
});
|
|
|
|
});
|