Merge origin/master and retain both Cloud skill fixture contracts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-30 18:03:08 -05:00
commit 7db456e235
58 files changed
+2225 -1922

No files matched your search

@@ -5,7 +5,7 @@ import type { LifecycleAgent, LifecycleStore, LifecycleDriver } from "../applica
import { startAgentLifecycle } from "../index.js";
import { createAgentLifecycleEffects } from "../../../services/agent-lifecycle.js";
import { logger } from "../../../middleware/logger.js";
import { lifecycleFailureMessage } from "../application/failure.js";
import { lifecycleFailureMessage, lifecycleVerificationNeedsRetry } from "../application/failure.js";
const agent: LifecycleAgent = { id: "agent", companyId: "company", lifecycleState: "verifying", lifecycleVersion: 3,
lifecycleRequiredPluginIds: null, lifecycleError: null,
@@ -67,6 +67,20 @@ describe("lifecycle readiness handoff", () => {
});
describe("lifecycle failure diagnostics", () => {
it.each(["harness_auth_failed", "harness_model_unavailable", "harness_command_unavailable", "harness_cwd_invalid", "harness_probe_unexpected_output"])("waits for repair only for a host verification failure: %s", code => {
const error = { code };
expect(lifecycleVerificationNeedsRetry({ stage: "host", phase: "verifying" }, error)).toBe(true);
expect(lifecycleVerificationNeedsRetry({ stage: "plugin", phase: "verifying" }, error)).toBe(false);
expect(lifecycleVerificationNeedsRetry({ stage: "host", phase: "cleaning_up" }, error)).toBe(false);
});
it("allows two automatic retries of a generic failed test before waiting for repair", () => {
for (const verificationFailures of [1, 2, 3, 8_000]) {
const context = { stage: "host" as const, phase: "verifying" as const, attempts: 8_000, verificationFailures };
expect(lifecycleVerificationNeedsRetry(context, { code: "harness_test_failed" })).toBe(verificationFailures >= 3);
}
});
it.each([
["host", "harness_model_unavailable", "Execution configuration check failed. The selected model is unavailable."],
["plugin", "required_plugin_unavailable", "Execution configuration check: required plugin step failed. A required plugin is unavailable."],
@@ -80,7 +94,8 @@ describe("lifecycle failure diagnostics", () => {
if (stage === "select_plugins") driver.requiredPluginIds.mockRejectedValue(error);
if (stage === "transition") store.change.mockRejectedValue(error);
await worker.process(agent.id);
expect(store.defer).toHaveBeenCalledWith(agent, expect.any(String), expect.stringContaining(expected), expect.any(Date));
expect(store.defer).toHaveBeenCalledWith(agent, expect.any(String), expect.stringContaining(expected), expect.any(Date),
...(stage === "host" ? [{ retryRequired: true }] : []));
expect(JSON.stringify(store.defer.mock.calls)).not.toContain("secret provider output");
expect(JSON.stringify(store.defer.mock.calls)).not.toContain("unknown private code");
await worker.stop();
@@ -0,0 +1,182 @@
import { randomUUID } from "node:crypto";
import { agents, companies, createDb, applyPendingMigrations, closeRegisteredClients, type Db } from "@paperclipai/db";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
import { createAgentLifecycle, configureAgentLifecycle } from "../../../services/agent-lifecycle.js";
import { createAgentLifecycleEffects } from "../../../services/agent-lifecycle.js";
import { startAgentLifecycle } from "../index.js";
import { createLifecycleStore } from "../adapters/postgres.js";
import { createLifecycleWorker } from "../application/worker.js";
import type { LifecycleAgent } from "../application/ports.js";
import { resolveExternalTestDatabaseUrl } from "../../../__tests__/helpers/external-test-database.js";
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_LIFECYCLE_VERIFICATION_RETRY_TEST_DATABASE_URL");
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("failed lifecycle verification retries", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
let companyId: string;
const workers: Array<{ stop(): Promise<void> }> = [];
beforeAll(async () => {
if (external) { await applyPendingMigrations(external); db = createDb(external); }
else { database = await startEmbeddedPostgresTestDatabase("paperclip-verification-retry-"); db = createDb(database.connectionString); }
}, 90_000);
afterAll(async () => { if (external) await closeRegisteredClients(external); else await database?.cleanup(); });
beforeEach(async () => {
companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Verification retry", issuePrefix: `V${companyId.slice(0, 7)}` });
});
afterEach(async () => {
for (const worker of workers.splice(0)) await worker.stop();
// Retire only this test's synthetic records so later coordinator reads stay isolated.
await db.update(agents).set({ lifecycleState: "terminated", status: "terminated" }).where(eq(agents.companyId, companyId));
});
const current = async (id: string) => (await db.select().from(agents).where(eq(agents.id, id)))[0]!;
async function failed(code = "harness_test_failed") {
const runHost = vi.fn(async (agent: LifecycleAgent) => {
if (agent.lifecycleState === "verifying") throw Object.assign(new Error("private provider detail"), { code });
return "complete" as const;
});
const driver = { requiredPluginIds: async () => [], runHost, runPlugin: async () => "complete" as const };
const work = configureAgentLifecycle(db, driver); workers.push(work);
const agent = await createAgentLifecycle(db).requestHire(companyId, { name: "Retry fixture", adapterType: "process", adapterConfig: { command: "missing-command" } });
await work.process(agent.id);
if (code === "harness_test_failed") {
for (let retry = 0; retry < 2; retry++) {
expect((await current(agent.id)).lifecycleOperation?.retryRequired).toBeUndefined();
await db.update(agents).set({ lifecycleOperation: sql`${agents.lifecycleOperation} - 'retryAt'` }).where(eq(agents.id, agent.id));
await work.process(agent.id);
}
}
return { agent: await current(agent.id), work, driver, runHost };
}
it("persists a failed test across worker restarts without scanning, claiming, or probing it again", async () => {
const { agent, work, driver, runHost } = await failed();
expect(agent).toMatchObject({ lifecycleState: "verifying", status: "paused", lifecycleOperation: { retryRequired: true } });
expect(agent.lifecycleOperation).not.toHaveProperty("retryAt");
expect(agent.lifecycleOperation).not.toHaveProperty("leaseOwner");
expect(agent.lifecycleError).toContain("Automatic verification is paused");
expect(agent.lifecycleError).not.toContain("private provider detail");
await work.stop();
const restarted = startAgentLifecycle(db, createAgentLifecycleEffects(), driver, () => true); workers.push(restarted);
const calls = runHost.mock.calls.length;
expect(await restarted.hasPending()).toBe(false);
expect(restarted.nextRunAt()).toBeNull();
await restarted.sweep();
const store = createLifecycleStore(db, createAgentLifecycleEffects());
const tomorrow = new Date(Date.now() + 86_400_000);
expect(await store.pending(100, tomorrow)).not.toContain(agent.id);
expect(await store.claim(agent.id, "new-owner", tomorrow)).toBeNull();
await work.process(agent.id);
expect(runHost).toHaveBeenCalledTimes(calls);
expect((await current(agent.id)).lifecycleOperation).toEqual(agent.lifecycleOperation);
});
it("permits an explicit retry of the same operation and completes verification before readiness", async () => {
const { agent, work, runHost } = await failed();
runHost.mockResolvedValue("complete");
await createAgentLifecycle(db).retry(agent.id);
expect((await current(agent.id)).lifecycleOperation).not.toHaveProperty("retryRequired");
expect((await current(agent.id)).lifecycleOperation).not.toHaveProperty("verificationFailures");
expect((await current(agent.id)).lifecycleOperation!.id).toBe(agent.lifecycleOperation!.id);
await work.process(agent.id);
expect(await current(agent.id)).toMatchObject({ lifecycleState: "ready", status: "idle", lifecycleError: null });
});
it("does not spend verification retries on claims deferred before the host test", async () => {
const [agent] = await db.insert(agents).values({ companyId, name: "Deferred verification", status: "paused",
lifecycleState: "verifying", lifecycleRequiredPluginIds: [],
lifecycleOperation: { id: randomUUID(), hostComplete: false, completedPluginIds: [], attempts: 0 } }).returning();
const store = createLifecycleStore(db, createAgentLifecycleEffects());
let allowed = true;
let deferBeforeHost = true;
const runHost = vi.fn(async () => { throw Object.assign(new Error("test failed"), { code: "harness_test_failed" }); });
const work = createLifecycleWorker({ ...store, async claim(...args) {
const claimed = await store.claim(...args);
if (claimed && deferBeforeHost) allowed = false;
return claimed;
} }, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost }, () => {}, () => allowed);
workers.push(work);
const due = async () => {
allowed = true;
await db.update(agents).set({ lifecycleOperation: sql`${agents.lifecycleOperation} - 'retryAt'` }).where(eq(agents.id, agent.id));
await work.process(agent.id);
};
await due(); await due();
expect(runHost).not.toHaveBeenCalled();
expect((await current(agent.id)).lifecycleOperation).toMatchObject({ attempts: 2 });
expect((await current(agent.id)).lifecycleOperation).not.toHaveProperty("verificationFailures");
deferBeforeHost = false;
await due();
expect((await current(agent.id)).lifecycleOperation).toMatchObject({ attempts: 3, verificationFailures: 1 });
expect((await current(agent.id)).lifecycleOperation?.retryRequired).toBeUndefined();
// Another deferred claim preserves the already recorded failed test.
deferBeforeHost = true; await due();
expect((await current(agent.id)).lifecycleOperation?.verificationFailures).toBe(1);
deferBeforeHost = false;
await due();
expect((await current(agent.id)).lifecycleOperation?.retryRequired).toBeUndefined();
await due();
expect(runHost).toHaveBeenCalledTimes(3);
expect((await current(agent.id)).lifecycleOperation).toMatchObject({ verificationFailures: 3, retryRequired: true });
});
it("recovers from a generic failed test before the automatic retry budget is exhausted", async () => {
const { agent, work, runHost } = await failed("harness_probe_timed_out");
runHost.mockRejectedValueOnce(Object.assign(new Error("temporary test failure"), { code: "harness_test_failed" }));
await db.update(agents).set({ lifecycleOperation: sql`${agents.lifecycleOperation} - 'retryAt'` }).where(eq(agents.id, agent.id));
await work.process(agent.id);
expect((await current(agent.id)).lifecycleOperation?.retryRequired).toBeUndefined();
runHost.mockResolvedValue("complete");
await db.update(agents).set({ lifecycleOperation: sql`${agents.lifecycleOperation} - 'retryAt'` }).where(eq(agents.id, agent.id));
await work.process(agent.id);
expect((await current(agent.id)).lifecycleState).toBe("ready");
});
it("rechecks changed execution configuration but leaves a name edit waiting", async () => {
const { agent, work, runHost } = await failed();
const lifecycle = createAgentLifecycle(db);
await lifecycle.updateConfiguration(agent.id, { name: "Renamed fixture" });
expect((await current(agent.id)).lifecycleOperation!.retryRequired).toBe(true);
runHost.mockResolvedValue("complete");
await lifecycle.updateConfiguration(agent.id, { adapterConfig: { command: process.execPath } });
const changed = await current(agent.id);
expect(changed.lifecycleOperation).not.toHaveProperty("retryRequired");
expect(changed.lifecycleOperation).not.toHaveProperty("verificationFailures");
expect(changed.lifecycleOperation!.id).not.toBe(agent.lifecycleOperation!.id);
await work.process(agent.id);
expect((await current(agent.id)).lifecycleState).toBe("ready");
});
it("does not let a stale verification failure park newly saved configuration", async () => {
const { agent, work } = await failed("harness_auth_failed");
await work.stop();
const store = createLifecycleStore(db, createAgentLifecycleEffects());
await store.change(agent.id, "retry");
const claimed = await store.claim(agent.id, "old-verification", new Date());
expect(claimed).not.toBeNull();
await createAgentLifecycle(db).updateConfiguration(agent.id, { adapterConfig: { command: process.execPath } });
expect(await store.defer(claimed!, "old-verification", "Old failure", new Date(), { retryRequired: true })).toBe(false);
expect((await current(agent.id)).lifecycleOperation?.retryRequired).toBeUndefined();
});
it.each(["harness_probe_timed_out", "test_environment_unavailable", "ECONNRESET", "unclassified"])("keeps automatic recovery for %s", async code => {
const { agent } = await failed(code);
expect(agent.lifecycleOperation).not.toHaveProperty("retryRequired");
expect(Date.parse(agent.lifecycleOperation!.retryAt!)).toBeGreaterThan(Date.now());
const store = createLifecycleStore(db, createAgentLifecycleEffects());
expect(await store.pending(100, new Date(Date.now() + 120_000))).toContain(agent.id);
expect(await store.claim(agent.id, "automatic-retry", new Date(Date.now() + 120_000))).not.toBeNull();
});
it("still completes termination and cleanup when verification needs repair", async () => {
const { agent, work, runHost } = await failed();
runHost.mockResolvedValue("complete");
await createAgentLifecycle(db).terminateAgent(agent.id);
await work.process(agent.id);
expect((await current(agent.id)).lifecycleState).toBe("terminated");
expect(runHost.mock.calls.map(([a]) => a.lifecycleState)).toContain("cleaning_up");
});
});
@@ -37,7 +37,8 @@ export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects
...(!prepareSetup && current.lifecycleHolds.includes("setup") && ["preparing", "verifying"].includes(current.lifecycleState)
? { lifecycleState: "pausing" as const } : {}),
lifecycleOperation: { ...current.lifecycleOperation!, id: randomUUID(), hostComplete: false,
completedPluginIds: [], leaseOwner: undefined, leaseUntil: undefined, retryAt: undefined },
completedPluginIds: [], attempts: 0, leaseOwner: undefined, leaseUntil: undefined,
retryAt: undefined, retryRequired: undefined, verificationFailures: undefined },
} : {}) }).where(eq(agents.id, id)).returning();
await effects.completeConfiguration(tx, current, updated, patch, options, publications);
return agentRecordQueries(tx).getById(id);
@@ -17,6 +17,7 @@ type ChangeOptions = { version?: number; owner?: string; reason?: string };
export const pendingLifecycleWork = and(
inArray(agents.lifecycleState, ["preparing", "verifying", "pausing", "resuming", "terminating", "cleaning_up"]),
sql`not (${agents.lifecycleState} = 'verifying' and ${agents.lifecycleHolds} @> '["setup"]'::jsonb)`,
sql`not (${agents.lifecycleState} = 'verifying' and coalesce(${agents.lifecycleOperation}->>'retryRequired', 'false') = 'true')`,
);
/** The caller owns the accounting transaction and has locked this agent. */
@@ -33,7 +34,7 @@ async function changeLockedAgent(tx: Db, effects: LifecycleEffects, agent: Lifec
if (agent.lifecycleOperation?.leaseUntil && Date.parse(agent.lifecycleOperation.leaseUntil) > Date.now()) return agent;
await effects.notifyWork(tx);
const [updated] = await tx.update(agents).set({ lifecycleError: null,
lifecycleOperation: sql`${agents.lifecycleOperation} - 'retryAt' - 'leaseOwner' - 'leaseUntil'`, updatedAt: new Date() }).where(eq(agents.id, agent.id)).returning();
lifecycleOperation: sql`${agents.lifecycleOperation} - 'retryAt' - 'retryRequired' - 'verificationFailures' - 'leaseOwner' - 'leaseUntil'`, updatedAt: new Date() }).where(eq(agents.id, agent.id)).returning();
return updated;
}
if (command === "resume" && !["ready", "paused", "pausing", "resuming"].includes(agent.lifecycleState)) transition(agent.lifecycleState, command);
@@ -177,11 +178,17 @@ export function createLifecycleStore(db: Db, effects: LifecycleEffects): Lifecyc
sql`${agents.lifecycleOperation}->>'leaseOwner' = ${owner}`)).returning({ id: agents.id });
return rows.length > 0;
},
async defer(agent, owner, error, now) {
async defer(agent, owner, error, now, options) {
const retry: Partial<AgentLifecycleOperation> = options?.retryRequired && agent.lifecycleState === "verifying"
? { retryRequired: true }
: { retryAt: new Date(now.getTime() + (error ? 60_000 : 2_000)).toISOString() };
if (options?.verificationFailures !== undefined && agent.lifecycleState === "verifying") {
retry.verificationFailures = options.verificationFailures;
}
const rows = await db.update(agents).set({
lifecycleError: error,
lifecycleOperation: sql`(${agents.lifecycleOperation} - 'leaseOwner' - 'leaseUntil')
|| jsonb_build_object('retryAt', ${new Date(now.getTime() + (error ? 60_000 : 2_000)).toISOString()}::text)`,
lifecycleOperation: sql`(${agents.lifecycleOperation} - 'leaseOwner' - 'leaseUntil' - 'retryAt' - 'retryRequired')
|| ${JSON.stringify(retry)}::jsonb`,
updatedAt: now,
}).where(and(eq(agents.id, agent.id), eq(agents.lifecycleVersion, agent.lifecycleVersion),
sql`${agents.lifecycleOperation}->>'leaseOwner' = ${owner}`)).returning({ id: agents.id });
@@ -15,6 +15,16 @@ const reasons = {
} as const;
const transientCodes = new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "40001", "40P01", "53300", "57P01"]);
const configurationFailures = new Set([
"harness_auth_failed", "harness_model_unavailable",
"harness_command_unavailable", "harness_cwd_invalid", "harness_probe_unexpected_output",
]);
export function lifecycleVerificationNeedsRetry(context: LifecycleFailure, error: unknown): boolean {
const code = lifecycleFailureCode(error) ?? "";
return context.phase === "verifying" && context.stage === "host"
&& (configurationFailures.has(code) || (code === "harness_test_failed" && (context.verificationFailures ?? 0) >= 3));
}
export function lifecycleFailureCode(error: unknown): string | undefined {
const candidate = error as { code?: unknown; cause?: { code?: unknown } } | null;
@@ -36,5 +46,6 @@ export function lifecycleFailureMessage(context: LifecycleFailure, error: unknow
const code = lifecycleFailureCode(error);
const reason = code && Object.hasOwn(reasons, code) ? reasons[code as keyof typeof reasons]
: "Paperclip could not complete this step. It will retry automatically. If this continues, contact your instance administrator.";
return `${step} failed. ${reason}`;
return `${step} failed. ${reason}${lifecycleVerificationNeedsRetry(context, error)
? " Automatic verification is paused. Update Configuration or choose Retry now." : ""}`;
}
@@ -20,7 +20,7 @@ export interface LifecycleStore {
setRequiredPlugins(agent: LifecycleAgent, owner: string, pluginIds: string[]): Promise<boolean>;
completeHost(agent: LifecycleAgent, owner: string): Promise<boolean>;
completePlugin(agent: LifecycleAgent, owner: string, pluginId: string): Promise<boolean>;
defer(agent: LifecycleAgent, owner: string, error: string | null, now: Date): Promise<boolean>;
defer(agent: LifecycleAgent, owner: string, error: string | null, now: Date, options?: { retryRequired: boolean; verificationFailures?: number }): Promise<boolean>;
pending(limit: number, now: Date): Promise<string[]>;
}
@@ -40,5 +40,7 @@ export interface LifecycleFailure {
phase?: AgentLifecycleState;
operationId?: string;
version?: number;
attempts?: number;
verificationFailures?: number;
pluginId?: string;
}
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import type { LifecycleDriver, LifecycleStore, LifecycleFailure } from "./ports.js";
import { isLifecycleWorkPending } from "../domain/policy.js";
import { lifecycleFailureMessage } from "./failure.js";
import { lifecycleFailureCode, lifecycleFailureMessage, lifecycleVerificationNeedsRetry } from "./failure.js";
/** Database leases protect each attempt across server processes. */
export function createLifecycleWorker(store: LifecycleStore, driver: LifecycleDriver, reportFailure: (context: LifecycleFailure, error: unknown) => void, canRun = () => true) {
@@ -20,7 +20,8 @@ export function createLifecycleWorker(store: LifecycleStore, driver: LifecycleDr
const agent = await attempt({ stage: "claim", agentId: id }, () => store.claim(id, owner, new Date()));
if (!agent || !isLifecycleWorkPending(agent.lifecycleState)) return;
const context = { agentId: agent.id, companyId: agent.companyId, phase: agent.lifecycleState,
operationId: agent.lifecycleOperation?.id, version: agent.lifecycleVersion };
operationId: agent.lifecycleOperation?.id, version: agent.lifecycleVersion,
attempts: agent.lifecycleOperation?.attempts };
let failureContext: LifecycleFailure = { ...context, stage: "host" };
const runStep = <T>(stage: LifecycleFailure["stage"], work: () => Promise<T>, pluginId?: string) => {
const stepContext = { ...context, stage, ...(pluginId ? { pluginId } : {}) };
@@ -70,8 +71,16 @@ export function createLifecycleWorker(store: LifecycleStore, driver: LifecycleDr
}
if (changed && !isLifecycleWorkPending(changed.lifecycleState)) return;
} catch (error) {
// Claims can defer before runHost. Only a failed host test spends this budget.
const verificationFailures = failureContext.stage === "host" && agent.lifecycleState === "verifying"
&& lifecycleFailureCode(error) === "harness_test_failed"
? (agent.lifecycleOperation?.verificationFailures ?? 0) + 1 : undefined;
failureContext = { ...failureContext, verificationFailures };
const message = lifecycleFailureMessage(failureContext, error);
if (!await runStep("defer", () => store.defer(agent, owner, message, new Date()))) continue;
const retryRequired = lifecycleVerificationNeedsRetry(failureContext, error);
if (!await runStep("defer", () => retryRequired || verificationFailures !== undefined
? store.defer(agent, owner, message, new Date(), { retryRequired, ...(verificationFailures !== undefined ? { verificationFailures } : {}) })
: store.defer(agent, owner, message, new Date()))) continue;
return;
} finally {
clearInterval(renewal);