mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Recovery tells a waiting parent or dependent when a child or blocker stalls on the board (`issue_blocker_stalled`) > - A legacy child that an older release bound to its parent's remote workspace failed its save, and its save recovery was exhausted. Its ended run still holds the shared workspace, days past the one-hour holder cutoff > - Workspace reservation refuses a run on such a hold and fails setup with "The workspace still has an unfinished writer or save operation." Every stall wake of the parent created a run that could only fail this way > - The stall sweep treated each failed run as an undelivered wake and retried it every ten minutes, with no backoff and no limit. On staging this made 72 failed runs (58 of 60 failures since a deploy), in pairs every ten minutes > - This pull request makes the stall sweep hold a wake while the waiter's workspace hold refuses a run, and bounds retries of a wake whose run fails > - The benefit is that a held workspace no longer produces a failed run every ten minutes, and the wake still arrives once the hold settles ## Linked Issues or Issue Description **What happened?** A parent task was woken by `issue_blocker_stalled` twice every ten minutes. Each run failed in setup (`setup_failed`, `executionRecovery.kind=bootstrap`, `providerWorkStarted=false`) with "The workspace still has an unfinished writer or save operation." The parent's remote workspace was held by a child's ended writer run whose save recovery was exhausted (`native_workspace_sync_out_retry_exhausted`, last failure `agent_stop_unconfirmed`). The child shared the parent's workspace because an older release created it before subtasks got their own workspace (#19860). **Expected behavior** A wake that cannot start because its workspace is held must not create a failed run every time. It should wait until the hold settles. Automatic retries must be bounded. **Root cause** - `server/src/services/heartbeat/workspace-preparation.ts` rethrows `RemoteWorkspaceWriterActiveError` as a setup failure when the ended writer is silent past `WORKSPACE_BUSY_HOLDER_STALE_AFTER_MS` (documented, intended). - `server/src/services/recovery/blocker-stall-wakeups.ts` retried every non-delivered wake row (including `failed`) after `REFUSED_WAKE_RETRY_MS` (10 minutes) with no backoff and no limit, and it did not check whether the waiter could start at all. ## What Changed - `remote-workspace-writer-hold.ts`: add `remoteWorkspaceHoldRefusesStart`, the one rule for a hold that reservation refuses outright (unattributed writer, or a writer silent past the cutoff with no live owner fence). - `workspace-preparation.ts`: use that rule instead of an inline copy, so reservation and wake senders cannot drift. - `blocker-stall-wakeups.ts`: before waking a waiter, read its bound remote workspace hold. When the hold refuses a run, do not wake it; count it as deferred and re-check at most hourly. A workspace with an unchanged-release baseline is left to reservation, which can release it. - `blocker-stall-wakeups.ts`: a stall wake whose run failed is retried after 10, then 20 minutes, and not after the third failed run. Refusals (wake-on-demand off, agent paused) keep the 10-minute retry. A new stall (new recovery action) wakes again. - `doc/execution-semantics.md`: document both rules. ## Verification - Red before the fix (`server/src/__tests__/issue-blocker-stall-wakeups.test.ts`): 3 new tests failed. The held-workspace test got a delivered wake; the backoff test got a third wake 11 minutes after the second failure. - Green after the fix: `npx vitest run src/__tests__/issue-blocker-stall-wakeups.test.ts` passes 20/20. - The new tests use the staging shape: a legacy `shared_workspace` remote workspace owned by the parent, a child bound to it, the child's failed native writer run ended four days ago with `native_workspace_sync_out_retry_exhausted`, a dirty authority, and the child's board-owned exhausted recovery action. They also prove that a writer inside the cutoff still gets its wake (the run parks in a workspace wait), and that the wake is delivered once the hold settles. - Related suites pass: `heartbeat-remote-workspace-busy.test.ts`, `heartbeat-dependency-scheduling.test.ts`, `native-cancelled-workspace-preservation.test.ts`, `src/modules/run-dispatch`. - `npx tsc --noEmit -p server` passes. - `pnpm verify` exits 2 because `doc/execution-semantics.md` is a global CI input; required CI supplies the broad coverage. ## Risks - Low. No schema or persisted-shape change. Saved state from the previous release (existing wake rows, existing workspace authority) is read as-is: earlier `failed` stall wake rows count toward the new three-run limit, which stops the current loop at once after deploy. - A parent whose workspace stays held is not woken for the stall until the hold settles. The stall is still listed on its page and the child's recovery action stays with the board. - Not fixed here: the exhausted save hold itself is never settled automatically (`agent_stop_unconfirmed` exhaustion in workspace recovery), and legacy children that still share a parent's workspace are not moved. Both touch workspace recovery code that is under concurrent change and are left for a follow-up. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edits, browser read-only inspection of staging). ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
5.8 KiB
TypeScript
100 lines
5.8 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { and, eq, gt, inArray, ne } from "drizzle-orm";
|
|
import { environmentLeases, heartbeatRunEvents, heartbeatRuns, type Db } from "@paperclipai/db";
|
|
import { retiredReusableLeasePredecessors } from "../environment-lease-handoff.js";
|
|
import { hasRemoteTerminationReceipt } from "../remote-execution-termination.js";
|
|
import { hasConfirmedSandboxStopAndRetain } from "../sandbox-stop-and-retain.js";
|
|
import { LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "../workspace-restore-recovery-state.js";
|
|
import { readNativeHarnessBackupStamp } from "./native-harness-backup-stamp.js";
|
|
import { nativeWorkspaceExportPendingForLease } from "./native-workspace-finalization-state.js";
|
|
|
|
/** Durable run events that prove a provider session did work in a run. */
|
|
export const NATIVE_PROVIDER_DURABLE_EVENT_TYPES = new Set([
|
|
"harness.ready",
|
|
"session.started",
|
|
"session.resumed",
|
|
"session.updated",
|
|
"turn.started",
|
|
"provider.event",
|
|
"provider.rpc_result",
|
|
]);
|
|
|
|
export function nativeHarnessBackupManifestSha256(manifestBytes: Buffer | string): string {
|
|
return `sha256:${createHash("sha256").update(manifestBytes).digest("hex")}`;
|
|
}
|
|
|
|
/**
|
|
* A created sandbox normally refuses a per-turn backup that has no recorded
|
|
* reusable lease, because the backup's source sandbox may still hold newer
|
|
* provider state. That ambiguity is absent when Paperclip itself destroyed the
|
|
* source sandbox: the provider confirmed the termination, the source lease's
|
|
* stamp names exactly this backup, and no provider work for the session was
|
|
* recorded after the backup completed. This is the same proof a confirmed
|
|
* replacement relies on, reached through a lease that was retired first.
|
|
*
|
|
* A settled workspace-restore hold is the other retired source. The hold
|
|
* changed the lease to `retain_on_failure`, which reuse never selects, and
|
|
* stopped the sandbox under a stop-only receipt that permits no provider work.
|
|
* Releases before the hold settlement restored warm reuse left such rows
|
|
* stranded, so this also unsticks state saved by those releases.
|
|
*/
|
|
export async function retiredSourceAuthorizesNativeBackupRestore(db: Db, input: {
|
|
companyId: string;
|
|
currentRunId: string;
|
|
normalizedSessionId: string;
|
|
manifest: { sourceProviderLeaseId: string; runnerInstanceId: string; normalizedSessionId: string };
|
|
manifestSha256: string;
|
|
}): Promise<boolean> {
|
|
if (!input.manifest.sourceProviderLeaseId || input.manifest.normalizedSessionId !== input.normalizedSessionId) return false;
|
|
const leases = await db.select().from(environmentLeases).where(and(
|
|
eq(environmentLeases.companyId, input.companyId),
|
|
eq(environmentLeases.providerLeaseId, input.manifest.sourceProviderLeaseId),
|
|
));
|
|
const source = leases.filter(lease => lease.companyId === input.companyId
|
|
&& lease.providerLeaseId === input.manifest.sourceProviderLeaseId);
|
|
const tips = source.filter(lease => !Object.hasOwn(lease.metadata ?? {}, "reusableLeaseSuccessor"));
|
|
if (tips.length !== 1) return false;
|
|
const lease = tips[0]!;
|
|
if (!retiredReusableLeasePredecessors(lease, source.filter(row => row.id !== lease.id))) return false;
|
|
const termination = lease.metadata?.remoteExecutionTermination as { state?: unknown } | undefined;
|
|
const destroyed = ["expired", "failed"].includes(lease.status) && hasRemoteTerminationReceipt(lease)
|
|
&& termination?.state === "destroyed";
|
|
const stamp = readNativeHarnessBackupStamp(lease.metadata?.nativeHarnessBackup, lease.providerLeaseId!);
|
|
if (!stamp || stamp.manifestSha256 !== input.manifestSha256
|
|
|| stamp.normalizedSessionId !== input.normalizedSessionId
|
|
|| stamp.runnerInstanceId !== input.manifest.runnerInstanceId) return false;
|
|
// A released lease can still be selected for resume; only a retired one is final.
|
|
if (!destroyed && !await settledRestoreHoldRetiresSource(db, lease)) return false;
|
|
// Provider work recorded after the checkpoint means the destroyed sandbox
|
|
// held newer state than this backup. Restoring would silently rewind it.
|
|
const later = await db.select({ runId: heartbeatRunEvents.runId }).from(heartbeatRunEvents)
|
|
.innerJoin(heartbeatRuns, eq(heartbeatRuns.id, heartbeatRunEvents.runId))
|
|
.where(and(
|
|
eq(heartbeatRunEvents.companyId, input.companyId),
|
|
eq(heartbeatRuns.companyId, input.companyId),
|
|
eq(heartbeatRuns.nativeSessionId, input.normalizedSessionId),
|
|
ne(heartbeatRuns.id, input.currentRunId),
|
|
inArray(heartbeatRunEvents.eventType, [...NATIVE_PROVIDER_DURABLE_EVENT_TYPES]),
|
|
gt(heartbeatRunEvents.createdAt, new Date(stamp.completedAt)),
|
|
)).limit(1);
|
|
return later.length === 0;
|
|
}
|
|
|
|
/**
|
|
* A stopped, released lease is retired only when it is a workspace-restore hold
|
|
* that nothing can select for resume: `retain_on_failure` with the hold's own
|
|
* marker, a confirmed stop-and-retain receipt whose state is `stopped`, and no
|
|
* unsaved writer or pending native workspace export bound to the allocation.
|
|
* A stopped `reuse_by_environment` lease stays ambiguous and fails closed.
|
|
*/
|
|
async function settledRestoreHoldRetiresSource(db: Db, lease: typeof environmentLeases.$inferSelect): Promise<boolean> {
|
|
const marker = lease.metadata?.workspaceRestoreRecovery as { schema?: unknown; runId?: unknown } | undefined;
|
|
if (lease.leasePolicy !== "retain_on_failure" || lease.status !== "released" || !lease.heartbeatRunId
|
|
|| marker?.schema !== LEGACY_WORKSPACE_RECOVERY_SCHEMA || marker.runId !== lease.heartbeatRunId) return false;
|
|
// Provider expiry cannot revive the allocation; it only makes the source
|
|
// more final. Every other stop-and-retain receipt check stays strict.
|
|
if (!hasConfirmedSandboxStopAndRetain({ ...lease, expiresAt: null })) return false;
|
|
// Includes the dirty remote writer source (`preparedRemoteWorkspaceSource`).
|
|
return !await nativeWorkspaceExportPendingForLease(db, lease);
|
|
}
|