Files
PaperClipAI/server/src/services/native-runtime/retained-provider-cache.ts
T
DottaandClaude 1ac8b6a711 fix(workspaces): stop re-saving a retained sandbox whose disk is still full (#20758)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - After a native run on a remote sandbox, Paperclip saves the sandbox
workspace back to durable storage. When that save fails, saved-work
recovery saves the files again from the retained sandbox, with no
provider turn.
> - A staging census of the last 48 hours
(`doc/plans/2026-11-11-staging-failures-vs-chaos-coverage.md`, class F)
found saves that failed because the sandbox disk was full.
> - #20678 already ends finalization at the first full-disk copy-back
and hands the work to saved-work recovery. Recovery then deletes
inactive tool caches that it can prove safe, and saves again.
> - When that save still found the disk full, recovery recorded a
transient `export_interrupted` failure with cause `provider_error`. It
then saved again on its automatic schedule (up to 5 retries in 30
minutes). Nothing frees space in a stopped, retained sandbox between
those attempts, so each attempt failed the same way. The task showed
"Recovering saved work…" and no Retry while this went on.
> - This pull request ends the recovery generation at the first such
failure with a named cause, `sandbox_disk_full`, and hands it to the
board with a Retry.
> - The benefit is that the task does not spend 30 minutes on saves that
cannot succeed, the user sees why, and one Retry saves the files and
starts one run.

## Linked Issues or Issue Description

Refs #20678, #20695 (named sandbox disk-full failures for copy-back and
restore)

**What happened?**
A native run on a remote sandbox finished, and its workspace copy-back
hit a full sandbox disk. Finalization handed the task to saved-work
recovery (#20678). Recovery's own save also hit the full disk. Recovery
classified this as `export_interrupted` / `retry_transient` with cause
`provider_error`, so it scheduled more automatic saves of the same
sandbox. Each one failed the same way. The task showed "Recovering saved
work…" with no Retry until the 30-minute window ran out.

**Expected behavior**
A save that still finds the disk full after the safe cache cleanup is
not repeated automatically. The recovery ends at once with the cause
`sandbox_disk_full`, and the board gets a Retry. The workspace keeps its
previous saved version, and the run's unsaved files stay in the retained
sandbox. One Retry saves the files once there is room and starts exactly
one run.

**Steps to reproduce**
1. Give a native run an accepted result whose copy-back failed
(`native_workspace_sync_out_retry_exhausted`).
2. Make every save from its retained sandbox fail with
`remote_workspace_sandbox_disk_full`, with no inactive tool cache to
delete.
3. Run saved-work recovery. Before this change, it records
`export_interrupted` with cause `provider_error` and schedules another
save.

## What Changed

- `workspace-recovery.ts`: a save that fails with
`remote_workspace_sandbox_disk_full` after a confirmed cache cleanup (or
with nothing to clean up) now fails the generation with
`export_interrupted`, retry class `user_action_required`, and cause `{
label: "sandbox_disk_full", code: "ENOSPC" }`. That class is not retried
automatically, and it is not a permanent-source class, so recovery never
falls back to the last save and never drops the run's unsaved files. The
next sweep hands the hold to the board.
- A cleanup whose outcome is unknown (lost acknowledgement, interrupted
journal) keeps the existing scheduled retry, because its journal resumes
on the next attempt and can still free space.
- `retained-provider-cache.ts`: `reclaimInactiveNativeProviderCache`
returns `{ changed, confirmed }` instead of a boolean, so the caller can
tell "nothing to delete" from "outcome unknown". It has one caller.
- `system-recovery-state.ts`: the board handoff text for this cause says
that the sandbox disk is full, that Paperclip deletes only inactive tool
caches it can prove safe, and that Retry saves the files again.
- New `workspace-recovery-disk-full.test.ts` covers the whole path: one
failed attempt, no further automatic saves, the board handoff text, the
previous saved version unchanged, the agent's files still in the
sandbox, no unfinished in-sandbox capture attempt, a Retry offered in
the task presentation and blocker, `POST /issues/:id/retry` accepted
(202), the files saved after Retry, and exactly one run started (also
with a replayed dispatch).
- `doc/execution-semantics.md`: documents the behavior.

## Verification

- Red on master: `cd server && npx vitest run
src/services/workspace-recovery-disk-full.test.ts` fails. The recovery
state is `scheduled` with cause `provider_error` instead of `exhausted`
with cause `sandbox_disk_full`.
- Green on this branch: the same command passes.
- Related suites pass: `workspace-recovery-retry-copyback.test.ts` (79
tests, including the 8 cache-cleanup resume tests that need the
scheduled retry after an unconfirmed cleanup),
`system-recovery-state.test.ts`,
`workspace-recovery-capture-policy.test.ts`,
`workspace-recovery-fallback.test.ts`,
`workspace-recovery-stop-proof.test.ts`,
`native-run-finalizer-telemetry.test.ts` (167 tests in all).
- `npx tsc --noEmit -p server/tsconfig.json` is clean.
- `pnpm verify`: exit 2 (broader verification required), because
`doc/execution-semantics.md` is a global CI input. The focused checks
above pass. Required CI supplies the broad coverage.

## Risks

- Low. The change touches only the saved-work recovery path when its
save fails with the exact full-disk message.
- Upgrade-safe: the failure code (`export_interrupted`), the retry class
(`user_action_required`) and the cause label all pass the earlier
release's recovery schema. An earlier release that reads this state
shows its generic "Retry to try again" text.
- A full-disk failure no longer gets automatic retries in its 30-minute
window. If space became free by itself in that window, the user must now
press Retry. Nothing in a stopped, retained sandbox frees space by
itself, so this is expected to be rare.
- No Runner (Rust) change, so no new sandbox image is needed.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file editing, test execution) and extended
reasoning.

## 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 <noreply@anthropic.com>
2026-11-11 01:31:50 -06:00

265 lines
20 KiB
TypeScript

import { and, asc, eq, sql } from "drizzle-orm";
import { heartbeatRuns, nativeRunFinalizations, withDedicatedDbConnection, workspaceOperations, type Db } from "@paperclipai/db";
import { z } from "zod";
import type { RemoteWorkspaceTarget } from "../remote-workspace-transport.js";
const schema = "paperclip.native-provider-cache-reclaim.v1";
const prepareSchema = "paperclip.native-provider-cache-prepare.v1";
const inventorySchema = "paperclip.native-provider-cache-inventory.v1";
const preparePhase = "native_provider_cache_reclaim_prepare";
const digestPattern = /^sha256:[a-f0-9]{64}$/;
const uuidPattern = /^[a-f0-9-]{36}$/;
const record = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> : {};
const numericIdentity = z.string().regex(/^\d{1,24}$/);
const identity = z.object({ dev: numericIdentity, ino: numericIdentity, generation: z.string().regex(/^[bc]:\d{1,24}$/) });
const inventoryCodec = z.object({ schema: z.literal(inventorySchema), sourceRunId: z.string().regex(uuidPattern),
manifestDigest: z.string().regex(digestPattern), manifestSha256: z.string().regex(/^[a-f0-9]{64}$/),
root: identity, pack: identity, manifest: identity, sessions: identity,
targets: z.array(identity.extend({ name: z.enum(["node_modules", "provider-assets"]) })).max(2),
availableBytesBefore: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).nullable(),
});
type Inventory = z.infer<typeof inventoryCodec>;
const stagedCodec = z.array(z.object({ name: z.enum(["node_modules", "provider-assets"]),
cache: identity, marker: identity, markerSha256: z.string().regex(/^[a-f0-9]{64}$/) })).max(2);
type Staged = z.infer<typeof stagedCodec>;
function verifiedCacheDigest(run: typeof heartbeatRuns.$inferSelect, workspaceId: string, target: RemoteWorkspaceTarget): string | null {
const profile = record(run.runnerProfileJson), state = record(profile.nativeProviderPackAdmissionState);
const admission = record(profile.nativeProviderPackAdmission), workspace = record(profile.remoteWorkspace);
if (!uuidPattern.test(target.leaseId ?? "") || state.schema !== "paperclip.native-provider-pack-admission-state.v1" || state.state !== "verified"
|| typeof state.admissionEpoch !== "string" || !uuidPattern.test(state.admissionEpoch)
|| admission.schema !== "paperclip.native-provider-pack-admission.v1" || admission.stage !== "session_admitted"
|| admission.authority !== "core_verified_selection" || admission.runId !== run.id
|| admission.companyId !== run.companyId || admission.agentId !== run.agentId
|| workspace.workspaceId !== workspaceId || workspace.remoteCwd !== target.remoteCwd || workspace.leaseId !== target.leaseId
|| typeof admission.providerPackDigest !== "string" || !digestPattern.test(admission.providerPackDigest)) return null;
return admission.providerPackDigest;
}
const reclaimScript = String.raw`
const fs=require('node:fs/promises'),path=require('node:path'),crypto=require('node:crypto'),{constants}=require('node:fs');
(async()=>{
const [mode,root,source,expected,raw]=process.argv.slice(1),schema='paperclip.native-provider-cache-reclaim.v1';
if(!['inventory','stage','reclaim'].includes(mode)||!path.isAbsolute(root)||!/^[a-f0-9-]{36}$/.test(source)
||!/^sha256:[a-f0-9]{64}$/.test(expected)||await fs.realpath(root)!==root)throw Error('invalid_binding');
const runtime=path.join(root,'.paperclip-runtime'),runner=path.join(runtime,'paperclip-runner'),pack=path.join(runner,'provider-pack');
const sessions=path.join(runner,'sessions'),file=path.join(pack,'provider-pack.json');
const stat=async p=>fs.lstat(p,{bigint:true}).catch(e=>{if(e.code==='ENOENT')return null;throw e;});
const directory=async p=>{const x=await fs.lstat(p,{bigint:true});if(!x.isDirectory()||x.isSymbolicLink()||await fs.realpath(p)!==p)throw Error('unsafe_directory');return x;};
const identity=x=>({dev:String(x.dev),ino:String(x.ino),generation:(x.birthtimeNs>0n?'b:'+x.birthtimeNs:'c:'+x.ctimeNs)});
const same=(a,b,generation=true)=>a&&b&&['dev','ino',...(generation?['generation']:[])].every(k=>a[k]===b[k]);
const rootIdentity=identity(await directory(root));await directory(runtime);await directory(runner);
const packIdentity=identity(await directory(pack)),sessionIdentity=identity(await directory(sessions)),m=await stat(file);
if(!m||!m.isFile()||m.isSymbolicLink()||m.nlink!==1n||m.size>524288n)throw Error('unsafe_manifest');
const bytes=await fs.readFile(file),manifest=JSON.parse(bytes),manifestIdentity=identity(m);
const canonical=v=>Array.isArray(v)?'['+v.map(canonical).join(',')+']':v&&typeof v==='object'?'{'+Object.entries(v).sort(([a],[b])=>a.localeCompare(b)).map(([k,x])=>JSON.stringify(k)+':'+canonical(x)).join(',')+'}':JSON.stringify(v);
const manifestSha256=crypto.createHash('sha256').update(bytes).digest('hex');
if(manifest.schema!=='paperclip-runner/remote-provider-pack/v1'||manifest.digest!==expected
||expected!=='sha256:'+crypto.createHash('sha256').update(canonical(manifest.payload)).digest('hex'))throw Error('manifest_changed');
const available=async()=>{try{const capacity=await fs.statfs(root),bytes=capacity.bavail*capacity.bsize;
return Number.isSafeInteger(bytes)?Math.max(0,bytes):null;}catch{return null;}};
const availableBytesBefore=await available();
if(mode==='inventory'){
const targets=[];
for(const name of ['node_modules','provider-assets']){const p=path.join(pack,name),x=await stat(p);if(!x)continue;
if(!x.isDirectory()||x.isSymbolicLink()||await fs.realpath(p)!==p)throw Error('unsafe_cache_directory');targets.push({name,...identity(x)});}
console.log(JSON.stringify({schema:'paperclip.native-provider-cache-inventory.v1',sourceRunId:source,manifestDigest:expected,
manifestSha256,root:rootIdentity,pack:packIdentity,manifest:manifestIdentity,sessions:sessionIdentity,
targets,availableBytesBefore}));return;
}
const request=JSON.parse(raw),plan=request.inventory;
if(!/^[a-f0-9-]{36}$/.test(request.preparationOperationId)||plan?.schema!=='paperclip.native-provider-cache-inventory.v1'
||plan.sourceRunId!==source||plan.manifestDigest!==expected||plan.manifestSha256!==manifestSha256
||!same(plan.root,rootIdentity)||!same(plan.pack,packIdentity,false)||!same(plan.manifest,manifestIdentity)||!same(plan.sessions,sessionIdentity)
||!Array.isArray(plan.targets)||plan.targets.length>2||new Set(plan.targets.map(x=>x.name)).size!==plan.targets.length)throw Error('plan_changed');
const markerName='.paperclip-reclaim-'+request.preparationOperationId+'.json';
const stagedPath=item=>path.join(pack,item.name+'.paperclip-reclaim-'+request.preparationOperationId);
const markerText=item=>JSON.stringify({preparationOperationId:request.preparationOperationId,source,expected,item});
const markerState=async(p,item,partial=false)=>{const x=await stat(p);
if(!x||!x.isFile()||x.isSymbolicLink()||x.nlink!==1n||x.size>4096n)throw Error('marker_changed');
const bytes=await fs.readFile(p),text=bytes.toString();
if(text!==markerText(item)&&!(partial&&markerText(item).startsWith(text)))throw Error('marker_changed');
return {identity:identity(x),sha256:crypto.createHash('sha256').update(bytes).digest('hex')};};
let freed=0n,visited=0;
const markerSpaceAvailable=async()=>{const bytes=await available();return bytes===null?freed>=262144n:bytes>=262144;};
const releaseBlocks=async root=>{
const visit=async dir=>{await directory(dir);for await(const entry of await fs.opendir(dir)){
if(++visited>8192||await markerSpaceAvailable())return;
if(entry.name===markerName)continue;
const p=path.join(dir,entry.name),x=await stat(p);if(!x||x.isSymbolicLink())continue;
if(x.isDirectory()){await visit(p);continue;}
if(!x.isFile()||x.nlink!==1n||x.size===0n||!(x.mode&0o222n))continue;
let handle;
try{handle=await fs.open(p,constants.O_RDWR|constants.O_NOFOLLOW);
const opened=await handle.stat({bigint:true});if(!same(identity(x),identity(opened))||opened.nlink!==1n)throw Error('cache_changed');
await handle.truncate(0);freed+=opened.blocks*512n;
}catch(error){if(!['EACCES','EPERM','EROFS'].includes(error.code))throw error;}finally{await handle?.close();}
}};await visit(root);};
const fallback=plan.targets.filter(item=>item.generation.startsWith('c:'));
for(const item of plan.targets){
if(!['node_modules','provider-assets'].includes(item.name))throw Error('plan_changed');
const p=path.join(pack,item.name),current=await stat(p);
if(item.generation.startsWith('c:')){
const staged=await stat(stagedPath(item));if(current&&staged)throw Error('cache_changed');
if(staged){await directory(stagedPath(item));if(!same(item,identity(staged),false))throw Error('cache_changed');
await markerState(path.join(stagedPath(item),markerName),item);}
else if(current){await directory(p);const marker=await stat(path.join(p,markerName));
if(!same(item,identity(current),!marker))throw Error('cache_changed');
if(marker)await markerState(path.join(p,markerName),item,true);}
}else if(current&&(!current.isDirectory()||current.isSymbolicLink()||await fs.realpath(p)!==p||!same(item,identity(current))))throw Error('cache_changed');
}
if(mode==='stage'){
const staged=[];
for(const item of plan.targets){if(await markerSpaceAvailable())break;
const p=path.join(pack,item.name);
if(await stat(p))await releaseBlocks(p);
else if(item.generation.startsWith('c:')&&await stat(stagedPath(item)))await releaseBlocks(stagedPath(item));
}
for(const item of fallback){const p=path.join(pack,item.name),dest=stagedPath(item),current=await stat(p);
if(current){const marker=path.join(p,markerName),existing=await stat(marker);
if(!existing||((await fs.readFile(marker)).toString()!==markerText(item))){
const handle=await fs.open(marker,constants.O_WRONLY|constants.O_NOFOLLOW|(existing?0:constants.O_CREAT|constants.O_EXCL),0o600);
try{if(existing&&!same(identity(existing),identity(await handle.stat({bigint:true}))))throw Error('marker_changed');
await handle.writeFile(markerText(item));await handle.sync();}finally{await handle.close();}
}
await fs.rename(p,dest);
}
const cache=await stat(dest);if(!cache)continue;
await directory(dest);if(!same(item,identity(cache),false))throw Error('cache_changed');
const marker=await markerState(path.join(dest,markerName),item);
staged.push({name:item.name,cache:identity(cache),marker:marker.identity,markerSha256:marker.sha256});
}
console.log(JSON.stringify({preparationOperationId:request.preparationOperationId,staged}));return;
}
if(!Array.isArray(request.staged)||request.staged.length>2||new Set(request.staged.map(x=>x.name)).size!==request.staged.length)throw Error('plan_changed');
for(const item of fallback){const witness=request.staged.find(x=>x.name===item.name),dest=stagedPath(item),current=await stat(dest);
if(await stat(path.join(pack,item.name)))throw Error('cache_changed');
if(!witness&&!current)continue;
if(!witness||!current||!same(witness.cache,identity(current),false)||!same(item,identity(current),false))throw Error('cache_changed');
const marker=await markerState(path.join(dest,markerName),item);
if(!same(witness.marker,marker.identity)||witness.markerSha256!==marker.sha256)throw Error('marker_changed');
}
let changed=false;
for(const item of plan.targets){
if(item.generation.startsWith('c:')){const p=stagedPath(item);if(!await stat(p))continue;
for await(const entry of await fs.opendir(p)){if(entry.name!==markerName)await fs.rm(path.join(p,entry.name),{recursive:true,force:false});}
changed=true;continue;}
const p=path.join(pack,item.name),current=await stat(p);if(!current)continue;
if(!current.isDirectory()||current.isSymbolicLink()||!same(item,identity(current)))throw Error('cache_changed');
await fs.rm(p,{recursive:true,force:false});changed=true;}
if(!same(plan.sessions,identity(await directory(sessions))))throw Error('sessions_changed');
console.log(JSON.stringify({schema,sourceRunId:source,manifestDigest:expected,
preparationOperationId:request.preparationOperationId,status:'evicted',changed,sessionDirectoryPreserved:true,
availableBytesBefore,availableBytesAfter:await available()}));
})().catch(()=>{process.stderr.write('native_provider_cache_reclaim_unconfirmed');process.exitCode=1;});`;
function readInventory(value: unknown, runId: string, expected: string): Inventory {
const parsed = inventoryCodec.parse(value);
if (parsed.sourceRunId !== runId || parsed.manifestDigest !== expected
|| new Set(parsed.targets.map(target => target.name)).size !== parsed.targets.length)
throw new Error("native_provider_cache_reclaim_unconfirmed");
return parsed;
}
export type NativeProviderCacheReclaim = { changed: boolean; confirmed: boolean };
export async function reclaimInactiveNativeProviderCache(input: {
db: Db; companyId: string; issueId: string; runId: string; workspaceId: string;
target: RemoteWorkspaceTarget; assertOwned: () => Promise<void>;
}): Promise<NativeProviderCacheReclaim> {
const nothing = { changed: false, confirmed: true } as const;
await input.assertOwned();
const [source] = await input.db.select({ run: heartbeatRuns, coordinator: nativeRunFinalizations })
.from(heartbeatRuns).innerJoin(nativeRunFinalizations, and(eq(nativeRunFinalizations.companyId, heartbeatRuns.companyId),
eq(nativeRunFinalizations.runId, heartbeatRuns.id)))
.where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, input.runId),
eq(nativeRunFinalizations.issueId, input.issueId))).limit(1);
if (!source || source.run.status !== "failed" || source.run.runtimeMode !== "native"
|| (source.run.nativeIssueId ?? source.run.issueId ?? source.run.contextSnapshot?.issueId) !== input.issueId
|| source.coordinator.phase !== "terminal_failure" || source.coordinator.leaseOwner !== null
|| record(source.coordinator.failureDetail).recoverable === true) return nothing;
const expected = verifiedCacheDigest(source.run, input.workspaceId, input.target);
if (!expected) return nothing;
const scope = and(eq(workspaceOperations.companyId, input.companyId), eq(workspaceOperations.issueId, input.issueId),
eq(workspaceOperations.heartbeatRunId, input.runId), eq(workspaceOperations.executionWorkspaceId, input.workspaceId));
const execute = async (mode: "inventory" | "stage" | "reclaim",
preparation?: { preparationOperationId: string; inventory: Inventory; staged?: Staged }) => {
await input.assertOwned();
const response = await input.target.runner!.execute({ command: "node", args: ["-e", reclaimScript,
mode, input.target.remoteCwd, input.runId, expected, JSON.stringify(preparation ?? null)],
cwd: input.target.remoteCwd, bypassSession: true, env: { NODE_OPTIONS: "", NODE_PATH: "" }, timeoutMs: 45_000 });
await input.assertOwned();
if (response.exitCode !== 0 || response.timedOut || Buffer.byteLength(response.stdout) > 16_384)
throw new Error("native_provider_cache_reclaim_unconfirmed");
return record(JSON.parse(response.stdout));
};
const startedAt = new Date();
let confirmed = false, changed = false, preparationOperationId: string | null = null;
let metadata: Record<string, unknown> = { schema, manifestDigest: expected, outcome: "unconfirmed" };
try {
let [prepared] = await input.db.select().from(workspaceOperations)
.where(and(scope, eq(workspaceOperations.phase, preparePhase), eq(workspaceOperations.status, "succeeded")))
.orderBy(asc(workspaceOperations.startedAt), asc(workspaceOperations.id)).limit(1);
if (!prepared) {
const inventory = readInventory(await execute("inventory"), input.runId, expected);
if (!inventory.targets.length) return nothing;
await input.assertOwned();
[prepared] = await withDedicatedDbConnection(input.db, journal => journal.transaction(async tx => {
await tx.execute(sql`select set_config('statement_timeout', '10000', true), set_config('lock_timeout', '2000', true)`);
return tx.insert(workspaceOperations).values({ companyId: input.companyId, issueId: input.issueId,
heartbeatRunId: input.runId, executionWorkspaceId: input.workspaceId, phase: preparePhase,
status: "succeeded", startedAt, finishedAt: new Date(),
metadata: { schema: prepareSchema, intentOnly: true, leaseId: input.target.leaseId,
remoteCwd: input.target.remoteCwd, inventory },
}).returning();
}));
}
if (!prepared || prepared.metadata?.schema !== prepareSchema || prepared.metadata.intentOnly !== true
|| prepared.metadata.leaseId !== input.target.leaseId || prepared.metadata.remoteCwd !== input.target.remoteCwd)
throw new Error("native_provider_cache_reclaim_unconfirmed");
const inventory = readInventory(prepared.metadata.inventory, input.runId, expected);
preparationOperationId = prepared.id;
metadata = { ...metadata, preparationOperationId };
const [completed] = await input.db.select({ id: workspaceOperations.id }).from(workspaceOperations).where(and(scope,
eq(workspaceOperations.phase, "native_provider_cache_reclaim"), eq(workspaceOperations.status, "succeeded"),
sql`${workspaceOperations.metadata}->>'preparationOperationId' = ${preparationOperationId}`,
sql`${workspaceOperations.metadata}->>'outcome' = 'evicted'`)).limit(1);
if (completed) return nothing;
let staged: Staged = [];
if (inventory.targets.some(target => target.generation.startsWith("c:"))) {
if (prepared.metadata.staged === undefined) {
const response = await execute("stage", { preparationOperationId, inventory });
if (response.preparationOperationId !== preparationOperationId) throw new Error("native_provider_cache_reclaim_unconfirmed");
staged = stagedCodec.parse(response.staged);
if (new Set(staged.map(item => item.name)).size !== staged.length || staged.some(item => !inventory.targets.some(target =>
target.name === item.name && target.generation.startsWith("c:") && target.dev === item.cache.dev && target.ino === item.cache.ino)))
throw new Error("native_provider_cache_reclaim_unconfirmed");
await input.assertOwned();
[prepared] = await withDedicatedDbConnection(input.db, journal => journal.transaction(async tx => {
await tx.execute(sql`select set_config('statement_timeout', '10000', true), set_config('lock_timeout', '2000', true)`);
return tx.update(workspaceOperations).set({ metadata: { ...prepared.metadata, staged } })
.where(and(scope, eq(workspaceOperations.id, preparationOperationId!))).returning();
}));
if (!prepared) throw new Error("native_provider_cache_reclaim_unconfirmed");
}
staged = stagedCodec.parse(prepared.metadata?.staged);
}
const receipt = await execute("reclaim", { preparationOperationId, inventory, staged });
if (receipt.schema !== schema || receipt.sourceRunId !== input.runId || receipt.manifestDigest !== expected
|| receipt.preparationOperationId !== preparationOperationId || receipt.status !== "evicted"
|| typeof receipt.changed !== "boolean" || receipt.sessionDirectoryPreserved !== true)
throw new Error("native_provider_cache_reclaim_unconfirmed");
metadata = { schema, manifestDigest: expected, preparationOperationId, outcome: receipt.status, changed: receipt.changed,
sessionDirectoryPreserved: true,
availableBytesBefore: typeof receipt.availableBytesBefore === "number" ? receipt.availableBytesBefore : null,
availableBytesAfter: typeof receipt.availableBytesAfter === "number" ? receipt.availableBytesAfter : null };
confirmed = true;
changed = receipt.changed;
} catch {}
await input.db.insert(workspaceOperations).values({ companyId: input.companyId, issueId: input.issueId,
heartbeatRunId: input.runId, executionWorkspaceId: input.workspaceId, phase: "native_provider_cache_reclaim",
status: confirmed ? "succeeded" : "failed", startedAt, finishedAt: new Date(), metadata });
return { changed, confirmed };
}