From 53937be283e694b953347af3c795a5451ca88686 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 4 Nov 2026 20:59:02 -0800 Subject: [PATCH] fix(agents): select environment plugins only for agents on that environment (#19173) --- doc/AGENT-LIFECYCLE.md | 16 ++ doc/DEVELOPING.md | 4 +- .../src/__tests__/environment-routes.test.ts | 19 +- .../instance-settings-routes.test.ts | 17 ++ .../__tests__/environment-plugins.test.ts | 163 ++++++++++++++++++ .../agent-lifecycle/adapters/configuration.ts | 10 +- .../agent-lifecycle/adapters/effects.ts | 1 + .../agent-lifecycle/adapters/postgres.ts | 3 +- .../agent-lifecycle/application/worker.ts | 8 +- server/src/routes/environments.ts | 6 + server/src/routes/instance-settings.ts | 5 + server/src/services/agent-lifecycle-driver.ts | 18 +- server/src/services/agent-lifecycle.ts | 8 +- .../src/services/plugin-environment-driver.ts | 54 +++++- 14 files changed, 313 insertions(+), 19 deletions(-) create mode 100644 server/src/modules/agent-lifecycle/__tests__/environment-plugins.test.ts diff --git a/doc/AGENT-LIFECYCLE.md b/doc/AGENT-LIFECYCLE.md index 4302e23247d..99601bea647 100644 --- a/doc/AGENT-LIFECYCLE.md +++ b/doc/AGENT-LIFECYCLE.md @@ -191,6 +191,22 @@ A disabled or removed plugin cannot silently release an existing requirement. Restore that plugin to complete the operation. A later plugin installation does not change an existing selection. +A plugin with an environment driver that declares `requiresAgent: true` is +selected only for an agent on that environment. The host uses the agent's default +environment, or the instance default when the agent has none. Other lifecycle +plugins are selected for all agents, including a sandbox provider that declares +`requiresAgent: true`. + +When an agent moves to such an environment, the host adds the plugin at the next +`preparing` or `resuming` step. An agent in `ready` cannot move to it until it is +paused, because the plugin must finish its work before the agent uses that +environment. An agent in `verifying` returns to `preparing`. An agent that moves away keeps the selection until it is terminated, +so the plugin keeps its resources until then. + +Saving an environment or the instance default that gives such a plugin to `ready` +or `verifying` agents without that selection returns a conflict. Pause those agents, +save the change, and then resume them. + Each request contains these fields: - `companyId` and `agentId` identify the agent. diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 6c5feb138d3..e518a616a0b 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2800,7 +2800,9 @@ 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 +need an agent-owned resource. The host selects that plugin's lifecycle work only for +agents on that environment. See [Required plugin work](./AGENT-LIFECYCLE.md#required-plugin-work). +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 diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index 9ad0bf972c4..51e82de3844 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -2,7 +2,7 @@ import type { Server } from "node:http"; import express from "express"; import request from "supertest"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { unprocessable } from "../errors.js"; +import { conflict, unprocessable } from "../errors.js"; import { environmentRoutes } from "../routes/environments.js"; import { errorHandler } from "../middleware/index.js"; @@ -33,6 +33,7 @@ const mockCloseWarmNativeSessionsForEnvironment = vi.hoisted(() => vi.fn(async () => ({ closed: 0, busy: 0, failed: 0 })), ); const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(async () => ({ defaultEnvironmentId: null })), listCompanyIds: vi.fn(), getGeneral: vi.fn(), getExperimental: vi.fn(), @@ -123,10 +124,13 @@ vi.mock("../services/execution-workspaces.js", () => ({ executionWorkspaceService: () => mockExecutionWorkspaceService, })); +const mockAssertAgentsReadyForEnvironmentChange = vi.hoisted(() => vi.fn(async () => {})); + vi.mock("../services/plugin-environment-driver.js", () => ({ // The runtime reads this published constant at import time. Mirror the real // value so the mocked module keeps the same reusable-lease method contract. REUSABLE_LEASE_WORKER_METHODS: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"], + assertAgentsReadyForEnvironmentChange: mockAssertAgentsReadyForEnvironmentChange, listReadyPluginEnvironmentDrivers: mockListReadyPluginEnvironmentDrivers, resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey, resolvePluginSandboxProviderConfigSchema: async (input: { db: unknown; driverKey: string }) => @@ -2452,6 +2456,19 @@ describe("environment routes", () => { expect(mockEnvironmentService.list).not.toHaveBeenCalled(); }); + it("refuses a config change that gives active agents an unprepared plugin", async () => { + const environment = createEnvironment(); + mockEnvironmentService.getById.mockResolvedValue(environment); + mockAssertAgentsReadyForEnvironmentChange.mockRejectedValueOnce(conflict("need setup first")); + const res = await request(createApp(localBoardActor())) + .patch(`/api/environments/${environment.id}?companyId=company-1`) + .send({ driver: "local", config: {} }); + expect(res.status).toBe(409); + expect(mockAssertAgentsReadyForEnvironmentChange).toHaveBeenCalledWith(expect.anything(), environment, + expect.objectContaining({ driver: "local" }), expect.objectContaining({ environmentId: environment.id })); + expect(mockEnvironmentService.update).not.toHaveBeenCalled(); + }); + it("logs a redacted update summary instead of raw config or metadata", async () => { const environment = createEnvironment(); mockEnvironmentService.getById.mockResolvedValue(environment); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index b586e1eaa00..eb5c0879cd2 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { hoistModuleGraph } from "./helpers/hoist-module-graph.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; +const mockAssertAgentsReadyForEnvironmentChange = vi.hoisted(() => vi.fn(async () => {})); const mockInstanceSettingsService = vi.hoisted(() => ({ get: vi.fn(), getGeneral: vi.fn(), @@ -47,6 +48,10 @@ function registerModuleMocks() { vi.doMock("../services/environments.js", () => ({ environmentService: () => mockEnvironmentService, })); + vi.doMock("../services/plugin-environment-driver.js", async (importOriginal) => ({ + ...await importOriginal(), + assertAgentsReadyForEnvironmentChange: mockAssertAgentsReadyForEnvironmentChange, + })); } // Identity object the mocked db.transaction hands to writers; tests assert @@ -359,6 +364,18 @@ describe("instance settings routes", () => { expect(mockLogActivity).toHaveBeenCalledTimes(2); }); + it("refuses an instance default that gives inheriting agents an unprepared plugin", async () => { + const app = await createApp(localAdminActor()); + const { conflict } = await import("../errors.js"); + mockAssertAgentsReadyForEnvironmentChange.mockRejectedValueOnce(conflict("need setup first")); + const res = await request(app) + .patch("/api/instance/settings") + .send({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" }); + expect(res.status).toBe(409); + expect(mockAssertAgentsReadyForEnvironmentChange.mock.lastCall?.at(3)).toEqual({ inheritsDefault: true }); + expect(mockInstanceSettingsService.update).not.toHaveBeenCalled(); + }); + it("clears the managed-default stamp marker on an explicit tenant default write", async () => { // A tenant write of defaultEnvironmentId reclassifies the default as // tenant-chosen: the reconciliation stamp marker on the managed diff --git a/server/src/modules/agent-lifecycle/__tests__/environment-plugins.test.ts b/server/src/modules/agent-lifecycle/__tests__/environment-plugins.test.ts new file mode 100644 index 00000000000..c4b48cedfde --- /dev/null +++ b/server/src/modules/agent-lifecycle/__tests__/environment-plugins.test.ts @@ -0,0 +1,163 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { agents, companies, environments, instanceSettings, plugins, 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 { configureAgentLifecycle, createAgentLifecycle, createAgentLifecycleEffects } from "../../../services/agent-lifecycle.js"; +import { createLifecycleDriver } from "../../../services/agent-lifecycle-driver.js"; +import { agentService as agentConfiguration } from "../../../services/agents.js"; +import { assertAgentsReadyForEnvironmentChange } from "../../../services/plugin-environment-driver.js"; +import { createLifecycleStore } from "../adapters/postgres.js"; + +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("environment-bound lifecycle plugins", () => { + let database: Awaited>; + let db: Db; + let companyId: string; + let boundPluginId: string; + let boundEnvironmentId: string; + let otherEnvironmentId: string; + const workers: ReturnType[] = []; + const call = vi.fn(async (_id, _method, input) => ({ operationId: input.operationId, version: input.version, status: "complete" })); + + beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-environment-plugins-"); db = createDb(database.connectionString); }, 90_000); + afterAll(async () => { for (const work of workers) await work.stop(); await database?.cleanup(); }); + beforeEach(async () => { + for (const work of workers.splice(0)) await work.stop(); + call.mockClear(); + await db.delete(plugins); + await db.update(instanceSettings).set({ defaultEnvironmentId: null }); + companyId = randomUUID(); boundPluginId = randomUUID(); boundEnvironmentId = randomUUID(); otherEnvironmentId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Environment plugins", issuePrefix: `E${companyId.slice(0, 7)}` }); + await db.insert(plugins).values({ id: boundPluginId, pluginKey: boundPluginId, packageName: "bound", 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: boundEnvironmentId, name: `Bound ${boundEnvironmentId}`, driver: "plugin", status: "active", config: { pluginKey: boundPluginId, driverKey: "tasks", driverConfig: {} } }, + { id: otherEnvironmentId, name: `Other ${otherEnvironmentId}`, driver: "sandbox", status: "active", config: { provider: "fixture" } }, + ]); + }); + + function worker() { + const work = configureAgentLifecycle(db, { ...createLifecycleDriver(db, { call } as never), runHost: async () => "complete" as const }); + workers.push(work); + return work; + } + async function agent(defaultEnvironmentId: string | null, fields: Partial = {}) { + const id = randomUUID(); + await db.insert(agents).values({ id, companyId, name: `Agent ${id}`, adapterType: "process", defaultEnvironmentId, ...fields }); + return id; + } + async function current(id: string) { return (await db.select().from(agents).where(eq(agents.id, id)))[0]!; } + async function selection(id: string) { + return createLifecycleDriver(db, { call } as never).requiredPluginIds((await createLifecycleStore(db, createAgentLifecycleEffects()).get(id))!); + } + + it("selects an environment-bound plugin only for agents on its environment", async () => { + const genericPluginId = randomUUID(); + await db.insert(plugins).values({ id: genericPluginId, pluginKey: genericPluginId, packageName: "generic", version: "1.0.0", status: "ready", + manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage"] } as never }); + expect(await selection(await agent(boundEnvironmentId))).toEqual(expect.arrayContaining([boundPluginId, genericPluginId])); + expect(await selection(await agent(otherEnvironmentId))).toEqual([genericPluginId]); + expect(await selection(await agent(null))).toEqual([genericPluginId]); + }); + + it("keeps selecting a sandbox provider that requires an agent for all agents", async () => { + const providerId = randomUUID(), sandboxId = randomUUID(), providerKey = `provider-${providerId.slice(0, 8)}`; + await db.insert(plugins).values({ id: providerId, pluginKey: providerId, packageName: "provider", version: "1.0.0", status: "ready", + manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage", "environment.drivers.register"], + environmentDrivers: [{ driverKey: providerKey, kind: "sandbox_provider", requiresAgent: true }] } as never }); + await db.insert(environments).values({ id: sandboxId, name: `Sandbox ${sandboxId}`, driver: "sandbox", status: "active", config: { provider: providerKey } }); + expect(await selection(await agent(sandboxId))).toEqual([providerId]); + expect(await selection(await agent(otherEnvironmentId))).toEqual([providerId]); + const id = await agent(otherEnvironmentId, { lifecycleRequiredPluginIds: [] }); + await agentConfiguration(db).update(id, { defaultEnvironmentId: sandboxId }); + expect((await current(id)).defaultEnvironmentId).toBe(sandboxId); + }); + + it("refuses an environment change that gives active agents an unprepared plugin", async () => { + const other = { driver: "sandbox", config: { provider: "fixture" } }; + const bound = { driver: "plugin", config: { pluginKey: boundPluginId, driverKey: "tasks" } }; + const users = { environmentId: otherEnvironmentId, inheritsDefault: false }; + const id = await agent(otherEnvironmentId, { lifecycleRequiredPluginIds: [] }); + await expect(assertAgentsReadyForEnvironmentChange(db, other, bound, users)).rejects.toThrow("need setup first"); + await db.update(agents).set({ lifecycleState: "paused", status: "paused" }).where(eq(agents.id, id)); + await expect(assertAgentsReadyForEnvironmentChange(db, other, bound, users)).resolves.toBeUndefined(); + await db.update(agents).set({ lifecycleState: "ready", status: "idle", lifecycleRequiredPluginIds: [boundPluginId] }).where(eq(agents.id, id)); + await expect(assertAgentsReadyForEnvironmentChange(db, other, bound, users)).resolves.toBeUndefined(); + await db.update(agents).set({ lifecycleRequiredPluginIds: [] }).where(eq(agents.id, id)); + await expect(assertAgentsReadyForEnvironmentChange(db, bound, bound, users)).resolves.toBeUndefined(); + await expect(assertAgentsReadyForEnvironmentChange(db, bound, other, users)).resolves.toBeUndefined(); + }); + + it("refuses an instance default that gives inheriting agents an unprepared plugin", async () => { + const other = { driver: "sandbox", config: { provider: "fixture" } }; + const bound = { driver: "plugin", config: { pluginKey: boundPluginId, driverKey: "tasks" } }; + await agent(null, { lifecycleRequiredPluginIds: [] }); + await expect(assertAgentsReadyForEnvironmentChange(db, other, bound, { inheritsDefault: true })).rejects.toThrow("need setup first"); + await expect(assertAgentsReadyForEnvironmentChange(db, other, bound, { environmentId: boundEnvironmentId, inheritsDefault: false })).resolves.toBeUndefined(); + }); + + it("uses the instance default for an agent with no environment", async () => { + await db.update(instanceSettings).set({ defaultEnvironmentId: boundEnvironmentId }); + expect(await selection(await agent(null))).toEqual([boundPluginId]); + expect(await selection(await agent(otherEnvironmentId))).toEqual([]); + }); + + it("does not call the plugin while an agent on another environment prepares", async () => { + const id = await agent(otherEnvironmentId, { lifecycleState: "resuming", status: "paused", + lifecycleOperation: { id: randomUUID(), hostComplete: false, completedPluginIds: [], attempts: 0 } }); + await worker().process(id); + expect(call).not.toHaveBeenCalled(); + expect((await current(id)).lifecycleRequiredPluginIds).toEqual([]); + }); + + it("adds the plugin when a paused agent moves to its environment and resumes", async () => { + const id = await agent(otherEnvironmentId, { lifecycleState: "paused", status: "paused", lifecycleHolds: ["manual"], pauseReason: "manual", lifecycleRequiredPluginIds: [] }); + await agentConfiguration(db).update(id, { defaultEnvironmentId: boundEnvironmentId }); + await createAgentLifecycle(db).resumeAgent(id); + await worker().process(id); + expect((await current(id)).lifecycleRequiredPluginIds).toEqual([boundPluginId]); + expect(call.mock.calls.map(args => args[2].phase)).toContain("resuming"); + }); + + it("returns a verifying agent to preparing when it moves to the plugin's environment", async () => { + const id = await agent(otherEnvironmentId, { lifecycleState: "verifying", status: "paused", lifecycleRequiredPluginIds: [], + lifecycleOperation: { id: randomUUID(), hostComplete: false, completedPluginIds: [], attempts: 0 } }); + await agentConfiguration(db).update(id, { defaultEnvironmentId: boundEnvironmentId }); + expect((await current(id)).lifecycleState).toBe("preparing"); + await worker().process(id); + expect((await current(id)).lifecycleRequiredPluginIds).toEqual([boundPluginId]); + expect(call.mock.calls.map(args => args[2].phase)).toContain("preparing"); + }); + + it("adds the plugin when an agent moves to its environment while preparing", async () => { + const id = await agent(otherEnvironmentId, { lifecycleState: "preparing", status: "paused", lifecycleRequiredPluginIds: [], + lifecycleOperation: { id: randomUUID(), hostComplete: true, completedPluginIds: [], attempts: 0 } }); + await agentConfiguration(db).update(id, { defaultEnvironmentId: boundEnvironmentId }); + await worker().process(id); + expect((await current(id)).lifecycleRequiredPluginIds).toEqual([boundPluginId]); + expect(call.mock.calls.map(args => args[2].phase)).toContain("preparing"); + }); + + it("keeps a verifying agent in verifying when the new environment needs no new plugin", async () => { + const id = await agent(boundEnvironmentId, { lifecycleState: "verifying", status: "paused", lifecycleRequiredPluginIds: [boundPluginId], + lifecycleOperation: { id: randomUUID(), hostComplete: false, completedPluginIds: [], attempts: 0 } }); + await agentConfiguration(db).update(id, { defaultEnvironmentId: otherEnvironmentId }); + expect((await current(id)).lifecycleState).toBe("verifying"); + }); + + it("refuses to move a ready agent to an environment whose plugin it has not selected", async () => { + const id = await agent(otherEnvironmentId, { lifecycleRequiredPluginIds: [] }); + await expect(agentConfiguration(db).update(id, { defaultEnvironmentId: boundEnvironmentId })).rejects.toThrow("Pause the agent"); + expect((await current(id)).defaultEnvironmentId).toBe(otherEnvironmentId); + }); + + it("lets a ready agent move when the plugin is selected, and keeps the selection when it leaves", async () => { + const id = await agent(boundEnvironmentId, { lifecycleRequiredPluginIds: [boundPluginId] }); + await agentConfiguration(db).update(id, { defaultEnvironmentId: otherEnvironmentId }); + expect(await current(id)).toMatchObject({ defaultEnvironmentId: otherEnvironmentId, lifecycleRequiredPluginIds: [boundPluginId] }); + await agentConfiguration(db).update(id, { defaultEnvironmentId: boundEnvironmentId }); + expect((await current(id)).defaultEnvironmentId).toBe(boundEnvironmentId); + }); +}); diff --git a/server/src/modules/agent-lifecycle/adapters/configuration.ts b/server/src/modules/agent-lifecycle/adapters/configuration.ts index 7acebf46d54..908cffe6236 100644 --- a/server/src/modules/agent-lifecycle/adapters/configuration.ts +++ b/server/src/modules/agent-lifecycle/adapters/configuration.ts @@ -25,6 +25,14 @@ 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 needsNewPlugin = ["ready", "verifying"].includes(current.lifecycleState) + && Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId") + && patch.defaultEnvironmentId !== current.defaultEnvironmentId + && await effects.environmentRequiresNewPlugin(tx, patch.defaultEnvironmentId, current.lifecycleRequiredPluginIds); + if (needsNewPlugin && current.lifecycleState === "ready") { + throw new AgentLifecycleConflict("This environment needs setup for the agent. Pause the agent, change its environment, and then resume it."); + } + const prepareEnvironment = needsNewPlugin && !current.lifecycleHolds.includes("setup"); const prepareSetup = changed && current.lifecycleHolds.length === 1 && current.lifecycleHolds[0] === "setup" && ["paused", "preparing", "verifying"].includes(current.lifecycleState) && await effects.setupRequiresPreparation(tx, current.companyId, @@ -35,7 +43,7 @@ export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects 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 || prepareEnvironment ? { 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, diff --git a/server/src/modules/agent-lifecycle/adapters/effects.ts b/server/src/modules/agent-lifecycle/adapters/effects.ts index e1787124063..a77ef6eb77a 100644 --- a/server/src/modules/agent-lifecycle/adapters/effects.ts +++ b/server/src/modules/agent-lifecycle/adapters/effects.ts @@ -8,6 +8,7 @@ import type { ActivityPublication } from "../../../types/activity-publication.js export interface LifecycleEffects { setupRequiresPreparation(db: Db, companyId: string, environmentId: string | null | undefined, profile: Pick): Promise; + environmentRequiresNewPlugin(db: Db, environmentId: string | null | undefined, selectedPluginIds: string[] | null): Promise; notifyWork(db: Db): Promise; beforeStatusChange(db: Db, companyId: string, agentId: string, before: string, after: string): Promise; reportFailure(context: LifecycleFailure, error: unknown): void; diff --git a/server/src/modules/agent-lifecycle/adapters/postgres.ts b/server/src/modules/agent-lifecycle/adapters/postgres.ts index 2ce6580ef41..4ca50b7edcc 100644 --- a/server/src/modules/agent-lifecycle/adapters/postgres.ts +++ b/server/src/modules/agent-lifecycle/adapters/postgres.ts @@ -158,7 +158,8 @@ export function createLifecycleStore(db: Db, effects: LifecycleEffects): Lifecyc async setRequiredPlugins(agent, owner, pluginIds) { const rows = await db.update(agents).set({ lifecycleRequiredPluginIds: pluginIds }) .where(and(eq(agents.id, agent.id), eq(agents.lifecycleVersion, agent.lifecycleVersion), - sql`${agents.lifecycleRequiredPluginIds} is null`, + agent.lifecycleRequiredPluginIds === null ? sql`${agents.lifecycleRequiredPluginIds} is null` + : sql`${agents.lifecycleRequiredPluginIds} = ${JSON.stringify(agent.lifecycleRequiredPluginIds)}::jsonb`, sql`${agents.lifecycleOperation}->>'leaseOwner' = ${owner}`)).returning({ id: agents.id }); return rows.length > 0; }, diff --git a/server/src/modules/agent-lifecycle/application/worker.ts b/server/src/modules/agent-lifecycle/application/worker.ts index 02d2804f5d6..27c533329b3 100644 --- a/server/src/modules/agent-lifecycle/application/worker.ts +++ b/server/src/modules/agent-lifecycle/application/worker.ts @@ -33,8 +33,12 @@ export function createLifecycleWorker(store: LifecycleStore, driver: LifecycleDr renewal.unref?.(); try { const operation = agent.lifecycleOperation!; - const pluginIds = agent.lifecycleRequiredPluginIds ?? await runStep("select_plugins", () => driver.requiredPluginIds(agent)); - if (agent.lifecycleRequiredPluginIds === null && !await runStep("save_plugins", () => store.setRequiredPlugins(agent, owner, pluginIds))) continue; + const saved = agent.lifecycleRequiredPluginIds; + const selecting = saved === null || (["preparing", "resuming"].includes(agent.lifecycleState) + && !operation.hostComplete && operation.completedPluginIds.length === 0); + const pluginIds = !selecting ? saved + : [...new Set([...(saved ?? []), ...await runStep("select_plugins", () => driver.requiredPluginIds(agent))])]; + if ((saved === null || pluginIds.length !== saved.length) && !await runStep("save_plugins", () => store.setRequiredPlugins(agent, owner, pluginIds))) continue; if (!canRun()) { await runStep("defer", () => store.defer(agent, owner, null, new Date())); return; diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index e0a24a73a15..eed8b648d46 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -52,6 +52,7 @@ import { secretService } from "../services/secrets.js"; import { listReadyPluginEnvironmentDrivers, type ReadyPluginWorkerRecovery, + assertAgentsReadyForEnvironmentChange, } from "../services/plugin-environment-driver.js"; import { getConfiguredSecretProvider } from "../secrets/configured-provider.js"; import { assertBoardOrgAccess, getActorInfo } from "./authz.js"; @@ -1216,6 +1217,11 @@ export function environmentRoutes( // Persist the config change and its binding rows atomically: a binding // ref that fails validation (e.g. a deleted secret) must roll the whole // save back instead of leaving the config re-pointed with stale bindings. + if (patch.config !== undefined || patch.driver !== undefined) { + const instanceDefault = (await instanceSettingsService(db).get()).defaultEnvironmentId; + await assertAgentsReadyForEnvironmentChange(db, existing, { driver: nextDriver, config: patch.config ?? existing.config }, + { environmentId: existing.id, inheritsDefault: instanceDefault === existing.id }); + } const environment = await db.transaction(async (tx) => { const updated = await svc.update(existing.id, patch, { db: tx }); if (!updated) return null; diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index 75d918bb358..154d83f0254 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -9,6 +9,7 @@ import { startTaskDrainRequestSchema, } from "@paperclipai/shared"; import { conflict, forbidden } from "../errors.js"; +import { assertAgentsReadyForEnvironmentChange } from "../services/plugin-environment-driver.js"; import { cloudTenantPrimaryCompanyId, getCloudStackContext, @@ -155,6 +156,10 @@ export function instanceSettingsRoutes(db: Db, pluginWorkers?: PluginWorkerManag "instance", typeof req.body.defaultEnvironmentId === "string" ? req.body.defaultEnvironmentId : null, ); + const previousId = (await svc.get()).defaultEnvironmentId; + const environmentById = async (id: unknown) => typeof id === "string" ? environments.getById(id) : null; + await assertAgentsReadyForEnvironmentChange(db, await environmentById(previousId), await environmentById(req.body.defaultEnvironmentId), + { inheritsDefault: true }); } // An explicit tenant write of the instance default reclassifies its // attribution: whatever the default becomes — including a deliberate diff --git a/server/src/services/agent-lifecycle-driver.ts b/server/src/services/agent-lifecycle-driver.ts index a7422ad7d41..b92ea79238a 100644 --- a/server/src/services/agent-lifecycle-driver.ts +++ b/server/src/services/agent-lifecycle-driver.ts @@ -1,5 +1,7 @@ import { and, eq, inArray } from "drizzle-orm"; -import { agents, plugins, pluginCompanySettings, type Db } from "@paperclipai/db"; +import { agents, environments, plugins, pluginCompanySettings, type Db } from "@paperclipai/db"; +import { instanceSettingsService } from "./instance-settings.js"; +import { pluginHasAgentEnvironments, pluginServesAgentEnvironment } from "./plugin-environment-driver.js"; import { heartbeatService } from "./heartbeat.js"; import { listInvalidOrgChainDescendantIds } from "./agent-invokability.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; @@ -15,12 +17,20 @@ export function createLifecycleDriver(db: Db, manager: PluginWorkerManager): Lif await heartbeat.resumeQueuedRunsForAgent(agent.companyId, agent.id); }, async requiredPluginIds(agent) { - if (agent.lifecycleRequiredPluginIds !== null) return agent.lifecycleRequiredPluginIds; + const selected = agent.lifecycleRequiredPluginIds; const installed = await db.select().from(plugins).where(inArray(plugins.status, ["ready", "error", "installed", "upgrade_pending"])); const disabled = await db.select({ pluginId: pluginCompanySettings.pluginId }).from(pluginCompanySettings) .where(and(eq(pluginCompanySettings.companyId, agent.companyId), eq(pluginCompanySettings.enabled, false))); - return installed.filter(plugin => !disabled.some(row => row.pluginId === plugin.id) && - plugin.manifestJson.agentLifecycle && plugin.manifestJson.capabilities.includes("agents.lifecycle.manage")).map(plugin => plugin.id); + const candidates = installed.filter(plugin => !disabled.some(row => row.pluginId === plugin.id) && + plugin.manifestJson.agentLifecycle && plugin.manifestJson.capabilities.includes("agents.lifecycle.manage")); + const [row] = await db.select({ environmentId: agents.defaultEnvironmentId }).from(agents).where(eq(agents.id, agent.id)); + const environmentId = row?.environmentId ?? (await instanceSettingsService(db).get()).defaultEnvironmentId; + const [environment] = environmentId ? await db.select().from(environments).where(eq(environments.id, environmentId)) : []; + if (selected !== null) { + const added = candidates.filter(plugin => pluginServesAgentEnvironment(plugin, environment) && !selected.includes(plugin.id)); + return [...selected, ...added.map(plugin => plugin.id)]; + } + return candidates.filter(plugin => !pluginHasAgentEnvironments(plugin) || pluginServesAgentEnvironment(plugin, environment)).map(plugin => plugin.id); }, async runPlugin(agent, pluginId) { const [plugin] = await db.select().from(plugins).where(eq(plugins.id, pluginId)); diff --git a/server/src/services/agent-lifecycle.ts b/server/src/services/agent-lifecycle.ts index ef42fb865c5..74549a387b5 100644 --- a/server/src/services/agent-lifecycle.ts +++ b/server/src/services/agent-lifecycle.ts @@ -23,7 +23,7 @@ import { applyAgentPolicyHoldsInTransaction } from "../modules/agent-lifecycle/i import { trackIdleWork } from "./task-admission.js"; import { environmentService } from "./environments.js"; import { instanceSettingsService } from "./instance-settings.js"; -import { pluginEnvironmentRequiresAgent } from "./plugin-environment-driver.js"; +import { pluginEnvironmentRequiresAgent, pluginForAgentEnvironment } from "./plugin-environment-driver.js"; import { isProviderApiSetupDraft } from "./native-runtime/provider-api-placement.js"; export { scheduleAgentLifecycle, processAgentLifecycle } from "../modules/agent-lifecycle/index.js"; @@ -42,6 +42,12 @@ export function createAgentLifecycleEffects(_hooks: BudgetServiceHooks = {}): li if (bound.length && !bound.includes(companyId)) throw new lifecycle.AgentLifecycleConflict("The environment belongs to another company"); return true; }, + async environmentRequiresNewPlugin(db, environmentId, selectedPluginIds) { + const id = environmentId ?? (await instanceSettingsService(db).get()).defaultEnvironmentId; + const environment = id ? await environmentService(db).getById(id) : null; + const plugin = environment ? await pluginForAgentEnvironment(db, environment) : null; + return plugin !== null && !selectedPluginIds?.includes(plugin.id); + }, notifyWork: tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.agentLifecycle), async beforeStatusChange(tx, companyId, agentId, before, after) { await notifyAttentionRetentionWork(tx, { companyId }); diff --git a/server/src/services/plugin-environment-driver.ts b/server/src/services/plugin-environment-driver.ts index 3e39c7dcc3e..ad5f170aaa2 100644 --- a/server/src/services/plugin-environment-driver.ts +++ b/server/src/services/plugin-environment-driver.ts @@ -1,5 +1,6 @@ -import { plugins, type Db } from "@paperclipai/db"; -import { sql } from "drizzle-orm"; +import { agents, plugins, type Db } from "@paperclipai/db"; +import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { conflict } from "../errors.js"; import type { EnvironmentProbeResult, PluginEnvironmentConfig, @@ -31,12 +32,49 @@ 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; +type AgentEnvironmentRef = { driver: string; config: Record | null }; +type AgentEnvironmentPlugin = { + pluginKey: string; + manifestJson: { environmentDrivers?: Array<{ driverKey: string; kind?: string; requiresAgent?: boolean }> }; +}; + +export function pluginHasAgentEnvironments(plugin: AgentEnvironmentPlugin) { + const drivers = plugin.manifestJson.environmentDrivers?.filter(driver => driver.requiresAgent === true) ?? []; + return drivers.length > 0 && drivers.every(driver => driver.kind !== "sandbox_provider"); +} + +export function pluginServesAgentEnvironment(plugin: AgentEnvironmentPlugin, environment: AgentEnvironmentRef | null | undefined) { + if (environment?.driver !== "plugin" || environment.config?.pluginKey !== plugin.pluginKey) return false; + return plugin.manifestJson.environmentDrivers?.some(driver => driver.requiresAgent === true && driver.kind !== "sandbox_provider" + && driver.driverKey === environment.config?.driverKey) ?? false; +} + +export async function pluginForAgentEnvironment(db: Db, environment: AgentEnvironmentRef) { + const pluginKey = environment.config?.pluginKey; + if (environment.driver !== "plugin" || typeof pluginKey !== "string") return null; + const plugin = await pluginRegistryService(db).getByKey(pluginKey); + return plugin && pluginServesAgentEnvironment(plugin, environment) ? plugin : null; +} + +/** Throws when a saved environment would give active agents a plugin that has not prepared them. */ +export async function assertAgentsReadyForEnvironmentChange(db: Db, before: AgentEnvironmentRef | null | undefined, + after: AgentEnvironmentRef | null | undefined, users: { environmentId?: string; inheritsDefault: boolean }) { + const plugin = after ? await pluginForAgentEnvironment(db, after) : null; + if (!plugin || (before && (await pluginForAgentEnvironment(db, before))?.id === plugin.id)) return; + const usesEnvironment = or(users.environmentId ? eq(agents.defaultEnvironmentId, users.environmentId) : undefined, + users.inheritsDefault ? isNull(agents.defaultEnvironmentId) : undefined); + if (!usesEnvironment) return; + const [waiting] = await db.select({ id: agents.id }).from(agents).where(and(usesEnvironment, + inArray(agents.lifecycleState, ["ready", "verifying"]), + sql`not coalesce(${agents.lifecycleRequiredPluginIds}, '[]'::jsonb) @> ${JSON.stringify([plugin.id])}::jsonb`)).limit(1); + if (waiting) { + throw conflict("Agents that use this environment need setup first. Pause those agents, save this change, and then resume them.", + { code: "environment_requires_agent_setup" }); + } +} + +export async function pluginEnvironmentRequiresAgent(db: Db, environment: AgentEnvironmentRef) { + return await pluginForAgentEnvironment(db, environment) !== null; } /**