fix(server): recognize completed no-save directory cleanup

Preserve unresolved local copies as enrollment blockers while accepting the owner receipt for a stopped, fully cleaned-up rejected save. Verify both incomplete states and successful enrollment with real PostgreSQL/S3.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-01 03:19:02 -06:00
1 parent 6d31d1265d
commit b32e90908f
2 files changed
+22 -2

No files matched your search

@@ -175,4 +175,20 @@ describe.skipIf(!process.env.PAPERCLIP_TEST_S3_ENDPOINT)("existing-volume enroll
expect((await db.select().from(providerTraceRecords).where(eq(providerTraceRecords.companyId, companyId)))
.every(trace => trace.status === "expired" && trace.deletedAt !== null)).toBe(true);
}, 120_000);
it("accepts an unavailable directory only after its owner confirms stop and no-save cleanup", async () => {
const row = await agent();
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId: row.id, status: "succeeded" }).returning();
await db.insert(agentInstructionWorkingCopies).values({ runId: run!.id, companyId, agentId: row.id,
responsibleUserId: "fixture", entryFile: "AGENTS.md", baseHash: "fixture", localRoot: path.join(home, "rejected-copy"),
executionRoot: path.join(home, "rejected-copy"), location: "local", state: "unavailable",
errorCode: "AGENT_FILES_LIMIT_EXCEEDED", receipt: { schema: "paperclip.agent-files.v1", cleanupPending: true } });
await expect(migrate()).rejects.toThrow("local_instruction_copies_require_settlement");
await db.update(agentInstructionWorkingCopies).set({ receipt: { schema: "paperclip.agent-files.v1", cleanupPending: false } })
.where(eq(agentInstructionWorkingCopies.runId, run!.id));
await expect(migrate()).rejects.toThrow("local_instruction_copies_require_settlement");
await db.update(agentInstructionWorkingCopies).set({ processStoppedAt: new Date() })
.where(eq(agentInstructionWorkingCopies.runId, run!.id));
expect((await migrate()).eligibility.eligible).toBe(true);
});
});
@@ -11,7 +11,7 @@ import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs/promises";
import path from "node:path";
import { and, asc, desc, eq, gt, inArray, notInArray, lte, or, isNull, sql, type SQL } from "drizzle-orm";
import { and, asc, desc, eq, gt, inArray, not, notInArray, lte, or, isNull, isNotNull, sql, type SQL } from "drizzle-orm";
import { agents, heartbeatRuns, agentInstructionWorkingCopies as copies, databaseWorkScopeKey, type Db } from "@paperclipai/db";
import {
prepareAdapterExecutionTargetRuntime,
@@ -43,10 +43,14 @@ const copySaves = new WeakMap<object, Map<string, Promise<void>>>();
/** A terminal run does not establish that its private local edits were saved.
* Enrollment may discard these paths only after this owner's save/resolution
* or explicit ownership transfer. Unavailable/conflicted copies fail closed. */
* or explicit ownership transfer. A rejected directory save is settled only
* after this owner confirms both process stop and completed no-save cleanup. */
export async function hasUnsettledLocalInstructionCopies(db: Db): Promise<boolean> {
return (await db.select({ runId: copies.runId }).from(copies).where(and(
eq(copies.location, "local"), notInArray(copies.state, ["saved", "unchanged", "resolved", "superseded"]),
not(and(eq(copies.state, "unavailable"), isNotNull(copies.processStoppedAt),
sql`coalesce(${copies.receipt}->>'schema', '') = 'paperclip.agent-files.v1'`,
sql`coalesce(${copies.receipt}->>'cleanupPending', '') = 'false'`)),
)).limit(1)).length > 0;
}