diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index e22fa6b0e46..688ee022aad 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2002,6 +2002,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 efe36ab31b3..5697ff86f4b 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -193,6 +193,8 @@ export interface SandboxProviderCapabilities { export interface PluginEnvironmentDriverDeclaration { /** Plugin-managed skill keys automatically included when this environment is selected. */ skillKeys?: string[]; + /** 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 5876c61d074..4555a2e19db 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -44,6 +44,13 @@ describe("plugin manifest validators", () => { } expect(pluginManifestV1Schema.safeParse({ ...manifest, capabilities: ["environment.drivers.register"] }).success).toBe(false); }); + 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 92a827afcb3..a151a690807 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -178,6 +178,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 4d30041567b..81d8ab77974 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -7,6 +7,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"; @@ -1797,7 +1798,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; } @@ -3181,7 +3182,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 }; } @@ -3229,7 +3230,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, @@ -5546,7 +5547,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 || @@ -5581,7 +5582,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, @@ -5590,7 +5591,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"))) { @@ -5606,7 +5607,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)); @@ -5684,7 +5685,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)); }} >