mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A native run can accept its result before the workspace copy-back finishes. If a server restart interrupts the copy-back, the run keeps an owner receipt for the server that stopped. > - Another server takes over only after the cloud platform proves that the old deployment stopped. After that proof, recovery checks that the task has not moved on, so old files never overwrite newer work. > - That check counted any queued run on the task as newer work. > - The user's answer to the run's question queues the assignee's next wake. That wake cannot claim while the saved run holds the task's execution lock, so it waits for the copy-back. > - Recovery waited for the wake and the wake waited for recovery. The task stayed stuck, and nothing was logged. > - This pull request ignores a later wake for the same assignee that has never started, and logs waits that the worker stops retrying. > - The benefit is that answering a question during an interrupted copy-back no longer strands the task. Every other newer-work check still applies. ## Linked Issues or Issue Description Refs #15824 (added platform-verified takeover and these binding checks) and #19111 (worker concurrency and ordering for the same copy-backs). This pull request keeps the rest of #15824's fence unchanged. **What happened?** On a hosted instance, a server restart interrupted a copy-back after the worker had written its owner receipt. The run had asked the user a question, and the user answered. The answer's wake was queued. Every later server got a valid stop proof for the old deployment and then got `binding_changed` from the shared binding check: the only failing condition was the queued answer wake. The worker treats `binding_changed` as a final wait and returns without logging. The run stayed `running` and the wake stayed `queued` for about 19 hours, across many restarts. **Expected behavior** A wake that is waiting for the saved run does not stop the saved run's recovery. Runs that could change the workspace still prevent takeover, and the publication check still stops a copy-back if such a run appears. **Steps to reproduce** 1. Accept a native result and leave a copy-back owner receipt from a stopped deployment. 2. Queue the assignee's next wake on the same task, for example by answering the run's question. 3. Let the reconciler or the workspace worker revisit the saved result with a verified stop proof. On `master` the takeover returns `binding_changed` and nothing is copied or committed. The new database tests run these steps against PostgreSQL. **Paperclip version or commit** `master` at `03eb49053fe`. **Deployment mode** Cloud-managed instances with platform stop verification. The same binding check also guards operator owner recovery, so self-hosted operator recovery had the same deadlock. ## What Changed - `server/src/services/native-runtime/native-workspace-recovery-binding.ts`: the newer-run check ignores a run when all of these hold: - It is `queued` and has never started (`startedAt` is null). - It belongs to the run's agent, and that agent is the task's assignee. - It was created after the saved run. - Its wake reason is not `source_scoped_recovery_action`. The run claim blocks such a run while the saved run holds the issue's execution lock (`lockIssueExecutionClaim` in `heartbeat/queue.ts`). Runs that have started, other agents' runs, recovery-action wakes and older queued runs still prevent takeover. - `server/src/services/native-runtime/native-workspace-finalization-work.ts`: log `runId` and `reason` when the worker stops retrying a wait (`binding_changed`, `owner_unverified`). It does not log `owner_active`. - `doc/native-workspace-finalization-recovery.md`: describe the waiting-wake rule in the shared binding section. - `server/src/services/native-runtime/native-workspace-queued-wake-recovery.test.ts` (new): the queued-wake tests, with a small fixture of their own. They are in a separate file so `native-workspace-finalizer.test.ts` stays under 1,000 lines. ## Verification - Failing first. These tests in `native-workspace-queued-wake-recovery.test.ts` fail against the original check and pass with this change: - Operator owner recovery with an answer wake queued. - Automatic takeover with the wake still queued. - Automatic takeover where the wake starts during copy-back. On `master` takeover is refused before this point; with the change, publication is stopped. - The real reconciler, from `retryable_failure`, with a stopped-deployment owner and a queued answer wake. It now copies once, commits the saved result and leaves the wake queued with its claim no longer blocked. - The fence is kept: three new cases (a requeued wake that already started, a recovery-action wake, another agent's queued run) still get `binding_changed`. They pass both before and after the change. - Changed test: "does not reopen an old result after a newer task execution exists" now inserts a started run instead of a never-started queued run of the same assignee. The queued case is now the new operator-recovery test above. - `cd server && npx vitest run src/services/native-runtime/native-workspace-queued-wake-recovery.test.ts src/services/native-runtime/native-workspace-finalizer.test.ts`: 2 files, 72 passed. With the original binding file restored, the 4 positive queued-wake tests failed and the 3 fenced cases passed. - Related suites (finalization work, platform proof, export retry, finalizer failure, recovery actions, owner-recovery routes, status reassessment, scoped deadline work): 8 files, 499 passed. - After rebasing onto `03eb49053fe`: finalizer and finalization-work suites, 97 passed. - `cd server && npx tsc --noEmit -p tsconfig.json`: pass. - Not verified on a live instance yet. The diagnosis comes from that instance's run, finalization, workspace-operation and recovery-action rows, plus a direct stop-proof request that returned a valid receipt. ## Risks - A copy-back can now finish while the assignee's wake is queued. The same-process retry path already does this, because it does not run the binding check. The takeover path now matches it for wakes that cannot start yet. - If some path starts such a wake despite the execution lock, `assertHeld` sees a running newer run and stops before publication. The window between that check and the write is the same as for the other binding conditions. - No schema migration. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with tool use (shell, file edits, test runs, read-only database and log queries). The runtime does not expose the context window or reasoning configuration. ## 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 - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
127 lines
8.2 KiB
TypeScript
127 lines
8.2 KiB
TypeScript
import { and, asc, eq, gt, ne, notExists } from "drizzle-orm";
|
|
import { heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, workspaceOperations, type Db } from "@paperclipai/db";
|
|
import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
|
|
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
|
|
import { logger } from "../../middleware/logger.js";
|
|
import { onNativeWorkspaceFinalizationRelease, type NativeWorkspaceFinalizationScope } from "../native-workspace-finalization-notifications.js";
|
|
import { registerScopedDeadlineWork } from "../scoped-deadline-work.js";
|
|
import { measureRunPhase, withRunWorkspaceFinalizationTiming } from "../execution-preparation-timing.js";
|
|
import { nativeRunnerOwnershipNotHeldCondition } from "./native-runner-ownership.js";
|
|
import { attemptNativeWorkspaceFinalization, recordNativeWorkspaceFinalizationFailure } from "./native-workspace-finalizer.js";
|
|
|
|
type Entry = { id: string; companyId: string; issueId: string; agentId: string; dueAt: number };
|
|
type AttemptInput = Parameters<typeof attemptNativeWorkspaceFinalization>[0];
|
|
|
|
|
|
const OWNERSHIP_RECHECK_MS = 30_000;
|
|
|
|
export function nativeWorkspaceFinalizationWork(db: Db, options: {
|
|
environmentRuntime?: AttemptInput["environmentRuntime"];
|
|
pluginWorkerManager?: AttemptInput["pluginWorkerManager"];
|
|
attempt?: typeof attemptNativeWorkspaceFinalization;
|
|
onWorkspaceSettled(input: { runId: string; companyId: string; agentId: string; succeeded: boolean }): Promise<void>;
|
|
}) {
|
|
function candidates(scope?: NativeWorkspaceFinalizationScope, after?: string) {
|
|
return db.select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
|
|
issueId: nativeRunFinalizations.issueId, nextAttemptAt: nativeRunFinalizations.nextAttemptAt,
|
|
leaseOwner: nativeRunFinalizations.leaseOwner, leaseExpiresAt: nativeRunFinalizations.leaseExpiresAt,
|
|
updatedAt: nativeRunFinalizations.updatedAt,
|
|
}).from(nativeRunFinalizations)
|
|
.innerJoin(heartbeatRuns, and(eq(heartbeatRuns.id, nativeRunFinalizations.runId),
|
|
eq(heartbeatRuns.companyId, nativeRunFinalizations.companyId)))
|
|
.innerJoin(issues, and(eq(issues.id, nativeRunFinalizations.issueId), eq(issues.companyId, nativeRunFinalizations.companyId)))
|
|
.innerJoin(nativeRunResults, and(eq(nativeRunResults.id, nativeRunFinalizations.resultId),
|
|
eq(nativeRunResults.runId, nativeRunFinalizations.runId), eq(nativeRunResults.companyId, nativeRunFinalizations.companyId),
|
|
eq(nativeRunResults.issueId, nativeRunFinalizations.issueId), eq(nativeRunResults.schemaStatus, "accepted")))
|
|
.where(and(eq(heartbeatRuns.runtimeMode, "native"), nativeRunnerOwnershipNotHeldCondition(),
|
|
ne(nativeRunFinalizations.phase, "terminal_failure"),
|
|
notExists(db.select({ id: workspaceOperations.id }).from(workspaceOperations).where(and(
|
|
eq(workspaceOperations.companyId, nativeRunFinalizations.companyId),
|
|
eq(workspaceOperations.heartbeatRunId, nativeRunFinalizations.runId), eq(workspaceOperations.issueId, nativeRunFinalizations.issueId),
|
|
eq(workspaceOperations.phase, "workspace_finalize"), eq(workspaceOperations.status, "succeeded"),
|
|
))),
|
|
scope ? eq(nativeRunFinalizations.companyId, scope.companyId) : undefined,
|
|
scope?.issueId ? eq(nativeRunFinalizations.issueId, scope.issueId) : undefined,
|
|
scope?.runId ? eq(nativeRunFinalizations.runId, scope.runId) : undefined,
|
|
after ? gt(nativeRunFinalizations.runId, after) : undefined,
|
|
)).orderBy(asc(nativeRunFinalizations.runId)).limit(100);
|
|
}
|
|
function entry(row: Awaited<ReturnType<typeof candidates>>[number]): Entry {
|
|
// Work without a saved retry is due now. Order it by when it became pending,
|
|
// so copy-backs interrupted by a later restart cannot starve older retries.
|
|
// The database clock may lead this process slightly; never defer such work.
|
|
const pendingSince = Math.min(row.updatedAt.getTime(), Date.now());
|
|
return { id: row.id, companyId: row.companyId, issueId: row.issueId, agentId: row.agentId,
|
|
dueAt: Math.max(row.nextAttemptAt?.getTime() ?? pendingSince,
|
|
row.leaseOwner ? row.leaseExpiresAt?.getTime() ?? 0 : 0) };
|
|
}
|
|
async function load(scope?: NativeWorkspaceFinalizationScope, signal?: AbortSignal, canRun = () => true): Promise<Entry[]> {
|
|
const entries: Entry[] = [];
|
|
let after: string | undefined;
|
|
while (!signal?.aborted && canRun()) {
|
|
const rows = await candidates(scope, after);
|
|
entries.push(...rows.map(entry));
|
|
if (rows.length < 100) return entries;
|
|
after = rows[rows.length - 1].id;
|
|
}
|
|
throw new Error("Native workspace finalization discovery stopped");
|
|
}
|
|
function check(current: Entry, signal?: AbortSignal): Promise<Entry | null> {
|
|
return withRunWorkspaceFinalizationTiming(current.id, () => checkWithinTrace(current, signal));
|
|
}
|
|
async function checkWithinTrace(current: Entry, signal?: AbortSignal): Promise<Entry | null> {
|
|
if (signal?.aborted) throw new Error("Native workspace finalization stopped");
|
|
const scope = { companyId: current.companyId, runId: current.id };
|
|
const [row] = await candidates(scope);
|
|
if (!row) return null;
|
|
const due = entry(row);
|
|
if (due.dueAt > Date.now()) return due;
|
|
if (signal?.aborted) throw new Error("Native workspace finalization stopped");
|
|
const outcome = await measureRunPhase("execution.workspace_finalize", () => (options.attempt ?? attemptNativeWorkspaceFinalization)({
|
|
db, runId: current.id, environmentRuntime: options.environmentRuntime, pluginWorkerManager: options.pluginWorkerManager,
|
|
}));
|
|
if (outcome.kind === "retry") return { ...due, dueAt: outcome.retryAt.getTime() };
|
|
if (outcome.kind === "waiting") {
|
|
if (outcome.reason === "busy" || outcome.reason === "platform_stop_pending") {
|
|
return { ...due, dueAt: Date.now() + OWNERSHIP_RECHECK_MS };
|
|
}
|
|
// This run is revisited only after a restart or a release notification.
|
|
if (outcome.reason !== "owner_active") {
|
|
logger.info({ runId: current.id, reason: outcome.reason }, "native workspace finalization paused until the run is notified again");
|
|
}
|
|
return null;
|
|
}
|
|
if (outcome.kind !== "completed" || outcome.operation.status === "succeeded") return null;
|
|
const failure = await recordNativeWorkspaceFinalizationFailure(db, current.id, outcome.operation);
|
|
if (failure.phase === "terminal_failure") {
|
|
await options.onWorkspaceSettled({ runId: row.id, companyId: row.companyId, agentId: row.agentId, succeeded: false });
|
|
return null;
|
|
}
|
|
const [remaining] = await candidates(scope);
|
|
return remaining ? entry(remaining) : null;
|
|
}
|
|
function register(coordinator: ReturnType<typeof createDeliveryWorkCoordinator>, canRun: () => boolean,
|
|
canRecover: () => Promise<boolean> = async () => true) {
|
|
const worker = registerScopedDeadlineWork(coordinator, {
|
|
queue: DELIVERY_QUEUES.nativeWorkspaceFinalization,
|
|
// A slow sandbox export can take many minutes. Independent tasks proceed
|
|
// in parallel; runs that share a task keep their copy-backs serialized.
|
|
// Retries saved while another copy-back is still exporting are admitted
|
|
// on schedule instead of waiting for that export to finish.
|
|
concurrentChecks: { limit: 4, key: (item: Entry) => `issue:${item.companyId}:${item.issueId}`, admitDueWhileActive: true },
|
|
decodeScope: (hint: string): NativeWorkspaceFinalizationScope | undefined => hint === "*" ? undefined : JSON.parse(hint),
|
|
matches: (item: Entry, scope?: NativeWorkspaceFinalizationScope) => !scope || item.companyId === scope.companyId
|
|
&& (!scope.issueId || item.issueId === scope.issueId) && (!scope.runId || item.id === scope.runId),
|
|
load: async (scope, signal) => await canRecover() ? load(scope, signal, canRun) : [],
|
|
check: async (item, signal) => {
|
|
if (!canRun()) throw new Error("Native workspace finalization paused");
|
|
if (!await canRecover()) return null;
|
|
return check(item, signal);
|
|
},
|
|
}, canRun);
|
|
const stop = onNativeWorkspaceFinalizationRelease(db, scope => worker.invalidate(JSON.stringify(scope)));
|
|
return { ...worker, stop };
|
|
}
|
|
return { load, check, register };
|
|
}
|