diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md
index cd78289ac2e..82becd8dba5 100644
--- a/doc/DEVELOPING.md
+++ b/doc/DEVELOPING.md
@@ -1999,6 +1999,15 @@ extra arguments, model variants, and inherited permission rules require the
Legacy runner; incompatible settings return remediation instead of being ignored.
Saved OpenCode agents keep their runner and runtime policies.
+Plugin environment drivers can declare `requiresAgent: true` when their leases
+need an agent-owned resource. Setup saves the draft and environment, runs its
+existing lifecycle preparation, and waits in `verifying` with the `setup` hold.
+The worker becomes idle there until the user finishes hiring. Connection tests
+use that draft's identity and a bounded Core-issued probe; ordinary tasks remain
+blocked. Environment changes save immediately, preserve the selected account,
+and invalidate the previous test. Tests without an agent report that prerequisite
+without allocating a lease. Finishing setup still verifies the saved configuration.
+
Pi also requires an explicit `provider/model` before testing or hiring; inactive
setup drafts may precede model selection. Install its pinned runtime on the
execution host with `paperclipai runtime setup pi` before testing the connection.
diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts
index e4b437f77ea..7c8f553386f 100644
--- a/packages/shared/src/types/plugin.ts
+++ b/packages/shared/src/types/plugin.ts
@@ -191,6 +191,8 @@ export interface SandboxProviderCapabilities {
}
export interface PluginEnvironmentDriverDeclaration {
+ /** Leases require a saved agent and its lifecycle preparation, including during setup. */
+ requiresAgent?: boolean;
/** Provides Runner execution, resource preparation, and PRP connections via environmentTask. */
supportsTasks?: boolean;
freshWorkspaceVersion?: 1;
diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts
index 43e789f0cf9..1bb2fa3d24a 100644
--- a/packages/shared/src/validators/plugin.test.ts
+++ b/packages/shared/src/validators/plugin.test.ts
@@ -33,6 +33,13 @@ describe("plugin capability constants", () => {
});
describe("plugin manifest validators", () => {
+ it("retains the agent prerequisite on environment drivers", () => {
+ const manifest = { ...buildSandboxProviderManifest({ requiresAgent: true }), agentLifecycle: true,
+ capabilities: ["environment.drivers.register", "agents.lifecycle.manage"] };
+ expect(pluginManifestV1Schema.parse(manifest).environmentDrivers?.[0]?.requiresAgent).toBe(true);
+ expect(pluginManifestV1Schema.safeParse(buildSandboxProviderManifest({ requiresAgent: "true" })).success).toBe(false);
+ expect(pluginManifestV1Schema.safeParse({ ...manifest, agentLifecycle: undefined }).success).toBe(false);
+ });
it("requires authority for required agent lifecycle participants", () => {
const manifest = { id: "example.lifecycle", apiVersion: 1, version: "0.1.0", displayName: "Lifecycle", description: "Lifecycle tests", author: "Tests", categories: ["automation"], entrypoints: { worker: "worker.js" }, agentLifecycle: true };
expect(pluginManifestV1Schema.safeParse({ ...manifest, capabilities: ["agents.read"] }).success).toBe(false);
diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts
index 4663958df23..0645658cb0b 100644
--- a/packages/shared/src/validators/plugin.ts
+++ b/packages/shared/src/validators/plugin.ts
@@ -177,6 +177,7 @@ export type SandboxProviderCapabilitiesInput = z.infer {
+ if (manifest.environmentDrivers?.some(driver => driver.requiresAgent) && !manifest.agentLifecycle) {
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent-dependent environments require agent lifecycle participation", path: ["agentLifecycle"] });
+ }
if (manifest.agentLifecycle && !manifest.capabilities.includes("agents.lifecycle.manage")) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Agent lifecycle participation requires agents.lifecycle.manage", path: ["capabilities"] });
}
diff --git a/server/src/__tests__/plugin-runner-task.integration.test.ts b/server/src/__tests__/plugin-runner-task.integration.test.ts
index ab9e91759db..eb1b28c0e26 100644
--- a/server/src/__tests__/plugin-runner-task.integration.test.ts
+++ b/server/src/__tests__/plugin-runner-task.integration.test.ts
@@ -64,6 +64,27 @@ databaseSuite("Core plugin Runner task lifecycle", () => {
});
afterAll(async () => { await stopDb?.(); });
+ 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" }]);
+ 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();
diff --git a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts
index 681a6ec6735..047c50a872a 100644
--- a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts
+++ b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts
@@ -16,7 +16,7 @@ import { errorHandler } from "../../../middleware/error-handler.js";
import { createLifecycleDriver } from "../../../services/agent-lifecycle-driver.js";
import { randomUUID } from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
-import { agents, approvals, projects, issues, heartbeatRuns, nativeRunFinalizations, budgetPolicies, environmentLeases, agentApiKeys, activityLog, agentConfigRevisions, userCompanyPreferences, companies, companyMemberships, principalPermissionGrants, plugins, pluginCompanySettings, createDb, type Db } from "@paperclipai/db";
+import { agents, approvals, projects, issues, heartbeatRuns, nativeRunFinalizations, budgetPolicies, environmentLeases, environments, agentApiKeys, activityLog, agentConfigRevisions, userCompanyPreferences, companies, companyMemberships, principalPermissionGrants, plugins, pluginCompanySettings, createDb, type Db } from "@paperclipai/db";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
import { createAgentLifecycle, configureAgentLifecycle, startAgentLifecycle } from "../../../services/agent-lifecycle.js";
@@ -140,6 +140,54 @@ const support = await getEmbeddedPostgresTestSupport();
await expect(lifecycle.requestHire(companyId, { id, name: "Early agent" }, { setupAgentId: id })).rejects.toThrow("no longer available");
});
+ it("prepares agent-dependent drafts, stays idle until finish, and invalidates changed environments", async () => {
+ const pluginId = randomUUID(), environmentId = randomUUID();
+ await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "agent-environment-test", version: "1.0.0", status: "ready",
+ manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage", "environment.drivers.register"],
+ environmentDrivers: [{ driverKey: "tasks", requiresAgent: true, supportsTasks: true }] } as never });
+ await db.insert(environments).values({ id: environmentId, name: "Agent-owned test computer", driver: "plugin", status: "active",
+ config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } });
+ const call = vi.fn(async (_id, _method, input) => ({ operationId: input.operationId, version: input.version, status: "complete" }));
+ const driver = createLifecycleDriver(db, { call } as never);
+ const runHost = vi.fn(async () => "complete" as const);
+ const work = configureAgentLifecycle(db, { ...driver, requiredPluginIds: async () => [pluginId], runHost });
+ workers.push(work);
+ const lifecycle = createAgentLifecycle(db);
+ const id = randomUUID();
+ const input = { id, name: "Early computer", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" }, defaultEnvironmentId: environmentId };
+ const draft = await lifecycle.requestHire(companyId, input, { setupOnly: true, runnerResolved: true });
+ expect(draft).toMatchObject({ lifecycleState: "preparing", status: "paused", pauseReason: "setup" });
+ await work.process(id);
+ expect(await current(id)).toMatchObject({ lifecycleState: "verifying", status: "paused", lifecycleHolds: ["setup"] });
+ expect(call.mock.calls.map(([, , request]) => request.phase)).toEqual(["preparing"]);
+ const store = createLifecycleStore(db);
+ expect(await store.pending(100, new Date())).not.toContain(id);
+ expect(await store.claim(id, "unexpected", new Date())).toBeNull();
+ await work.process(id);
+ expect(runHost).toHaveBeenCalledTimes(1);
+ await expect(agentConfiguration(db).createApiKey(id, "Incomplete computer")).rejects.toThrow("Finish agent setup");
+ await expect(lifecycle.resumeAgent(id)).rejects.toThrow("Finish agent setup");
+ await lifecycle.pauseAgent(id, "budget"); await work.process(id);
+ expect(await current(id)).toMatchObject({ lifecycleState: "paused", lifecycleHolds: ["setup", "budget"] });
+ await lifecycle.updateConfiguration(id, { adapterConfig: { provider: "codex", model: "changed" } });
+ expect((await current(id)).lifecycleState).toBe("paused");
+ // Restore only the setup hold to exercise environment changes under the wizard's authority.
+ await db.update(agents).set({ lifecycleHolds: ["setup"], pauseReason: "setup" }).where(eq(agents.id, id));
+ await lifecycle.updateConfiguration(id, { adapterConfig: { provider: "codex", model: "prepared" } });
+ await work.process(id);
+ expect((await current(id)).lifecycleState).toBe("verifying");
+ const [local] = await db.select().from(environments).where(eq(environments.driver, "local"));
+ await lifecycle.updateConfiguration(id, { defaultEnvironmentId: local.id }); await work.process(id);
+ expect((await current(id)).lifecycleState).toBe("paused");
+ await lifecycle.updateConfiguration(id, { defaultEnvironmentId: environmentId }); await work.process(id);
+ expect((await current(id)).lifecycleState).toBe("verifying");
+ const finished = await lifecycle.requestHire(companyId, input, { setupAgentId: id, runnerResolved: true });
+ expect(finished.id).toBe(id);
+ await work.process(id);
+ expect(await current(id)).toMatchObject({ lifecycleState: "ready", lifecycleHolds: [] });
+ expect(call.mock.calls.map(([, , request]) => request.phase).at(-1)).toBe("verifying");
+ });
+
it("freezes the final setup configuration for hire approval", async () => {
const lifecycle = createAgentLifecycle(db);
const id = randomUUID();
diff --git a/server/src/modules/agent-lifecycle/adapters/configuration.ts b/server/src/modules/agent-lifecycle/adapters/configuration.ts
index 65333d4bbbd..011508a0d78 100644
--- a/server/src/modules/agent-lifecycle/adapters/configuration.ts
+++ b/server/src/modules/agent-lifecycle/adapters/configuration.ts
@@ -21,10 +21,17 @@ export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects
assertConfigurationFields(patch);
const changed = ["adapterType", "adapterConfig", "runtimeConfig", "defaultEnvironmentId"].some(key =>
Object.prototype.hasOwnProperty.call(patch, key) && !jsonEqual(patch[key as keyof typeof patch], current[key as keyof typeof current]));
- const invalidate = changed && ["preparing", "verifying", "resuming"].includes(current.lifecycleState);
+ const prepareSetup = changed && current.lifecycleHolds.length === 1 && current.lifecycleHolds[0] === "setup"
+ && ["paused", "preparing", "verifying"].includes(current.lifecycleState)
+ && await effects.setupRequiresPreparation(tx, current.companyId,
+ Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId") ? patch.defaultEnvironmentId : current.defaultEnvironmentId);
+ const invalidate = changed && (["preparing", "verifying", "resuming"].includes(current.lifecycleState) || prepareSetup);
if (invalidate) await effects.notifyWork(tx);
const [updated] = await tx.update(agents).set({ ...patch, updatedAt: new Date(), ...(invalidate ? {
lifecycleVersion: current.lifecycleVersion + 1, lifecycleError: null,
+ ...(prepareSetup ? { lifecycleState: "preparing" as const } : {}),
+ ...(!prepareSetup && current.lifecycleHolds.includes("setup") && ["preparing", "verifying"].includes(current.lifecycleState)
+ ? { lifecycleState: "pausing" as const } : {}),
lifecycleOperation: { ...current.lifecycleOperation!, id: randomUUID(), hostComplete: false,
completedPluginIds: [], leaseOwner: undefined, leaseUntil: undefined, retryAt: undefined },
} : {}) }).where(eq(agents.id, id)).returning();
diff --git a/server/src/modules/agent-lifecycle/adapters/effects.ts b/server/src/modules/agent-lifecycle/adapters/effects.ts
index 4c12d36866e..e2fd735b1db 100644
--- a/server/src/modules/agent-lifecycle/adapters/effects.ts
+++ b/server/src/modules/agent-lifecycle/adapters/effects.ts
@@ -6,6 +6,7 @@ import type { ActivityPublication } from "../../../types/activity-publication.js
/** Services supply integrations; lifecycle owns the transaction and state writes. */
export interface LifecycleEffects {
+ setupRequiresPreparation(db: Db, companyId: string, environmentId?: string | null): Promise;
notifyWork(db: Db): Promise;
reportFailure(context: LifecycleFailure, error: unknown): void;
deleteDependencies(db: Db, companyId: string, agentId: string): Promise;
diff --git a/server/src/modules/agent-lifecycle/adapters/postgres.ts b/server/src/modules/agent-lifecycle/adapters/postgres.ts
index 2d885a4f1c1..3e9a50a3c45 100644
--- a/server/src/modules/agent-lifecycle/adapters/postgres.ts
+++ b/server/src/modules/agent-lifecycle/adapters/postgres.ts
@@ -13,6 +13,12 @@ export function assertRootDatabase(db: Db) {
type LifecycleRow = typeof agents.$inferSelect;
type ChangeOptions = { version?: number; owner?: string; reason?: string };
+// Prepared drafts wait for the wizard; they have no background verification obligation.
+export const pendingLifecycleWork = and(
+ inArray(agents.lifecycleState, ["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"]),
+ sql`not (${agents.lifecycleState} = 'verifying' and ${agents.lifecycleHolds} @> '["setup"]'::jsonb)`,
+);
+
/** The caller owns the accounting transaction and has locked this agent. */
async function changeLockedAgent(tx: Db, effects: LifecycleEffects, agent: LifecycleRow,
command: LifecycleCommand, options: ChangeOptions = {}, policyHolds?: string[], restartPause = false) {
@@ -47,8 +53,9 @@ async function changeLockedAgent(tx: Db, effects: LifecycleEffects, agent: Lifec
}
if (command === "pause") holds = [...new Set([...holds, reason])];
if (command === "resume") holds = holds.filter(hold => reason === "user" ? ["budget", "company_archived", "company_paused"].includes(hold) : hold !== reason);
+ const blockingHolds = holds.filter(hold => hold !== "setup" || !["preparing", "verifying"].includes(agent.lifecycleState));
let next = command === "reconcile"
- ? holds.length && !["pausing", "paused"].includes(agent.lifecycleState) ? "pausing" as const
+ ? blockingHolds.length && !["pausing", "paused"].includes(agent.lifecycleState) ? "pausing" as const
: !holds.length && agent.lifecycleState === "paused" ? "resuming" as const : agent.lifecycleState
: command === "resume" && (holds.length || agent.lifecycleState === "pausing") ? agent.lifecycleState
: transition(agent.lifecycleState, command, agent.lifecycleOperation?.resumeState);
@@ -131,7 +138,7 @@ export function createLifecycleStore(db: Db, effects: LifecycleEffects): Lifecyc
lifecycleOperation: sql`coalesce(${agents.lifecycleOperation}, '{"id":"", "hostComplete":false, "completedPluginIds":[], "attempts":0}'::jsonb)
|| jsonb_build_object('leaseOwner', ${owner}::text, 'leaseUntil', ${(new Date(now.getTime() + 120_000)).toISOString()}::text,
'attempts', coalesce((${agents.lifecycleOperation}->>'attempts')::integer, 0) + 1)`,
- }).where(and(eq(agents.id, id), inArray(agents.lifecycleState, ["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"]),
+ }).where(and(eq(agents.id, id), pendingLifecycleWork,
sql`(${agents.lifecycleState} in ('pausing', 'terminating', 'cleaning_up') or exists (
select 1 from ${companies} where ${companies.id} = ${agents.companyId} and ${companies.status} = 'active'))`,
sql`coalesce((${agents.lifecycleOperation}->>'leaseUntil')::timestamptz, '-infinity') <= ${now.toISOString()}::timestamptz`,
@@ -179,7 +186,7 @@ export function createLifecycleStore(db: Db, effects: LifecycleEffects): Lifecyc
},
async pending(limit, now) {
return db.select({ id: agents.id }).from(agents).where(and(
- inArray(agents.lifecycleState, ["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"]),
+ pendingLifecycleWork,
sql`coalesce((${agents.lifecycleOperation}->>'leaseUntil')::timestamptz, '-infinity') <= ${now.toISOString()}::timestamptz`,
sql`coalesce((${agents.lifecycleOperation}->>'retryAt')::timestamptz, '-infinity') <= ${now.toISOString()}::timestamptz`,
)).orderBy(asc(agents.updatedAt), asc(agents.id)).limit(limit).then(rows => rows.map(row => row.id));
diff --git a/server/src/modules/agent-lifecycle/adapters/records.ts b/server/src/modules/agent-lifecycle/adapters/records.ts
index a41b89d1533..176894eeb7b 100644
--- a/server/src/modules/agent-lifecycle/adapters/records.ts
+++ b/server/src/modules/agent-lifecycle/adapters/records.ts
@@ -4,6 +4,7 @@ import { and, eq, sql } from "drizzle-orm";
import { agents, agentApiKeys, companies, type Db } from "@paperclipai/db";
import { conflict, notFound } from "../../../errors.js";
import { agentRecordQueries, type CreateAgentOptions, type CreateAgentData } from "../../../lib/agent-records.js";
+import { isAgentSetupDraft } from "../domain/policy.js";
export function agentRecords(db: Db, effects: LifecycleEffects) {
const { getById } = agentRecordQueries(db);
@@ -25,7 +26,7 @@ export function agentRecords(db: Db, effects: LifecycleEffects) {
if (!existing) throw notFound("Agent setup not found");
return existing;
}
- if (setup && (setup.lifecycleState !== "paused" || !setup.lifecycleHolds.includes("setup"))) {
+ if (setup && (!isAgentSetupDraft(setup) || !setup.lifecycleHolds.includes("setup"))) {
throw conflict("Agent setup is no longer available");
}
if (options?.setupAgentId && !setup) throw notFound("Agent setup not found");
@@ -35,8 +36,9 @@ export function agentRecords(db: Db, effects: LifecycleEffects) {
const [company] = await tx.select({ status: companies.status }).from(companies).where(eq(companies.id, companyId));
const companyHolds = company?.status === "paused" ? ["company_paused"] : company?.status === "archived" ? ["company_archived"] : [];
const holds = [...(status === "paused" ? [pauseReason ?? "manual"] : []), ...companyHolds];
+ const prepareSetup = options?.setupOnly && !companyHolds.length && await effects.setupRequiresPreparation(txDb, companyId, prepared.defaultEnvironmentId);
const lifecycleState = status === "pending_approval" ? "pending_approval" : status === "terminated" ? "terminated"
- : status === "paused" ? "paused" : companyHolds.length ? "pausing" : "preparing";
+ : prepareSetup ? "preparing" : status === "paused" ? "paused" : companyHolds.length ? "pausing" : "preparing";
if (lifecycleState === "preparing" || lifecycleState === "pausing") await effects.notifyWork(tx);
const values: typeof agents.$inferInsert = {
...prepared,
diff --git a/server/src/modules/agent-lifecycle/domain/policy.ts b/server/src/modules/agent-lifecycle/domain/policy.ts
index 520193ee6fc..458c1d729b5 100644
--- a/server/src/modules/agent-lifecycle/domain/policy.ts
+++ b/server/src/modules/agent-lifecycle/domain/policy.ts
@@ -5,6 +5,10 @@ export class AgentLifecycleConflict extends Error {
constructor(message: string) { super(message); this.name = "AgentLifecycleConflict"; }
}
+export function isAgentSetupDraft(agent: { lifecycleState?: string; pauseReason?: string | null }) {
+ return agent.pauseReason === "setup" && ["paused", "preparing", "verifying"].includes(agent.lifecycleState ?? "");
+}
+
export function canConfigureAgentConnection(agent: { lifecycleState: string; status: string }): boolean {
return agent.lifecycleState === "preparing" || agent.lifecycleState === "verifying"
|| (agent.lifecycleState === "ready" && !["paused", "terminated", "pending_approval"].includes(agent.status));
diff --git a/server/src/modules/agent-lifecycle/index.ts b/server/src/modules/agent-lifecycle/index.ts
index bf8cf0dc4aa..429fab07f88 100644
--- a/server/src/modules/agent-lifecycle/index.ts
+++ b/server/src/modules/agent-lifecycle/index.ts
@@ -8,7 +8,7 @@ import { updateAgentConfiguration } from "./adapters/configuration.js";
import type { AgentConfigurationPatch, UpdateAgentOptions } from "../../lib/agent-records.js";
import { purgeAgent } from "./adapters/deletion.js";
import { agentRecords } from "./adapters/records.js";
-import { assertRootDatabase, createLifecycleStore } from "./adapters/postgres.js";
+import { assertRootDatabase, createLifecycleStore, pendingLifecycleWork } from "./adapters/postgres.js";
import { hasCompanyHoldMismatch, projectCompanyHolds } from "./adapters/company-policy.js";
import { createLifecycleWorker } from "./application/worker.js";
import type { LifecycleDriver } from "./application/ports.js";
@@ -16,12 +16,11 @@ import type { LifecycleDriver } from "./application/ports.js";
export { applyAgentPolicyHoldsInTransaction } from "./adapters/postgres.js";
export { companyHoldMismatch, fenceActiveCompanyAgentStopsInTransaction } from "./adapters/company-policy.js";
-export { AgentLifecycleConflict, canConfigureAgentConnection, isAgentAwaitingSetup } from "./domain/policy.js";
+export { AgentLifecycleConflict, canConfigureAgentConnection, isAgentAwaitingSetup, isAgentSetupDraft } from "./domain/policy.js";
export type { HireDecisionTarget } from "./adapters/approvals.js";
export type { LifecycleDriver, LifecycleAgent, LifecycleFailure } from "./application/ports.js";
-const pendingStates = ["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"] as const;
const wakes = new WeakMap void>();
const workers = new WeakMap>();
@@ -116,7 +115,7 @@ export function startAgentLifecycle(db: Db, effects: LifecycleEffects, driver: L
const [pending] = await db.select({ due: sql`min(greatest(
coalesce(extract(epoch from (${agents.lifecycleOperation}->>'leaseUntil')::timestamptz) * 1000, 0),
coalesce(extract(epoch from (${agents.lifecycleOperation}->>'retryAt')::timestamptz) * 1000, 0)))::double precision` })
- .from(agents).where(inArray(agents.lifecycleState, pendingStates));
+ .from(agents).where(pendingLifecycleWork);
const due = pending?.due == null ? null : Number(pending.due);
const now = Date.now();
// Already-due work uses the coordinator's bounded retry. A zero-delay
diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts
index 7351ab8fb20..44833effc76 100644
--- a/server/src/routes/agents.ts
+++ b/server/src/routes/agents.ts
@@ -6,6 +6,7 @@ import { resolveNewAgentRunnerForCompany } from "../services/agent-runner-select
import { agentEnvironmentTestService } from "../services/agent-environment-test.js";
import { qualifyAgentRunner } from "../services/agent-runner-qualification.js";
import { createAgentLifecycle } from "../services/agent-lifecycle.js";
+import { isAgentSetupDraft } from "../modules/agent-lifecycle/index.js";
import { agentIdentityService } from "../services/agent-identity.js";
import { aiConnectionRouterService, poolMemberRuntimeConfig } from "../services/ai-connection-router.js";
@@ -1796,7 +1797,7 @@ export function agentRoutes(
id: string; companyId: string; createdByUserId?: string | null; lifecycleState?: string; pauseReason?: string | null;
}, allowCompleted = false) {
if (req.actor.type === "board" && req.actor.userId && targetAgent.createdByUserId === req.actor.userId
- && (allowCompleted || (targetAgent.lifecycleState === "paused" && targetAgent.pauseReason === "setup"))) {
+ && (allowCompleted || isAgentSetupDraft(targetAgent))) {
await assertCanCreateAgentsForCompany(req, targetAgent.companyId);
return;
}
@@ -3180,7 +3181,7 @@ export function agentRoutes(
const testEnvironmentId = await resolveAdapterTestEnvironmentId(companyId, environmentId, binding.provider === "github");
if (testEnvironmentId) await assertAdapterTestEnvironmentForCompany(companyId, testEnvironmentId, adapterType);
const hasSavedAgent = !newAgent || (isUuidLike(agentId) && Boolean(await svc.getById(agentId)));
- if (!hasSavedAgent && !test && testEnvironmentId && (await environmentsSvc.getById(testEnvironmentId))?.driver === "plugin") {
+ if (newAgent && !test && testEnvironmentId && (await environmentsSvc.getById(testEnvironmentId))?.driver === "plugin") {
// The lifecycle verifies the saved agent after its plugin can create the agent-owned computer.
return { connectionId: selection.connection.id };
}
@@ -3228,7 +3229,7 @@ export function agentRoutes(
if (savedAgent.companyId !== companyId) throw notFound("Agent not found");
await assertCanConfigureAgentSetup(req, savedAgent);
}
- const testingSetup = savedAgent?.lifecycleState === "paused" && savedAgent.pauseReason === "setup";
+ const testingSetup = savedAgent ? isAgentSetupDraft(savedAgent) : false;
const poolBinding = requestedBinding ?? (savedAgent?.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(savedAgent.runtimeConfig.aiConnection) : undefined);
const requestedEnvironmentId = await resolveAdapterTestEnvironmentId(
companyId,
@@ -5545,7 +5546,7 @@ export function agentRoutes(
&& (["openai_dot", "muse"].includes(String(rawEffectiveAdapterConfig.provider)) || ["openai_dot", "muse"].includes(String(existingRunnerProvider)))) {
await assertSelectableAdapterType(requestedAdapterType, rawEffectiveAdapterConfig);
}
- if (!(existing.lifecycleState === "paused" && existing.pauseReason === "setup") && (
+ if (!isAgentSetupDraft(existing) && (
changingAdapterType ||
(requestedAdapterType === "paperclip_runner" &&
(requestedAdapterConfig !== null ||
@@ -5580,7 +5581,7 @@ export function agentRoutes(
}
const nextAiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record;
if (existing.runtimeConfig.aiConnection && requestedRuntimeConfig && !requestedRuntimeConfig.aiConnection
- && !(existing.lifecycleState === "paused" && existing.pauseReason === "setup")) {
+ && !isAgentSetupDraft(existing)) {
const existingBinding = aiRuntimeConnectionBindingSchema.safeParse(existing.runtimeConfig.aiConnection).data;
if (existingBinding && agentHarnessType(existing.adapterType, existing.adapterConfig) === agentHarnessType(requestedAdapterType, nextAiConfig)
&& (existingBinding.mode === "router" || isAiConnectionCompatible(existingBinding,
@@ -5589,7 +5590,7 @@ export function agentRoutes(
}
}
if (!requestedRuntimeConfig?.aiConnection && !existing.runtimeConfig.aiConnection
- && !(existing.lifecycleState === "paused" && existing.pauseReason === "setup")
+ && !isAgentSetupDraft(existing)
&& requestedAdapterType === "paperclip_runner" && nextAiConfig.provider === "acpx"
&& nextAiConfig.acpxAgent === "copilot"
&& (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId"))) {
@@ -5605,7 +5606,7 @@ export function agentRoutes(
const copilotConfigurationChanged = requestedAdapterType === "paperclip_runner" && aiConfig.provider === "acpx" && aiConfig.acpxAgent === "copilot"
&& (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId"));
if (runtimeSettings || changed || copilotConfigurationChanged || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) {
- const configuringSetup = existing.lifecycleState === "paused" && existing.pauseReason === "setup";
+ const configuringSetup = isAgentSetupDraft(existing);
await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding,
(patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null,
!configuringSetup && (!runtimeSettings || changed), configuringSetup, Boolean(runtimeSettings));
@@ -5683,7 +5684,7 @@ export function agentRoutes(
const id = req.params.id as string;
const existing = await getAccessibleAgent(req, res, id);
if (!existing) return;
- await assertCanUpdateAgent(req, existing);
+ await assertCanConfigureAgentSetup(req, existing);
const agent = await svcLifecycle.retry(id);
if (!agent) { res.status(404).json({ error: "Agent not found" }); return; }
const actor = getActorInfo(req);
diff --git a/server/src/services/agent-environment-test.ts b/server/src/services/agent-environment-test.ts
index 1bf658d31df..cce3f35065d 100644
--- a/server/src/services/agent-environment-test.ts
+++ b/server/src/services/agent-environment-test.ts
@@ -2,7 +2,8 @@ import { join } from "node:path";
import { probePluginRunnerEnvironment } from "./native-runtime/plugin-runner-probe.js";
import { registerPluginTaskCredentialHandoff } from "./plugin-task-credential-writeback.js";
import { randomUUID } from "node:crypto";
-import type { Db } from "@paperclipai/db";
+import { agents, type Db } from "@paperclipai/db";
+import { and, eq } from "drizzle-orm";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils";
import { parseObject } from "@paperclipai/adapter-utils/server-utils";
@@ -19,6 +20,7 @@ import { validateAiApiKey } from "./ai-api-key-test.js";
import { probeCopilotExecutionTarget } from "./copilot-connection-probe.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
+import { pluginEnvironmentRequiresAgent } from "./plugin-environment-driver.js";
const asRecord = (value: unknown): Record | null => value && typeof value === "object" && !Array.isArray(value) ? value as Record : null;
export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: PluginWorkerManager) {
const instanceSettings = instanceSettingsService(db);
@@ -212,6 +214,17 @@ export function agentEnvironmentTestService(db: Db, pluginWorkerManager?: Plugin
// agent lease) and archive it on release instead of deleting it, so the
// operator can inspect the exact sandbox from the provider dashboard while
// provider-side expiry reaps it later.
+ if (environment.driver === "plugin" && await pluginEnvironmentRequiresAgent(db, environment)) {
+ const [agent] = input.agentId ? await db.select({ state: agents.lifecycleState }).from(agents)
+ .where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId))) : [];
+ if (!agent || ["paused", "preparing"].includes(agent.state)) return {
+ executionTarget: null, environmentName: environment.name, fallbackChecks: [{
+ code: !input.agentId ? "environment_agent_required" : "environment_agent_preparing", level: "warn",
+ message: !input.agentId ? "This environment needs a saved agent before its connection can be tested."
+ : "The agent computer is being prepared. Test the connection when preparation finishes.",
+ }], release: noopRelease,
+ };
+ }
const testEnvironment = environment.driver === "sandbox"
? {
...environment,
diff --git a/server/src/services/agent-lifecycle.ts b/server/src/services/agent-lifecycle.ts
index fc0eb85522d..b158987755f 100644
--- a/server/src/services/agent-lifecycle.ts
+++ b/server/src/services/agent-lifecycle.ts
@@ -12,12 +12,23 @@ import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notificatio
import { fenceActiveCompanyAgentStopsInTransaction as fenceActiveCompanyStops } from "../modules/agent-lifecycle/index.js";
import { applyAgentPolicyHoldsInTransaction } from "../modules/agent-lifecycle/index.js";
import { trackIdleWork } from "./task-admission.js";
+import { environmentService } from "./environments.js";
+import { instanceSettingsService } from "./instance-settings.js";
+import { pluginEnvironmentRequiresAgent } from "./plugin-environment-driver.js";
export { scheduleAgentLifecycle, processAgentLifecycle } from "../modules/agent-lifecycle/index.js";
export type { LifecycleDriver, LifecycleAgent } from "../modules/agent-lifecycle/index.js";
export function createAgentLifecycleEffects(_hooks: BudgetServiceHooks = {}): lifecycle.LifecycleEffects {
return {
+ async setupRequiresPreparation(db, companyId, environmentId) {
+ const id = environmentId ?? (await instanceSettingsService(db).get()).defaultEnvironmentId;
+ const environment = id ? await environmentService(db).getById(id) : null;
+ if (!environment || !await pluginEnvironmentRequiresAgent(db, environment)) return false;
+ const bound = await environmentService(db).listBoundCompanyIds(environment.id);
+ if (bound.length && !bound.includes(companyId)) throw new lifecycle.AgentLifecycleConflict("The environment belongs to another company");
+ return true;
+ },
notifyWork: tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.agentLifecycle),
reportFailure(context, error) {
const code = lifecycle.lifecycleFailureCode(error);
diff --git a/server/src/services/plugin-environment-driver.ts b/server/src/services/plugin-environment-driver.ts
index f26f32483ff..68fafdca165 100644
--- a/server/src/services/plugin-environment-driver.ts
+++ b/server/src/services/plugin-environment-driver.ts
@@ -31,6 +31,14 @@ import {
import { pluginRegistryService } from "./plugin-registry.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
+export async function pluginEnvironmentRequiresAgent(db: Db, environment: { driver: string; config: Record | null }) {
+ if (environment.driver !== "plugin") return false;
+ const config = environment.config;
+ if (typeof config?.pluginKey !== "string" || typeof config.driverKey !== "string") return false;
+ const plugin = await pluginRegistryService(db).getByKey(config.pluginKey);
+ return plugin?.manifestJson.environmentDrivers?.some(driver => driver.driverKey === config.driverKey && driver.requiresAgent === true) ?? false;
+}
+
/**
* The worker methods a sandbox provider must advertise before the host reuses
* a provider lease across runs. The host resumes a lease through
diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx
index e79f33bd438..9bb13435f46 100644
--- a/ui/src/components/new-agent/NewAgentSetup.tsx
+++ b/ui/src/components/new-agent/NewAgentSetup.tsx
@@ -194,12 +194,13 @@ function Setup({
const savedAgent = useQuery({
queryKey: queryKeys.agents.detail(recordId ?? "new"),
queryFn: () => agentsApi.get(recordId!, companyId),
- enabled: Boolean(recordId) && !currentCreated && !creating.current,
+ enabled: Boolean(recordId) && !creating.current,
+ refetchInterval: query => query.state.data?.pauseReason === "setup"
+ && ["preparing", "pausing"].includes(query.state.data.lifecycleState ?? "") && !query.state.data.lifecycleError ? 1000 : false,
retry: false,
});
const created =
- currentCreated ??
- (savedAgent.data?.companyId === companyId ? savedAgent.data : null);
+ (savedAgent.data?.companyId === companyId ? savedAgent.data : null) ?? currentCreated;
const completed = Boolean(created && created.pauseReason !== "setup");
useEffect(() => {
if (brandType !== "pi_local" || created?.adapterType !== "paperclip_runner"
@@ -218,6 +219,7 @@ function Setup({
null,
);
const [error, setError] = useState(null);
+ const [environmentSaving, setEnvironmentSaving] = useState(false);
const generation = useRef(0);
const qualificationTransition = useRef<{ run: number; companyId: string; environmentId: string | null; runner: AgentRunner } | null>(null);
const savingRef = useRef(false);
@@ -410,14 +412,15 @@ function Setup({
(!(managedOnly || forced.forced) || environmentId),
);
const needsCurrentSetup = ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType);
- const ready = Boolean(created) && connectionSettingsReady && (!needsCurrentSetup || (
+ const preparingComputer = created?.pauseReason === "setup" && ["preparing", "pausing"].includes(created.lifecycleState ?? "");
+ const ready = Boolean(created) && !environmentSaving && !preparingComputer && connectionSettingsReady && (!needsCurrentSetup || (
envs.isSuccess && !envs.isFetching
&& settings.isSuccess && !settings.isFetching
&& experimental.isSuccess && !experimental.isFetching
&& general.isSuccess && !general.isFetching
&& (runner !== "auto" || (adapters.isSuccess && !adapters.isFetching))
));
- const busy = testState === "running" || saving;
+ const busy = testState === "running" || saving || environmentSaving;
function buildConfig(
nextConnection = connection,
@@ -604,7 +607,7 @@ function Setup({
setScreen(binding || !connectionAdapter ? "runtime" : "connect");
}, [savedAgent.data, companyId, connectionAdapter, brandType]);
- async function saveDraft(nextConnection = connection, exit = false, testedConfig?: Record) {
+ async function saveDraft(nextConnection = connection, exit = false, testedConfig?: Record, selectedEnvironment = environmentOverride) {
if (!created || completed) return;
const binding = runtimeAiBinding ?? nextConnection?.aiConnection;
const config = testedConfig ?? buildConfig(nextConnection);
@@ -625,7 +628,7 @@ function Setup({
submitted = true;
const agent = await agentsApi.update(created.id, {
adapterType: requestAdapterType, runner, adapterConfig: config,
- defaultEnvironmentId: environmentOverride || (forced.forced || managedOnly ? environmentId : null),
+ defaultEnvironmentId: selectedEnvironment || (forced.forced || managedOnly ? environmentId : null),
runtimeConfig: { ...created.runtimeConfig, ...(binding ? { aiConnection: binding } : {}) },
...(nextConnection?.applyStoredClaudeLogin ? { applyStoredClaudeLogin: true } : {}),
}, companyId);
@@ -678,6 +681,7 @@ function Setup({
const config = preparedConfig(nextConnection, qualified?.runner);
const tested = await testAgentSetup({
companyId,
+ agentId: created?.id,
adapterType: requestAdapterType,
providerAdapter: brandType,
...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
@@ -686,7 +690,7 @@ function Setup({
aiConnection: runtimeAiBinding ?? nextConnection?.aiConnection,
environmentId,
});
- if (run !== generation.current) return null;
+ if (run !== generation.current) return null;
setResult(tested);
setTestState(blocking(tested) ? "fail" : tested.status);
return (
@@ -882,6 +886,18 @@ function Setup({
{environmentError}
)}
+ {preparingComputer && Preparing your agent computer…
}
+ {created?.lifecycleError &&
+
{created.lifecycleError}
+
+
}
{setupError && (
{setupError.message}
@@ -1374,10 +1390,13 @@ function Setup({
value={environmentOverride}
disabled={forced.forced}
onChange={(event) => {
- setEnvironmentOverride(event.target.value);
- setConnection(null);
+ const selected = event.target.value;
+ setEnvironmentOverride(selected);
resetTest();
- if (connectionAdapter) setScreen("connect");
+ setEnvironmentSaving(true);
+ void saveDraft(connection, false, undefined, selected)
+ .catch(cause => setError(cause instanceof Error ? cause.message : "Could not save the environment."))
+ .finally(() => setEnvironmentSaving(false));
}}
>