mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 22:13:59 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A web server replacement must preserve remote execution and later task continuation. > - The active-run handoff now survives deletion of the departing server home. > - The next admission still compared disposable instruction and skill paths as configuration. > - A native receipt could also settle without the old adapter returning its terminal metadata. > - A replacement can also collide with a run row locked by the serving controller. > - This change preserves durable session identity and terminal evidence, and defers locked recovery work so overlap can reach readiness. ## Linked Issues or Issue Description Refs #18543, #18366, #9977, #18118. **What happened?** A real Codex/Daytona journey replaced both control-plane servers and deleted their homes. Reads, saves, saved files and active execution passed. A later useful answer used a new provider session. All four run records lacked the native terminal contract. The whole qualification therefore failed. A later attempt also exposed startup failure: the replacement waited for a row lock held by the serving controller and aborted on its one-second lock timeout. **Expected behavior** An unchanged managed instruction entry and immutable skill version retain the session across server cache relocation. Actual content and configuration changes still reset it. A committed native receipt retains its terminal contract even when the originating adapter never returns. A busy recovery row defers through the existing scheduler while the replacement reaches readiness. **Steps to reproduce** Run the `rolling-web-deployment.runner-codex.daytona.recover-controller` Product E2E cell with separate controller homes and shared PostgreSQL/S3. The focused regressions reproduce the failures without a model call. ## What Changed - Read the managed instruction entry digest from the company-scoped durable file head. Do not restore or read the cache for this check. - Omit disposable instruction paths only when that durable authority exists. Fingerprint selected immutable skill versions in both runtime skill inputs. - Preserve the existing local and external instruction path checks, and unversioned skill identity. - Publish the accepted native terminal contract in the same transaction as the canonical result receipt. - Skip locked recovery rows and schedule the same run for retry. Avoid an audit write that would block on the same row. No ownership is transferred while busy. - Keep replacement readiness independent of an in-progress skill selection; migration and cache eviction still require exact pins. Missing durable skill versions still refuse startup. - Project only the instruction digest in SQL instead of loading the entire agent-file manifest per admission. - Add real PostgreSQL lock contention tests for the issue, run and coordinator, including the heartbeat startup caller and later claim. - Limit native test teardown to its owned database on a shared test host. The old behavior also fails on clean master. - Add cache-loss, scope, changed-content, changed-version and finalization regressions. Update the deployment qualification record. ## Verification - Both focused regressions failed before the source changes. - All 249 tests pass across `heartbeat-workspace-session`, `agent-file-durability`, and `paperclip-control-plane-port`. - Server typecheck and exact-revision build pass. - Assembled admission ran 59 passing cases but failed during native-suite teardown. The teardown failure reproduced on clean master. All 16 native resumption cases and teardown pass after the database-scope fix. - All three row-lock regressions fail with the pre-fix lock timeout and pass with deferred recovery. The full recovery suite passes 74 cases, including the startup caller; eight Docker-specific cases were not enabled. - The real attempt at `d2165dec5d` failed when B aborted on the run lock. Its credential and bucket were removed; all three provider sandboxes were independently confirmed absent. The next real attempt at `aae8a53d01` failed a different readiness gate while a run was preparing skill pins. That race now has a failing-before regression; all 33 readiness, durable-file and managed-skill tests pass after the fix. Its owned resources are cleaned. The `a15a12651c` live attempt passed both web replacements: 765 reads, 78 saves and 78 verified saves with zero HTTP probe failures. The first three turns preserved the provider session and native terminal contract. The fourth continuation failed because a safely suspended remote Runner was treated as live/idle; that distinct recovery gap is being handled in a follow-up. All three owned sandboxes were independently confirmed absent. No claim of full Railway qualification yet. ## Risks - The new fingerprint shape may reset a session once when upgrading from older fingerprint metadata. It is stable across subsequent cache replacements. - A versioned skill is identified by its immutable selected version; materialization and admission still enforce availability. Unversioned skills retain their existing path boundary. - A native terminal describes the accepted execution result. Workspace and issue settlement remain separate authorities. - This does not yet prove the ten-deploy staging campaign, natural database autosuspension or the production canary. ## Model Used OpenAI GPT-6 Codex, with reasoning, repository editing, shell execution and live integration-test tools. The exact runtime model variant and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
238 lines
15 KiB
TypeScript
238 lines
15 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { randomUUID } from "node:crypto";
|
|
import { Readable } from "node:stream";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { beforeAll, beforeEach, afterEach, afterAll, describe, expect, it, vi } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { agents, agentFileRoots, authUsers, companies, companyMemberships, principalPermissionGrants, heartbeatRuns, createDb, closeFileStorageDb } from "@paperclipai/db";
|
|
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { agentFileStore, adoptAgentFiles, fileHash } from "../services/agent-file-store.js";
|
|
import { invalidateAgentFileProjection, persistDurableAgentFiles } from "../services/agent-file-durability.js";
|
|
import { materializeInstructionBytes } from "../services/agent-instruction-files.js";
|
|
import { agentInstructionsService, resolveManagedInstructionsRoot } from "../services/agent-instructions.js";
|
|
import { AGENT_FILE_OBJECT_GRACE_MS, collectAgentFileObjects, migrateAgentFiles } from "../services/agent-file-maintenance.js";
|
|
import { getStorageService } from "../storage/index.js";
|
|
|
|
const objects = vi.hoisted(() => ({ bytes: new Map<string, Buffer>(), rejectWrites: false, corruptReads: false, readDelayMs: 0, activeReads: 0, peakReads: 0 }));
|
|
vi.mock("../storage/index.js", () => ({ getStorageService: () => ({
|
|
provider: "s3",
|
|
async putFile(input: { companyId: string; namespace: string; body: Readable; byteSize: number; sha256: string }) {
|
|
if (objects.rejectWrites) throw new Error("fixture object-store outage");
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of input.body) chunks.push(Buffer.from(chunk));
|
|
const body = Buffer.concat(chunks);
|
|
expect(body.length).toBe(input.byteSize);
|
|
expect(fileHash(body)).toBe(input.sha256);
|
|
const objectKey = `${input.companyId}/${input.namespace}/${randomUUID()}`;
|
|
objects.bytes.set(objectKey, body);
|
|
return { objectKey };
|
|
},
|
|
async getObject(companyId: string, objectKey: string, options: { signal?: AbortSignal } = {}) {
|
|
expect(objectKey.startsWith(`${companyId}/`)).toBe(true);
|
|
const body = objects.bytes.get(objectKey);
|
|
if (!body) throw new Error("fixture object missing");
|
|
return { stream: Readable.from((async function* () {
|
|
objects.activeReads++;
|
|
objects.peakReads = Math.max(objects.peakReads, objects.activeReads);
|
|
try {
|
|
if (objects.readDelayMs) await delay(objects.readDelayMs, undefined, { signal: options.signal });
|
|
yield objects.corruptReads ? Buffer.from("wrong bytes") : body;
|
|
} finally { objects.activeReads--; }
|
|
})()) };
|
|
},
|
|
async headObject(_companyId: string, key: string) { return { exists: objects.bytes.has(key), lastModified: new Date(0) }; },
|
|
async deleteObject(companyId: string, key: string, options: { allVersions: boolean }) {
|
|
expect(key.startsWith(`${companyId}/`)).toBe(true);
|
|
expect(options.allVersions).toBe(true);
|
|
objects.bytes.delete(key);
|
|
},
|
|
}) }));
|
|
|
|
describe("durable agent file authority", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
let home: string, root: string, companyId: string, agentId: string, userId: string;
|
|
const actor = () => ({ type: "board" as const, userId, source: "session" as const });
|
|
const store = () => agentFileStore(db);
|
|
beforeAll(async () => {
|
|
database = await startEmbeddedPostgresTestDatabase("durable-agent-files-");
|
|
db = createDb(database.connectionString);
|
|
}, 90_000);
|
|
afterAll(async () => {
|
|
if (db) { await closeFileStorageDb(db, 1); await db.$client.end({ timeout: 1 }); }
|
|
await database?.cleanup();
|
|
});
|
|
beforeEach(async () => {
|
|
home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "durable-agent-files-")));
|
|
vi.stubEnv("PAPERCLIP_HOME", home); vi.stubEnv("PAPERCLIP_DURABLE_AGENT_FILES", "true");
|
|
objects.bytes.clear(); objects.rejectWrites = false; objects.corruptReads = false;
|
|
objects.readDelayMs = 0; objects.activeReads = 0; objects.peakReads = 0;
|
|
companyId = randomUUID(); agentId = randomUUID(); userId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "File authority", issuePrefix: randomUUID().slice(0, 8) });
|
|
await db.insert(authUsers).values({ id: userId, name: "Editor", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "File owner" });
|
|
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: "operator" });
|
|
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId,
|
|
permissionKey: "agents:configure", scope: { agentIds: [agentId] } });
|
|
root = resolveManagedInstructionsRoot({ id: agentId, companyId, name: "File owner", adapterConfig: {} });
|
|
});
|
|
afterEach(async () => {
|
|
vi.unstubAllEnvs();
|
|
await fs.rm(home, { recursive: true, force: true });
|
|
});
|
|
async function replaceFilesystem() {
|
|
await fs.rm(home, { recursive: true, force: true });
|
|
home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "durable-agent-replacement-")));
|
|
vi.stubEnv("PAPERCLIP_HOME", home);
|
|
root = resolveManagedInstructionsRoot({ id: agentId, companyId, name: "File owner", adapterConfig: {} });
|
|
}
|
|
|
|
it("reads instruction compatibility from the committed head after deleting the entire home", async () => {
|
|
await materializeInstructionBytes(root, "AGENTS.md", Buffer.from("Original"));
|
|
await db.update(agents).set({ adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root,
|
|
instructionsFilePath: path.join(root, "AGENTS.md"), instructionsEntryFile: "AGENTS.md" } }).where(eq(agents.id, agentId));
|
|
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
|
|
const instructions = agentInstructionsService(db);
|
|
expect(await instructions.getManagedEntryFingerprint(agent)).toBeNull();
|
|
await store().read(companyId, agentId, "AGENTS.md", actor());
|
|
const expected = { entryFile: "AGENTS.md", contentHash: `sha256:${fileHash(Buffer.from("Original"))}` };
|
|
expect(await instructions.getManagedEntryFingerprint(agent)).toEqual(expected);
|
|
await replaceFilesystem();
|
|
expect(await instructions.getManagedEntryFingerprint(agent)).toEqual(expected);
|
|
await expect(fs.stat(root)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(await instructions.getManagedEntryFingerprint({ ...agent, companyId: randomUUID() })).toBeNull();
|
|
expect(await instructions.getManagedEntryFingerprint({ ...agent, adapterConfig: {
|
|
...agent.adapterConfig, instructionsBundleMode: "external",
|
|
} })).toBeNull();
|
|
expect(await instructions.getManagedEntryFingerprint({ ...agent, adapterConfig: {
|
|
...agent.adapterConfig, instructionsEntryFile: "missing.md",
|
|
} })).toEqual({ entryFile: "missing.md", contentHash: null });
|
|
});
|
|
|
|
it("imports legacy bytes, modes and empty entries, then restores without any old filesystem", async () => {
|
|
const binary = Buffer.alloc(2 * 1024 * 1024, 0x81);
|
|
await materializeInstructionBytes(root, "AGENTS.md", Buffer.from("Original"));
|
|
await materializeInstructionBytes(root, "nested/data.bin", binary);
|
|
await materializeInstructionBytes(root, "empty.txt", Buffer.alloc(0));
|
|
await materializeInstructionBytes(root, "script.sh", Buffer.from("#!/bin/sh\n"));
|
|
await fs.chmod(path.join(root, "script.sh"), 0o755);
|
|
await fs.mkdir(path.join(root, "empty-folder"));
|
|
await db.update(agents).set({ adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root } }).where(eq(agents.id, agentId));
|
|
expect(await store().read(companyId, agentId, "AGENTS.md", actor())).toEqual(Buffer.from("Original"));
|
|
await replaceFilesystem();
|
|
vi.stubEnv("PAPERCLIP_DURABLE_AGENT_FILES", "false");
|
|
expect(await store().read(companyId, agentId, "nested/data.bin", actor())).toEqual(binary);
|
|
expect(await store().read(companyId, agentId, "empty.txt", actor())).toEqual(Buffer.alloc(0));
|
|
expect((await fs.stat(path.join(root, "script.sh"))).mode & 0o777).toBe(0o755);
|
|
expect((await fs.stat(path.join(root, "empty-folder"))).isDirectory()).toBe(true);
|
|
});
|
|
|
|
it("restores a cold folder with bounded parallel streams and settles all failed work before cleanup", async () => {
|
|
const contents = Buffer.alloc(128 * 1024, 0x81);
|
|
for (let index = 0; index < 40; index++) {
|
|
await materializeInstructionBytes(root, `nested/file-${index}.bin`, contents);
|
|
}
|
|
expect(await migrateAgentFiles(db, { companyId, apply: true, writersStopped: true })).toMatchObject({ pageReady: true });
|
|
await replaceFilesystem();
|
|
objects.readDelayMs = 25;
|
|
expect((await store().read(companyId, agentId, "nested/file-39.bin", actor()))?.equals(contents)).toBe(true);
|
|
expect(objects.peakReads).toBeGreaterThan(1);
|
|
expect(objects.peakReads).toBeLessThanOrEqual(8);
|
|
expect(objects.activeReads).toBe(0);
|
|
for (let index = 0; index < 40; index++) {
|
|
expect((await fs.readFile(path.join(root, `nested/file-${index}.bin`))).equals(contents)).toBe(true);
|
|
}
|
|
|
|
// A corrupt replacement must preserve the old projection and await every
|
|
// cancelled sibling stream before deleting its temporary directory.
|
|
await invalidateAgentFileProjection(root);
|
|
objects.corruptReads = true;
|
|
await expect(store().read(companyId, agentId, "nested/file-39.bin", actor())).rejects.toThrow(/committed (size|digest)/);
|
|
expect(objects.activeReads).toBe(0);
|
|
expect((await fs.readdir(path.dirname(root))).filter(name => name.startsWith(".agent-files-"))).toEqual([]);
|
|
expect((await fs.readFile(path.join(root, "nested/file-39.bin"))).equals(contents)).toBe(true);
|
|
objects.corruptReads = false;
|
|
expect((await store().read(companyId, agentId, "nested/file-39.bin", actor()))?.equals(contents)).toBe(true);
|
|
});
|
|
|
|
it("does not promote a rejected first save through subsequent local adoption", async () => {
|
|
objects.rejectWrites = true;
|
|
await expect(store().write({ companyId, agentId, path: "failed.txt", bytes: Buffer.from("uncommitted"), baseHash: null }, actor()))
|
|
.rejects.toThrow("fixture object-store outage");
|
|
expect(await db.select().from(agentFileRoots).where(eq(agentFileRoots.agentId, agentId))).toEqual([]);
|
|
objects.rejectWrites = false;
|
|
expect(await store().read(companyId, agentId, "failed.txt", actor())).toBeNull();
|
|
await replaceFilesystem();
|
|
expect(await store().read(companyId, agentId, "failed.txt", actor())).toBeNull();
|
|
});
|
|
|
|
it("dry-runs migration without adopting files and requires stopped writers for publication", async () => {
|
|
await materializeInstructionBytes(root, "AGENTS.md", Buffer.from("Legacy"));
|
|
const input = { companyId, apply: false, writersStopped: false };
|
|
expect(await migrateAgentFiles(db, input)).toMatchObject({ results: [{ agentId, state: "needs_import" }], pageReady: false });
|
|
expect(await db.select().from(agentFileRoots).where(eq(agentFileRoots.agentId, agentId))).toEqual([]);
|
|
await expect(migrateAgentFiles(db, { ...input, apply: true })).rejects.toThrow("stopped writers");
|
|
expect(await migrateAgentFiles(db, { ...input, apply: true, writersStopped: true })).toMatchObject({ pageReady: true });
|
|
await replaceFilesystem();
|
|
expect(await store().read(companyId, agentId, "AGENTS.md", actor())).toEqual(Buffer.from("Legacy"));
|
|
});
|
|
|
|
it("refuses migration while the agent has queued or running execution", async () => {
|
|
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running" }).returning();
|
|
for (const status of ["running", "queued"]) {
|
|
await db.update(heartbeatRuns).set({ status }).where(eq(heartbeatRuns.id, run!.id));
|
|
expect(await migrateAgentFiles(db, { companyId, apply: true, writersStopped: true }))
|
|
.toMatchObject({ results: [{ agentId, state: "active_execution" }], pageReady: false });
|
|
expect(await db.select().from(agentFileRoots).where(eq(agentFileRoots.agentId, agentId))).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("retains current and unadopted objects, then collects expired unreferenced and deleted-owner objects", async () => {
|
|
const prefix = `${companyId}/agent-files/${agentId}/`;
|
|
const abandoned = `${prefix}abandoned`;
|
|
objects.bytes.set(abandoned, Buffer.from("uncertain upload"));
|
|
const provider = { id: "s3" as const, listObjects: async () => ({ keys: [...objects.bytes.keys()].filter(key => key.startsWith(prefix)) }) };
|
|
const input = { companyId, agentId, apply: true, now: AGENT_FILE_OBJECT_GRACE_MS + 1 };
|
|
const collect = (options = input) => collectAgentFileObjects(db, provider, getStorageService(), options);
|
|
expect(await collect()).toMatchObject({ retained: 1, deleted: 0 });
|
|
await store().write({ companyId, agentId, path: "AGENTS.md", bytes: Buffer.from("Current"), baseHash: null }, actor());
|
|
expect(await collect({ ...input, now: 1 })).toMatchObject({ retained: 2, deleted: 0 });
|
|
expect(await collect({ ...input, apply: false })).toMatchObject({ retained: 1, eligible: 1, deleted: 0 });
|
|
expect(objects.bytes.has(abandoned)).toBe(true);
|
|
expect(await collect()).toMatchObject({ retained: 1, deleted: 1 });
|
|
await replaceFilesystem();
|
|
expect(await store().read(companyId, agentId, "AGENTS.md", actor())).toEqual(Buffer.from("Current"));
|
|
await db.delete(agents).where(eq(agents.id, agentId));
|
|
expect(await collect()).toMatchObject({ retained: 0, deleted: 1 });
|
|
expect(objects.bytes.size).toBe(0);
|
|
});
|
|
|
|
it("refuses to treat a missing legacy filesystem as an empty durable folder", async () => {
|
|
await materializeInstructionBytes(root, "AGENTS.md", Buffer.from("Not migrated yet"));
|
|
await db.update(agents).set({ adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root } }).where(eq(agents.id, agentId));
|
|
await replaceFilesystem();
|
|
await expect(store().read(companyId, agentId, "AGENTS.md", actor()))
|
|
.rejects.toMatchObject({ status: 503, details: { code: "AGENT_FILES_MIGRATION_REQUIRED" } });
|
|
expect(await db.select().from(agentFileRoots).where(eq(agentFileRoots.agentId, agentId))).toEqual([]);
|
|
});
|
|
|
|
it("restores the committed head after SQL rollback and rejects corrupted object bytes", async () => {
|
|
await store().write({ companyId, agentId, path: "AGENTS.md", bytes: Buffer.from("Committed"), baseHash: null }, actor());
|
|
await expect(db.transaction(async tx => {
|
|
const [owner] = await tx.select().from(agents).where(eq(agents.id, agentId)).for("update");
|
|
await adoptAgentFiles(tx, owner!);
|
|
await invalidateAgentFileProjection(root);
|
|
await materializeInstructionBytes(root, "AGENTS.md", Buffer.from("Rolled back"));
|
|
await persistDurableAgentFiles(tx, owner!, root);
|
|
throw new Error("fixture transaction rollback");
|
|
})).rejects.toThrow("fixture transaction rollback");
|
|
expect(await store().read(companyId, agentId, "AGENTS.md", actor())).toEqual(Buffer.from("Committed"));
|
|
await replaceFilesystem(); objects.corruptReads = true;
|
|
await expect(store().read(companyId, agentId, "AGENTS.md", actor())).rejects.toThrow(/committed (size|digest)/);
|
|
objects.corruptReads = false;
|
|
expect(await store().read(companyId, agentId, "AGENTS.md", actor())).toEqual(Buffer.from("Committed"));
|
|
});
|
|
});
|