mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 11:46:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native task recovery must save unfinished workspace exports before continuation. > - A prepared export can still refer to a sandbox with a warm reuse policy. > - The strict recovery inspector rejects that policy and exhausts source verification. > - Ordinary reuse must not give the same unfinished allocation to another run. > - An existing user Retry can authorize bounded file maintenance after exact ownership checks. > - This change retains that exact source and preserves the original failure record. > - Recovery completes only when files, stop evidence and policy settlement agree. ## Linked Issues or Issue Description **What happened?** A failed native run has a prepared `keep_running` workspace export. Its exact sandbox lease still has `reuse_by_environment` policy. Native export cleanup can preserve that policy while it stops the sandbox. Generic workspace recovery then rejects the source with `lease_policy_not_retained`. A saved user Retry remains blocked before file export. **Expected behavior** Keep unfinished exports out of ordinary warm reuse. With current user authority and an unchanged saved Retry, retain the exact source for one bounded maintenance generation. Preserve files and return continuation to normal admission. Keep all ambiguous sources held. **Steps to reproduce** 1. Prepare a native workspace export with a warm sandbox and `keep_running` disposition. 2. Save a normal board Retry for the failed source run. 3. Persist its first exhausted source-verification failure before file export. 4. Run the normal maintenance worker. Before this change, source policy rejection leaves the saved files unrestored. Refs #17773 for source diagnostics. Related #17839 records a missing preservation obligation during blocked restart. This change handles an existing prepared warm export whose verification already failed. ## What Changed - Exclude unfinished physical allocations from reuse and obsolete-lease cleanup across every historical lease row. Reuse a clean candidate or acquire a fresh sandbox through normal selection. Preserve exact same-run reattachment, workspace-less execution and ordinary task admission checks. - Preserve the newer prepared remote-workspace writer proof for native and legacy runs. Share its binding check between single-source recovery and one batched allocation read. - Keep persisted recovery-action IDs when constructing continuation source checks. Synthetic sources cannot grant snapshot receipt authority. - Permit one audited maintenance adoption for the first exhausted source-verification attempt. Require an unadmitted canonical saved board Retry, current user authority, unchanged assignment and latest source run. - Keep all company, source, allocation, workspace writer, controller and physical finalization ownership checks. The public read-only inspector keeps its strict policy check. - Pin the exact source for file maintenance. Preserve the previous generation and reuse the existing bounded recovery worker. Create no provider turn or new user request. - Restore the warm policy in the same transaction as verified native copyback completion. A failed write or changed settlement keeps a recovery obligation. - When local merge metadata is missing, settle the exact verified snapshot while keeping its unfinished allocation retained. Deliver the saved Retry through normal fresh isolation and attach the verified files. Keep the accepted result as history. - Require native copyback proof before reopening an accepted result's original workspace finalizer. - Validate snapshot receipts through one shared boundary for warm, retained and ephemeral sources. Bind the receipt to the current generation's operation IDs, company, issue, run, recovery action, lease and provider allocation. - Retire fixture input before removing recovery proof, and use the same configured heartbeat for HTTP and worker dispatch. Keep the exact catalog refusal and no-provider-work assertions. - Document the recovery contract in technical documentation. Leave capability maps unchanged for this bug fix. ## Verification - Candidate-selection RED: all six clean-candidate and fresh-allocation controls fail on unchanged `f54e7baf538` because a pending export aborts the whole acquisition. GREEN: the focused suite passes **15/15 cases** after candidate filtering. Both provider paths preserve the protected lease and never resume or destroy its allocation. Obsolete fingerprints cannot send that allocation to cleanup. Historical-row fencing, modern native/legacy writers, exact same-run reuse, workspace-less execution and the single-read allocation-history check remain covered. - The existing reusable-runtime selection cases passed **40/40**. Driver/capability contract suites passed **5/5**. Reusable service controls passed **2/2**. After merging current master, the combined lease/provider suites pass 61 cases and the recovery integration selection passes 36 cases, including the upstream inherited-session and resolved-interruption assertions. This gives 97 passing targeted cases on the final source. A duplicate test-app declaration from the automatic merge was removed while retaining the upstream assertions. - The source-history repair has meaningful red/green evidence: three valid retired-handoff cases could not complete under the old blanket history-row rejection. Settlement now uses the same full source inspector as adoption. It accepts exact retired handoffs and still rejects a predecessor that becomes active after export. The 26 focused history, rollback, snapshot and ambiguity cases passed before this last merge. - Server typecheck passes on the final merged source. Module boundaries and `git diff --check` pass. Full local repository tests and a successful current-head full build are not claimed. Current-head CI is complete on `0ef974d76483e11fb0b0304bcb05bbd3b56e2ef9`: both required gates pass, with 54 successful checks, three expected skips, one neutral Paperclip review check and a successful security status. [Full CI evidence](https://github.com/paperclipai/paperclip/actions/runs/41904339370). Greptile reviewed that exact head at **5/5** with no actionable findings. Its check completed successfully. [Ponytail also reviewed the exact head at 5/5](https://github.com/paperclipai/paperclip/pull/17916#issuecomment-6640083408). No review threads remain unresolved, and the branch is mergeable. - Earlier local broad runs exposed a dropped recovery-action ID in the merged source projection and deferred test input being revisited after its records were removed. Both were repaired. Later local disk exhaustion caused manifest and saved-file reserve failures; generated caches were removed only from these checkouts. The affected saved-file control passed unchanged after cleanup. The original logs are retained. No disk reserve or timeout was lowered. - On the prior head, the first [full CI attempt](https://github.com/paperclipai/paperclip/actions/runs/41899038530) received simultaneous runner shutdowns. Four jobs were cancelled, and two late jobs have lost-runner annotations with unavailable logs. The Runner job had five earlier ACPX failures whose full assertion report was lost on interruption. Their cause remains unproven. Identical Runner source passed the [#19004 control](https://github.com/paperclipai/paperclip/actions/runs/41898305521/job/126814429725), including all seven enabled tests in that file. - That attempt's [browser shard](https://github.com/paperclipai/paperclip/actions/runs/41899038530/job/126817404595) returned to the exact saved task URL, but its conversation was still loading at the unchanged heading deadline. The [artifact](https://github.com/paperclipai/paperclip/actions/runs/41899038530/artifacts/13358840931) is retained. All five Cloud-auth cases passed locally on the unchanged head and in the [same-source CI control](https://github.com/paperclipai/paperclip/actions/runs/41898305521/job/126814429966). The delay remains unexplained. One rerun of failed/interrupted jobs was submitted after the workflow completed. This new source revision requires fresh CI. - The earlier [original-head workflow](https://github.com/paperclipai/paperclip/actions/runs/40552228175) passed all 48 jobs, and prior head `f54e7baf538` received Greptile 5/5. These results do not establish readiness for the new head. The earlier Superagent scan was neutral after a pinned-base checkout failure; its external analysis remains unverified. - These tests use real PostgreSQL and controlled provider boundaries. Hosted rollout and original-task acceptance remain unverified. ## Risks - Maintenance can resume and stop the exact sandbox for file export. It cannot admit a provider turn, choose another allocation or bypass normal continuation gates. - A saved Retry alone is insufficient. A changed source, newer run, active writer, competing lease, lost user authority or previous adoption keeps the hold. - Policy restoration can fail. Completion and restoration now commit together, so normal recovery remains responsible for a retry. - Snapshot preservation cannot repair missing local merge metadata. Its old allocation remains explicitly retained. A snapshot receipt grants no permission to reuse or destroy that allocation, or to replay the accepted result. - The allocation check uses one projected join per candidate over the existing provider lease index. It adds no polling timer or recurring scan. A fresh fallback can create another sandbox while the unfinished source remains retained; it grants no authority to execute a provider turn. - This change does not establish the cause of a prior provider failure or prove that any specific hosted sandbox still contains its original files. ## Model Used OpenAI Codex, GPT-6 family, for the original change. OpenAI GPT-6.1 Sol (`gpt-6.1-sol`), xhigh reasoning, for the merge and integration repairs. Both used repository tools and PostgreSQL verification. ## 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 - [x] All Paperclip CI gates are green - [x] 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> Co-authored-by: Devin Foley <devin@paperclip.ing>
281 lines
22 KiB
TypeScript
281 lines
22 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { agents, agentWakeupRequests, applyPendingMigrations, companies, createDb, environmentLeases, environments, executionWorkspaces, heartbeatRuns, issueRecoveryActions, issues, nativeRunFinalizations, plugins } from "@paperclipai/db";
|
|
import type { Environment } from "@paperclipai/shared";
|
|
import type { PluginWorkerManager } from "../plugin-worker-manager.js";
|
|
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
|
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
|
|
import { environmentRuntimeService } from "../environment-runtime.js";
|
|
import * as leases from "../environments.js";
|
|
import * as providers from "../sandbox-provider-runtime.js";
|
|
import { createSystemRecovery, failSystemRecovery, reserveSystemRecoveryAttempt, SystemRecoveryFailure } from "../system-recovery-state.js";
|
|
import { recoverRetainedWorkspace } from "../workspace-recovery.js";
|
|
import { nativeWorkspaceExportPendingForLease, nativeWorkspaceExportPendingForResource } from "./native-workspace-finalization-state.js";
|
|
|
|
describe("native workspace export warm reuse fence", () => {
|
|
let db: ReturnType<typeof createDb>;
|
|
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
beforeAll(async () => {
|
|
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_WORKSPACE_RECOVERY_TEST_DATABASE_URL");
|
|
if (external) { await applyPendingMigrations(external); temporary = { connectionString: external, cleanup: async () => {} }; }
|
|
else temporary = await startEmbeddedPostgresTestDatabase("native-warm-reuse-");
|
|
db = createDb(temporary.connectionString);
|
|
}, 30_000);
|
|
afterEach(() => vi.restoreAllMocks());
|
|
afterAll(async () => { await db?.$client.end(); await temporary?.cleanup(); });
|
|
|
|
async function seed(plugin: boolean) {
|
|
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(), pluginId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Native warm fence", issuePrefix: `W${companyId.slice(0, 6)}` });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", adapterType: "paperclip_runner" });
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Keep the source", assigneeAgentId: agentId });
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, nativeIssueId: issueId, runtimeMode: "native", status: "running" });
|
|
const provider = plugin ? "native-fence-plugin" : "fake";
|
|
if (plugin) await db.insert(plugins).values({ id: pluginId, pluginKey: `native-fence.${pluginId}`,
|
|
packageName: "native-fence-provider", version: "1.0.0", apiVersion: 1, categories: ["automation"], status: "ready",
|
|
manifestJson: { id: `native-fence.${pluginId}`, apiVersion: 1, version: "1.0.0", displayName: "Native fence provider",
|
|
description: "Controlled lifecycle boundary", author: "Paperclip", categories: ["automation"],
|
|
capabilities: ["environment.drivers.register"], entrypoints: { worker: "dist/worker.js" },
|
|
environmentDrivers: [{ driverKey: provider, kind: "sandbox_provider", displayName: "Native fence", supportsReusableLeases: true,
|
|
configSchema: { type: "object", properties: { image: { type: "string" }, reuseLease: { type: "boolean" } } } }] } });
|
|
const [environment] = await db.insert(environments).values({ name: runId, driver: "sandbox", status: "active",
|
|
config: { provider, image: "fake:test", reuseLease: true } }).returning();
|
|
const providerLeaseId: string = randomUUID();
|
|
let acquisitions = 0;
|
|
const call = vi.fn(async (_id: string, method: string, input?: Record<string, unknown>) => {
|
|
if (method === "environmentAcquireLease") return { providerLeaseId: acquisitions++ ? randomUUID() : providerLeaseId,
|
|
metadata: { remoteCwd: "/work" } };
|
|
if (method === "environmentResumeLease") return { providerLeaseId: String(input?.providerLeaseId ?? providerLeaseId), metadata: { remoteCwd: "/work" } };
|
|
if (["environmentReleaseLease", "environmentStopLease"].includes(method)) return { providerLeaseId, state: "stopped" };
|
|
if (method === "environmentDestroyLease") return { providerLeaseId, state: "destroyed" };
|
|
throw new Error(method);
|
|
});
|
|
const worker = { isRunning: () => true, call, getWorker: () => ({ supportedMethods: ["environmentResumeLease",
|
|
"environmentReleaseLease", "environmentStopLease", "environmentDestroyLease"] }) } as unknown as PluginWorkerManager;
|
|
if (!plugin) {
|
|
const original = providers.getSandboxProvider;
|
|
vi.spyOn(providers, "getSandboxProvider").mockImplementation(key => {
|
|
const value = original(key);
|
|
return value && key === "fake" ? Object.assign(Object.create(value), { supportsReusableLeases: true }) : value;
|
|
});
|
|
}
|
|
const runtime = environmentRuntimeService(db, { pluginWorkerManager: worker });
|
|
const input = { companyId, agentId, issueId, environment: environment as unknown as Environment,
|
|
heartbeatRunId: runId, persistedExecutionWorkspace: null };
|
|
const acquired = await runtime.acquireRunLease(input);
|
|
await runtime.releaseRunLeases(runId, "released", undefined, "keep_running");
|
|
const reference = { schema: "paperclip.native-workspace-sync/v3", state: "prepared", leaseId: acquired.lease.id,
|
|
providerLeaseId: acquired.lease.providerLeaseId, workspaceId: issueId, remoteCwd: "/work", resourceDisposition: "keep_running",
|
|
descriptorSha256: "a".repeat(64), baselineSha256: "b".repeat(64), finalHostSha256: null };
|
|
await db.update(heartbeatRuns).set({ status: "failed", runnerProfileJson: { nativeWorkspaceSync: reference } }).where(eq(heartbeatRuns.id, runId));
|
|
const nextRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: nextRunId, companyId, agentId, issueId, nativeIssueId: issueId, runtimeMode: "native", status: "running" });
|
|
call.mockClear();
|
|
return { runtime, input, runId, nextRunId, reference, lease: acquired.lease, call };
|
|
}
|
|
|
|
async function expectFreshAllocation(f: Awaited<ReturnType<typeof seed>>, runtime = f.runtime) {
|
|
const [protectedLease] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.lease.id));
|
|
const fresh = await runtime.acquireRunLease({ ...f.input, heartbeatRunId: f.nextRunId });
|
|
expect(fresh.lease.providerLeaseId).not.toBe(f.lease.providerLeaseId);
|
|
expect(fresh.lease.heartbeatRunId).toBe(f.nextRunId);
|
|
expect((await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.lease.id)))[0]).toEqual(protectedLease);
|
|
expect(f.call.mock.calls.map(call => call[1])).not.toContain("environmentResumeLease");
|
|
expect(f.call.mock.calls.map(call => call[1])).not.toContain("environmentDestroyLease");
|
|
return fresh;
|
|
}
|
|
|
|
it.each([false, true])("allocates a fresh sandbox while retaining its unfinished source for plugin=%s", async plugin => {
|
|
const f = await seed(plugin);
|
|
const acquire = vi.spyOn(providers, "acquireSandboxProviderLease");
|
|
const destroy = vi.spyOn(providers, "destroySandboxProviderLease");
|
|
await expectFreshAllocation(f);
|
|
expect(destroy).not.toHaveBeenCalled();
|
|
if (plugin) expect(f.call.mock.calls.map(call => call[1])).toEqual(["environmentAcquireLease"]);
|
|
else expect(acquire).toHaveBeenCalledWith(expect.objectContaining({ reusableProviderLeaseId: null }));
|
|
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].runnerProfileJson)
|
|
.toMatchObject({ nativeWorkspaceSync: { state: "prepared", leaseId: f.lease.id } });
|
|
});
|
|
|
|
it.each([{ plugin: false, obsolete: false }, { plugin: true, obsolete: false },
|
|
{ plugin: false, obsolete: true }, { plugin: true, obsolete: true }])(
|
|
"reuses a clean candidate without touching a pending allocation: plugin=$plugin obsolete=$obsolete", async ({ plugin, obsolete }) => {
|
|
const f = await seed(plugin), cleanRunId = randomUUID(), cleanLeaseId = randomUUID(), cleanProviderLeaseId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: cleanRunId, companyId: f.input.companyId, agentId: f.input.agentId,
|
|
issueId: f.input.issueId, runtimeMode: "legacy", status: "succeeded", finishedAt: new Date() });
|
|
await db.insert(environmentLeases).values({ id: cleanLeaseId, companyId: f.input.companyId, environmentId: f.input.environment.id,
|
|
heartbeatRunId: cleanRunId, issueId: f.input.issueId, provider: f.lease.provider, providerLeaseId: cleanProviderLeaseId,
|
|
leasePolicy: "reuse_by_environment", status: "released", cleanupStatus: "success", metadata: f.lease.metadata });
|
|
if (obsolete) await db.update(environmentLeases).set({ metadata: { ...f.lease.metadata, reusableSandboxLease: null } })
|
|
.where(eq(environmentLeases.id, f.lease.id));
|
|
const [protectedLease] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.lease.id));
|
|
// The provider boundary returns the exact clean allocation selected by
|
|
// the real policy. No source inspection or database reads are mocked.
|
|
const acquire = vi.spyOn(providers, "acquireSandboxProviderLease");
|
|
if (!plugin) acquire.mockImplementation(async input => ({ providerLeaseId: input.reusableProviderLeaseId!,
|
|
metadata: { provider: "fake", image: "fake:test", reuseLease: true } }));
|
|
const destroy = vi.spyOn(providers, "destroySandboxProviderLease");
|
|
const resumed = await f.runtime.acquireRunLease({ ...f.input, heartbeatRunId: f.nextRunId });
|
|
expect(resumed.lease).toMatchObject({ heartbeatRunId: f.nextRunId, providerLeaseId: cleanProviderLeaseId });
|
|
expect((await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.lease.id)))[0]).toEqual(protectedLease);
|
|
expect((await db.select().from(environmentLeases).where(eq(environmentLeases.id, cleanLeaseId)))[0].status).toBe("expired");
|
|
expect(destroy).not.toHaveBeenCalled();
|
|
if (plugin) {
|
|
expect(f.call.mock.calls.map(call => call[1])).toEqual(["environmentResumeLease"]);
|
|
expect(f.call.mock.calls[0]?.[2]).toMatchObject({ providerLeaseId: cleanProviderLeaseId });
|
|
} else expect(acquire).toHaveBeenCalledWith(expect.objectContaining({ reusableProviderLeaseId: cleanProviderLeaseId }));
|
|
});
|
|
|
|
it("checks the allocation even when the candidate snapshot contains only a rebound row", async () => {
|
|
const f = await seed(true);
|
|
const service = leases.environmentService(db);
|
|
const [rebound] = await db.insert(environmentLeases).values({ companyId: f.input.companyId, environmentId: f.input.environment.id,
|
|
issueId: f.input.issueId, heartbeatRunId: f.nextRunId, provider: f.lease.provider, providerLeaseId: f.lease.providerLeaseId,
|
|
leasePolicy: "reuse_by_environment", status: "retained", metadata: f.lease.metadata }).returning();
|
|
const list = service.listLeases;
|
|
service.listLeases = async (...args) => (await list(...args)).filter(lease => lease.id === rebound.id);
|
|
vi.spyOn(leases, "environmentService").mockReturnValue(service);
|
|
const runtime = environmentRuntimeService(db, { pluginWorkerManager: { isRunning: () => true, call: f.call,
|
|
getWorker: () => ({ supportedMethods: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"] }) } as unknown as PluginWorkerManager });
|
|
await expectFreshAllocation(f, runtime);
|
|
expect(f.call.mock.calls.map(call => call[1])).toEqual(["environmentAcquireLease"]);
|
|
});
|
|
|
|
it.each(["native", "legacy"])("fences a historical modern %s writer in one allocation read", async runtimeMode => {
|
|
const f = await seed(true), workspaceId = randomUUID();
|
|
const reference = { schema: "paperclip.remote-workspace-run.v1", workspaceId, leaseId: f.lease.id,
|
|
providerLeaseId: f.lease.providerLeaseId!, remoteCwd: "/work", epoch: 1, state: "prepared", snapshotId: null };
|
|
const authority = { schema: "paperclip.remote-workspace.v1", epoch: 1, snapshotId: null, source: { kind: "empty" },
|
|
writerRunId: f.runId, dirty: true, location: { environmentId: f.input.environment.id,
|
|
leaseId: f.lease.id, providerLeaseId: f.lease.providerLeaseId!, remoteCwd: "/work" } };
|
|
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId: f.input.companyId,
|
|
sourceIssueId: f.input.issueId, name: "Unsaved remote source", cwd: "/work", providerRef: "/work",
|
|
providerType: "cloud_sandbox", mode: "isolated_workspace", strategyType: "git_worktree",
|
|
status: "active", metadata: { remoteWorkspace: authority } });
|
|
await db.update(environmentLeases).set({ executionWorkspaceId: workspaceId }).where(eq(environmentLeases.id, f.lease.id));
|
|
await db.update(heartbeatRuns).set({ runtimeMode, nativeIssueId: runtimeMode === "native" ? f.input.issueId : null,
|
|
runnerProfileJson: { remoteWorkspace: reference } }).where(eq(heartbeatRuns.id, f.runId));
|
|
const [rebound] = await db.insert(environmentLeases).values({ companyId: f.input.companyId, environmentId: f.input.environment.id,
|
|
issueId: f.input.issueId, heartbeatRunId: f.nextRunId, provider: f.lease.provider, providerLeaseId: f.lease.providerLeaseId,
|
|
leasePolicy: "reuse_by_environment", status: "retained", metadata: f.lease.metadata }).returning();
|
|
const reads = vi.spyOn(db, "select");
|
|
expect(await nativeWorkspaceExportPendingForResource(db, rebound, f.nextRunId)).toBe(true);
|
|
expect(reads).toHaveBeenCalledTimes(1);
|
|
reads.mockRestore();
|
|
await expectFreshAllocation(f);
|
|
expect(f.call.mock.calls.map(call => call[1])).toEqual(["environmentAcquireLease"]);
|
|
// A saved descriptor or a changed writer cannot keep fencing an allocation.
|
|
await db.update(heartbeatRuns).set({ runnerProfileJson: { remoteWorkspace: { ...reference, state: "saved" } } })
|
|
.where(eq(heartbeatRuns.id, f.runId));
|
|
expect(await nativeWorkspaceExportPendingForResource(db, rebound, f.nextRunId)).toBe(false);
|
|
await db.update(heartbeatRuns).set({ runnerProfileJson: { remoteWorkspace: reference } }).where(eq(heartbeatRuns.id, f.runId));
|
|
await db.update(executionWorkspaces).set({ metadata: { remoteWorkspace: { ...authority, writerRunId: f.nextRunId } } })
|
|
.where(eq(executionWorkspaces.id, workspaceId));
|
|
expect(await nativeWorkspaceExportPendingForResource(db, rebound, f.nextRunId)).toBe(false);
|
|
});
|
|
|
|
it("rechecks a prepared export after selecting a stale candidate and before resuming it", async () => {
|
|
const f = await seed(true);
|
|
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceSync: { ...f.reference,
|
|
state: "finalized", finalHostSha256: "c".repeat(64) } } }).where(eq(heartbeatRuns.id, f.runId));
|
|
const service = leases.environmentService(db), list = service.listLeases;
|
|
service.listLeases = async (...args) => {
|
|
const snapshot = await list(...args);
|
|
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceSync: f.reference } }).where(eq(heartbeatRuns.id, f.runId));
|
|
return snapshot;
|
|
};
|
|
vi.spyOn(leases, "environmentService").mockReturnValue(service);
|
|
const runtime = environmentRuntimeService(db, { pluginWorkerManager: { isRunning: () => true, call: f.call,
|
|
getWorker: () => ({ supportedMethods: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"] }) } as unknown as PluginWorkerManager });
|
|
await expectFreshAllocation(f, runtime);
|
|
expect(f.call.mock.calls.map(call => call[1])).toEqual(["environmentAcquireLease"]);
|
|
});
|
|
|
|
it("preserves exact same-run reattachment without allocating a replacement", async () => {
|
|
const f = await seed(true);
|
|
await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, f.runId));
|
|
const resumed = await f.runtime.acquireRunLease(f.input);
|
|
expect(resumed.lease.id).toBe(f.lease.id);
|
|
expect(f.call.mock.calls.map(call => call[1])).toEqual(["environmentResumeLease"]);
|
|
});
|
|
|
|
it("checks a long allocation history in one read and allows finalized workspace reuse", async () => {
|
|
const f = await seed(true);
|
|
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceSync: { ...f.reference,
|
|
state: "finalized", finalHostSha256: "c".repeat(64) } } }).where(eq(heartbeatRuns.id, f.runId));
|
|
const history = Array.from({ length: 64 }, () => randomUUID());
|
|
await db.insert(heartbeatRuns).values(history.map(id => ({ id, companyId: f.input.companyId, agentId: f.input.agentId,
|
|
issueId: f.input.issueId, nativeIssueId: f.input.issueId, runtimeMode: "native", status: "succeeded",
|
|
runnerProfileJson: { nativeWorkspaceSync: { ...f.reference, state: "finalized", finalHostSha256: "c".repeat(64) } } })));
|
|
await db.insert(environmentLeases).values(history.map(heartbeatRunId => ({ companyId: f.input.companyId,
|
|
environmentId: f.input.environment.id, heartbeatRunId, provider: f.lease.provider,
|
|
providerLeaseId: f.lease.providerLeaseId, leasePolicy: "reuse_by_environment", status: "released" })));
|
|
const reads = vi.spyOn(db, "select");
|
|
expect(await nativeWorkspaceExportPendingForResource(db, f.lease, f.nextRunId)).toBe(false);
|
|
expect(reads).toHaveBeenCalledTimes(1);
|
|
reads.mockRestore();
|
|
const resumed = await f.runtime.acquireRunLease({ ...f.input, heartbeatRunId: f.nextRunId });
|
|
expect(resumed.lease.id).not.toBe(f.lease.id);
|
|
expect(resumed.lease).toMatchObject({ heartbeatRunId: f.nextRunId, providerLeaseId: f.lease.providerLeaseId });
|
|
expect((await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.lease.id)))[0])
|
|
.toMatchObject({ heartbeatRunId: f.runId, status: "expired" });
|
|
expect(f.call.mock.calls.map(call => call[1])).toEqual(["environmentResumeLease"]);
|
|
});
|
|
|
|
it.each(["grok_bot", "muse"])("does not fence an exactly bound workspace-less %s run", async kind => {
|
|
const f = await seed(true);
|
|
const binding = { runId: f.runId, companyId: f.input.companyId, agentId: f.input.agentId, issueId: f.input.issueId };
|
|
const profile = { nativeWorkspaceSync: f.reference, nativeExecutionInput: {
|
|
schema: kind === "muse" ? "paperclip.native-execution-input.v8" : "paperclip.native-execution-input.v6",
|
|
binding, workspace: { access: "none", cwd: null }, provider: { kind, bridgeRevision: "muse-v1" } } };
|
|
await db.update(heartbeatRuns).set({ driverKind: kind === "muse" ? "muse_external" : "grok_bot_mcp",
|
|
runnerProfileJson: profile }).where(eq(heartbeatRuns.id, f.runId));
|
|
expect(await nativeWorkspaceExportPendingForLease(db, f.lease)).toBe(false);
|
|
expect(await nativeWorkspaceExportPendingForResource(db, f.lease, f.nextRunId)).toBe(false);
|
|
await db.update(heartbeatRuns).set({ runnerProfileJson: { ...profile, nativeExecutionInput: {
|
|
...profile.nativeExecutionInput, binding: { ...binding, runId: randomUUID() } } } }).where(eq(heartbeatRuns.id, f.runId));
|
|
expect(await nativeWorkspaceExportPendingForResource(db, f.lease, f.nextRunId)).toBe(true);
|
|
});
|
|
|
|
it("does not take over a source while an ordinary provider resume is awaiting its database handoff", async () => {
|
|
const f = await seed(true), requestId = randomUUID(), actionId = randomUUID();
|
|
await db.update(heartbeatRuns).set({ runnerProfileJson: {} }).where(eq(heartbeatRuns.id, f.runId));
|
|
let entered!: () => void, release!: () => void;
|
|
const started = new Promise<void>(resolve => { entered = resolve; });
|
|
const pending = new Promise<void>(resolve => { release = resolve; });
|
|
f.call.mockImplementation(async (_id: string, method: string) => {
|
|
if (method !== "environmentResumeLease") throw new Error(method);
|
|
entered(); await pending;
|
|
return { providerLeaseId: f.lease.providerLeaseId!, metadata: { remoteCwd: "/work" } };
|
|
});
|
|
const ordinary = f.runtime.acquireRunLease({ ...f.input, heartbeatRunId: f.nextRunId });
|
|
try {
|
|
await started;
|
|
const failure = failSystemRecovery(reserveSystemRecoveryAttempt(createSystemRecovery(f.runId, requestId))!,
|
|
new SystemRecoveryFailure("source_binding_invalid", "service_required"));
|
|
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceSync: f.reference },
|
|
resultJson: { workspaceRestoreFailure: "restore_failed", workspaceRestoreRecovery: {
|
|
schema: "paperclip.workspace-restore-recovery.v1", leaseIds: [f.lease.id] } } }).where(eq(heartbeatRuns.id, f.runId));
|
|
await db.insert(nativeRunFinalizations).values({ companyId: f.input.companyId, issueId: f.input.issueId,
|
|
runId: f.runId, phase: "terminal_failure" });
|
|
await db.insert(issueRecoveryActions).values({ id: actionId, companyId: f.input.companyId, sourceIssueId: f.input.issueId,
|
|
kind: "active_run_watchdog", ownerType: "system", cause: "native_workspace_sync_out_retry_exhausted", fingerprint: f.runId,
|
|
nextAction: "Preserve the source", evidence: { runId: f.runId, systemRecovery: failure, workspaceRecovery: {
|
|
schema: "paperclip.workspace-recovery.v1", runId: f.runId, status: "blocked", stage: "verifying_stop",
|
|
attempt: 1, requestIds: [requestId] } } });
|
|
await db.insert(agentWakeupRequests).values({ id: requestId, companyId: f.input.companyId, agentId: f.input.agentId,
|
|
source: "on_demand", reason: "task_retry", status: "deferred_issue_execution", requestedByActorType: "user", requestedByActorId: "board",
|
|
payload: { issueId: f.input.issueId, taskRetry: { schema: "paperclip.task-retry.v1", generationId: requestId,
|
|
sourceRunId: f.runId, actorId: "board", actorSource: "local_implicit", strategy: "evaluate" } } });
|
|
await recoverRetainedWorkspace({ db, companyId: f.input.companyId, issueId: f.input.issueId,
|
|
runId: f.runId, actionId, environmentRuntime: f.runtime });
|
|
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, actionId));
|
|
expect(action.evidence.systemRecovery).toEqual(failure);
|
|
expect(action.evidence.nativeSourceRetention).toBeUndefined();
|
|
expect((await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.lease.id)))[0].leasePolicy).toBe("reuse_by_environment");
|
|
expect(f.call.mock.calls.map(call => call[1])).toEqual(["environmentResumeLease"]);
|
|
} finally { release(); await ordinary; }
|
|
});
|
|
});
|