From b32e90908f40c9a1f2d797e134be9ac390b58487 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 1 Nov 2026 03:19:02 -0600 Subject: [PATCH] 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 --- server/src/__tests__/ephemeral-migration.test.ts | 16 ++++++++++++++++ .../services/agent-instruction-working-copies.ts | 8 ++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/ephemeral-migration.test.ts b/server/src/__tests__/ephemeral-migration.test.ts index b1f335bc537..69733b6eb8d 100644 --- a/server/src/__tests__/ephemeral-migration.test.ts +++ b/server/src/__tests__/ephemeral-migration.test.ts @@ -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); + }); }); diff --git a/server/src/services/agent-instruction-working-copies.ts b/server/src/services/agent-instruction-working-copies.ts index 2d6414c8506..2a8b8eb0d3b 100644 --- a/server/src/services/agent-instruction-working-copies.ts +++ b/server/src/services/agent-instruction-working-copies.ts @@ -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>>(); /** 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 { 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; }