fix(server): preserve terminal workspace recovery cursor precision

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-24 15:27:55 -07:00
1 parent a1a7bfd915
commit 28a197cdfd
4 files changed
+74 -15

No files matched your search

@@ -1,3 +1,4 @@
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import * as localServiceSupervisor from "../services/local-service-supervisor.js";
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { execFile } from "node:child_process";
@@ -160,7 +161,8 @@ describe("execution workspace config helpers", () => {
});
});
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_EXECUTION_WORKSPACES_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalUrl ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
@@ -258,8 +260,8 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
}>();
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-execution-workspaces-service-");
db = createDb(tempDb.connectionString);
if (!externalUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-execution-workspaces-service-");
db = createDb(externalUrl ?? tempDb!.connectionString);
svc = executionWorkspaceService(db, {
resolvePullRequestDetails: vi.fn(async (companyId, reference) =>
pullRequestDetailsByKey.get(`${companyId}:${reference.number}`)
@@ -295,6 +297,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
@@ -1124,6 +1127,8 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
.set({ sourceIssueId: openIssueId, updatedAt: new Date(Date.UTC(2020, 0, index + 1)) })
.where(eq(executionWorkspaces.id, workspaceId));
}
await db.execute(sql`update execution_workspaces set updated_at = updated_at + interval '456 microseconds'
where id in (${sql.join(skippedWorkspaceIds.map(id => sql`${id}::uuid`), sql`, `)})`);
// The eligible workspace is newest, so it sorts after the whole skipped page.
const eligible = await seedAncestryTerminalWorkspace({ updatedAt: new Date(Date.UTC(2020, 0, 9)) });
+7 -12
View File
@@ -40,7 +40,7 @@ import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { isDeepStrictEqual, promisify } from "node:util";
import { and, asc, desc, eq, gt, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
executionWorkspaces,
@@ -1746,7 +1746,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
// required: without it the query re-selects the oldest ineligible rows every
// sweep and starves eligible rows behind them. The cursor resets to the start
// when a sweep reaches the end of the candidate set.
let terminalSweepCursor: { updatedAt: Date; id: string } | null = null;
let terminalSweepCursor: { updatedAt: string; id: string } | null = null;
const terminalInspections = new Map<string, { revision: string; issueIds: Set<string>; nextAt: number }>();
const terminalWorkspaceDeadlines = new Map<string, number>();
const isolatedRehomeDeadlines = new Map<string, number>();
@@ -3312,17 +3312,11 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
? lte(executionWorkspaces.updatedAt, boundary)
: undefined;
const cursorFilter = cursor
? or(
gt(executionWorkspaces.updatedAt, cursor.updatedAt),
and(
eq(executionWorkspaces.updatedAt, cursor.updatedAt),
gt(executionWorkspaces.id, cursor.id),
),
)
? sql`(${executionWorkspaces.updatedAt}, ${executionWorkspaces.id}) > (${cursor.updatedAt}::timestamptz, ${cursor.id}::uuid)`
: undefined;
const scanFilter = and(baseCandidateFilter, boundaryFilter, cursorFilter);
const candidates = await db
.select()
.select({ workspace: executionWorkspaces, cursorAt: sql<string>`${executionWorkspaces.updatedAt}::text` })
.from(executionWorkspaces)
.where(scanFilter)
.orderBy(asc(executionWorkspaces.updatedAt), asc(executionWorkspaces.id))
@@ -3335,7 +3329,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
terminalSweepBoundary = null;
} else {
const lastCandidate = candidates[candidates.length - 1]!;
terminalSweepCursor = { updatedAt: lastCandidate.updatedAt, id: lastCandidate.id };
terminalSweepCursor = { updatedAt: lastCandidate.cursorAt, id: lastCandidate.workspace.id };
}
const result = {
checked: candidates.length,
@@ -3355,7 +3349,8 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
};
const readinessByWorkingTree = new Map<string, ReturnType<typeof inspectGitCloseReadiness>>();
for (let workspace of candidates) {
for (const candidate of candidates) {
let workspace = candidate.workspace;
signal?.throwIfAborted();
// Trash deletion is part of compaction, so a sweep that skips compaction
// also leaves leftover trash for a later sweep.