Files
PaperClipAI/server/src/modules/agent-lifecycle/adapters/configuration.ts
T
Devin FoleyandPaperclip 81ecaf0654 fix(server): bound failed lifecycle verification (#18383)
Failed lifecycle verification could allocate another test environment every minute indefinitely. Count failed host verification tests separately from claims, persist a repair wait for confirmed configuration failures or three generic failed tests, and clear it through authorized retry or execution configuration changes.

Preserve typed temporary execution-target failures through adapter checks so timeouts, transport failures and interrupted probes keep automatic recovery. Retain existing readiness, authorization, termination and cleanup gates. Document the policy and add real PostgreSQL persistence and actual OpenCode adapter regressions.

Verified 398 focused tests (one Windows-only test skipped on macOS), repository-wide typecheck and build, structural checks, fully green current-head CI and Greptile 5/5 with no unresolved comments.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-30 15:29:43 -07:00

52 lines
3.8 KiB
TypeScript

import { isDeepStrictEqual } from "node:util";
import { conflict } from "../../../errors.js";
import { agentConfigurationRevision } from "../../../lib/agent-configuration-revision.js";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { agents, type Db } from "@paperclipai/db";
import { agentRecordQueries, AGENT_CONFIGURATION_FIELDS, jsonEqual, type AgentConfigurationPatch, type UpdateAgentOptions } from "../../../lib/agent-records.js";
import type { ActivityPublication } from "../../../types/activity-publication.js";
import { AgentLifecycleConflict } from "../domain/policy.js";
import type { LifecycleEffects } from "./effects.js";
export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects, id: string, data: AgentConfigurationPatch, options?: UpdateAgentOptions, publications: ActivityPublication[] = []) {
if (!("nestedIndex" in tx)) throw new AgentLifecycleConflict("Configuration integration requires a database transaction");
assertConfigurationFields(data);
const [current] = await tx.select().from(agents).where(eq(agents.id, id)).for("no key update");
if (!current) return null;
if (options?.expectedConfigurationRevision && options.expectedConfigurationRevision !== agentConfigurationRevision(current)) {
throw conflict("Agent configuration changed while you were editing. Reload the saved configuration and apply your changes again.");
}
if (options?.expectedConfiguration && Object.entries(options.expectedConfiguration).some(([key, value]) =>
!isDeepStrictEqual(current[key as keyof typeof current], value))) {
throw conflict("Agent configuration changed while saving. Refresh the roster and review the changes again.");
}
const patch = await effects.prepareConfiguration(tx, current, data, options);
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 prepareSetup = changed && current.lifecycleHolds.length === 1 && current.lifecycleHolds[0] === "setup"
&& ["paused", "preparing", "verifying"].includes(current.lifecycleState)
&& await effects.setupRequiresPreparation(tx, current.companyId,
Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId") ? patch.defaultEnvironmentId : current.defaultEnvironmentId);
const invalidate = changed && (["preparing", "verifying", "resuming"].includes(current.lifecycleState) || prepareSetup);
if (invalidate) await effects.notifyWork(tx);
const [updated] = await tx.update(agents).set({ ...patch, updatedAt: new Date(), ...(invalidate ? {
lifecycleVersion: current.lifecycleVersion + 1, lifecycleError: null,
...(prepareSetup ? { lifecycleState: "preparing" as const } : {}),
...(!prepareSetup && current.lifecycleHolds.includes("setup") && ["preparing", "verifying"].includes(current.lifecycleState)
? { lifecycleState: "pausing" as const } : {}),
lifecycleOperation: { ...current.lifecycleOperation!, id: randomUUID(), hostComplete: false,
completedPluginIds: [], 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);
}
function assertConfigurationFields(data: AgentConfigurationPatch) {
if (Object.keys(data).some(field => !AGENT_CONFIGURATION_FIELDS.some(allowed => allowed === field))) {
throw new AgentLifecycleConflict("Unsupported configuration field; use the command that owns this field, such as a lifecycle command");
}
}