mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The heartbeat service records each agent turn and releases its working files. > - A turn can save its work and finish before instruction-copy cleanup runs. > - A cleanup exception can replace that completed result with an adapter failure. > - This also loses result accounting and can prevent environment lease release. > - This pull request records a cleanup warning and keeps the original run outcome. > - The existing recovery sweep retries cleanup from the durable working-copy record. ## Linked Issues or Issue Description Related cleanup and lock work: #14866 and #14869. Related, distinct work: #14695 retains warm-process files; #12021 handles provider-process SIGTERM after a terminal result. **What happened?** An agent saved its plan, posted a comment, and requested approval. The provider completed its turn. Instruction-copy cleanup then timed out on a directory lock. Its exception escaped a `finally` block and replaced the provider result, so the completed turn showed `Run failed`. **Expected behavior** Keep the provider outcome, usage, cost, saved work, and pending approval. Record a cleanup warning and let the existing recovery sweep retry. A real provider failure must keep its original error. A failed file save must keep its failed-save receipt. **Steps to reproduce** 1. Complete a legacy adapter turn that saves work and requests approval. 2. Make instruction-copy release throw a directory-lock timeout. 3. Read the run result. Before this change, the cleanup error replaces the provider outcome. The new heartbeat tests reproduce the failure without a live provider or external service. **Paperclip version or commit** The regression reproduces on `c83df091b1a5207375eaf23466bb5c62e4e1518e`. This branch is rebased onto `cf8ad63c80`. **Deployment mode** Server-managed agent execution with persistent instruction working copies. ## What Changed - Catch instruction-copy release failures in both heartbeat teardown paths. Stop repeating a failed cleanup attempt within the same run. - Write a sanitized `instruction_cleanup` warning. A warning-write failure also preserves the run result. - Test successful, failed, and throwing providers; both teardown paths; warning-write failure; accounting; approval state; and execution-control release. - Extend the held-lock test to prove a fresh recovery worker removes the deferred copy and preserves its failed-save receipt. - Document deferred cleanup and the run-log event. ## Verification - Red proof: all five new heartbeat regression cases fail with the original release calls. - At head `20bea4f431c916d2f5db1970213aab85f5daa34c`, all 417 tests passed across heartbeat process recovery, agent directory working copies, and directory merge locks. - Full local `pnpm -r typecheck`, `pnpm build`, and `git diff --check` passed. - [GitHub CI](https://github.com/paperclipai/paperclip/actions/runs/37031119795) passed at this head. All 53 reported checks passed; the two Storybook checks were correctly skipped. This includes general and serialized tests, browser shards, runner verification, build, typecheck, and the canary dry run. - Greptile reviewed this head with 5/5, no code comments, and no unresolved review threads. The branch has no merge conflicts. - The local `pnpm test:run` attempt was stopped after it reported eight failures in unchanged suites. Four Slack/AgentMail cases selected an unrelated ancestor skills directory and failed with `ENOENT`; the two Slack cases passed with a temporary local skill-root link, which was then removed. Three company-skill cases reproduced macOS `EACCES` errors when renaming read-only cache directories. One gateway case passed when rerun alone. No full local-suite pass is claimed; the complete CI test jobs passed. ## Risks - Cleanup errors now leave recovery work pending. The durable working-copy record remains available for the existing retry sweep. - This change preserves provider failures and failed-save receipts. It does not claim that unsaved file edits were saved. - Native instruction reservation errors retain their existing behavior because they guard process ownership. - No schema, lockfile, workflow, API, or UI changes. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository tools, and code execution. The exact backend model ID and context-window size are not exposed by 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>
981 lines
71 KiB
TypeScript
981 lines
71 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import { execFile as execFileCallback } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import * as executionTargetTools from "@paperclipai/adapter-utils/execution-target";
|
|
import * as ssh from "@paperclipai/adapter-utils/ssh";
|
|
const execFile = promisify(execFileCallback);
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { agentFileStore, fileHash, inspectAgentFile, snapshotAgentFiles, MAX_AGENT_FILE_BYTES, MAX_AGENT_DIRECTORY_BYTES, MAX_AGENT_DIRECTORY_ENTRIES } from "../services/agent-file-store.js";
|
|
import { agents, companies, authUsers, companyMemberships, principalPermissionGrants, heartbeatRuns, environmentLeases, environments, agentInstructionWorkingCopies, agentInstructionRevisions, agentInstructionHeads, createDb } from "@paperclipai/db";
|
|
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
|
|
import { agentInstructionWorkingCopyService, instructionWorkingCopyGuidance } from "../services/agent-instruction-working-copies.js";
|
|
import { resolveManagedInstructionsRoot } from "../services/agent-instructions.js";
|
|
import { buildNativeRuntimeContext } from "../services/native-runtime/runtime-context.js";
|
|
import type { EnvironmentRuntimeService } from "../services/environment-runtime.js";
|
|
import { remoteTerminationReceipt } from "../services/remote-execution-termination.js";
|
|
import { AgentDirectoryReuseInvalidatedError, agentDirectoryWorkingCopyService } from "../services/agent-directory-working-copies.js";
|
|
import { withDirectoryMergeLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
|
import { heartbeatRunEvents } from "@paperclipai/db";
|
|
|
|
describe("persistent agent directories", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
let copies: ReturnType<typeof agentInstructionWorkingCopyService>;
|
|
let revisions: ReturnType<typeof agentInstructionRevisionService>;
|
|
const previousHome = process.env.PAPERCLIP_HOME;
|
|
let home: string;
|
|
let companyId: string, agentId: string, userId: string, root: string;
|
|
const entryFile = "policy/INSTRUCTIONS.txt";
|
|
const initial = "\uFEFF# Original\r\n☃\n";
|
|
const target = () => ({ companyId, agentId });
|
|
const board = () => ({ type: "board" as const, userId, source: "session" as const });
|
|
async function run(options: { warm?: boolean; reuseRunId?: string } = {}) {
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
|
|
return (await copies.prepare({ ...target(), runId, cwd: home, ...options }))!;
|
|
}
|
|
beforeAll(async () => {
|
|
home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "instruction-working-copies-")));
|
|
process.env.PAPERCLIP_HOME = home;
|
|
database = await startEmbeddedPostgresTestDatabase("instruction-copies-db-");
|
|
db = createDb(database.connectionString);
|
|
copies = agentInstructionWorkingCopyService(db);
|
|
revisions = agentInstructionRevisionService(db);
|
|
}, 90_000);
|
|
afterAll(async () => {
|
|
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
|
|
await database?.cleanup();
|
|
if (home) {
|
|
const writable = async (dir: string) => {
|
|
await fs.chmod(dir, 0o700);
|
|
for (const entry of await fs.readdir(dir, { withFileTypes: true })) if (entry.isDirectory()) await writable(path.join(dir, entry.name));
|
|
};
|
|
await writable(home);
|
|
await fs.rm(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
beforeEach(async () => {
|
|
companyId = randomUUID(); agentId = randomUUID(); userId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Instruction tests", issuePrefix: randomUUID().slice(0, 8) });
|
|
await db.insert(authUsers).values({ id: userId, name: "Editor", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
|
root = resolveManagedInstructionsRoot({ ...target(), id: agentId, name: "Target", adapterConfig: {} });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Target", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: entryFile } });
|
|
await db.insert(companyMemberships).values([
|
|
{ companyId, principalType: "user", principalId: userId, membershipRole: "operator" },
|
|
{ companyId, principalType: "agent", principalId: agentId, membershipRole: "member" },
|
|
]);
|
|
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [agentId] } });
|
|
await fs.mkdir(path.dirname(path.join(root, entryFile)), { recursive: true });
|
|
await fs.writeFile(path.join(root, entryFile), initial);
|
|
});
|
|
|
|
it.each([".paperclip-runtime/state", "notes/.paperclip-runtime/state", "promptTemplate.legacy.md"])("rejects reserved board path %s before mutation", async (reserved) => {
|
|
await expect(agentFileStore(db).write({ ...target(), path: reserved, bytes: Buffer.from("reserved"), baseHash: null }, board())).rejects.toMatchObject({ status: 422 });
|
|
await expect(fs.stat(path.join(root, reserved))).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(await run()).toBeTruthy();
|
|
});
|
|
|
|
it("round trips nested text, empty directories and binary bytes independently of task files", async () => {
|
|
const first = await run();
|
|
expect(first.localRoot.startsWith(path.join(home, ".paperclip-runtime"))).toBe(false);
|
|
await fs.mkdir(path.join(first.localRoot, "notes", "empty"), { recursive: true });
|
|
await fs.writeFile(path.join(first.localRoot, "notes", "fact.txt"), "remember me");
|
|
const bytes = Buffer.from([0, 255, 17, 128, 9]);
|
|
await fs.writeFile(path.join(first.localRoot, "image.bin"), bytes);
|
|
await fs.writeFile(path.join(home, "task-only.txt"), "not personal");
|
|
expect((await copies.collectStopped({ companyId, runId: first.runId }))?.state).toBe("saved");
|
|
copies = agentInstructionWorkingCopyService(db);
|
|
const next = await run();
|
|
expect(await fs.readFile(path.join(next.localRoot, "notes", "fact.txt"), "utf8")).toBe("remember me");
|
|
expect(await fs.readFile(path.join(next.localRoot, "image.bin"))).toEqual(bytes);
|
|
expect((await fs.stat(path.join(next.localRoot, "notes", "empty"))).isDirectory()).toBe(true);
|
|
await expect(fs.stat(path.join(next.localRoot, "task-only.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, agentId))).toHaveLength(0);
|
|
});
|
|
|
|
async function sparseFile(filename: string, size: number) {
|
|
const handle = await fs.open(filename, "w");
|
|
try { await handle.truncate(size); } finally { await handle.close(); }
|
|
}
|
|
|
|
it("saves and restores files beyond the former file and folder limits, with streaming inspection and download", async () => {
|
|
const first = await run();
|
|
const size = 66 * 1024 * 1024;
|
|
await sparseFile(path.join(first.localRoot, "large.bin"), size);
|
|
expect((await copies.collectStopped({ companyId, runId: first.runId }))?.state).toBe("saved");
|
|
await copies.release(companyId, first.runId);
|
|
const next = await run();
|
|
const restored = await inspectAgentFile(next.localRoot, "large.bin");
|
|
expect(restored).toMatchObject({ size, bytes: null });
|
|
const store = agentFileStore(db);
|
|
const download = (await store.download(companyId, agentId, "large.bin", board()))!;
|
|
expect(download.size).toBe(size);
|
|
// The lock is released before consuming the stream. An editor replacement
|
|
// must not change the already opened download's bytes.
|
|
await store.write({ ...target(), path: "large.bin", bytes: Buffer.from("replacement"), baseHash: restored!.hash }, board());
|
|
const hash = createHash("sha256");
|
|
let downloaded = 0;
|
|
for await (const chunk of download.stream) { downloaded += chunk.length; hash.update(chunk); }
|
|
expect(downloaded).toBe(size);
|
|
expect(hash.digest("hex")).toBe(restored!.hash);
|
|
expect(await fs.readFile(path.join(root, "large.bin"), "utf8")).toBe("replacement");
|
|
await store.write({ ...target(), path: "empty.bin", bytes: Buffer.alloc(0), baseHash: null }, board());
|
|
const empty = (await store.download(companyId, agentId, "empty.bin", board()))!;
|
|
expect(empty.size).toBe(0);
|
|
for await (const _chunk of empty.stream) throw new Error("Empty download must have no chunks");
|
|
}, 30_000);
|
|
|
|
it("accepts the exact 256 MiB file boundary without retaining a text buffer", async () => {
|
|
expect(MAX_AGENT_FILE_BYTES).toBe(256 * 1024 * 1024);
|
|
await sparseFile(path.join(root, "boundary.bin"), MAX_AGENT_FILE_BYTES);
|
|
expect(await inspectAgentFile(root, "boundary.bin")).toMatchObject({ size: MAX_AGENT_FILE_BYTES, bytes: null });
|
|
});
|
|
|
|
it("checks quota for a small editor save without reading unrelated file bytes", async () => {
|
|
const asset = path.join(root, "large.bin");
|
|
await sparseFile(asset, MAX_AGENT_FILE_BYTES);
|
|
const open = vi.spyOn(fs, "open");
|
|
try {
|
|
await agentFileStore(db).write({ ...target(), path: "note.txt", bytes: Buffer.from("small edit"), baseHash: null }, board());
|
|
expect(open.mock.calls.some(([filename]) => filename === asset)).toBe(false);
|
|
expect(await fs.readFile(path.join(root, "note.txt"), "utf8")).toBe("small edit");
|
|
} finally { open.mockRestore(); }
|
|
});
|
|
|
|
it("reports an oversized run file without a partial save and removes its temporary copy", async () => {
|
|
const copy = await run();
|
|
await sparseFile(path.join(copy.localRoot, "too-large.bin"), MAX_AGENT_FILE_BYTES + 1);
|
|
await fs.writeFile(path.join(copy.localRoot, entryFile), "changed instructions");
|
|
const failed = await copies.collectStopped({ companyId, runId: copy.runId });
|
|
expect(failed).toMatchObject({ state: "unavailable", errorCode: "AGENT_FILES_LIMIT_EXCEEDED", candidateHash: null, nextAttemptAt: null, attempts: 1 });
|
|
expect(failed?.receipt?.storageWarning).toContain("Runs can continue");
|
|
expect(failed?.errorMessage).toContain('"too-large.bin" exceeds the 256 MiB');
|
|
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
|
|
await expect(fs.stat(path.join(root, "too-large.bin"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect((await copies.reportUnavailable(companyId, copy.runId))?.errorCode).toBe("AGENT_FILES_LIMIT_EXCEEDED");
|
|
await copies.release(companyId, copy.runId);
|
|
await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect((await copies.get(companyId, copy.runId))?.receipt?.baseline).toBeUndefined();
|
|
const next = await run();
|
|
expect(await fs.readFile(path.join(next.localRoot, entryFile), "utf8")).toBe(initial);
|
|
await fs.writeFile(path.join(next.localRoot, "next-task.txt"), "still working");
|
|
expect((await copies.collectStopped({ companyId, runId: next.runId }))?.state).toBe("saved");
|
|
expect(await fs.readFile(path.join(root, "next-task.txt"), "utf8")).toBe("still working");
|
|
});
|
|
|
|
it.each([false, true])("warns on each run at the file limit and clears the warning after ordinary agent cleanup (warm=%s)", async (warm) => {
|
|
await sparseFile(path.join(root, "full.bin"), MAX_AGENT_FILE_BYTES);
|
|
const first = await run({ warm });
|
|
expect(first.receipt?.storageWarning).toContain("256 MiB");
|
|
expect(instructionWorkingCopyGuidance(first)).toContain("Runs can continue");
|
|
const unchanged = await copies[warm ? "checkpointWarm" : "collectStopped"]({ companyId, runId: first.runId });
|
|
expect(unchanged).toMatchObject({ state: warm ? "warm_saved" : "unchanged", errorCode: null });
|
|
if (warm) expect(unchanged?.receipt?.checkpointState).toBe("unchanged");
|
|
expect(unchanged?.receipt?.storageWarning).toContain("Agent storage is full");
|
|
copies = agentInstructionWorkingCopyService(db);
|
|
const second = await run({ warm, ...(warm ? { reuseRunId: first.runId } : {}) });
|
|
if (warm) expect(second.localRoot).toBe(first.localRoot);
|
|
expect(second.receipt?.storageWarning).toContain("Agent storage is full");
|
|
await fs.unlink(path.join(second.localRoot, "full.bin"));
|
|
await fs.writeFile(path.join(second.localRoot, "task-output.txt"), "work continues");
|
|
expect(await copies.collectStopped({ companyId, runId: second.runId })).toMatchObject({ state: "saved", receipt: { storageWarning: null } });
|
|
const next = await run();
|
|
expect(next.receipt?.storageWarning).toBeNull();
|
|
expect(await fs.readFile(path.join(next.localRoot, "task-output.txt"), "utf8")).toBe("work continues");
|
|
}, 30_000);
|
|
|
|
it("starts successive runs with an already oversized saved file and lets the agent remove it", async () => {
|
|
await sparseFile(path.join(root, "old-large.bin"), MAX_AGENT_FILE_BYTES + 1);
|
|
const first = await run();
|
|
expect(first.state).toBe("prepared");
|
|
expect(first.receipt?.storageWarning).toContain("256 MiB");
|
|
expect(await fs.readFile(path.join(first.localRoot, entryFile), "utf8")).toBe(initial);
|
|
const stopped = await copies.collectStopped({ companyId, runId: first.runId });
|
|
expect(stopped).toMatchObject({ state: "unavailable", errorCode: "AGENT_FILES_LIMIT_EXCEEDED" });
|
|
await expect(fs.stat(first.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
copies = agentInstructionWorkingCopyService(db);
|
|
const cleanup = await run();
|
|
expect(cleanup.state).toBe("prepared");
|
|
await fs.unlink(path.join(cleanup.localRoot, "old-large.bin"));
|
|
expect(await copies.collectStopped({ companyId, runId: cleanup.runId })).toMatchObject({ state: "saved", receipt: { storageWarning: null } });
|
|
expect((await run()).receipt?.storageWarning).toBeNull();
|
|
}, 30_000);
|
|
|
|
it("starts when the existing folder exceeds its total quota and accepts agent cleanup", async () => {
|
|
for (let index = 0; index < 8; index++) await fs.writeFile(path.join(root, `quota-part-${index}.bin`), "fixture");
|
|
const lstat = fs.lstat.bind(fs);
|
|
// Keep this regression small on disk while exercising the real filesystem,
|
|
// snapshots, database receipts, restore, and merge with 2 GiB of metadata.
|
|
const sizes = vi.spyOn(fs, "lstat").mockImplementation(async (...args: Parameters<typeof fs.lstat>) => {
|
|
const stat = await lstat(...args);
|
|
if (path.basename(String(args[0])).startsWith("quota-part-")) Object.assign(stat, { size: MAX_AGENT_FILE_BYTES });
|
|
return stat;
|
|
});
|
|
try {
|
|
const first = await run();
|
|
expect(first.receipt?.storageWarning).toContain("2 GiB");
|
|
expect((await copies.collectStopped({ companyId, runId: first.runId }))?.errorCode).toBe("AGENT_FILES_LIMIT_EXCEEDED");
|
|
const cleanup = await run();
|
|
for (let index = 0; index < 8; index++) await fs.unlink(path.join(cleanup.localRoot, `quota-part-${index}.bin`));
|
|
await fs.writeFile(path.join(cleanup.localRoot, "small.txt"), "recovered");
|
|
expect(await copies.collectStopped({ companyId, runId: cleanup.runId })).toMatchObject({ state: "saved", receipt: { storageWarning: null } });
|
|
expect((await run()).receipt?.storageWarning).toBeNull();
|
|
} finally { sizes.mockRestore(); }
|
|
});
|
|
|
|
it("rejects more than 2 GiB in aggregate before hashing or saving any file", async () => {
|
|
expect(MAX_AGENT_DIRECTORY_BYTES).toBe(2 * 1024 * 1024 * 1024);
|
|
const copy = await run();
|
|
for (let index = 0; index < 8; index++) await sparseFile(path.join(copy.localRoot, `part-${index}.bin`), MAX_AGENT_FILE_BYTES);
|
|
// The eight allowed files exactly fill the quota; the entry is additional.
|
|
const result = await copies.collectStopped({ companyId, runId: copy.runId });
|
|
expect(result).toMatchObject({ state: "unavailable", errorCode: "AGENT_FILES_LIMIT_EXCEEDED" });
|
|
expect(result?.errorMessage).toContain("2 GiB total storage limit");
|
|
expect(await fs.readdir(root)).toEqual(["policy"]);
|
|
});
|
|
|
|
it("rejects excessive entry count before snapshotting", async () => {
|
|
const directory = path.join(home, "many-entries");
|
|
await fs.mkdir(directory);
|
|
await fs.writeFile(path.join(directory, "empty"), "");
|
|
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
// Exercise the walker bound without creating 100,001 physical files.
|
|
const readdir = vi.spyOn(fs, "readdir").mockResolvedValue(Array(MAX_AGENT_DIRECTORY_ENTRIES + 1).fill(entries[0]));
|
|
try {
|
|
await expect(snapshotAgentFiles(directory)).rejects.toMatchObject({ status: 422, message: expect.stringContaining("100,000-entry limit") });
|
|
} finally { readdir.mockRestore(); }
|
|
}, 30_000);
|
|
|
|
it("keeps the entry's 1 MiB limit and cleans up failed synchronization", async () => {
|
|
const copy = await run();
|
|
await fs.writeFile(path.join(copy.localRoot, entryFile), "a".repeat(1024 * 1024 + 1));
|
|
const result = await copies.collectStopped({ companyId, runId: copy.runId });
|
|
expect(result?.state).toBe("unavailable");
|
|
await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(result?.errorMessage).toContain("at most 1 MiB");
|
|
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
|
|
});
|
|
|
|
it("merges independent changes and uses the last synchronization for same-file edits", async () => {
|
|
const a = await run(), b = await run();
|
|
await fs.writeFile(path.join(a.localRoot, "a.txt"), "A");
|
|
await fs.writeFile(path.join(b.localRoot, "b.txt"), "B");
|
|
await Promise.all([a, b].map(copy => copies.collectStopped({ companyId, runId: copy.runId })));
|
|
expect(await fs.readFile(path.join(root, "a.txt"), "utf8")).toBe("A");
|
|
expect(await fs.readFile(path.join(root, "b.txt"), "utf8")).toBe("B");
|
|
const c = await run(), d = await run();
|
|
await fs.writeFile(path.join(c.localRoot, "a.txt"), "C");
|
|
await fs.writeFile(path.join(d.localRoot, "a.txt"), "D");
|
|
await fs.writeFile(path.join(c.localRoot, "b.txt"), "new B");
|
|
expect((await copies.collectStopped({ companyId, runId: c.runId }))?.state).toBe("saved");
|
|
expect((await copies.collectStopped({ companyId, runId: d.runId }))?.state).toBe("saved");
|
|
expect(await fs.readFile(path.join(root, "a.txt"), "utf8")).toBe("D");
|
|
expect(await fs.readFile(path.join(root, "b.txt"), "utf8")).toBe("new B");
|
|
expect(await copies.list(companyId, agentId, board())).toEqual([]);
|
|
for (const copy of [a, b, c, d]) await expect(fs.stat(path.dirname(copy.localRoot))).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(await fs.readdir(path.join(path.dirname(root), "file-sync", "runs"))).toEqual([]);
|
|
});
|
|
|
|
it("makes concurrent edit/delete races last-sync-wins and preserves unrelated new files", async () => {
|
|
await fs.mkdir(path.join(root, "notes"));
|
|
await fs.writeFile(path.join(root, "notes", "old.txt"), "old");
|
|
const deleting = await run(), editing = await run();
|
|
await fs.rm(path.join(deleting.localRoot, "notes"), { recursive: true });
|
|
await fs.writeFile(path.join(editing.localRoot, "notes", "old.txt"), "edited");
|
|
await fs.writeFile(path.join(editing.localRoot, "notes", "new.txt"), "unrelated");
|
|
await copies.collectStopped({ companyId, runId: editing.runId });
|
|
expect((await copies.collectStopped({ companyId, runId: deleting.runId }))?.state).toBe("saved");
|
|
await expect(fs.stat(path.join(root, "notes", "old.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(await fs.readFile(path.join(root, "notes", "new.txt"), "utf8")).toBe("unrelated");
|
|
const remove = await run(), change = await run();
|
|
await fs.unlink(path.join(remove.localRoot, "notes", "new.txt"));
|
|
await fs.writeFile(path.join(change.localRoot, "notes", "new.txt"), "restored by later edit");
|
|
await copies.collectStopped({ companyId, runId: remove.runId });
|
|
await copies.collectStopped({ companyId, runId: change.runId });
|
|
expect(await fs.readFile(path.join(root, "notes", "new.txt"), "utf8")).toBe("restored by later edit");
|
|
});
|
|
|
|
it("handles a concurrently replaced parent and keeps the later file change", async () => {
|
|
await fs.mkdir(path.join(root, "notes"));
|
|
await fs.writeFile(path.join(root, "notes", "fact.txt"), "old");
|
|
const replacing = await run(), editing = await run();
|
|
await fs.rm(path.join(replacing.localRoot, "notes"), { recursive: true });
|
|
await fs.writeFile(path.join(replacing.localRoot, "notes"), "now a file");
|
|
await fs.writeFile(path.join(editing.localRoot, "notes", "fact.txt"), "new fact");
|
|
await copies.collectStopped({ companyId, runId: replacing.runId });
|
|
expect(await fs.readFile(path.join(root, "notes"), "utf8")).toBe("now a file");
|
|
expect((await copies.collectStopped({ companyId, runId: editing.runId }))?.state).toBe("saved");
|
|
expect(await fs.readFile(path.join(root, "notes", "fact.txt"), "utf8")).toBe("new fact");
|
|
});
|
|
|
|
it("cleans stopped copies after a crash during cleanup without touching active copies", async () => {
|
|
const finished = await run(), active = await run();
|
|
await db.update(agentInstructionWorkingCopies).set({ state: "saved", processStoppedAt: new Date() }).where(eq(agentInstructionWorkingCopies.runId, finished.runId));
|
|
copies = agentInstructionWorkingCopyService(db);
|
|
await copies.recoverCaptured();
|
|
await expect(fs.stat(finished.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect((await fs.stat(active.localRoot)).isDirectory()).toBe(true);
|
|
});
|
|
|
|
it("cleans up a staging failure before any provider starts", async () => {
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
|
|
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 1, signal: null, timedOut: false, stdout: "", stderr: "fixture failure" });
|
|
try {
|
|
await expect(copies.prepare({ ...target(), runId, cwd: home, target: { kind: "remote", transport: "ssh", environmentId: randomUUID(), remoteCwd: "/fixture/task",
|
|
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd: "/fixture/task" } } })).rejects.toThrow("Could not exclude");
|
|
const row = (await copies.get(companyId, runId))!;
|
|
expect(row.state).toBe("unavailable");
|
|
expect(row.receipt?.baseline).toBeUndefined();
|
|
await expect(fs.stat(path.dirname(row.localRoot))).rejects.toMatchObject({ code: "ENOENT" });
|
|
} finally { shell.mockRestore(); }
|
|
});
|
|
|
|
it.each(["succeeded", "failed", "cancelled", "timed_out", "interrupted"])("registers staging ownership before copying and cleans preparation for a %s run after restart", async (status) => {
|
|
const original = fs.cp.bind(fs);
|
|
const copy = vi.spyOn(fs, "cp").mockImplementationOnce(async (source, destination, options) => {
|
|
const rows = await db.select().from(agentInstructionWorkingCopies).where(eq(agentInstructionWorkingCopies.agentId, agentId));
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({ state: "preparing", localRoot: destination });
|
|
return original(source, destination, options);
|
|
});
|
|
const interrupted = await run();
|
|
copy.mockRestore();
|
|
const active = await run();
|
|
for (const row of [interrupted, active]) {
|
|
await db.update(agentInstructionWorkingCopies).set({ state: "preparing", baseHash: "preparing", receipt: { schema: "paperclip.agent-files.v1" } }).where(eq(agentInstructionWorkingCopies.runId, row.runId));
|
|
}
|
|
await db.update(heartbeatRuns).set({ status }).where(eq(heartbeatRuns.id, interrupted.runId));
|
|
copies = agentInstructionWorkingCopyService(db);
|
|
await copies.recoverStopped();
|
|
expect((await copies.get(companyId, interrupted.runId))?.state).toBe("unavailable");
|
|
await expect(fs.stat(path.dirname(interrupted.localRoot))).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect((await fs.stat(active.localRoot)).isDirectory()).toBe(true);
|
|
});
|
|
|
|
it("retains remote cleanup across restart and failed retries until the original lease is cleaned", async () => {
|
|
const copy = await run();
|
|
const environmentId = randomUUID(), leaseId = randomUUID(), remoteCwd = "/fixture/task";
|
|
const executionRoot = path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", agentId, copy.runId);
|
|
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" });
|
|
await db.insert(environmentLeases).values({ id: leaseId, companyId, environmentId, heartbeatRunId: copy.runId, provider: "daytona", providerLeaseId: "original-sandbox" });
|
|
await db.update(agentInstructionWorkingCopies).set({ state: "saved", processStoppedAt: new Date(), location: `remote:${environmentId}`, executionRoot,
|
|
receipt: { ...copy.receipt, cleanup: { leaseId, remoteCwd } } }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
|
|
copies = agentInstructionWorkingCopyService(db);
|
|
await copies.recoverCaptured();
|
|
const pending = (await copies.get(companyId, copy.runId))!;
|
|
expect(pending.receipt).toMatchObject({ cleanupPending: true, cleanup: { leaseId, remoteCwd } });
|
|
expect(pending.receipt?.baseline).toBeUndefined();
|
|
await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
const execute = vi.fn().mockRejectedValueOnce(new Error("provider temporarily unavailable")).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
|
|
copies = agentInstructionWorkingCopyService(db, { environmentRuntime: { execute } as unknown as EnvironmentRuntimeService });
|
|
for (const expectedPending of [true, false]) {
|
|
await db.update(agentInstructionWorkingCopies).set({ nextAttemptAt: null }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
|
|
await copies.recoverCaptured();
|
|
expect((await copies.get(companyId, copy.runId))?.receipt?.cleanupPending).toBe(expectedPending);
|
|
}
|
|
expect(execute).toHaveBeenCalledTimes(2);
|
|
expect(execute).toHaveBeenLastCalledWith(expect.objectContaining({ lease: expect.objectContaining({ id: leaseId, providerLeaseId: "original-sandbox" }),
|
|
command: "rm", args: ["-rf", "--", executionRoot], bypassSession: true }));
|
|
await copies.recoverCaptured();
|
|
expect(execute).toHaveBeenCalledTimes(2);
|
|
await copies.release(companyId, copy.runId);
|
|
expect((await copies.get(companyId, copy.runId))?.receipt?.cleanupPending).toBe(false);
|
|
expect(execute).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("finishes remote cleanup from a destruction receipt after the environment is deleted", async () => {
|
|
const copy = await run();
|
|
const environmentId = randomUUID(), leaseId = randomUUID(), remoteCwd = "/fixture/task";
|
|
const lease = { id: leaseId, companyId, environmentId, heartbeatRunId: copy.runId, provider: "daytona", providerLeaseId: "destroyed-sandbox" };
|
|
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" });
|
|
await db.insert(environmentLeases).values({ ...lease, status: "released", releasedAt: new Date(), cleanupStatus: "success",
|
|
metadata: { remoteExecutionTermination: remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: "destroyed" }) } });
|
|
await db.update(agentInstructionWorkingCopies).set({ state: "saved", processStoppedAt: new Date(), location: `remote:${environmentId}`,
|
|
executionRoot: path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", agentId, copy.runId),
|
|
receipt: { ...copy.receipt, cleanupPending: true, cleanup: { leaseId, remoteCwd } } }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
|
|
await db.delete(environments).where(eq(environments.id, environmentId));
|
|
copies = agentInstructionWorkingCopyService(db);
|
|
await copies.recoverCaptured();
|
|
expect((await copies.get(companyId, copy.runId))?.receipt?.cleanupPending).toBe(false);
|
|
await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it.each([false, true])("rejects symlinks without saving any part of the tree, preserving an existing full-storage warning: %s", async (full) => {
|
|
if (full) await sparseFile(path.join(root, "full.bin"), MAX_AGENT_FILE_BYTES);
|
|
const copy = await run();
|
|
await fs.writeFile(path.join(copy.localRoot, "innocent.txt"), "changed");
|
|
await fs.symlink(path.join(home, "outside"), path.join(copy.localRoot, "escape"));
|
|
const result = await copies.collectStopped({ companyId, runId: copy.runId });
|
|
expect(result?.state).toBe("unavailable");
|
|
expect(result?.errorCode).toBe("AGENT_FILES_SAVE_FAILED");
|
|
expect(result?.receipt?.storageWarning).toBe(copy.receipt?.storageWarning);
|
|
if (full) expect(result?.receipt?.storageWarning).toContain("Agent storage is full");
|
|
await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(fs.stat(path.join(root, "innocent.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
}, 30_000);
|
|
|
|
it("adopts the last deployed head once and bridges an old collector without appending history", async () => {
|
|
const id = randomUUID();
|
|
const content = "last deployed revision";
|
|
await db.insert(agentInstructionRevisions).values({ id, ...target(), entryFile, contentBase64: Buffer.from(content).toString("base64"), contentHash: fileHash(Buffer.from(content)), byteLength: content.length, source: "board" });
|
|
await db.insert(agentInstructionHeads).values({ ...target(), entryFile, revisionId: id });
|
|
const current = await revisions.readCurrent(target(), board());
|
|
expect(current?.content).toBe(content);
|
|
await revisions.commit({ ...target(), entryFile, content: "new directory contents", baseRevisionId: id, source: "cleanup" }, board());
|
|
await revisions.materializeCurrent(target());
|
|
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe("new directory contents");
|
|
await expect(revisions.commit({ ...target(), entryFile, content: "stale legacy run", baseRevisionId: id, source: "cleanup" }, board())).rejects.toMatchObject({ status: 409 });
|
|
expect(await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, agentId))).toHaveLength(1);
|
|
});
|
|
|
|
it("ordinary file edits leave the loaded instruction digest unchanged", async () => {
|
|
const make = async () => {
|
|
const copy = await run();
|
|
const context = await buildNativeRuntimeContext({ db, agent: { id: agentId, companyId, name: "Target", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: entryFile } },
|
|
runId: copy.runId, runtimeConfig: {}, runtimeSkillEntries: [], instructionWorkingCopy: { rootPath: copy.executionRoot, entryPath: entryFile, kind: "agent_files" } });
|
|
return { copy, context };
|
|
};
|
|
const before = await make();
|
|
await fs.writeFile(path.join(before.copy.localRoot, "notes.txt"), "new personal knowledge");
|
|
await copies.collectStopped({ companyId, runId: before.copy.runId });
|
|
const after = await make();
|
|
expect(after.context.aggregateDigest).toBe(before.context.aggregateDigest);
|
|
expect(after.context.instructions.bundle.fileCount).toBe(1);
|
|
});
|
|
it("checkpoints three managed turns into one live directory, fences old cleanup, and persists after retirement", async () => {
|
|
let copy = await run({ warm: true });
|
|
const original = copy;
|
|
for (let turn = 1; turn <= 3; turn++) {
|
|
await fs.appendFile(path.join(copy.localRoot, "memory.txt"), `turn ${turn}\n`);
|
|
const saved = (await copies.checkpointWarm({ companyId, runId: copy.runId }))!;
|
|
expect(saved.state).toBe("warm_saved");
|
|
expect(saved.processStoppedAt).toBeNull();
|
|
expect(await agentFileStore(db).read(companyId, agentId, "memory.txt", board())).toEqual(Buffer.from(Array.from({ length: turn }, (_, i) => `turn ${i + 1}\n`).join("")));
|
|
await copies.release(companyId, copy.runId);
|
|
expect(await fs.stat(copy.localRoot)).toBeTruthy();
|
|
if (turn < 3) {
|
|
expect(await copies.canReuseWarm(companyId, agentId, copy.runId)).toBe(true);
|
|
copy = await run({ warm: true, reuseRunId: copy.runId });
|
|
expect(copy.executionRoot).toBe(original.executionRoot);
|
|
await copies.collectStopped({ companyId, runId: original.runId });
|
|
await copies.release(companyId, original.runId);
|
|
expect(await fs.stat(copy.localRoot)).toBeTruthy();
|
|
}
|
|
}
|
|
// A child may write after the last turn's checkpoint; session retirement
|
|
// must collect this delta using the current owner, not the first run.
|
|
await fs.writeFile(path.join(copy.localRoot, "late.txt"), "after turn");
|
|
expect((await copies.collectStopped({ companyId, runId: copy.runId }))?.state).toBe("saved");
|
|
await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
const fresh = await run();
|
|
expect(await fs.readFile(path.join(fresh.localRoot, "memory.txt"), "utf8")).toBe("turn 1\nturn 2\nturn 3\n");
|
|
expect(await fs.readFile(path.join(fresh.localRoot, "late.txt"), "utf8")).toBe("after turn");
|
|
});
|
|
|
|
it("rechecks canonical edits at handoff and releases the writer lock before retirement and fresh restore", async () => {
|
|
const first = await run({ warm: true });
|
|
await copies.checkpointWarm({ companyId, runId: first.runId });
|
|
expect(await copies.canReuseWarm(companyId, agentId, first.runId)).toBe(true);
|
|
await agentFileStore(db).write({ ...target(), path: "editor.txt", bytes: Buffer.from("new canonical content"), baseHash: null }, board());
|
|
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
|
|
await expect(copies.prepare({ ...target(), runId, cwd: home, warm: true, reuseRunId: first.runId })).rejects.toBeInstanceOf(AgentDirectoryReuseInvalidatedError);
|
|
expect(JSON.parse(await fs.readFile(path.join(path.dirname(first.localRoot), "owner.json"), "utf8")).runId).toBe(first.runId);
|
|
// Both paths need the canonical writer lock. A handoff failure must release
|
|
// it before orchestration stops the old session and restores a fresh copy.
|
|
expect((await copies.collectStopped({ companyId, runId: first.runId }))?.state).toBe("unchanged");
|
|
const next = (await copies.prepare({ ...target(), runId, cwd: home, warm: true }))!;
|
|
expect(next.localRoot).not.toBe(first.localRoot);
|
|
expect(await fs.readFile(path.join(next.localRoot, "editor.txt"), "utf8")).toBe("new canonical content");
|
|
}, 10_000);
|
|
|
|
it("attaches the successor collector before fallible post-handoff bookkeeping", async () => {
|
|
const first = await run({ warm: true });
|
|
await copies.checkpointWarm({ companyId, runId: first.runId });
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
|
|
let collectorRunId = first.runId;
|
|
const handoff = vi.fn((copy: typeof first) => { collectorRunId = copy.runId; });
|
|
const directories = agentDirectoryWorkingCopyService(db, copies.get, async () => { throw new Error("injected post-handoff database failure"); });
|
|
|
|
await expect(directories.prepare({ ...target(), runId, cwd: home, warm: true, reuseRunId: first.runId, onWarmHandoff: handoff }))
|
|
.rejects.toThrow("injected post-handoff database failure");
|
|
expect(handoff).toHaveBeenCalledOnce();
|
|
expect(collectorRunId).toBe(runId);
|
|
await fs.writeFile(path.join(first.localRoot, "last-write.txt"), "saved after failed handoff");
|
|
// The stale collector cannot touch the successor; the attached collector
|
|
// still saves and cleans the actual owner after process retirement.
|
|
await copies.collectStopped({ companyId, runId: first.runId });
|
|
expect((await fs.stat(first.localRoot)).isDirectory()).toBe(true);
|
|
expect((await copies.collectStopped({ companyId, runId: collectorRunId }))?.state).toBe("saved");
|
|
expect(await fs.readFile(path.join(root, "last-write.txt"), "utf8")).toBe("saved after failed handoff");
|
|
await expect(fs.stat(first.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
}, 10_000);
|
|
it("retires loaded instruction edits while allowing ordinary personal-file edits to stay warm", async () => {
|
|
const copy = await run({ warm: true });
|
|
await fs.writeFile(path.join(copy.localRoot, entryFile), "# New loaded policy\n");
|
|
expect((await copies.checkpointWarm({ companyId, runId: copy.runId }))?.state).toBe("warm_saved");
|
|
expect(await copies.canReuseWarm(companyId, agentId, copy.runId)).toBe(false);
|
|
await copies.collectStopped({ companyId, runId: copy.runId });
|
|
const fresh = await run({ warm: true });
|
|
expect(fresh.executionRoot).not.toBe(copy.executionRoot);
|
|
expect(await fs.readFile(path.join(fresh.localRoot, entryFile), "utf8")).toBe("# New loaded policy\n");
|
|
});
|
|
it("refreshes after editor changes and preserves concurrent unrelated files", async () => {
|
|
const copy = await run({ warm: true });
|
|
await fs.writeFile(path.join(copy.localRoot, "mine.txt"), "from run");
|
|
await agentFileStore(db).write({ ...target(), path: "board.txt", bytes: Buffer.from("from board"), baseHash: null }, board());
|
|
expect((await copies.checkpointWarm({ companyId, runId: copy.runId }))?.state).toBe("warm_saved");
|
|
expect(await copies.canReuseWarm(companyId, agentId, copy.runId)).toBe(false);
|
|
expect(await fs.readFile(path.join(root, "board.txt"), "utf8")).toBe("from board");
|
|
expect(await fs.readFile(path.join(root, "mine.txt"), "utf8")).toBe("from run");
|
|
await copies.collectStopped({ companyId, runId: copy.runId });
|
|
});
|
|
it("keeps an invalid warm checkpoint unsaved and requests stopped collection", async () => {
|
|
const copy = await run({ warm: true });
|
|
await fs.symlink(root, path.join(copy.localRoot, "escape"));
|
|
const failed = await copies.checkpointWarm({ companyId, runId: copy.runId });
|
|
expect(failed?.state).toBe("prepared");
|
|
expect(failed?.errorCode).toBe("AGENT_FILES_CHECKPOINT_UNSTABLE");
|
|
expect(failed?.processStoppedAt).toBeNull();
|
|
expect(await copies.canReuseWarm(companyId, agentId, copy.runId)).toBe(false);
|
|
await fs.rm(path.join(copy.localRoot, "escape"));
|
|
expect((await copies.collectStopped({ companyId, runId: copy.runId }))?.state).toBe("unchanged");
|
|
});
|
|
it("rejects over-quota warm changes without replacing saved bytes, then permits a clean future run", async () => {
|
|
const copy = await run({ warm: true });
|
|
const handle = await fs.open(path.join(copy.localRoot, "too-large.bin"), "w");
|
|
await handle.truncate(MAX_AGENT_FILE_BYTES + 1); await handle.close();
|
|
expect((await copies.checkpointWarm({ companyId, runId: copy.runId }))?.errorCode).toBe("AGENT_FILES_CHECKPOINT_UNSTABLE");
|
|
const stopped = (await copies.collectStopped({ companyId, runId: copy.runId }))!;
|
|
expect(stopped).toMatchObject({ state: "unavailable", errorCode: "AGENT_FILES_LIMIT_EXCEEDED" });
|
|
expect(stopped.receipt?.storageWarning).toContain("Agent storage is full");
|
|
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
|
|
await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(await run({ warm: true })).toBeTruthy();
|
|
});
|
|
it("rechecks authorization even for an unchanged warm checkpoint", async () => {
|
|
const copy = await run({ warm: true });
|
|
await copies.checkpointWarm({ companyId, runId: copy.runId });
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
|
|
await db.update(companyMemberships).set({ membershipRole: "member" }).where(eq(companyMemberships.companyId, companyId));
|
|
expect((await copies.checkpointWarm({ companyId, runId: copy.runId }))?.errorCode).toBe("AGENT_FILES_CHECKPOINT_UNSTABLE");
|
|
expect(await copies.canReuseWarm(companyId, agentId, copy.runId)).toBe(false);
|
|
expect((await copies.collectStopped({ companyId, runId: copy.runId }))?.state).toBe("unavailable");
|
|
});
|
|
it("reclaims a warm remote owner after verified sandbox destruction without claiming unsaved tail bytes", async () => {
|
|
const copy = await run({ warm: true });
|
|
await fs.writeFile(path.join(copy.localRoot, "saved.txt"), "checkpoint");
|
|
await copies.checkpointWarm({ companyId, runId: copy.runId });
|
|
const environmentId = randomUUID(), leaseId = randomUUID(), remoteCwd = "/fixture/task";
|
|
const lease = { id: leaseId, companyId, environmentId, heartbeatRunId: copy.runId, provider: "daytona", providerLeaseId: "destroyed-warm" };
|
|
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" });
|
|
await db.insert(environmentLeases).values({ ...lease, status: "released", releasedAt: new Date(), cleanupStatus: "success",
|
|
metadata: { remoteExecutionTermination: remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: "destroyed" }) } });
|
|
await db.update(heartbeatRuns).set({ status: "succeeded", runtimeMode: "native" }).where(eq(heartbeatRuns.id, copy.runId));
|
|
const saved = (await copies.get(companyId, copy.runId))!;
|
|
await db.update(agentInstructionWorkingCopies).set({ location: `remote:${environmentId}`,
|
|
executionRoot: path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", agentId, copy.runId),
|
|
receipt: { ...saved.receipt, cleanup: { leaseId, remoteCwd } } }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
|
|
copies = agentInstructionWorkingCopyService(db);
|
|
await copies.recoverStopped();
|
|
const recovered = (await copies.get(companyId, copy.runId))!;
|
|
expect(recovered).toMatchObject({ state: "unavailable", errorCode: "AGENT_FILES_FINAL_COLLECTION_UNAVAILABLE" });
|
|
expect(recovered.receipt?.cleanupPending).toBe(false);
|
|
expect(await fs.readFile(path.join(root, "saved.txt"), "utf8")).toBe("checkpoint");
|
|
await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
it("uses the workspace transport to restore after destruction of the remote filesystem", async () => {
|
|
const remoteCwd = path.join(home, "remote-task");
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
await execFile("git", ["init", remoteCwd]);
|
|
const runner: import("@paperclipai/adapter-utils/command-managed-runtime").CommandManagedRuntimeRunner = {
|
|
execute: async input => {
|
|
const startedAt = new Date().toISOString();
|
|
const env = { ...process.env, ...input.env };
|
|
const args = [...(input.args ?? [])];
|
|
if (input.stdin != null && (args[0] === "-c" || args[0] === "-lc")) {
|
|
env.PAPERCLIP_TEST_STDIN = input.stdin;
|
|
args[1] = `printf '%s' "$PAPERCLIP_TEST_STDIN" | (${args[1]})`;
|
|
}
|
|
try {
|
|
const result = await execFile(input.command, args, { cwd: input.cwd, env, timeout: input.timeoutMs, maxBuffer: 32 * 1024 * 1024 });
|
|
return { exitCode: 0, signal: null, timedOut: false, stdout: result.stdout, stderr: result.stderr, pid: null, startedAt };
|
|
} catch (error) {
|
|
const e = error as { code?: number; signal?: NodeJS.Signals; stdout?: string; stderr?: string };
|
|
return { exitCode: typeof e.code === "number" ? e.code : 1, signal: e.signal ?? null, timedOut: false, stdout: e.stdout ?? "", stderr: e.stderr ?? "", pid: null, startedAt };
|
|
}
|
|
},
|
|
};
|
|
const executionTarget = { kind: "remote" as const, transport: "sandbox" as const, environmentId: randomUUID(), remoteCwd, runner };
|
|
const prepare = async () => {
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
|
|
return (await copies.prepare({ ...target(), runId, cwd: home, target: executionTarget }))!;
|
|
};
|
|
await fs.mkdir(path.join(root, "build"));
|
|
await fs.writeFile(path.join(root, "build", "personal.txt"), "cache-like names are still agent files");
|
|
const first = await prepare();
|
|
expect(await fs.readFile(path.join(first.executionRoot, "build", "personal.txt"), "utf8")).toBe("cache-like names are still agent files");
|
|
await fs.mkdir(path.join(first.executionRoot, "notes"));
|
|
await fs.writeFile(path.join(first.executionRoot, "notes", "bytes.bin"), Buffer.from([0, 128, 255]));
|
|
await fs.mkdir(path.join(first.executionRoot, "node_modules"));
|
|
await fs.writeFile(path.join(first.executionRoot, "node_modules", "personal.txt"), "retain this too");
|
|
await fs.writeFile(path.join(remoteCwd, "task-only.txt"), "task");
|
|
expect((await execFile("git", ["-C", remoteCwd, "status", "--porcelain", "--untracked-files=all"])).stdout).toBe("?? task-only.txt\n");
|
|
expect((await copies.collectStopped({ companyId, runId: first.runId, target: executionTarget }))?.state).toBe("saved");
|
|
await copies.release(companyId, first.runId);
|
|
expect((await copies.get(companyId, first.runId))?.receipt?.baseline).toBeUndefined();
|
|
await expect(fs.stat(first.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await fs.rm(remoteCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd);
|
|
copies = agentInstructionWorkingCopyService(db);
|
|
const second = await prepare();
|
|
expect(await fs.readFile(path.join(second.executionRoot, "notes", "bytes.bin"))).toEqual(Buffer.from([0,128,255]));
|
|
expect(await fs.readFile(path.join(second.executionRoot, "node_modules", "personal.txt"), "utf8")).toBe("retain this too");
|
|
await expect(fs.stat(path.join(second.executionRoot, "task-only.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
await copies.collectStopped({ companyId, runId: second.runId, target: executionTarget });
|
|
await copies.release(companyId, second.runId);
|
|
});
|
|
|
|
it("checkpoints and reuses the remote directory through the real transport without copying unchanged bytes", async () => {
|
|
const remoteCwd = path.join(home, "warm-remote-task");
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
const runner: import("@paperclipai/adapter-utils/command-managed-runtime").CommandManagedRuntimeRunner = {
|
|
execute: async input => {
|
|
const startedAt = new Date().toISOString();
|
|
const env = { ...process.env, ...input.env };
|
|
const args = [...(input.args ?? [])];
|
|
if (input.stdin != null && (args[0] === "-c" || args[0] === "-lc")) {
|
|
env.PAPERCLIP_TEST_STDIN = input.stdin;
|
|
args[1] = `printf '%s' "$PAPERCLIP_TEST_STDIN" | (${args[1]})`;
|
|
}
|
|
try {
|
|
const result = await execFile(input.command, args, { cwd: input.cwd, env, timeout: input.timeoutMs, maxBuffer: 32 * 1024 * 1024 });
|
|
return { exitCode: 0, signal: null, timedOut: false, stdout: result.stdout, stderr: result.stderr, pid: null, startedAt };
|
|
} catch (error) {
|
|
const e = error as { code?: number; signal?: NodeJS.Signals; stdout?: string; stderr?: string };
|
|
return { exitCode: typeof e.code === "number" ? e.code : 1, signal: e.signal ?? null, timedOut: false, stdout: e.stdout ?? "", stderr: e.stderr ?? "", pid: null, startedAt };
|
|
}
|
|
},
|
|
};
|
|
const executionTarget = { kind: "remote" as const, transport: "sandbox" as const, environmentId: randomUUID(), remoteCwd, runner };
|
|
const prepare = async (reuseRunId?: string) => {
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
|
|
return (await copies.prepare({ ...target(), runId, cwd: home, target: executionTarget, warm: true, reuseRunId }))!;
|
|
};
|
|
let copy = await prepare();
|
|
const original = copy;
|
|
await fs.writeFile(path.join(copy.executionRoot, "image.bin"), Buffer.alloc(32768, 93));
|
|
for (let turn = 1; turn <= 3; turn++) {
|
|
await fs.appendFile(path.join(copy.executionRoot, "memory.txt"), `turn ${turn}\n`);
|
|
const saved = (await copies.checkpointWarm({ companyId, runId: copy.runId, target: executionTarget }))!;
|
|
expect(saved, JSON.stringify({ code: saved.errorCode, message: saved.errorMessage })).toMatchObject({ state: "warm_saved", errorCode: null });
|
|
if (turn > 1) expect(saved.receipt?.checkpointStats).toMatchObject({ copiedFiles: 1, copiedBytes: turn * 7, hashedBytes: turn * 7 });
|
|
expect(await fs.readFile(path.join(root, "memory.txt"), "utf8")).toBe(Array.from({ length: turn }, (_, i) => `turn ${i + 1}\n`).join(""));
|
|
expect((await fs.readdir(path.dirname(copy.localRoot))).filter(name => name.startsWith("checkpoint-"))).toEqual([]);
|
|
expect((await fs.readdir(path.join(copy.executionRoot, ".paperclip-runtime"))).filter(name => name.startsWith("checkpoint-"))).toEqual([]);
|
|
if (turn < 3) { copy = await prepare(copy.runId); expect(copy.executionRoot).toBe(original.executionRoot); }
|
|
}
|
|
await copies.collectStopped({ companyId, runId: original.runId, target: executionTarget });
|
|
expect(await fs.stat(copy.executionRoot)).toBeTruthy();
|
|
await copies.collectStopped({ companyId, runId: copy.runId, target: executionTarget });
|
|
await expect(fs.stat(copy.executionRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it("stages SSH agent files at the registered root without a nested task workspace", async () => {
|
|
const remoteCwd = path.join(home, "ssh-task");
|
|
const exclude = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "" });
|
|
const stage = vi.spyOn(ssh, "syncDirectoryToSsh").mockImplementation(async input => {
|
|
await fs.mkdir(path.dirname(input.remoteDir), { recursive: true });
|
|
await fs.cp(input.localDir, input.remoteDir, { recursive: true });
|
|
});
|
|
const restore = vi.spyOn(ssh, "restoreWorkspaceFromSshExecution").mockImplementation(async input => {
|
|
await fs.cp(input.remoteDir!, input.localDir, { recursive: true });
|
|
});
|
|
try {
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
|
|
const target = { kind: "remote" as const, transport: "ssh" as const, environmentId: randomUUID(), remoteCwd,
|
|
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd } };
|
|
const copy = (await copies.prepare({ companyId, agentId, runId, cwd: home, target }))!;
|
|
expect(stage).toHaveBeenCalledWith(expect.objectContaining({ remoteDir: copy.executionRoot }));
|
|
expect(await fs.readFile(path.join(copy.executionRoot, entryFile), "utf8")).toBe(initial);
|
|
await fs.writeFile(path.join(copy.executionRoot, "ssh-note.txt"), "persistent SSH file");
|
|
expect((await copies.collectStopped({ companyId, runId, target }))?.state).toBe("saved");
|
|
expect(restore).toHaveBeenCalledWith(expect.objectContaining({ remoteDir: copy.executionRoot, restoreGitHistory: false }));
|
|
expect(await fs.readFile(path.join(root, "ssh-note.txt"), "utf8")).toBe("persistent SSH file");
|
|
} finally { stage.mockRestore(); restore.mockRestore(); exclude.mockRestore(); }
|
|
});
|
|
|
|
it("deduplicates concurrent stopped callbacks and discards completed operational snapshots", async () => {
|
|
const copy = await run();
|
|
await fs.writeFile(path.join(copy.localRoot, "note.txt"), "one write");
|
|
const results = await Promise.all([copies.collectStopped({ companyId, runId: copy.runId }), copies.collectStopped({ companyId, runId: copy.runId })]);
|
|
expect(results.every(result => result?.state === "saved")).toBe(true);
|
|
// Simulate a crash after save but before release. The restart sweeper must
|
|
// reclaim operational baselines without touching canonical files.
|
|
copies = agentInstructionWorkingCopyService(db);
|
|
await copies.recoverCaptured();
|
|
const row = await copies.get(companyId, copy.runId);
|
|
expect(row?.receipt?.schema).toBe("paperclip.agent-files.v1");
|
|
expect(row?.receipt?.baseline).toBeUndefined();
|
|
expect(await fs.readFile(path.join(root, "note.txt"), "utf8")).toBe("one write");
|
|
});
|
|
|
|
it.each(["prepared", "unavailable", "warm_saved", "superseded", "saved", "unchanged", "resolved"])(
|
|
"does not acquire a held directory lock for a no-op %s release", async state => {
|
|
const copy = await run();
|
|
const stopped = ["superseded", "saved", "unchanged", "resolved"].includes(state);
|
|
await db.update(agentInstructionWorkingCopies).set({ state, processStoppedAt: stopped ? new Date() : null,
|
|
receipt: { ...copy.receipt, ...(stopped ? { cleanupPending: false } : {}) } })
|
|
.where(eq(agentInstructionWorkingCopies.runId, copy.runId));
|
|
const before = await copies.get(companyId, copy.runId);
|
|
await withDirectoryMergeLock(path.resolve(copy.localRoot, "../../.."), async () => {
|
|
// Any attempted nested acquisition fails promptly instead of hanging the test.
|
|
let now = Date.now();
|
|
const clock = vi.spyOn(Date, "now").mockImplementation(() => now += 31_000);
|
|
try { await copies.release(companyId, copy.runId); } finally { clock.mockRestore(); }
|
|
expect(await copies.get(companyId, copy.runId)).toEqual(before);
|
|
expect(await fs.readFile(path.join(copy.localRoot, entryFile), "utf8")).toBe(initial);
|
|
});
|
|
});
|
|
|
|
async function unavailableRemote(options: { state?: "stopped" | "destroyed"; receiptMismatch?: string } = {}) {
|
|
const copy = await run();
|
|
const environmentId = randomUUID(), leaseId = randomUUID(), remoteCwd = "/fixture/cleanup-task";
|
|
const lease = { id: leaseId, companyId, environmentId, heartbeatRunId: copy.runId, provider: "daytona", providerLeaseId: "cleanup-fixture" };
|
|
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" });
|
|
const receipt = options.state ? remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: options.state })! : undefined;
|
|
if (receipt && options.receiptMismatch) (receipt as Record<string, unknown>)[options.receiptMismatch] = randomUUID();
|
|
await db.insert(environmentLeases).values({ ...lease, ...(receipt ? { status: "released", releasedAt: new Date(), cleanupStatus: "success",
|
|
metadata: { remoteExecutionTermination: receipt } } : {}) });
|
|
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, copy.runId));
|
|
await db.update(agentInstructionWorkingCopies).set({ state: "unavailable", errorCode: "INSTRUCTION_COLLECTION_UNAVAILABLE",
|
|
errorMessage: "Fixture could not collect files", candidateHash: "preserved-candidate", candidateBase64: Buffer.from("preserved bytes").toString("base64"), attempts: 3,
|
|
location: `remote:${environmentId}`, executionRoot: path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", agentId, copy.runId),
|
|
receipt: { ...copy.receipt, cleanup: { leaseId, remoteCwd } } }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
|
|
return { copy: (await copies.get(companyId, copy.runId))!, lease, environmentId };
|
|
}
|
|
|
|
it.each([undefined, "stopped", "companyId", "runId", "leaseId", "provider", "providerLeaseId"])(
|
|
"defers unavailable cleanup without exact destruction proof (%s)", async proof => {
|
|
const { copy } = await unavailableRemote({ state: proof === undefined ? undefined : proof === "stopped" ? "stopped" : "destroyed",
|
|
receiptMismatch: proof && proof !== "stopped" ? proof : undefined });
|
|
const execute = vi.fn();
|
|
copies = agentInstructionWorkingCopyService(db, { environmentRuntime: { execute } as unknown as EnvironmentRuntimeService });
|
|
const before = Date.now();
|
|
await withDirectoryMergeLock(path.resolve(copy.localRoot, "../../.."), () => copies.recoverStopped());
|
|
const deferred = (await copies.get(companyId, copy.runId))!;
|
|
expect(deferred).toMatchObject({ state: "unavailable", processStoppedAt: null, attempts: 3,
|
|
candidateHash: copy.candidateHash, candidateBase64: copy.candidateBase64, errorCode: copy.errorCode, errorMessage: copy.errorMessage, receipt: copy.receipt });
|
|
expect(deferred.nextAttemptAt!.getTime()).toBeGreaterThanOrEqual(before + 30_000);
|
|
expect(await copies.recoverStopped()).toBe(0);
|
|
expect(await fs.readFile(path.join(copy.localRoot, entryFile), "utf8")).toBe(initial);
|
|
expect(execute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("cleans an unavailable copy after exact destruction without claiming a save or executing remotely", async () => {
|
|
const { copy, environmentId } = await unavailableRemote({ state: "destroyed" });
|
|
await fs.writeFile(path.join(copy.localRoot, "unsaved.txt"), "unsaved edit");
|
|
await db.delete(environments).where(eq(environments.id, environmentId));
|
|
const execute = vi.fn();
|
|
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockRejectedValue(new Error("must not execute"));
|
|
copies = agentInstructionWorkingCopyService(db, { environmentRuntime: { execute } as unknown as EnvironmentRuntimeService });
|
|
try {
|
|
await copies.recoverStopped();
|
|
const recovered = (await copies.get(companyId, copy.runId))!;
|
|
expect(recovered).toMatchObject({ state: "unavailable", candidateHash: copy.candidateHash, candidateBase64: copy.candidateBase64, attempts: 3,
|
|
errorCode: copy.errorCode, errorMessage: copy.errorMessage, receipt: { cleanupPending: false } });
|
|
expect(recovered.processStoppedAt).toBeInstanceOf(Date);
|
|
expect(recovered.nextAttemptAt).toBeNull();
|
|
await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(fs.stat(path.join(root, "unsaved.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
|
|
expect(execute).not.toHaveBeenCalled(); expect(shell).not.toHaveBeenCalled();
|
|
} finally { shell.mockRestore(); }
|
|
});
|
|
|
|
it.each([false, true])("preserves uncollected local edits and their unavailable receipt even after local stop proof (%s)", async stopped => {
|
|
const copy = await run();
|
|
await fs.writeFile(path.join(copy.localRoot, "unsaved-local.txt"), "recoverable local edit");
|
|
await db.update(heartbeatRuns).set({ status: "failed", runtimeMode: "native" }).where(eq(heartbeatRuns.id, copy.runId));
|
|
await copies.reportUnavailable(companyId, copy.runId);
|
|
if (stopped) await db.insert(heartbeatRunEvents).values({ companyId, runId: copy.runId, agentId, seq: 1, eventType: "native.local_process_stopped", stream: "system", level: "info", message: "fixture stop" });
|
|
const before = (await copies.get(companyId, copy.runId))!;
|
|
expect(before).toMatchObject({ state: "unavailable", attempts: 0, processStoppedAt: null });
|
|
await copies.recoverStopped();
|
|
const patch = vi.fn(async () => { throw new Error("local copy must not be changed"); });
|
|
await agentDirectoryWorkingCopyService(db, copies.get, patch).recoverUnavailable(before);
|
|
expect(patch).not.toHaveBeenCalled();
|
|
expect(await copies.get(companyId, copy.runId)).toEqual(before);
|
|
expect(await fs.readFile(path.join(copy.localRoot, "unsaved-local.txt"), "utf8")).toBe("recoverable local edit");
|
|
await expect(fs.stat(path.join(root, "unsaved-local.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it("serializes same-run preparation after recovery commits stop proof and before it removes bytes", async () => {
|
|
const { copy, lease } = await unavailableRemote({ state: "destroyed" });
|
|
const remoteCwd = "/fixture/cleanup-task";
|
|
const executionTarget = { kind: "remote" as const, transport: "ssh" as const, environmentId: lease.environmentId, leaseId: lease.id, remoteCwd,
|
|
spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd } };
|
|
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "" });
|
|
const stage = vi.spyOn(ssh, "syncDirectoryToSsh").mockResolvedValue(undefined);
|
|
let stopCommitted!: () => void, finishCleanup!: () => void;
|
|
const committed = new Promise<void>(resolve => { stopCommitted = resolve; });
|
|
const proceed = new Promise<void>(resolve => { finishCleanup = resolve; });
|
|
const directories = agentDirectoryWorkingCopyService(db, copies.get, async (row, values) => {
|
|
const [updated] = await db.update(agentInstructionWorkingCopies).set(values).where(eq(agentInstructionWorkingCopies.runId, row.runId)).returning();
|
|
if (values.processStoppedAt) { stopCommitted(); await proceed; }
|
|
return updated!;
|
|
});
|
|
const recovering = directories.recoverUnavailable(copy);
|
|
await committed;
|
|
let prepareSettled = false;
|
|
const preparing = copies.prepare({ ...target(), runId: copy.runId, cwd: home, target: executionTarget }).then(row => { prepareSettled = true; return row; });
|
|
try {
|
|
try {
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
expect(prepareSettled).toBe(false);
|
|
expect((await copies.get(companyId, copy.runId))?.state).toBe("unavailable");
|
|
} finally { finishCleanup(); await recovering; }
|
|
const prepared = await preparing;
|
|
expect(prepared).toMatchObject({ state: "prepared", processStoppedAt: null });
|
|
expect(await fs.readFile(path.join(copy.localRoot, entryFile), "utf8")).toBe(initial);
|
|
// A stale cleanup callback now observes the live lifecycle and does nothing.
|
|
await directories.release(copy);
|
|
expect(await fs.readFile(path.join(copy.localRoot, entryFile), "utf8")).toBe(initial);
|
|
} finally { shell.mockRestore(); stage.mockRestore(); }
|
|
});
|
|
|
|
it("keeps stopped pending cleanup behind a held lock and lets recovery proceed to other agents", async () => {
|
|
const blocked = await run();
|
|
await db.update(agentInstructionWorkingCopies).set({ state: "unavailable", processStoppedAt: new Date() }).where(eq(agentInstructionWorkingCopies.runId, blocked.runId));
|
|
// A distinct canonical agent has its own lifecycle lock.
|
|
const firstAgent = agentId, firstRoot = root;
|
|
agentId = randomUUID();
|
|
root = resolveManagedInstructionsRoot({ ...target(), id: agentId, name: "Other", adapterConfig: {} });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Other", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: entryFile } });
|
|
await db.insert(companyMemberships).values({ companyId, principalType: "agent", principalId: agentId, membershipRole: "member" });
|
|
await db.update(principalPermissionGrants).set({ scope: { agentIds: [firstAgent, agentId] } }).where(eq(principalPermissionGrants.companyId, companyId));
|
|
await fs.mkdir(path.dirname(path.join(root, entryFile)), { recursive: true }); await fs.writeFile(path.join(root, entryFile), initial);
|
|
const other = await run();
|
|
await db.update(agentInstructionWorkingCopies).set({ state: "unavailable", processStoppedAt: new Date() }).where(eq(agentInstructionWorkingCopies.runId, other.runId));
|
|
agentId = firstAgent; root = firstRoot;
|
|
await withDirectoryMergeLock(path.resolve(blocked.localRoot, "../../.."), async () => {
|
|
let now = Date.now();
|
|
const clock = vi.spyOn(Date, "now").mockImplementation(() => now += 31_000);
|
|
try {
|
|
await expect(copies.release(companyId, blocked.runId)).rejects.toMatchObject({ code: "ERR_WORKSPACE_RESTORE_LOCK_TIMEOUT" });
|
|
await copies.recoverCaptured();
|
|
} finally { clock.mockRestore(); }
|
|
expect((await copies.get(companyId, blocked.runId))?.nextAttemptAt).toBeInstanceOf(Date);
|
|
expect(await fs.readFile(path.join(blocked.localRoot, entryFile), "utf8")).toBe(initial);
|
|
await expect(fs.stat(other.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
// A fresh recovery worker retries after the holder releases the lock. It
|
|
// cleans the pending copy without changing the failed-save receipt.
|
|
const deferred = (await copies.get(companyId, blocked.runId))!;
|
|
await db.update(agentInstructionWorkingCopies).set({ nextAttemptAt: new Date(0) })
|
|
.where(eq(agentInstructionWorkingCopies.runId, blocked.runId));
|
|
await agentInstructionWorkingCopyService(db).recoverCaptured();
|
|
expect(await copies.get(companyId, blocked.runId)).toMatchObject({ state: deferred.state,
|
|
errorCode: deferred.errorCode, errorMessage: deferred.errorMessage,
|
|
candidateHash: deferred.candidateHash, receipt: { cleanupPending: false }, nextAttemptAt: null });
|
|
await expect(fs.stat(blocked.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it.each(["environment", "path", "lease-run"])("rejects destruction outside the registered copy binding (%s)", async mismatch => {
|
|
const { copy, lease } = await unavailableRemote({ state: "destroyed" });
|
|
if (mismatch === "environment") await db.update(agentInstructionWorkingCopies).set({ location: `remote:${randomUUID()}` }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
|
|
if (mismatch === "path") await db.update(agentInstructionWorkingCopies).set({ executionRoot: "/fixture/foreign" }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
|
|
if (mismatch === "lease-run") {
|
|
const other = await run();
|
|
const moved = { ...lease, heartbeatRunId: other.runId };
|
|
await db.update(environmentLeases).set({ heartbeatRunId: other.runId, metadata: { remoteExecutionTermination: remoteTerminationReceipt(moved, { providerLeaseId: moved.providerLeaseId, state: "destroyed" }) } }).where(eq(environmentLeases.id, lease.id));
|
|
}
|
|
await copies.recoverStopped();
|
|
expect((await copies.get(companyId, copy.runId))?.processStoppedAt).toBeNull();
|
|
expect(await fs.readFile(path.join(copy.localRoot, entryFile), "utf8")).toBe(initial);
|
|
});
|
|
|
|
it("backs off an unproven batch so the next unavailable copy can be cleaned", async () => {
|
|
const deferred: string[] = [];
|
|
for (let i = 0; i < 20; i++) {
|
|
const { copy } = await unavailableRemote();
|
|
deferred.push(copy.runId);
|
|
await db.update(agentInstructionWorkingCopies).set({ updatedAt: new Date(i) }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
|
|
}
|
|
const { copy: ready } = await unavailableRemote({ state: "destroyed" });
|
|
await db.update(agentInstructionWorkingCopies).set({ updatedAt: new Date(20) }).where(eq(agentInstructionWorkingCopies.runId, ready.runId));
|
|
await copies.recoverStopped();
|
|
expect((await copies.get(companyId, ready.runId))?.processStoppedAt).toBeNull();
|
|
await copies.recoverStopped();
|
|
expect((await copies.get(companyId, ready.runId))?.receipt?.cleanupPending).toBe(false);
|
|
for (const runId of deferred) expect(await copies.get(companyId, runId)).toMatchObject({ state: "unavailable", processStoppedAt: null, attempts: 3 });
|
|
});
|
|
|
|
it("continues cleanup when both retry-reservation writes fail for the first copy", async () => {
|
|
const first = await run(), second = await run();
|
|
for (const [index, copy] of [first, second].entries()) {
|
|
await db.update(agentInstructionWorkingCopies).set({ state: "unavailable", processStoppedAt: new Date(), updatedAt: new Date(index),
|
|
errorCode: "INSTRUCTION_COLLECTION_UNAVAILABLE", candidateHash: "preserved", candidateBase64: Buffer.from("candidate").toString("base64") })
|
|
.where(eq(agentInstructionWorkingCopies.runId, copy.runId));
|
|
}
|
|
const before = await copies.get(companyId, first.runId);
|
|
const failReservation = () => { throw new Error("fixture retry-reservation write failure"); };
|
|
const update = vi.spyOn(db, "update").mockImplementationOnce(failReservation).mockImplementationOnce(failReservation);
|
|
try { await copies.recoverCaptured(); } finally { update.mockRestore(); }
|
|
expect(await copies.get(companyId, first.runId)).toEqual(before);
|
|
expect(await fs.readFile(path.join(first.localRoot, entryFile), "utf8")).toBe(initial);
|
|
expect(await copies.get(companyId, second.runId)).toMatchObject({ state: "unavailable", errorCode: "INSTRUCTION_COLLECTION_UNAVAILABLE", candidateHash: "preserved", receipt: { cleanupPending: false } });
|
|
await expect(fs.stat(second.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it.each([false, true])("does not execute a cached transport after destruction, including an ambiguous stop-proof commit (%s)", async interrupted => {
|
|
const runId = randomUUID(), environmentId = randomUUID(), leaseId = randomUUID(), remoteCwd = "/fixture/cached-task";
|
|
const lease = { id: leaseId, companyId, environmentId, heartbeatRunId: runId, provider: "daytona", providerLeaseId: "cached-fixture" };
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
|
|
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" });
|
|
await db.insert(environmentLeases).values(lease);
|
|
const execute = vi.fn(), restoreWorkspace = vi.fn(), cleanupWorkspaceSnapshot = vi.fn().mockResolvedValue(undefined);
|
|
const executionTarget = { kind: "remote" as const, transport: "sandbox" as const, environmentId, leaseId, remoteCwd, runner: { execute } };
|
|
const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "" });
|
|
const stage = vi.spyOn(executionTargetTools, "prepareAdapterExecutionTargetRuntime").mockImplementation(async () => ({ target: executionTarget,
|
|
workspaceRemoteDir: path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", agentId, runId), runtimeRootDir: null,
|
|
assetDirs: {}, additionalSourceDirs: {}, additionalSourceFailures: [], workspaceSyncSnapshot: null, restoreWorkspace, cleanupWorkspaceSnapshot }));
|
|
try {
|
|
const copy = (await copies.prepare({ ...target(), runId, cwd: home, target: executionTarget }))!;
|
|
await copies.reportUnavailable(companyId, runId);
|
|
await copies.release(companyId, runId);
|
|
expect(cleanupWorkspaceSnapshot).not.toHaveBeenCalled();
|
|
shell.mockClear(); stage.mockClear();
|
|
await db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, runId));
|
|
await db.update(environmentLeases).set({ status: "released", releasedAt: new Date(), cleanupStatus: "success",
|
|
metadata: { remoteExecutionTermination: remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: "destroyed" }) } }).where(eq(environmentLeases.id, leaseId));
|
|
if (interrupted) {
|
|
const directories = agentDirectoryWorkingCopyService(db, copies.get, async (row, values) => {
|
|
const [updated] = await db.update(agentInstructionWorkingCopies).set(values).where(eq(agentInstructionWorkingCopies.runId, row.runId)).returning();
|
|
if (values.processStoppedAt) throw new Error("fixture lost update response after commit");
|
|
return updated!;
|
|
});
|
|
await expect(directories.recoverUnavailable((await copies.get(companyId, runId))!)).rejects.toThrow("lost update response");
|
|
expect((await copies.get(companyId, runId))?.receipt?.cleanupDestroyedOnly).toBe(true);
|
|
await copies.recoverCaptured();
|
|
} else await copies.recoverStopped();
|
|
expect(await copies.get(companyId, runId)).toMatchObject({ state: "unavailable", errorCode: "INSTRUCTION_COLLECTION_UNAVAILABLE", receipt: { cleanupPending: false } });
|
|
await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(cleanupWorkspaceSnapshot).toHaveBeenCalledOnce();
|
|
expect(execute).not.toHaveBeenCalled(); expect(restoreWorkspace).not.toHaveBeenCalled(); expect(shell).not.toHaveBeenCalled(); expect(stage).not.toHaveBeenCalled();
|
|
} finally { shell.mockRestore(); stage.mockRestore(); }
|
|
});
|
|
|
|
});
|