mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Existing local workspaces need durable imports before a volume-free deployment. > - Imports must acquire the same source locks as workspace writers. > - Try-only admission can repeatedly lose to background maintenance, even when each writer finishes. > - This pull request adds an optional bounded wait to the canonical migration APIs. > - Operators can join the source-lock queue while all existing preservation checks remain active. ## Linked Issues or Issue Description **What happened?** An online import can repeatedly return `workspace_source_busy` while background workspace maintenance reacquires its project source lock. The import does not join the PostgreSQL lock queue. **Expected behavior** An administrative import can wait for a finite writer to finish. One wait budget covers the complete source scope. Timeout or cancellation must release acquired locks before the import reserves a journal. **Steps to reproduce** 1. Hold the source lock for an eligible local workspace. 2. Start an online import while the writer holds that lock. 3. Release the writer. Before this change, the import has already refused admission. With `sourceLockWaitMs`, the import acquires the source scope and commits normally. **Paperclip version or commit** Reproduced on master `0e3fdebea8257bdff11573081c76b306f12db230`. **Deployment mode** External PostgreSQL with an online workspace import. Related preservation work: https://github.com/paperclipai/paperclip/pull/20311. ## What Changed - Add optional `sourceLockWaitMs` to singleton and cohort migration APIs. Omitted options keep current admission behavior. - Queue the sorted source keys with one decreasing wait budget. Abort cancels only the dedicated backend identified by PID and backend start time. - Use an independent source transaction for queued singleton imports. Keep lifecycle checks, fresh source checks, journal ownership, and publication after commit. - Document the canonical API option. Add real PostgreSQL importer regressions for release, timeout, abort, and partial-key cleanup. - Correct the inherited task-case lookup summary so the existing search-discovery regression passes without changing its assertion or search algorithm. ## Verification - Baseline RED: the new singleton regression fails on the unchanged product because the import returns busy without queueing. - `pnpm exec vitest run server/src/services/remote-workspace-migration.test.ts -t 'queued|bounds the whole source queue'`: 20 passed, including five new cases. - `pnpm exec vitest run server/src/services/isolated-remote-workspace-source-lock.test.ts`: 4 passed. - Server layer typecheck, `node scripts/check-module-boundaries.mjs`, and `git diff --check` passed. - Tests used Node 24.22.2 and the existing embedded PostgreSQL fixture. Hosted rollout verification remains pending. - CI build, typecheck, browser suites, all serialized suites, and the other general shards passed. The final server shard failed only the inherited `case-history-api-discovery.test.ts:14` search-ranking assertion. Its unchanged local regression reproduces on the base and passes 3/3 with a separate one-line route-summary correction. The shared one-line summary correction is included in follow-up commit `8163ffa9d4728134676b84c8c242a5bf62c802b5`; the unchanged case-discovery suite passes 3/3 locally. The original CI attempt remains failed: https://github.com/paperclipai/paperclip/actions/runs/42704666536/job/129458605783. ## Risks Queued admission can hold earlier source keys while it waits for a later key. The caller controls the finite budget. The abort path opens a short-lived dedicated cancellation connection. If cancellation cannot connect, PostgreSQL's remaining lock timeout still bounds the wait. This option must be called at the root of administrative work; an external scope that already holds the same source locks can cause self-contention. The existing pre-held journal-resume path must keep its omitted-option behavior. ## Model Used OpenAI Codex, based on GPT-6, with tool use and code execution. The exact deployment identifier 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 - [ ] All Paperclip CI gates are green - [ ] 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>
183 lines
10 KiB
TypeScript
183 lines
10 KiB
TypeScript
import { AsyncLocalStorage } from "node:async_hooks";
|
|
import { withDedicatedDbConnection, type Db } from "@paperclipai/db";
|
|
import { sql } from "drizzle-orm";
|
|
import path from "node:path";
|
|
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
|
import { canonicalRepositoryRoot } from "./execution-workspace-source-roots.js";
|
|
import { readWorkspaceGitSourceRoots } from "./workspace-git-source-roots.js";
|
|
import { withWorkspaceLockKeepalive } from "./native-runtime/native-workspace-lock-keepalive.js";
|
|
|
|
type SourceTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
|
type SourceLockScope = { db: Db; companyId: string; keys: Set<string>; transaction: SourceTransaction; active: boolean };
|
|
const sourceLocks = new AsyncLocalStorage<SourceLockScope>();
|
|
export class IsolatedRepositorySourceBusy extends Error {}
|
|
export type WorkspaceSourceLockWait = { waitMs: number; signal?: AbortSignal };
|
|
|
|
async function acquireQueuedSourceLocks(db: Db, transaction: SourceTransaction, keys: string[], wait: WorkspaceSourceLockWait) {
|
|
wait.signal?.throwIfAborted();
|
|
const deadline = performance.now() + wait.waitMs;
|
|
const [settings] = await transaction.execute(sql`select current_setting('lock_timeout') as lock_timeout,
|
|
pg_backend_pid() as pid, backend_start::text from pg_stat_activity where pid=pg_backend_pid()`);
|
|
let cancelling: Promise<unknown> | undefined;
|
|
const cancel = () => {
|
|
cancelling ??= withDedicatedDbConnection(db, async control => {
|
|
const timer = setTimeout(() => { void control.$client.end({ timeout: 0 }); }, 1000);
|
|
try {
|
|
await control.execute(sql`select pg_cancel_backend(pid) from pg_stat_activity
|
|
where pid=${Number(settings.pid)} and backend_start=${String(settings.backend_start)}::timestamptz
|
|
and datname=current_database() and usename=current_user`);
|
|
} finally { clearTimeout(timer); }
|
|
}).catch(() => undefined);
|
|
};
|
|
wait.signal?.addEventListener("abort", cancel, { once: true });
|
|
try {
|
|
for (const key of keys) {
|
|
wait.signal?.throwIfAborted();
|
|
const remaining = Math.ceil(deadline - performance.now());
|
|
if (remaining <= 0) throw new IsolatedRepositorySourceBusy("workspace_source_busy");
|
|
await transaction.execute(sql`select set_config('lock_timeout', ${`${remaining}ms`}, true)`);
|
|
await transaction.execute(sql`select pg_advisory_xact_lock(hashtextextended(${key}, 0))`);
|
|
}
|
|
wait.signal?.throwIfAborted();
|
|
if (performance.now() >= deadline) throw new IsolatedRepositorySourceBusy("workspace_source_busy");
|
|
await transaction.execute(sql`select set_config('lock_timeout', ${String(settings.lock_timeout)}, true)`);
|
|
wait.signal?.throwIfAborted();
|
|
} catch (error) {
|
|
wait.signal?.throwIfAborted();
|
|
if ((error as { cause?: { code?: string }; code?: string }).cause?.code === "55P03"
|
|
|| (error as { code?: string }).code === "55P03") throw new IsolatedRepositorySourceBusy("workspace_source_busy");
|
|
throw error;
|
|
} finally {
|
|
wait.signal?.removeEventListener("abort", cancel);
|
|
await cancelling;
|
|
}
|
|
}
|
|
|
|
// Timers and detached promises keep the async context that created them, so
|
|
// work can see a scope whose transaction has committed and whose dedicated
|
|
// connection is closed. Only a live scope holds locks.
|
|
function heldScope(): SourceLockScope | undefined {
|
|
const scope = sourceLocks.getStore();
|
|
return scope?.active ? scope : undefined;
|
|
}
|
|
|
|
/** Acquires source locks in a new owner scope and ignores an inherited one.
|
|
* Use it at the root of independent work, such as a run's workspace
|
|
* preparation, that can start inside another operation's source scope.
|
|
* Joining that scope would tie these locks to the other transaction. */
|
|
export function withIndependentWorkspaceSourceLocks<T>(db: Db,
|
|
input: { companyId: string; issueIds?: readonly string[]; projectIds?: readonly string[] },
|
|
action: (transaction: SourceTransaction) => Promise<T>, tryOnly = false, wait?: WorkspaceSourceLockWait): Promise<T> {
|
|
return sourceLocks.exit(() => withWorkspaceSourceLocks(db, input, action, tryOnly, wait));
|
|
}
|
|
|
|
export function withIsolatedRepositorySourceLock<T>(db: Db, companyId: string, issueId: string,
|
|
action: () => Promise<T>, tryOnly = false): Promise<T> {
|
|
return withWorkspaceSourceLocks(db, { companyId, issueIds: [issueId] }, action, tryOnly);
|
|
}
|
|
|
|
export async function withWorkspaceSourceLocks<T>(db: Db,
|
|
input: { companyId: string; issueIds?: readonly string[]; projectIds?: readonly string[] },
|
|
action: (transaction: SourceTransaction) => Promise<T>, tryOnly = false, wait?: WorkspaceSourceLockWait): Promise<T> {
|
|
if (wait && (!Number.isSafeInteger(wait.waitMs) || wait.waitMs < 1 || wait.waitMs > 2_147_483_647))
|
|
throw new Error("workspace_source_wait_invalid");
|
|
const keys = [...new Set([
|
|
...(input.issueIds ?? []).map(id => `isolated_repository_source:${input.companyId}:${id}`),
|
|
...(input.projectIds ?? []).map(id => `managed_project_source:${input.companyId}:${id}`),
|
|
])].sort();
|
|
if (keys.length > 128) throw new Error("workspace_source_scope_limit");
|
|
const held = heldScope();
|
|
if (wait && held?.db === db) throw new Error("workspace_source_wait_requires_independent_scope");
|
|
const acquire = async (scope: SourceLockScope, nonblocking: boolean) => {
|
|
for (const key of keys) {
|
|
if (scope.keys.has(key)) continue;
|
|
const lock = nonblocking ? sql`pg_try_advisory_xact_lock(hashtextextended(${key}, 0))`
|
|
: sql`pg_advisory_xact_lock(hashtextextended(${key}, 0))`;
|
|
const rows = await scope.transaction.execute(sql`select ${lock} as acquired`);
|
|
if (nonblocking && !rows[0]?.acquired) throw new IsolatedRepositorySourceBusy("workspace_source_busy");
|
|
scope.keys.add(key);
|
|
}
|
|
};
|
|
if (held?.db === db) {
|
|
if (new Set([...held.keys, ...keys]).size > 128) throw new Error("workspace_source_scope_limit");
|
|
if (held.companyId !== input.companyId) throw new Error("workspace_source_company_mismatch");
|
|
const onlyProjectKeys = keys.filter(key => !held.keys.has(key)).every(key => key.startsWith("managed_project_source:"));
|
|
const alreadyHasProject = [...held.keys].some(key => key.startsWith("managed_project_source:"));
|
|
await acquire(held, !onlyProjectKeys || alreadyHasProject);
|
|
return action(held.transaction);
|
|
}
|
|
return withDedicatedDbConnection(db, connection => connection.transaction(async transaction => {
|
|
const scope: SourceLockScope = { db, companyId: input.companyId, transaction, keys: new Set<string>(), active: true };
|
|
try {
|
|
if (wait) {
|
|
await acquireQueuedSourceLocks(db, transaction, keys, wait);
|
|
for (const key of keys) scope.keys.add(key);
|
|
} else await acquire(scope, tryOnly);
|
|
const rows = await transaction.execute(sql`select setting::bigint as idle_timeout_ms from pg_settings where name = 'idle_in_transaction_session_timeout'`);
|
|
return await withWorkspaceLockKeepalive(transaction, Number(rows[0]?.idle_timeout_ms),
|
|
() => sourceLocks.run(scope, () => action(transaction)));
|
|
} finally {
|
|
scope.active = false;
|
|
}
|
|
}));
|
|
}
|
|
|
|
export async function managedProjectSourceIdentity(companyId: string, cwd: string): Promise<string | null> {
|
|
const root = await canonicalRepositoryRoot(path.join(resolvePaperclipInstanceRoot(), "projects"));
|
|
const target = await canonicalRepositoryRoot(cwd);
|
|
const relative = path.relative(root, target);
|
|
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
|
const [owner, projectId] = relative.split(path.sep);
|
|
if (owner !== companyId) throw new Error("workspace_source_company_mismatch");
|
|
return projectId && /^[a-f0-9-]{36}$/.test(projectId) ? projectId : null;
|
|
}
|
|
|
|
export async function isolatedTaskSourceIdentity(companyId: string, cwd: string): Promise<string | null> {
|
|
const root = await canonicalRepositoryRoot(resolvePaperclipInstanceRoot());
|
|
const relative = path.relative(root, await canonicalRepositoryRoot(cwd)).split(path.sep);
|
|
if (!["isolated-workspaces", "isolated-workspace-attempts", "isolated-repository-sources"].includes(relative[0])) return null;
|
|
if (relative[0] === "isolated-repository-sources" && relative[3] !== "checkout") return null;
|
|
if (relative[1] !== companyId) throw new Error("workspace_source_company_mismatch");
|
|
return relative[2] && /^[a-f0-9-]{36}$/.test(relative[2]) ? relative[2] : null;
|
|
}
|
|
|
|
export async function retainManagedProjectSource(db: Db, companyId: string, cwd: string): Promise<void> {
|
|
const projectId = await managedProjectSourceIdentity(companyId, cwd);
|
|
if (!projectId) return;
|
|
if (heldScope()?.db !== db) throw new Error("workspace_source_admission_scope_required");
|
|
await withWorkspaceSourceLocks(db, { companyId, projectIds: [projectId] }, async () => undefined, true);
|
|
}
|
|
|
|
export async function workspaceSourceIdentities(companyId: string, cwd: string) {
|
|
cwd = await canonicalRepositoryRoot(cwd);
|
|
const git = await readWorkspaceGitSourceRoots(cwd, { ancestors: true });
|
|
const roots = [cwd, ...(git ? [git.root, git.gitDirectory.path, git.commonDirectory.path,
|
|
...git.objectDirectories.map(directory => directory.path)] : [])];
|
|
const projectIds = new Set<string>(), issueIds = new Set<string>(), unownedPaths: string[] = [];
|
|
for (const target of new Set(roots)) {
|
|
const projectId = await managedProjectSourceIdentity(companyId, target);
|
|
const issueId = await isolatedTaskSourceIdentity(companyId, target);
|
|
if (projectId) projectIds.add(projectId);
|
|
if (issueId) issueIds.add(issueId);
|
|
if (!projectId && !issueId) unownedPaths.push(target);
|
|
}
|
|
return { projectIds: [...projectIds].sort(), issueIds: [...issueIds].sort(), unownedPaths, git };
|
|
}
|
|
|
|
export async function retainCurrentWorkspaceSource(cwd: string): Promise<void> {
|
|
const scope = heldScope();
|
|
if (!scope) return;
|
|
const identities = await workspaceSourceIdentities(scope.companyId, cwd);
|
|
await withWorkspaceSourceLocks(scope.db, { companyId: scope.companyId, ...identities }, async () => undefined, true);
|
|
}
|
|
|
|
export async function assertCurrentWorkspaceSourceHeld(cwd: string): Promise<void> {
|
|
const scope = heldScope();
|
|
if (!scope) throw new Error("workspace_source_admission_scope_required");
|
|
const identities = await workspaceSourceIdentities(scope.companyId, cwd);
|
|
if (identities.projectIds.some(id => !scope.keys.has(`managed_project_source:${scope.companyId}:${id}`))
|
|
|| identities.issueIds.some(id => !scope.keys.has(`isolated_repository_source:${scope.companyId}:${id}`))) {
|
|
throw new IsolatedRepositorySourceBusy("workspace_source_busy");
|
|
}
|
|
}
|