Files
PaperClipAI/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts
T
Nicky LeachandPaperclip 50b5a08af3 Keep draft computer preparation behind hire approval
Companies that require hire approval must not allocate agent-owned resources from an unapproved setup draft. Save the selected account with a pending verification warning and let the approved hire prepare and verify its computer. Other companies retain background wizard preparation.

Verified 123 lifecycle, policy, lease, and API tests; 112 wizard/helper tests; server/UI typechecks; map and token checks.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-26 10:10:19 -07:00

1036 lines
71 KiB
TypeScript

import { createAgentLifecycle as createLifecycleCommands } from "../index.js";
import { createAgentLifecycleEffects } from "../../../services/agent-lifecycle.js";
import { deleteCompany } from "../../../services/company-deletion.js";
import { agentLifecycleCompanyDeletion } from "../company-deletion.js";
import { requireServerAdapter } from "../../../adapters/index.js";
import { agentExecutionsHaveStopped } from "../../../services/agent-execution-stop.js";
import { remoteTerminationReceipt } from "../../../services/remote-execution-termination.js";
import { approvalService } from "../../../services/approvals.js";
import { updateAgentConfigurationInTransaction } from "../../../services/agent-configuration-transaction.js";
import { agentService as agentConfiguration } from "../../../services/agents.js";
import { readFileSync } from "node:fs";
import express from "express";
import request from "supertest";
import { agentRoutes } from "../../../routes/agents.js";
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, 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";
import { projectCompanyHolds } from "../adapters/company-policy.js";
import { createLifecycleStore as createStore } from "../adapters/postgres.js";
import { transition } from "../domain/policy.js";
import type { LifecycleAgent } from "../application/ports.js";
import { budgetService } from "../../../services/budgets.js";
const createLifecycleStore = (db: Db) => createStore(db, createAgentLifecycleEffects());
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("agent lifecycle commands", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
let companyId: string;
const workers: ReturnType<typeof configureAgentLifecycle>[] = [];
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-agent-lifecycle-"); db = createDb(database.connectionString); }, 90_000);
afterAll(async () => { for (const worker of workers) await worker.stop(); await database?.cleanup(); });
beforeEach(async () => {
for (const work of workers) await work.stop();
companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Lifecycle test", issuePrefix: `L${companyId.slice(0, 7)}` });
});
function worker(runHost: (agent: LifecycleAgent) => Promise<"complete" | "pending"> = async () => "complete") {
const value = configureAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost });
workers.push(value); return value;
}
async function hire(status = "idle") { return createAgentLifecycle(db).requestHire(companyId, { name: "Agent", status, adapterType: "process" }); }
async function current(id: string) { return (await db.select().from(agents).where(eq(agents.id, id)))[0]!; }
it("rejects an outdated configuration snapshot under the write lock", async () => {
const agent = await hire();
const snapshot = await current(agent.id);
const expectedConfiguration = { adapterType: snapshot.adapterType, adapterConfig: snapshot.adapterConfig, runtimeConfig: snapshot.runtimeConfig, defaultEnvironmentId: snapshot.defaultEnvironmentId };
await createAgentLifecycle(db).updateConfiguration(agent.id, { runtimeConfig: { heartbeat: { intervalSec: 42 } } });
await expect(createAgentLifecycle(db).updateConfiguration(agent.id, { runtimeConfig: { aiConnection: {} } }, { expectedConfiguration })).rejects.toThrow("configuration changed while saving");
expect((await current(agent.id)).runtimeConfig).toEqual({ heartbeat: { intervalSec: 42 } });
});
it("maps existing states without starting setup or clearing pause reasons", async () => {
const rows = await db.insert(agents).values(["idle", "running", "error", "paused", "pending_approval", "terminated"].map(status => ({
companyId, name: status, status, pauseReason: status === "paused" ? "budget" : null,
}))).returning();
const migration = readFileSync(new URL("../../../../../packages/db/src/migrations/0320_daily_onslaught.sql", import.meta.url), "utf8");
await db.execute(sql.raw(migration.slice(migration.indexOf('UPDATE "agents"'))));
for (const row of rows) {
const mapped = await current(row.id);
expect(mapped.lifecycleState).toBe(["paused", "pending_approval", "terminated"].includes(row.status) ? row.status : "ready");
expect(mapped.lifecycleOperation).toBeNull();
expect(mapped.lifecycleRequiredPluginIds).toBeNull();
expect(mapped.lifecycleHolds).toEqual(row.status === "paused" ? ["budget"] : []);
}
});
it("records the human creator from options and ignores a creator in the insert data", async () => {
const lifecycle = createAgentLifecycle(db);
const created = await lifecycle.requestHire(companyId, { name: "Created by member", createdByUserId: "spoofed-user" }, { createdByUserId: "member-1" });
expect(created.createdByUserId).toBe("member-1");
expect((await current(created.id)).createdByUserId).toBe("member-1");
const system = await lifecycle.requestHire(companyId, { name: "System hire", createdByUserId: "spoofed-user" });
expect((await current(system.id)).createdByUserId).toBeNull();
});
it("backfills the creator from the earliest user creation activity in the same company", async () => {
const [byUser, byAgent, foreign] = await db.insert(agents).values(["By user", "By agent", "Foreign log"].map(name => ({ companyId, name }))).returning();
const otherCompanyId = randomUUID();
await db.insert(companies).values({ id: otherCompanyId, name: "Other", issuePrefix: `O${otherCompanyId.slice(0, 7)}` });
await db.insert(activityLog).values([
{ companyId, actorType: "user", actorId: "first-user", action: "agent.hire_created", entityType: "agent", entityId: byUser.id, createdAt: new Date("2026-01-01T00:00:00Z") },
{ companyId, actorType: "user", actorId: "later-user", action: "agent.created", entityType: "agent", entityId: byUser.id, createdAt: new Date("2026-01-02T00:00:00Z") },
{ companyId, actorType: "agent", actorId: randomUUID(), action: "agent.hire_created", entityType: "agent", entityId: byAgent.id },
{ companyId: otherCompanyId, actorType: "user", actorId: "foreign-user", action: "agent.created", entityType: "agent", entityId: foreign.id },
]);
const migration = readFileSync(new URL("../../../../../packages/db/src/migrations/0343_new_mentor.sql", import.meta.url), "utf8");
for (let pass = 0; pass < 2; pass++) {
for (const statement of migration.split("--> statement-breakpoint")) {
await db.execute(sql.raw(statement));
}
}
expect((await current(byUser.id)).createdByUserId).toBe("first-user");
expect((await current(byAgent.id)).createdByUserId).toBeNull();
expect((await current(foreign.id)).createdByUserId).toBeNull();
});
it("commits the record before preparation and verifies before admission", async () => {
const observed: string[] = [];
const work = worker(async agent => {
expect((await current(agent.id)).lifecycleState).toBe(agent.lifecycleState);
observed.push(agent.lifecycleState); return "complete";
});
const agent = await hire();
expect(agent).toMatchObject({ lifecycleState: "preparing", status: "paused" });
await work.process(agent.id);
expect(observed).toEqual(["preparing", "verifying"]);
expect(await current(agent.id)).toMatchObject({ lifecycleState: "ready", status: "idle" });
expect(agent).not.toHaveProperty("lifecycleOperation");
});
it("keeps early setup inactive and completes the same record without renaming it", async () => {
const runHost = vi.fn(async () => "complete" as const);
const work = worker(runHost);
const lifecycle = createAgentLifecycle(db);
const id = randomUUID();
const input = { id, name: "Early agent", adapterType: "process" };
const first = await lifecycle.requestHire(companyId, input, { setupOnly: true });
const retry = await lifecycle.requestHire(companyId, input, { setupOnly: true });
expect(first.id).toBe(id);
expect(retry.id).toBe(id);
await work.process(id);
expect(runHost).not.toHaveBeenCalled();
expect(await current(id)).toMatchObject({ status: "paused", lifecycleState: "paused", lifecycleHolds: ["setup"] });
expect(await db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, id))).toHaveLength(0);
await expect(agentConfiguration(db).createApiKey(id, "Incomplete setup")).rejects.toThrow("Finish agent setup");
await expect(lifecycle.resumeAgent(id)).rejects.toThrow("Finish agent setup");
const finished = await lifecycle.requestHire(companyId, { id, name: "Early agent", adapterType: "process", status: "idle" }, { setupAgentId: id });
expect(finished).toMatchObject({ id, name: "Early agent" });
await work.process(id);
expect(await current(id)).toMatchObject({ status: "idle", lifecycleState: "ready", lifecycleHolds: [] });
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(1);
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");
await store.change(id, "reconcile"); await work.process(id);
expect(await current(id)).toMatchObject({ lifecycleState: "verifying", lifecycleHolds: ["setup"] });
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");
await lifecycle.updateConfiguration(id, { defaultEnvironmentId: local.id });
expect((await current(id)).lifecycleState).toBe("pausing");
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("does not prepare agent-owned resources before required hire approval", async () => {
const pluginId = randomUUID(), environmentId = randomUUID();
await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId));
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "approval-computer-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: "Approval computer", driver: "plugin", status: "active",
config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } });
const runHost = vi.fn(async () => "complete" as const);
const work = worker(runHost);
const lifecycle = createAgentLifecycle(db);
const draft = await lifecycle.requestHire(companyId, { name: "Unapproved computer", adapterType: "paperclip_runner",
adapterConfig: { provider: "codex" }, defaultEnvironmentId: environmentId }, { setupOnly: true, runnerResolved: true });
await work.process(draft.id);
await lifecycle.updateConfiguration(draft.id, { adapterConfig: { provider: "codex", model: "changed" } });
await work.process(draft.id);
expect(await current(draft.id)).toMatchObject({ lifecycleState: "paused", lifecycleHolds: ["setup"] });
expect(runHost).not.toHaveBeenCalled();
});
it("freezes the final setup configuration for hire approval", async () => {
const lifecycle = createAgentLifecycle(db);
const id = randomUUID();
await lifecycle.requestHire(companyId, { id, name: "Approval setup", status: "paused", pauseReason: "setup", adapterType: "process" }, { setupOnly: true });
await lifecycle.requestHire(companyId, { id, name: "Approval setup", status: "pending_approval", adapterType: "process", adapterConfig: { command: "verified-command" } }, { setupAgentId: id });
expect(await current(id)).toMatchObject({ lifecycleState: "pending_approval", lifecycleHolds: [], adapterConfig: { command: "verified-command" } });
await expect(lifecycle.updateConfiguration(id, { adapterConfig: { command: "changed" } })).rejects.toThrow("cannot be changed before board approval");
});
it("rejects completing a foreign setup or adopting an ordinary paused agent", async () => {
const lifecycle = createAgentLifecycle(db);
const setup = await lifecycle.requestHire(companyId, { name: "Scoped setup", status: "paused", pauseReason: "setup" }, { setupOnly: true });
const otherCompanyId = randomUUID();
await db.insert(companies).values({ id: otherCompanyId, name: "Other", issuePrefix: `S${otherCompanyId.slice(0, 7)}` });
await expect(lifecycle.requestHire(otherCompanyId, { name: "Foreign" }, { setupAgentId: setup.id })).rejects.toThrow("no longer available");
const paused = await hire("paused");
await expect(lifecycle.requestHire(companyId, { name: "Paused" }, { setupAgentId: paused.id })).rejects.toThrow("no longer available");
expect((await current(setup.id)).lifecycleHolds).toEqual(["setup"]);
});
it("creates a board-owned setup before requesting approval for its final configuration", async () => {
await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId));
await db.insert(companyMemberships).values(["board", "other-creator"].map(principalId => ({ companyId, principalType: "user", principalId, membershipRole: "member" })));
await db.insert(principalPermissionGrants).values(["board", "other-creator"].map(principalId => ({ companyId, principalType: "user", principalId, permissionKey: "agents:create" })));
function app(actor: Express.Request["actor"]) {
const value = express(); value.use(express.json());
value.use((req, _res, next) => { req.actor = actor; next(); });
value.use("/api", agentRoutes(db)); value.use(errorHandler); return value;
}
const board: Express.Request["actor"] = { type: "board", source: "session", userId: "board", companyIds: [companyId],
memberships: [{ companyId, membershipRole: "member", status: "active" }] };
const setupId = randomUUID();
const body = { setupId, name: "Wizard agent", adapterType: "process" };
const url = `/api/companies/${companyId}/agents`;
const attempts = await Promise.all(Array.from({ length: 4 }, () => request(app(board)).post(url).send(body)));
for (const attempt of attempts) {
expect(attempt.status, JSON.stringify(attempt.body)).toBe(201);
expect(attempt.body).toMatchObject({ id: setupId, pauseReason: "setup", lifecycleState: "paused" });
}
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(1);
expect(await db.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "agent")))).toHaveLength(0);
await request(app({ ...board, userId: "other-creator" })).post(url).send(body).expect(404);
const other = await createAgentLifecycle(db).requestHire(companyId, {
name: "Other creator", adapterType: "process", status: "paused", adapterConfig: { command: "private-command" },
runtimeConfig: { privateSetting: "private-value" },
}, { createdByUserId: "other-creator" });
const configProbe = await request(app(board)).post(url).send({ ...body, setupId: other.id });
expect(configProbe.status).toBe(404);
expect(configProbe.body).not.toHaveProperty("adapterConfig");
expect(configProbe.body).not.toHaveProperty("runtimeConfig");
expect(await db.select().from(approvals).where(eq(approvals.companyId, companyId))).toHaveLength(0);
const saved = await request(app(board)).patch(`/api/agents/${setupId}`).send({ adapterConfig: { command: "draft-command", env: { SETUP_LABEL: { type: "plain", value: "saved-value" } } } });
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
const probe = vi.spyOn(requireServerAdapter("process"), "testEnvironment").mockResolvedValue({ adapterType: "process", status: "pass", checks: [], testedAt: new Date().toISOString() });
try {
const probeBody = { agentId: setupId, adapterConfig: saved.body.adapterConfig };
const testUrl = `/api/companies/${companyId}/adapters/process/test-environment`;
const tested = await request(app(board)).post(testUrl).send(probeBody);
expect(tested.status, JSON.stringify(tested.body)).toBe(200);
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ config: expect.objectContaining({ env: { SETUP_LABEL: "saved-value" } }) }));
await request(app({ ...board, userId: "other-creator" })).post(testUrl).send(probeBody).expect(403);
expect(probe).toHaveBeenCalledTimes(1);
} finally { probe.mockRestore(); }
await request(app({ ...board, userId: "other-creator" })).patch(`/api/agents/${setupId}`).send({ adapterConfig: { command: "foreign-command" } }).expect(403);
const denied = await request(app({ ...board, companyIds: [], memberships: [] })).post(url).send({ ...body, setupId: randomUUID() });
expect([403, 404]).toContain(denied.status);
await request(app({ type: "agent", source: "agent_key", companyId, agentId: setupId })).post(url).send({ ...body, setupId: randomUUID() }).expect(403);
const finished = await request(app(board)).post(`/api/companies/${companyId}/agent-hires`).send({
setupAgentId: setupId, name: "Wizard agent", adapterType: "process", adapterConfig: { command: "verified-command", env: { SETUP_LABEL: { type: "plain", value: "***REDACTED***" } } },
});
expect(finished.status, JSON.stringify(finished.body)).toBe(201);
expect(finished.body.agent).toMatchObject({ id: setupId, name: "Wizard agent", lifecycleState: "pending_approval" });
expect(finished.body.approval.payload).toMatchObject({ agentId: setupId, adapterConfig: { command: "verified-command" } });
expect((await current(setupId)).adapterConfig.env).toEqual({ SETUP_LABEL: { type: "plain", value: "saved-value" } });
expect(await db.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, setupId)))).toHaveLength(1);
const finishBody = { setupAgentId: setupId, name: "Changed on retry", adapterType: "process", adapterConfig: { command: "unapproved-command" } };
const hireUrl = `/api/companies/${companyId}/agent-hires`;
const retries = await Promise.all(Array.from({ length: 3 }, () => request(app(board)).post(hireUrl).send(finishBody)));
for (const retry of retries) {
expect(retry.status, JSON.stringify(retry.body)).toBe(200);
expect(retry.body.agent).toMatchObject({ id: setupId, name: "Wizard agent", adapterConfig: { command: "verified-command" } });
expect(retry.body.approval.id).toBe(finished.body.approval.id);
}
await request(app({ ...board, userId: "other-creator" })).post(hireUrl).send(finishBody).expect(403);
const reopened = await request(app(board)).post(url).send(body);
expect(reopened.status).toBe(201);
expect(reopened.body).toMatchObject({ id: setupId, status: "pending_approval" });
await request(app({ ...board, userId: "other-creator" })).post(url).send(body).expect(404);
await db.delete(approvals).where(eq(approvals.id, finished.body.approval.id));
const recovered = await request(app(board)).post(hireUrl).send(finishBody);
expect(recovered.status, JSON.stringify(recovered.body)).toBe(200);
expect(recovered.body.approval.payload).toMatchObject({ name: "Wizard agent", adapterConfig: { command: "verified-command" } });
await approvalService(db).approve(recovered.body.approval.id, "board");
const approvedRetry = await request(app(board)).post(hireUrl).send(finishBody);
expect(approvedRetry.status).toBe(200);
expect(approvedRetry.body.approval.status).toBe("approved");
expect(await db.select().from(approvals).where(eq(approvals.companyId, companyId))).toHaveLength(1);
});
it("restores hidden setup values when a native Codex record uses the Codex onboarding choice", async () => {
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "board", membershipRole: "owner" });
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "board", companyIds: [companyId] }; next(); });
app.use("/api", agentRoutes(db)); app.use(errorHandler);
const setupId = randomUUID();
const created = await request(app).post(`/api/companies/${companyId}/agents`).send({ setupId, name: "Native saved setup",
adapterType: "paperclip_runner", adapterConfig: { provider: "codex", env: { SETUP_LABEL: { type: "plain", value: "saved-native-value" } } },
});
expect(created.status, JSON.stringify(created.body)).toBe(201);
expect(created.body.adapterConfig.env.SETUP_LABEL).toEqual({ type: "plain", value: "***REDACTED***" });
const probe = vi.spyOn(requireServerAdapter("paperclip_runner"), "testEnvironment").mockResolvedValue({ adapterType: "paperclip_runner", status: "pass", checks: [], testedAt: new Date().toISOString() });
try {
const tested = await request(app).post(`/api/companies/${companyId}/adapters/codex_local/test-environment`).send({
agentId: setupId, runner: "paperclip", adapterConfig: created.body.adapterConfig,
});
expect(tested.status, JSON.stringify(tested.body)).toBe(200);
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ config: expect.objectContaining({ env: { SETUP_LABEL: "saved-native-value" } }) }));
} finally { probe.mockRestore(); }
const finished = await request(app).post(`/api/companies/${companyId}/agent-hires`).send({ setupAgentId: setupId, name: "Native saved setup",
adapterType: "codex_local", runner: "paperclip", adapterConfig: created.body.adapterConfig,
});
expect(finished.status, JSON.stringify(finished.body)).toBe(201);
expect((await current(setupId)).adapterConfig.env).toEqual({ SETUP_LABEL: { type: "plain", value: "saved-native-value" } });
});
it("holds an incomplete Copilot setup but still requires a model at final hire", async () => {
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "board", membershipRole: "owner" });
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "board", companyIds: [companyId] }; next(); });
app.use("/api", agentRoutes(db)); app.use(errorHandler);
const setupId = randomUUID();
const body = { name: "Copilot setup", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "copilot" } };
const created = await request(app).post(`/api/companies/${companyId}/agents`).send({ ...body, setupId });
expect(created.status, JSON.stringify(created.body)).toBe(201);
expect(created.body).toMatchObject({ id: setupId, pauseReason: "setup", lifecycleState: "paused" });
const finished = await request(app).post(`/api/companies/${companyId}/agent-hires`).send({ ...body, setupAgentId: setupId });
expect(finished.status, JSON.stringify(finished.body)).toBe(422);
expect(finished.body.details?.code ?? finished.body.code).toBe("paperclip_runner_acpx_model_required");
expect((await current(setupId)).lifecycleHolds).toContain("setup");
await request(app).post(`/api/companies/${companyId}/agents`).send(body).expect(422);
});
it("lets a held name-only record choose Copilot before connecting an account", async () => {
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "board", membershipRole: "owner" });
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "board", companyIds: [companyId] }; next(); });
app.use("/api", agentRoutes(db)); app.use(errorHandler);
const setupId = randomUUID();
await request(app).post(`/api/companies/${companyId}/agents`).send({ setupId, name: "Name-only Copilot", adapterType: "process", metadata: { setupAdapterPending: true } }).expect(201);
const config = { provider: "acpx", acpxAgent: "copilot" };
const saved = await request(app).patch(`/api/agents/${setupId}`).send({ adapterType: "paperclip_runner", adapterConfig: config, metadata: { setupAdapterPending: false } });
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
expect(saved.body).toMatchObject({ id: setupId, pauseReason: "setup", adapterConfig: config });
expect(saved.body.runtimeConfig.aiConnection).toBeUndefined();
const finished = await request(app).post(`/api/companies/${companyId}/agent-hires`).send({ setupAgentId: setupId, name: "Name-only Copilot", adapterType: "paperclip_runner", adapterConfig: { ...config, model: "gpt-5.6-luna" } });
expect(finished.status, JSON.stringify(finished.body)).toBe(422);
expect(finished.body.error).toMatch(/connection|account/i);
expect((await current(setupId)).lifecycleHolds).toContain("setup");
});
it("rolls back a hire and its grants when hire initialization fails", async () => {
const effects = createAgentLifecycleEffects();
const lifecycle = createLifecycleCommands(db, { ...effects,
initializeHire: async (tx, agent, options) => {
await effects.initializeHire(tx, agent, options);
throw new Error("Hire initialization failed");
},
});
await expect(lifecycle.requestHire(companyId, { name: "Rollback hire", adapterType: "process" }))
.rejects.toThrow("Hire initialization failed");
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(0);
expect(await db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId))).toHaveLength(0);
});
it("runs the saved harness test and keeps a failed configuration out of ready", async () => {
const driver = createLifecycleDriver(db, {} as never);
const work = worker(driver.runHost);
const agent = await hire();
await work.process(agent.id);
expect(await current(agent.id)).toMatchObject({ lifecycleState: "verifying", status: "paused" });
await agentConfiguration(db).update(agent.id, { adapterConfig: { command: process.execPath } });
await work.process(agent.id);
expect(await current(agent.id)).toMatchObject({ lifecycleState: "ready", status: "idle", lifecycleError: null });
});
it.each(["claude_local", "gemini_local"])("keeps %s authentication warnings out of ready", async adapterType => {
const adapter = requireServerAdapter(adapterType);
const probe = vi.spyOn(adapter, "testEnvironment").mockResolvedValue({ adapterType, status: "warn",
testedAt: new Date().toISOString(), checks: [{ code: `${adapterType.split("_")[0]}_hello_probe_auth_required`, level: "warn", message: "Login required" }] });
try {
const agent = await createAgentLifecycle(db).requestHire(companyId, { name: "Login test", adapterType, runner: "legacy" });
const work = worker(createLifecycleDriver(db, {} as never).runHost);
await work.process(agent.id);
expect(probe).toHaveBeenCalled();
expect(await current(agent.id)).toMatchObject({ lifecycleState: "verifying", lifecycleError: expect.stringContaining("Execution configuration check failed. Authentication failed.") });
} finally { probe.mockRestore(); }
});
it("recovers an expired native owner only after the cancelled run and controller stop", async () => {
const agent = await hire();
const [issue] = await db.insert(issues).values({ companyId, title: "Cancelled native run" }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id, nativeIssueId: issue.id,
status: "cancelled", invocationSource: "on_demand", runtimeMode: "native",
startedAt: new Date(), finishedAt: new Date(), processPid: process.pid }).returning();
await db.insert(nativeRunFinalizations).values({ companyId, issueId: issue.id, runId: run.id,
phase: "executing", leaseOwner: "stopped-server", leaseExpiresAt: new Date(0), controllerPid: process.pid });
expect(await agentExecutionsHaveStopped(db, [agent.id])).toBe(false);
await db.update(nativeRunFinalizations).set({ controllerPid: null }).where(eq(nativeRunFinalizations.runId, run.id));
expect(await agentExecutionsHaveStopped(db, [agent.id])).toBe(false);
await db.update(heartbeatRuns).set({ processPid: 2_000_000_000 }).where(eq(heartbeatRuns.id, run.id));
await db.update(nativeRunFinalizations).set({ leaseExpiresAt: new Date(Date.now() + 60_000) }).where(eq(nativeRunFinalizations.runId, run.id));
expect(await agentExecutionsHaveStopped(db, [agent.id])).toBe(false);
await db.update(nativeRunFinalizations).set({ leaseExpiresAt: new Date(0) }).where(eq(nativeRunFinalizations.runId, run.id));
expect(await agentExecutionsHaveStopped(db, [agent.id])).toBe(true);
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, run.id)))[0]).toMatchObject({ leaseOwner: null, leaseExpiresAt: null });
});
it("does not advance termination while a cancelled process still runs", async () => {
const agent = await hire();
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id,
status: "cancelled", invocationSource: "on_demand", runtimeMode: "legacy",
startedAt: new Date(), finishedAt: new Date(), processPid: process.pid }).returning();
const driver = createLifecycleDriver(db, {} as never);
const work = worker(driver.runHost);
await createAgentLifecycle(db).terminateAgent(agent.id);
await work.process(agent.id);
expect((await current(agent.id)).lifecycleState).toBe("terminating");
await db.update(heartbeatRuns).set({ processPid: null,
resultJson: { executionCancellation: { state: "acknowledged" } } }).where(eq(heartbeatRuns.id, run.id));
await createAgentLifecycle(db).retry(agent.id);
await work.process(agent.id);
expect((await current(agent.id)).lifecycleState).toBe("terminated");
});
it("does not cancel a reporting agent when its manager pauses", async () => {
const parent = await hire();
const [child] = await db.insert(agents).values({ companyId, name: "Report", reportsTo: parent.id,
status: "idle", lifecycleState: "ready" }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: child.id,
status: "queued", invocationSource: "on_demand" }).returning();
const work = worker(createLifecycleDriver(db, {} as never).runHost);
await createAgentLifecycle(db).pauseAgent(parent.id);
await work.process(parent.id);
expect((await current(parent.id)).lifecycleState).toBe("paused");
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]!.status).toBe("queued");
});
it("retains company data until every agent finishes termination", async () => {
const agent = await hire();
const [project] = await db.insert(projects).values({ companyId, name: "Keep until cleanup", leadAgentId: agent.id }).returning();
await db.insert(issues).values({ companyId, projectId: project.id, title: "Keep this task", assigneeAgentId: agent.id });
const pending = worker(async () => "pending");
await expect(deleteCompany(db, companyId)).rejects.toThrow("Complete termination");
expect(await db.select().from(projects).where(eq(projects.id, project.id))).toHaveLength(1);
expect(await db.select().from(issues).where(eq(issues.companyId, companyId))).toHaveLength(1);
await pending.stop();
const complete = worker();
await createAgentLifecycle(db).retry(agent.id);
await complete.process(agent.id);
await deleteCompany(db, companyId);
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(0);
expect(await db.select().from(companies).where(eq(companies.id, companyId))).toHaveLength(0);
});
it("awaits only the deleted company's agents when a coordinator owns lifecycle wakeups", async () => {
const agent = await hire();
const [otherCompany] = await db.insert(companies).values({ name: "Unaffected", issuePrefix: `U${companyId.slice(0, 7)}` }).returning();
const [otherAgent] = await db.insert(agents).values({ companyId: otherCompany.id, name: "Pending elsewhere",
lifecycleState: "preparing", lifecycleOperation: { id: randomUUID(), hostComplete: false, completedPluginIds: [], attempts: 0 } }).returning();
const phases: string[] = [];
const work = startAgentLifecycle(db, { requiredPluginIds: async () => [], runPlugin: async () => "complete",
runHost: async snapshot => { expect(snapshot.id).toBe(agent.id); phases.push(snapshot.lifecycleState); return "complete"; } }, () => true);
const wake = vi.fn();
work.setWake(wake);
try {
await deleteCompany(db, companyId);
expect(phases).toEqual(["terminating", "cleaning_up"]);
expect(wake).toHaveBeenCalled();
expect(await db.select().from(companies).where(eq(companies.id, companyId))).toHaveLength(0);
expect((await current(otherAgent.id)).lifecycleState).toBe("preparing");
} finally { await work.stop(); }
});
it("requires a transaction for the company deletion integration", async () => {
await expect(agentLifecycleCompanyDeletion.deleteCompanyData(db as never, companyId))
.rejects.toThrow("Company deletion requires a database transaction");
});
it("rejects individual deletion before cleanup and rolls back failed cleanup", async () => {
const agent = await hire();
const effects = createAgentLifecycleEffects();
const cleanup = vi.fn(effects.deleteDependencies);
const lifecycle = createLifecycleCommands(db, { ...effects, deleteDependencies: cleanup });
await expect(lifecycle.purgeAgent(agent.id)).rejects.toThrow("Complete termination");
expect(cleanup).not.toHaveBeenCalled();
await db.update(agents).set({ lifecycleState: "terminated", status: "terminated" }).where(eq(agents.id, agent.id));
const [key] = await db.insert(agentApiKeys).values({ companyId, agentId: agent.id, name: "Keep on rollback", keyHash: randomUUID() }).returning();
cleanup.mockImplementation(async (tx, companyId, id) => {
await effects.deleteDependencies(tx, companyId, id);
throw new Error("Dependency cleanup failed");
});
await expect(lifecycle.purgeAgent(agent.id)).rejects.toThrow("Dependency cleanup failed");
expect(await current(agent.id)).toMatchObject({ lifecycleState: "terminated" });
expect(await db.select().from(agentApiKeys).where(eq(agentApiKeys.id, key.id))).toHaveLength(1);
});
it("deletes terminated and rejected agents only in the requested company", async () => {
await db.insert(agents).values((["terminated", "rejected"] as const).map(lifecycleState => ({
companyId, name: lifecycleState, status: "terminated", lifecycleState,
})));
const [otherCompany] = await db.insert(companies).values({ name: "Keep this company", issuePrefix: `K${companyId.slice(0, 7)}` }).returning();
const [otherAgent] = await db.insert(agents).values({ companyId: otherCompany.id, name: "Keep this agent",
status: "terminated", lifecycleState: "terminated" }).returning();
await deleteCompany(db, companyId);
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(0);
expect(await current(otherAgent.id)).toMatchObject({ companyId: otherCompany.id });
expect(await db.select().from(companies).where(eq(companies.id, otherCompany.id))).toHaveLength(1);
});
it("rolls back agent and company data deletion when a later operation fails", async () => {
const agent = await hire("terminated");
const [project] = await db.insert(projects).values({ companyId, name: "Keep on rollback" }).returning();
const removeAgents = agentLifecycleCompanyDeletion.deleteCompanyData;
const failure = vi.spyOn(agentLifecycleCompanyDeletion, "deleteCompanyData").mockImplementation(async (tx, id) => {
await removeAgents(tx, id);
expect(await tx.select().from(agents).where(eq(agents.companyId, id))).toHaveLength(0);
throw new Error("Later deletion failed");
});
try {
await expect(deleteCompany(db, companyId)).rejects.toThrow("Later deletion failed");
expect(await current(agent.id)).toMatchObject({ lifecycleState: "terminated" });
expect(await db.select().from(projects).where(eq(projects.id, project.id))).toHaveLength(1);
expect(await db.select().from(companies).where(eq(companies.id, companyId))).toHaveLength(1);
} finally { failure.mockRestore(); }
});
it("blocks a concurrent hire until company deletion commits", async () => {
let reportLock!: (pid: number) => void;
let rejectLock!: (error: unknown) => void;
const locked = new Promise<number>((resolve, reject) => { reportLock = resolve; rejectLock = reject; });
let release!: () => void;
const released = new Promise<void>(resolve => { release = resolve; });
const removeAgents = agentLifecycleCompanyDeletion.deleteCompanyData;
const pause = vi.spyOn(agentLifecycleCompanyDeletion, "deleteCompanyData").mockImplementation(async (tx, id) => {
const [backend] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`);
reportLock(backend!.pid);
await released;
await removeAgents(tx, id);
});
const deletion = deleteCompany(db, companyId);
void deletion.catch(rejectLock);
let hiring: Promise<unknown> | undefined;
try {
const pid = await locked;
hiring = hire().then(() => "created", error => error.message);
await vi.waitFor(async () => {
const blocked = await db.execute(sql`select 1 from pg_stat_activity
where datname = current_database() and ${pid} = any(pg_blocking_pids(pid))`);
expect(blocked).toHaveLength(1);
});
release();
await deletion;
expect(await hiring).toBe("Company not found");
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(0);
} finally {
release();
await Promise.allSettled([deletion, hiring]);
pause.mockRestore();
}
});
it("requires cleanup proof after a remote run is already cancelled", async () => {
const agent = await hire();
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id,
status: "cancelled", invocationSource: "on_demand", runtimeMode: "native",
startedAt: new Date(), finishedAt: new Date() }).returning();
const [lease] = await db.insert(environmentLeases).values({ companyId, heartbeatRunId: run.id,
provider: "test", providerLeaseId: "test-lease", status: "pending_cleanup",
cleanupStatus: "failed", releasedAt: new Date() }).returning();
expect(await agentExecutionsHaveStopped(db, [agent.id])).toBe(false);
await db.update(environmentLeases).set({ status: "released", cleanupStatus: "success" })
.where(eq(environmentLeases.id, lease.id));
expect(await agentExecutionsHaveStopped(db, [agent.id])).toBe(false);
await db.update(environmentLeases).set({ metadata: {
remoteExecutionTermination: remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: "destroyed" }),
} }).where(eq(environmentLeases.id, lease.id));
expect(await agentExecutionsHaveStopped(db, [agent.id])).toBe(true);
});
it("does not prepare a proposed hire until approval", async () => {
const observed: string[] = [];
const work = worker(async agent => { observed.push(agent.lifecycleState); return "complete"; });
const agent = await hire("pending_approval");
await work.process(agent.id);
expect(observed).toEqual([]);
const approval = await approvalService(db).create(companyId, { type: "hire_agent", payload: { agentId: agent.id }, status: "pending" });
await approvalService(db).approve(approval.id, "board");
await vi.waitFor(async () => expect((await current(agent.id)).lifecycleState).toBe("ready"));
expect(observed).toEqual(["preparing", "verifying"]);
});
it.each(["approved", "rejected"] as const)("uses the same %s hire decision for an agent and its approval", async decision => {
const agent = await hire("pending_approval");
const approval = await approvalService(db).create(companyId, { type: "hire_agent",
requestedByUserId: "requester", payload: { agentId: agent.id }, status: "pending" });
const lifecycle = createAgentLifecycle(db);
const decide = decision === "approved" ? lifecycle.approveHire : lifecycle.rejectHire;
const first = await decide(agent.id, "board");
expect(first).toMatchObject({ applied: true, approval: { id: approval.id, status: decision },
agent: { lifecycleState: decision === "approved" ? "preparing" : "rejected" } });
const version = (await current(agent.id)).lifecycleVersion;
expect(await decide({ approvalId: approval.id }, "board")).toMatchObject({ applied: false, hireApprovedAgentId: null });
expect((await current(agent.id)).lifecycleVersion).toBe(version);
});
it("rolls back the approval and agent together when activation fails", async () => {
const agent = await hire("pending_approval");
const approval = await approvalService(db).create(companyId, { type: "hire_agent",
payload: { agentId: agent.id }, status: "pending" });
const lifecycle = createLifecycleCommands(db, { ...createAgentLifecycleEffects(),
recordCreation: async () => { throw new Error("Creation event failed"); } });
await expect(lifecycle.approveHire(agent.id, "board")).rejects.toThrow("Creation event failed");
expect((await current(agent.id)).lifecycleState).toBe("pending_approval");
expect((await db.select().from(approvals).where(eq(approvals.id, approval.id)))[0].status).toBe("pending");
});
it("keeps the hire credential owner and recovers it for migrated approvals", async () => {
for (const savedOwner of [null, "original-owner"]) {
const [agent] = await db.insert(agents).values({ companyId, name: "Migrated hire", status: "pending_approval",
lifecycleState: "pending_approval", lifecycleOperation: savedOwner ? {
id: randomUUID(), hostComplete: false, completedPluginIds: [], attempts: 0, responsibleUserId: savedOwner,
} : null }).returning();
const approval = await approvalService(db).create(companyId, { type: "hire_agent",
requestedByUserId: "requesting-member", payload: { agentId: agent.id }, status: "pending" });
await approvalService(db).approve(approval.id, "approving-member");
expect((await current(agent.id)).lifecycleOperation?.responsibleUserId).toBe(savedOwner ?? "requesting-member");
}
});
it("rejects transaction injection and generic state writes", async () => {
const agent = await hire("pending_approval");
await expect(db.transaction(async tx => createAgentLifecycle(tx as unknown as Db))).rejects.toThrow("root database");
await expect(agentConfiguration(db).update(agent.id, { status: "idle" } as never)).rejects.toThrow("lifecycle command");
await expect(createAgentLifecycle(db).requestHire(companyId, { name: "Bypass", lifecycleRequiredPluginIds: [] } as never)).rejects.toThrow("Lifecycle fields");
});
it("clears a rejected hire from the user's primary selection", async () => {
const agent = await createAgentLifecycle(db).requestHire(companyId, { name: "Proposed", status: "pending_approval" }, { createdByUserId: "board" });
const preference = () => db.select().from(userCompanyPreferences).where(eq(userCompanyPreferences.companyId, companyId));
expect((await preference())[0]!.primaryAgentId).toBe(agent.id);
const approval = await approvalService(db).create(companyId, { type: "hire_agent", payload: { agentId: agent.id }, status: "pending" });
await approvalService(db).reject(approval.id, "board");
expect((await preference())[0]!.primaryAgentId).toBeNull();
expect((await current(agent.id)).lifecycleState).toBe("rejected");
});
it("rolls back configuration and its revision when the requested transition fails", async () => {
const lifecycle = createAgentLifecycle(db);
const agent = await hire("terminated");
await expect(lifecycle.updateAndTransition(agent.id, "resume", { name: "Must not persist" },
{ recordRevision: { createdByUserId: "board", source: "patch" } })).rejects.toThrow("Cannot resume");
expect((await current(agent.id)).name).toBe("Agent");
expect(await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agent.id))).toEqual([]);
const active = await hire();
expect(await lifecycle.updateAndTransition(active.id, "pause", { name: "Paused agent" })).toMatchObject({ name: "Paused agent", lifecycleState: "pausing" });
});
it("preserves a disabled positive budget at approval and applies explicit configuration changes", async () => {
const agent = await hire("pending_approval");
const budgets = budgetService(db);
await budgets.upsertPolicy(companyId, { scopeType: "agent", scopeId: agent.id, amount: 1000, isActive: false }, null);
const approval = await approvalService(db).create(companyId, { type: "hire_agent",
payload: { agentId: agent.id, budgetMonthlyCents: 1000 }, status: "pending" });
const policy = async () => (await db.select().from(budgetPolicies).where(eq(budgetPolicies.scopeId, agent.id)))[0]!;
await approvalService(db).approve(approval.id, "board");
expect(await policy()).toMatchObject({ amount: 1000, isActive: false });
await agentConfiguration(db).update(agent.id, { budgetMonthlyCents: 2000 });
expect(await policy()).toMatchObject({ amount: 2000, isActive: true });
await agentConfiguration(db).update(agent.id, { budgetMonthlyCents: 0 });
expect(await policy()).toMatchObject({ amount: 0, isActive: false });
});
it.each(["updateConfiguration", "updateAndTransition"] as const)("limits %s budget reconciliation to the edited agent", async command => {
const lifecycle = createAgentLifecycle(db);
const one = await hire();
const two = await lifecycle.requestHire(companyId, { name: "Other budget target" });
await db.update(companies).set({ status: "paused" }).where(eq(companies.id, companyId));
if (command === "updateConfiguration") await lifecycle.updateConfiguration(one.id, { budgetMonthlyCents: 1000 });
else await lifecycle.updateAndTransition(one.id, "pause", { budgetMonthlyCents: 1000 });
// Company state blocks admission; its inherited holds project separately.
expect((await current(one.id)).lifecycleHolds).not.toContain("company_paused");
expect((await current(two.id)).lifecycleHolds).toEqual([]);
});
it("blocks invocation without synchronously projecting inherited company holds", async () => {
const one = await hire();
const two = await createAgentLifecycle(db).requestHire(companyId, { name: "Another agent" });
await db.update(companies).set({ status: "paused" }).where(eq(companies.id, companyId));
expect(await budgetService(db).getInvocationBlock(companyId, one.id)).not.toBeNull();
expect((await current(one.id)).lifecycleHolds).toEqual([]);
expect((await current(two.id)).lifecycleHolds).toEqual([]);
});
it("keeps a manual hold when company policy changes", async () => {
const work = worker(); const lifecycle = createAgentLifecycle(db); const agent = await hire(); await work.process(agent.id);
await lifecycle.pauseAgent(agent.id); await work.process(agent.id);
await db.update(companies).set({ status: "archived" }).where(eq(companies.id, companyId));
await projectCompanyHolds(db, createAgentLifecycleEffects(), companyId); await work.process(agent.id);
await db.update(companies).set({ status: "active" }).where(eq(companies.id, companyId));
await projectCompanyHolds(db, createAgentLifecycleEffects(), companyId); await work.process(agent.id);
expect(await current(agent.id)).toMatchObject({ lifecycleState: "paused", lifecycleHolds: ["manual"] });
await lifecycle.resumeAgent(agent.id); await work.process(agent.id);
expect((await current(agent.id)).lifecycleState).toBe("ready");
});
it("defers automatic policy changes while a task holds the agent lock", async () => {
const agent = await hire("paused");
await db.transaction(async tx => {
await tx.select().from(agents).where(eq(agents.id, agent.id)).for("update");
expect(await createLifecycleStore(db).change(agent.id, "reconcile")).toBeNull();
});
expect(await createLifecycleStore(db).change(agent.id, "reconcile")).not.toBeNull();
});
it("retains the failed phase and the same operation for retry", async () => {
let fail = true;
const work = worker(async agent => { if (agent.lifecycleState === "verifying" && fail) throw new Error("private detail"); return "complete"; });
const agent = await hire(); await work.process(agent.id);
const failed = await current(agent.id);
expect(failed.lifecycleState).toBe("verifying");
expect(failed.lifecycleError).not.toContain("private detail");
fail = false;
await createAgentLifecycle(db).retry(agent.id); await work.process(agent.id);
expect((await current(agent.id)).lifecycleState).toBe("ready");
});
it("fences a late completion when termination replaces preparation", async () => {
const agent = await hire("paused");
const store = createLifecycleStore(db);
await createAgentLifecycle(db).resumeAgent(agent.id);
const claimed = await store.claim(agent.id, "old", new Date());
expect(claimed).not.toBeNull();
await store.setRequiredPlugins(claimed!, "old", []);
await createAgentLifecycle(db).terminateAgent(agent.id);
expect(await store.completeHost(claimed!, "old")).toBe(false);
expect(await store.change(agent.id, "complete", { owner: "old", version: claimed!.lifecycleVersion })).toBeNull();
expect((await current(agent.id)).lifecycleState).toBe("terminating");
});
it("revokes keys at termination and waits for cleanup before deletion", async () => {
let pending = true;
const phases: string[] = [];
const work = worker(async agent => { phases.push(agent.lifecycleState); return agent.lifecycleState === "cleaning_up" && pending ? "pending" : "complete"; });
const lifecycle = createAgentLifecycle(db); const agent = await hire(); await work.process(agent.id);
await db.insert(agentApiKeys).values({ agentId: agent.id, companyId, name: "Test", keyHash: randomUUID() });
await lifecycle.terminateAgent(agent.id); await work.process(agent.id);
expect((await current(agent.id)).lifecycleState).toBe("cleaning_up");
expect((await db.select().from(agentApiKeys).where(eq(agentApiKeys.agentId, agent.id)))[0].revokedAt).not.toBeNull();
await expect(agentConfiguration(db).remove(agent.id)).rejects.toThrow("Complete termination");
pending = false; await lifecycle.retry(agent.id); await work.process(agent.id);
expect(phases).toContain("terminating");
expect((await current(agent.id)).lifecycleState).toBe("terminated");
await agentConfiguration(db).remove(agent.id);
expect(await current(agent.id)).toBeUndefined();
});
it("retains required plugin IDs and checks the operation and company on each reply", async () => {
const agent = await hire("paused");
const pluginId = randomUUID();
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "lifecycle-test", version: "1.0.0", status: "ready",
manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage"] } as never });
const call = vi.fn(async (_id, _method, input) => ({ operationId: input.operationId, version: input.version, status: "complete" }));
const driver = createLifecycleDriver(db, { call } as never);
const snapshot = (await createLifecycleStore(db).get(agent.id))!;
expect(await driver.requiredPluginIds(snapshot)).toContain(pluginId);
await db.insert(pluginCompanySettings).values({ companyId, pluginId, enabled: false });
expect(await driver.requiredPluginIds(snapshot)).not.toContain(pluginId);
expect(await driver.requiredPluginIds({ ...snapshot, lifecycleRequiredPluginIds: [pluginId] })).toContain(pluginId);
await expect(driver.runPlugin(snapshot, pluginId)).rejects.toThrow("unavailable");
await db.update(pluginCompanySettings).set({ enabled: true }).where(eq(pluginCompanySettings.pluginId, pluginId));
expect(await driver.runPlugin(snapshot, pluginId)).toBe("complete");
expect(call).toHaveBeenCalledWith(pluginId, "agentLifecycle", { companyId, agentId: agent.id,
operationId: snapshot.lifecycleOperation!.id, version: snapshot.lifecycleVersion, phase: "paused", instanceSecrets: {} }, 30_000);
call.mockResolvedValueOnce({ operationId: "stale", version: snapshot.lifecycleVersion, status: "complete" });
await expect(driver.runPlugin(snapshot, pluginId)).rejects.toThrow("Invalid lifecycle result");
});
it("drives required resource work through setup, pause, resume, and confirmed cleanup", async () => {
const pluginId = randomUUID();
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "resource-lifecycle-test", version: "1.0.0", status: "ready",
manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage"] } as never });
let resourcesReady = false;
let resourcesRemoved = false;
const requests: import("@paperclipai/shared").AgentLifecycleRequest[] = [];
const call = async (id: string, method: string, input: import("@paperclipai/shared").AgentLifecycleRequest) => {
expect(id).toBe(pluginId);
expect(method).toBe("agentLifecycle");
expect(input.companyId).toBe(companyId);
expect((await current(input.agentId)).lifecycleOperation?.hostComplete).toBe(true);
requests.push(input);
const pending = (input.phase === "preparing" && !resourcesReady) || (input.phase === "cleaning_up" && !resourcesRemoved);
return { operationId: input.operationId, version: input.version, status: pending ? "pending" : "complete" };
};
const driver = createLifecycleDriver(db, { call } as never);
// Provider/harness execution is stubbed; plugin dispatch and durable transitions are real.
const work = configureAgentLifecycle(db, { ...driver, requiredPluginIds: async () => [pluginId], runHost: async () => "complete" });
workers.push(work);
const lifecycle = createAgentLifecycle(db);
const agent = await hire();
await work.process(agent.id);
expect(await current(agent.id)).toMatchObject({ lifecycleState: "preparing", status: "paused", lifecycleError: null });
const preparation = requests[0];
resourcesReady = true;
await lifecycle.retry(agent.id); await work.process(agent.id);
expect(requests[1]).toEqual(preparation);
expect((await current(agent.id)).lifecycleState).toBe("ready");
await lifecycle.pauseAgent(agent.id); await work.process(agent.id);
expect((await current(agent.id)).lifecycleState).toBe("paused");
await lifecycle.resumeAgent(agent.id); await work.process(agent.id);
expect((await current(agent.id)).lifecycleState).toBe("ready");
await lifecycle.terminateAgent(agent.id); await work.process(agent.id);
expect((await current(agent.id)).lifecycleState).toBe("cleaning_up");
await expect(lifecycle.purgeAgent(agent.id)).rejects.toThrow("Complete termination");
resourcesRemoved = true;
await lifecycle.retry(agent.id); await work.process(agent.id);
expect((await current(agent.id)).lifecycleState).toBe("terminated");
expect(requests.map(request => request.phase)).toEqual([
"preparing", "preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up", "cleaning_up",
]);
expect(requests.every(request => request.agentId === agent.id)).toBe(true);
});
it("keeps host completion and required plugin results across a worker restart", async () => {
const pluginIds = [randomUUID(), randomUUID()];
const runHost = vi.fn(async () => "complete" as const);
const firstPlugin = vi.fn(async (_agent: LifecycleAgent, pluginId: string) =>
pluginId === pluginIds[0] ? "complete" as const : "pending" as const);
const first = configureAgentLifecycle(db, { requiredPluginIds: async () => pluginIds, runHost, runPlugin: firstPlugin });
workers.push(first);
const agent = await hire();
await first.process(agent.id);
expect(await current(agent.id)).toMatchObject({ lifecycleState: "preparing", lifecycleRequiredPluginIds: pluginIds,
lifecycleOperation: { hostComplete: true, completedPluginIds: [pluginIds[0]] } });
await first.stop();
const discover = vi.fn(async () => []);
const resumedPlugin = vi.fn(async (_agent: LifecycleAgent, _pluginId: string) => "complete" as const);
const resumed = configureAgentLifecycle(db, { requiredPluginIds: discover, runHost, runPlugin: resumedPlugin });
workers.push(resumed);
await createAgentLifecycle(db).retry(agent.id);
await resumed.process(agent.id);
expect((await current(agent.id)).lifecycleState).toBe("ready");
expect(discover).not.toHaveBeenCalled();
expect(runHost).toHaveBeenCalledTimes(2);
expect(resumedPlugin.mock.calls.map(call => call[1])).toEqual([pluginIds[1], ...pluginIds]);
});
it("requires host and plugin completion and freezes the selected plugin IDs", async () => {
const agent = await hire();
const store = createLifecycleStore(db);
const claimed = (await store.claim(agent.id, "test", new Date()))!;
const pluginId = randomUUID();
expect(await store.setRequiredPlugins(claimed, "test", [pluginId])).toBe(true);
expect(await store.setRequiredPlugins(claimed, "test", [])).toBe(false);
expect(await store.completePlugin(claimed, "test", randomUUID())).toBe(false);
const complete = () => store.change(agent.id, "complete", { owner: "test", version: claimed.lifecycleVersion });
expect(await complete()).toBeNull();
expect(await store.completePlugin(claimed, "test", pluginId)).toBe(true);
expect(await complete()).toBeNull();
expect(await store.completeHost(claimed, "test")).toBe(true);
expect(await complete()).toMatchObject({ lifecycleState: "verifying" });
expect(await store.completePlugin(claimed, "test", pluginId)).toBe(false);
});
it("fences verification when the saved configuration changes", async () => {
const lifecycle = createAgentLifecycle(db); const store = createLifecycleStore(db);
const agent = await hire();
const claimed = (await store.claim(agent.id, "configuration-test", new Date()))!;
await store.setRequiredPlugins(claimed, "configuration-test", []);
await agentConfiguration(db).update(agent.id, { adapterConfig: { command: "echo" } });
expect(await store.completeHost(claimed, "configuration-test")).toBe(false);
expect((await lifecycle.get(agent.id))!.lifecycleVersion).toBeGreaterThan(claimed.lifecycleVersion);
});
it("invalidates only changed execution configuration during setup", async () => {
const lifecycle = createAgentLifecycle(db);
const agent = await hire();
const before = await current(agent.id);
await lifecycle.updateConfiguration(agent.id, { name: "Profile only" });
await lifecycle.updateConfiguration(agent.id, { adapterConfig: before.adapterConfig });
expect((await current(agent.id)).lifecycleVersion).toBe(before.lifecycleVersion);
await lifecycle.updateConfiguration(agent.id, { runtimeConfig: { heartbeat: { enabled: false } } });
expect((await current(agent.id)).lifecycleVersion).toBe(before.lifecycleVersion + 1);
const work = worker();
await work.process(agent.id);
const ready = await current(agent.id);
expect(ready.lifecycleState).toBe("ready");
await lifecycle.updateConfiguration(agent.id, { adapterConfig: { command: "echo" } });
expect(await current(agent.id)).toMatchObject({ lifecycleState: "ready", lifecycleVersion: ready.lifecycleVersion });
});
it("rejects identity, accounting, and unknown fields through every configuration entry point", async () => {
const agent = await hire();
const lifecycle = createAgentLifecycle(db);
const before = await current(agent.id);
for (const patch of [{ id: randomUUID() }, { companyId: randomUUID() }, { spentMonthlyCents: 0 },
{ spendMonthUtc: "2099-01" }, { lastHeartbeatAt: new Date() }, { errorReason: null },
{ createdAt: new Date() }, { updatedAt: new Date() }, { lifecycleState: "ready" }, { futureColumn: true }]) {
await expect(lifecycle.updateConfiguration(agent.id, patch as never)).rejects.toThrow("Unsupported configuration field");
await expect(lifecycle.updateAndTransition(agent.id, "pause", patch as never)).rejects.toThrow("Unsupported configuration field");
await expect(db.transaction(tx => updateAgentConfigurationInTransaction(tx as unknown as Db, agent.id, patch as never)))
.rejects.toThrow("Unsupported configuration field");
}
expect(await current(agent.id)).toEqual(before);
const effects = createAgentLifecycleEffects();
const malformed = createLifecycleCommands(db, { ...effects, prepareConfiguration: async () => ({ id: randomUUID() }) as never });
await expect(malformed.updateConfiguration(agent.id, { name: "Changed" })).rejects.toThrow("Unsupported configuration field");
expect(await current(agent.id)).toEqual(before);
});
it("records only the changed configuration fields for a legacy appearance", async () => {
const [agent] = await db.insert(agents).values({ companyId, name: "Legacy", appearance: null }).returning();
await createAgentLifecycle(db).updateConfiguration(agent.id, { name: "Renamed" },
{ recordRevision: { source: "test", createdByUserId: "board" } });
const revisions = await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agent.id));
expect(revisions).toHaveLength(1);
expect(revisions[0].changedKeys).toEqual(["name"]);
expect(revisions[0].beforeConfig.appearance).toEqual(revisions[0].afterConfig.appearance);
});
it("accepts the unchanged resolved appearance of a pending hire", async () => {
const [agent] = await db.insert(agents).values({ companyId, name: "Pending legacy", status: "pending_approval", appearance: null }).returning();
const resolved = await agentConfiguration(db).getById(agent.id);
await expect(createAgentLifecycle(db).updateConfiguration(agent.id, { appearance: resolved!.appearance }))
.resolves.toMatchObject({ id: agent.id });
await expect(createAgentLifecycle(db).updateConfiguration(agent.id, { name: "Changed" }))
.rejects.toThrow("Pending approval agent configuration cannot be changed");
});
it("rolls back a root configuration command when dependent records fail", async () => {
const agent = await hire();
const before = await current(agent.id);
const effects = createAgentLifecycleEffects();
const lifecycle = createLifecycleCommands(db, { ...effects, completeConfiguration: async (...args) => {
await effects.completeConfiguration(...args);
throw new Error("Configuration completion failed");
} });
await expect(lifecycle.updateConfiguration(agent.id, { adapterConfig: { command: "echo" } },
{ recordRevision: { source: "test", createdByUserId: "board" } })).rejects.toThrow("Configuration completion failed");
expect(await current(agent.id)).toMatchObject({ adapterConfig: before.adapterConfig,
lifecycleVersion: before.lifecycleVersion, lifecycleOperation: before.lifecycleOperation });
expect(await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agent.id))).toHaveLength(0);
});
it("rolls back configuration and verification invalidation in the binding transaction", async () => {
const agent = await hire();
const before = await current(agent.id);
await expect(updateAgentConfigurationInTransaction(db, agent.id, { name: "Uncommitted" }))
.rejects.toThrow("requires a database transaction");
expect(() => updateAgentConfigurationInTransaction(db, agent.id, { budgetMonthlyCents: 100 } as never))
.toThrow("Budget changes require a root lifecycle command");
await expect(db.transaction(async tx => agentConfiguration(tx as unknown as Db).update(agent.id, { name: "Uncommitted" })))
.rejects.toThrow("root database");
await expect(db.transaction(async tx => {
await updateAgentConfigurationInTransaction(tx as unknown as Db, agent.id, { adapterConfig: { command: "echo" } });
expect((await tx.select().from(agents).where(eq(agents.id, agent.id)))[0].lifecycleVersion).toBe(before.lifecycleVersion + 1);
throw new Error("Binding change failed");
})).rejects.toThrow("Binding change failed");
expect(await current(agent.id)).toMatchObject({ adapterConfig: before.adapterConfig,
lifecycleVersion: before.lifecycleVersion, lifecycleOperation: before.lifecycleOperation });
});
it("permits a board retry and rejects an agent or a different company", async () => {
const agent = await hire();
function app(actor: Express.Request["actor"]) {
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = actor; next(); });
app.use("/api", agentRoutes(db)); app.use(errorHandler); return app;
}
const board: Express.Request["actor"] = { type: "board", source: "session", userId: "board", companyIds: [companyId],
memberships: [{ companyId, membershipRole: "admin", status: "active" }] };
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "board", membershipRole: "admin" });
await db.insert(principalPermissionGrants).values(["agents:create", "agents:configure"].map(permissionKey => ({ companyId, principalType: "user", principalId: "board", permissionKey })));
const url = `/api/agents/${agent.id}/lifecycle/retry`;
const response = await request(app(board)).post(url).send({});
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.lifecycleState).toBe("preparing");
expect(response.body).not.toHaveProperty("lifecycleOperation");
expect(await db.select().from(activityLog).where(and(eq(activityLog.entityId, agent.id), eq(activityLog.action, "agent.lifecycle_retried"))))
.toEqual([expect.objectContaining({ actorType: "user", actorId: "board" })]);
await request(app({ type: "agent", source: "agent_key", companyId, agentId: agent.id })).post(url).send({}).expect(403);
const denied = await request(app({ ...board, companyIds: [], memberships: [] })).post(url).send({});
expect([403, 404]).toContain(denied.status);
});
it("recovers an expired lease and never accepts an expired completion", async () => {
const agent = await hire("paused"); const store = createLifecycleStore(db);
await createAgentLifecycle(db).resumeAgent(agent.id);
const claim = await store.claim(agent.id, "first", new Date(Date.now() - 180_000));
expect(claim).not.toBeNull();
await store.setRequiredPlugins(claim!, "first", []);
await store.completeHost(claim!, "first");
expect(await store.change(agent.id, "complete", { owner: "first", version: claim!.lifecycleVersion })).toBeNull();
expect(await store.claim(agent.id, "second", new Date())).not.toBeNull();
});
});
describe("lifecycle transition rules", () => {
it("does not bypass authorization, verification, or cleanup", () => {
expect(() => transition("pending_approval", "resume")).toThrow();
expect(transition("preparing", "complete")).toBe("verifying");
expect(transition("terminating", "complete")).toBe("cleaning_up");
expect(transition("cleaning_up", "complete")).toBe("terminated");
expect(() => transition("terminated", "resume")).toThrow();
expect(transition("terminating", "terminate")).toBe("terminating");
});
});