mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 10:40:33 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A volume-free web server must preserve retained native session data. > - An old failed follow-up can keep its own snapshot while its controller file still belongs to the preceding successful turn. > - The Runner correctly refuses to import that file as the failed turn's execution authority. > - That refusal also blocks migration when no warm supervisor is present. > - This pull request preserves the file after it proves the older run's lineage, and uses existing typed database authority when present. > - Failed history and files survive, while normal execution authority stays unchanged. ## Linked Issues or Issue Description Refs #19024, which preserves the related older-supervisor case. The duplicate search found no PR for this older-controller case. **What happened?** Offline native-controller migration stops with `native_runner_checkpoint_binding_mismatch` for a failed retained follow-up. The local controller still contains the preceding successful run. A per-turn source can have no supervisor file, so the older-supervisor archive path does not apply. Read-only checks matched the database snapshots and controller-file hashes between the original source and the isolated rehearsal copy. **Expected behavior** Preserve the available file as evidence through the existing encrypted archive. Require a valid older controller, a completed successful prior run, and the same company, agent, task, native session, Runner and provider thread. Keep successful sources and active owners outside this archival fallback. When a valid typed database checkpoint already exists, derive only its missing recovery metadata and ignore obsolete local projections. **Steps to reproduce** Create a successful native turn and a later failed follow-up in the same session. Retain the successful controller file. Keep the failed snapshot and admitted input in the database. Leave the per-turn source without a supervisor. Run offline migration. The new positive regression fails with the binding mismatch before this change. ## What Changed - Share the scoped older-successful-run proof with the existing supervisor preservation path. - Validate the older controller through the Runner parser and artifact checks before archive publication. - Archive this failed legacy source without changing its snapshot, status, ownership, queued work or session pointer. - Preserve the normal refusal for foreign, changed, active, successful, invalid and already-durable cases. - Permit evidence archival for a stale observed coordinator only with exact host-owned stop receipts for every remote lease, no current task ownership, no result and no retry deadline. Recheck the proof before publication. - Use execution start for lifecycle ordering. A queued follow-up can predate its prior turn completion, and a remote stop can predate a later recovery timeout. - Resolve destruction receipts through the existing reusable-lease handoff verifier. Reject stopped tips, broken chains and competing tips. - Prefer a valid typed database checkpoint over a stale or absent local warm supervisor. Add only missing recovery metadata after current admitted-input and checkpoint binding checks. - Document the evidence and execution-authority boundary. ## Verification - Positive regression: red before the owner change and green afterward. - `pnpm exec vitest run server/src/services/native-runtime/native-controller-migration.integration.test.ts server/src/services/native-runtime/native-controller-recovery.integration.test.ts server/src/services/native-runtime/native-harness-backup-storage.integration.test.ts`: 81 checks passed across migration, recovery and durable storage. - The checks cover storage failure, repeated migration, exact file restoration after old-home deletion, unchanged history and ownership, and refusal to treat the archive as executable recovery authority. - A second regression reproduced the archive fence refusal for an observed failed source with a valid stopped-remote receipt. Negative controls cover missing, foreign, old and revoked receipts, live leases, pending results, retry deadlines and task ownership. Revoking the receipt during upload prevents publication. - Two additional regressions cover a follow-up queued before the prior turn finished and a remote stop before a later logical failure. Both fail with the earlier timestamp comparisons and pass with execution-start ordering. Genuine execution overlap and old receipts stay blocked. - Two historical source snapshots and controller-file hashes matched in a read-only live-versus-copy check. The first candidate reached the archive fence and exposed the observed-coordinator case. That failure is retained. A fresh candidate replay is required for the expanded proof. It does not qualify the full packaged migration or hosted Railway handoff. - Existing typed-authority checks cover stale and missing supervisors, repeat migration, byte-identical checkpoint rows, unchanged snapshots/owners/history, old-home deletion and ordinary queued-turn recovery. Foreign Runner, session and snapshot controls remain blocked. - Handoff checks cover an exact destroyed successor, a stopped successor, a broken proof and a competing tip. - Isolated replay of the two retained typed-checkpoint sources imported only derived recovery metadata. Checks confirmed unchanged checkpoint rows (12 and 9 parts), history, snapshots, finalization owners and task-session pointers. The replay used three source-module overlays with a blocked provider network; it does not qualify the released bundle. - Final-head CI attempt 1 passed typecheck, build and the migration suites but failed one unrelated budget-concurrency assertion in `decision-models.test.ts` (one provider call observed instead of two). The original log is retained. The isolated local comparison cannot classify the timing failure because this checkout has stale linked budget schema exports. The one retry of that failed CI job passed, and final-head CI attempt 2 is green. The original failure remains retained; its cause is not established. - Full local typecheck, repository tests and build are not claimed. The server typecheck reports pre-existing linked declaration mismatches outside the changed files; the full clean CI typecheck has passed. This checkout uses linked generated declarations; clean CI is required before merge. Prior headd71dc913had passing full CI and a 5/5 Greptile review. Head8ff3a22109adds the handoff and typed-authority fixes. Its fresh full CI is green and Greptile is 5/5, with no unresolved review threads. Earlier head7729 hit unrelated 5-second CLI ZIP and 15-second setup-token test timeouts; those failures remain retained. ## Risks This path preserves evidence for a source that has already failed. It does not make that source resumable. The Runner must accept the older controller under its own identity and artifact binding. Scoped successful history must match its session and provider thread. The archive owner still requires stopped writers, a finished failed run and no live lease. An observed coordinator also needs exact remote-stop receipts after execution started, no accepted result or retry deadline, and no current task ownership. The coordinator stays unchanged. Existing durable authority stays authoritative. ## Model Used OpenAI Codex, exact model ID `gpt-6.1-sol`, with reasoning, tool use and code execution. The host does not expose context-window size. ## 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 described the issue in-PR following the bug template - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id or instance-derived details - [x] I have run the relevant tests locally; verification limits are listed above - [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 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>
476 lines
41 KiB
TypeScript
476 lines
41 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterEach, expect, it, vi } from "vitest";
|
|
import { agentTaskSessions, environmentLeases, environments, heartbeatRuns, issues, nativeHarnessBackups, nativeRunFinalizations, nativeRunnerCheckpoints } from "@paperclipai/db";
|
|
import { captureLegacyDurablePrpCheckpoint, DurablePrpControlPlane, parseNativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
|
|
import { describeEmbeddedPostgres, nativeRestartFixture } from "../../__tests__/helpers/native-restart.js";
|
|
import { nativeToolContractFingerprintForTarget } from "./native-session-resume.js";
|
|
import { nativeControllerMigrationDescriptor } from "./native-session-executor.js";
|
|
import { hasLocalNativeControllerObligations, migrateNativeControllers } from "./native-controller-migration.js";
|
|
import { insertMigratedNativeRunnerCheckpoint, loadNativeRunnerCheckpoint } from "./native-runner-checkpoint.js";
|
|
import { createNativeControllerRecovery, resolveNativeControllerRecovery } from "./native-controller-recovery.js";
|
|
import { resolveRemoteProviderPackAuthority } from "./remote-provider-pack-authority.js";
|
|
import { canonicalProviderPackJson } from "./remote-provider-pack-verification.js";
|
|
import { createReusableLeaseHandoff } from "../environment-lease-handoff.js";
|
|
import { remoteTerminationReceipt } from "../remote-execution-termination.js";
|
|
import { nativeSha256 } from "./canonical.js";
|
|
import { createLocalEncryptedCipher } from "../../secrets/local-encrypted-provider.js";
|
|
import { createLocalDiskStorageProvider } from "../../storage/local-disk-provider.js";
|
|
import { createStorageService } from "../../storage/service.js";
|
|
import { legacyNativeControllerArchive } from "./native-controller-archive.js";
|
|
import { restoreDurableNativeHarnessBackup } from "./native-harness-backup-storage.js";
|
|
|
|
describeEmbeddedPostgres.each(["import", "archive", "success", "batch", "durable_failed",
|
|
"stale_confirmed", "stale_foreign", "stale_changed", "stale_queued", "stale_overlap", "stale_success", "stale_busy", "stale_durable", "stale_durable_absent",
|
|
"stale_durable_runner", "stale_durable_session", "stale_durable_snapshot",
|
|
"controller_confirmed", "controller_foreign", "controller_thread", "controller_runner", "controller_success", "controller_busy", "controller_invalid", "controller_durable",
|
|
"controller_observed_stopped", "controller_observed_stop_before_finish", "controller_observed_missing", "controller_observed_foreign",
|
|
"controller_observed_live", "controller_observed_result", "controller_observed_retry", "controller_observed_task", "controller_observed_old_receipt", "controller_observed_revoked",
|
|
"controller_observed_handoff", "controller_observed_handoff_stopped", "controller_observed_handoff_broken", "controller_observed_handoff_fork"])("offline native controller migration (%s)", scenario => {
|
|
const environment = nativeRestartFixture();
|
|
afterEach(() => vi.unstubAllEnvs());
|
|
async function fixture(commandCount = 1, warm = false) {
|
|
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "73".repeat(32));
|
|
const f = await environment.seedRun(randomUUID());
|
|
const { companyId, agentId, paperclipHome, runtimeRoot } = environment.current;
|
|
const binding = { companyId, agentId, issueId: f.issueId, runId: f.runId, executionWorkspaceId: f.native.environmentLeaseId };
|
|
const execution = parseNativeExecutionInput({
|
|
schema: "paperclip.native-execution-input.v1", provider: { kind: "codex", model: null }, binding,
|
|
task: { identifier: "MIG-1", title: "Retained work", description: null, prompt: "Continue the work.", workMode: "standard" },
|
|
workspace: { cwd: "/workspace", repoUrl: null, repoRef: null, branchName: null },
|
|
session: { normalizedSessionId: f.native.normalizedSessionId, driverKind: "codex_app_server", protocolVersion: 1,
|
|
lifecyclePolicy: warm ? { mode: "warm", idleTimeoutMs: 60_000 } : { mode: "per_turn", idleTimeoutMs: null } },
|
|
completionContract: { id: randomUUID(), sha256: "fixture", schemaVersion: "paperclip.completion-contract.v1",
|
|
contract: { revision: "1", objective: "Continue", criteria: [{ id: "objective", requirement: "Save work." }] } },
|
|
credentialBindings: [], interactionResponses: [],
|
|
});
|
|
const fingerprint = nativeToolContractFingerprintForTarget("remote", "codex");
|
|
const paths = nativeControllerMigrationDescriptor(execution, fingerprint);
|
|
const identity = { runId: f.runId, runnerInstanceId: f.native.runnerInstanceId, normalizedSessionId: f.native.normalizedSessionId,
|
|
environmentLeaseId: f.native.environmentLeaseId, turnId: f.native.turnId, itemId: f.native.itemId };
|
|
const core = new DurablePrpControlPlane({ stateDirectory: join(paths.root, "control-plane"), identity,
|
|
expectedRunnerVersion: "migration-fixture", expectedRunnerDigest: `sha256:${"a".repeat(64)}` });
|
|
core.issueBootstrapTicket();
|
|
for (let index = 0; index < commandCount; index++) core.queueCommand("session.snapshot",
|
|
{ diagnostic: "x".repeat(commandCount > 1 ? 6000 : 0) }, `retained-command-${index}`);
|
|
await core.stop();
|
|
const file = join(paths.root, "control-plane/control-plane-state.json");
|
|
const state = JSON.parse(await readFile(file, "utf8"));
|
|
state.tickets = {};
|
|
const source = JSON.stringify(state); await writeFile(file, source, { mode: 0o600 });
|
|
const packBinding = { companyId, agentId, workspaceId: binding.executionWorkspaceId,
|
|
sessionId: f.native.normalizedSessionId, runnerInstanceId: f.native.runnerInstanceId,
|
|
environmentLeaseId: f.native.environmentLeaseId, remoteCwd: "/remote/workspace", providerDigest: nativeSha256(execution.provider) };
|
|
const payload = {
|
|
runnerSourceRevision: "a".repeat(40), target: { platform: "linux", architecture: "x64" },
|
|
pins: { opencode: "1.18.34" }, artifacts: {
|
|
nodeCommand: { path: "node", sha256: `sha256:${"1".repeat(64)}` },
|
|
opencodeCommand: { path: "opencode", sha256: `sha256:${"2".repeat(64)}` },
|
|
},
|
|
};
|
|
const pack = resolveRemoteProviderPackAuthority({ stateDirectory: paths.root, reattach: false, binding: packBinding,
|
|
current: () => ({ schema: "paperclip-runner/remote-provider-pack/v1", payload,
|
|
digest: `sha256:${createHash("sha256").update(canonicalProviderPackJson(payload)).digest("hex")}` }),
|
|
runnerArtifact: () => ({ version: "migration-fixture", digest: `sha256:${"a".repeat(64)}` }) });
|
|
pack.verified();
|
|
const snapshot = { backendKind: "mock" as const, sessionId: f.native.normalizedSessionId,
|
|
identity: { companyId, agentId, issueId: f.issueId, runId: f.runId, sessionId: f.native.normalizedSessionId } };
|
|
const profile = { ...f.preparedProfile, nativeExecutionInput: execution, nativeToolContractFingerprint: fingerprint, sessionCheckpoint: snapshot,
|
|
nativeWorkspaceSync: { schema: "paperclip.native-workspace-sync/v1", state: "prepared", descriptorSha256: "a".repeat(64),
|
|
baselineSha256: "b".repeat(64), finalHostSha256: null, workspaceId: binding.executionWorkspaceId, leaseId: randomUUID(),
|
|
providerLeaseId: randomUUID(), remoteCwd: "/remote/workspace", resourceDisposition: "destroy" } };
|
|
await f.db.update(heartbeatRuns).set({ status: "failed", finishedAt: new Date(), runnerProfileJson: profile }).where(eq(heartbeatRuns.id, f.runId));
|
|
await f.db.update(nativeRunFinalizations).set({ phase: "terminal_failure", leaseOwner: null }).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
const queuedId = randomUUID();
|
|
await f.db.insert(heartbeatRuns).values({ id: queuedId, companyId, agentId, issueId: f.issueId, status: "queued" });
|
|
await f.db.update(issues).set({ executionRunId: queuedId }).where(eq(issues.id, f.issueId));
|
|
const [session] = await f.db.insert(agentTaskSessions).values({ companyId, agentId, adapterType: "paperclip_runner",
|
|
taskKey: f.issueId, lastRunId: f.runId }).returning();
|
|
return { ...f, companyId, agentId, paperclipHome, runtimeRoot, paths, file, source, profile, session, queuedId, identity, snapshot };
|
|
}
|
|
if (scenario === "import") it("imports an exact settled authority once, preserves queued work, and resumes from an empty home", async () => {
|
|
const f = await fixture();
|
|
const { companyId, agentId, paperclipHome, runtimeRoot, profile, file, source, session, queuedId, identity, snapshot } = f;
|
|
expect(await hasLocalNativeControllerObligations(f.db)).toBe(true);
|
|
await f.db.update(heartbeatRuns).set({ runnerProfileJson: { ...profile,
|
|
nativeWorkspaceSync: { ...profile.nativeWorkspaceSync, workspaceId: randomUUID() } } }).where(eq(heartbeatRuns.id, f.runId));
|
|
await expect(migrateNativeControllers(f.db, { writersStopped: true, signal: AbortSignal.timeout(30_000) }))
|
|
.rejects.toThrow("native_controller_migration_artifact_binding");
|
|
await f.db.update(heartbeatRuns).set({ runnerProfileJson: profile }).where(eq(heartbeatRuns.id, f.runId));
|
|
expect(profile.nativeWorkspaceSync.leaseId).not.toBe(identity.environmentLeaseId);
|
|
expect(profile.nativeWorkspaceSync.workspaceId).toBe(identity.environmentLeaseId);
|
|
const foreignProtocol = JSON.parse(source);
|
|
foreignProtocol.identity.environmentLeaseId = randomUUID();
|
|
await writeFile(file, JSON.stringify(foreignProtocol), { mode: 0o600 });
|
|
await expect(migrateNativeControllers(f.db, { writersStopped: true, signal: AbortSignal.timeout(30_000) }))
|
|
.rejects.toThrow("saved provider pack authority is invalid or does not match this execution");
|
|
expect(await f.db.select().from(nativeRunnerCheckpoints).where(eq(nativeRunnerCheckpoints.runId, f.runId))).toHaveLength(0);
|
|
await writeFile(file, source, { mode: 0o600 });
|
|
await f.db.update(nativeRunFinalizations).set({ leaseOwner: "unsettled-owner" }).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
await expect(migrateNativeControllers(f.db, { writersStopped: true, signal: AbortSignal.timeout(30_000) })).rejects.toThrow("normal_ownership_recovery_required");
|
|
expect(await f.db.select().from(nativeRunnerCheckpoints).where(eq(nativeRunnerCheckpoints.runId, f.runId))).toHaveLength(0);
|
|
await f.db.update(nativeRunFinalizations).set({ leaseOwner: "expired-owner", leaseExpiresAt: new Date(0),
|
|
phase: "retryable_failure", recoveryState: "resuming_session" }).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
const [preservedOwner] = await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
expect(await migrateNativeControllers(f.db, { writersStopped: true, signal: AbortSignal.timeout(30_000) }))
|
|
.toMatchObject({ imported: 1, sourceAlreadyAbsent: 0 });
|
|
expect(await migrateNativeControllers(f.db, { writersStopped: true, signal: AbortSignal.timeout(30_000) })).toMatchObject({ unchanged: 1, imported: 0 });
|
|
expect(await hasLocalNativeControllerObligations(f.db)).toBe(false);
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]).toEqual(preservedOwner);
|
|
expect(await readFile(file, "utf8")).toBe(source);
|
|
expect((await f.db.select().from(agentTaskSessions).where(eq(agentTaskSessions.id, session.id)))[0].lastRunId).toBe(f.runId);
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, queuedId)))[0].status).toBe("queued");
|
|
await rm(paperclipHome, { recursive: true, force: true });
|
|
await f.db.update(heartbeatRuns).set({ nativeIssueId: f.issueId, nativeSessionId: f.native.normalizedSessionId,
|
|
status: "running", runtimeMode: "native" }).where(eq(heartbeatRuns.id, queuedId));
|
|
const recovered = await resolveNativeControllerRecovery(f.db, { companyId, agentId, issueId: f.issueId,
|
|
normalizedSessionId: f.native.normalizedSessionId, currentRunId: queuedId });
|
|
expect(recovered.kind).toBe("recoverable");
|
|
if (recovered.kind !== "recoverable") throw new Error(JSON.stringify(recovered));
|
|
const replacement = join(runtimeRoot, "replacement"); await mkdir(replacement, { mode: 0o700 });
|
|
recovered.checkpoint.restoreSessionDirectory(replacement);
|
|
expect(JSON.parse(await readFile(join(replacement, "control-plane/control-plane-state.json"), "utf8")).identity).toEqual(identity);
|
|
expect(recovered.snapshot).toEqual(snapshot);
|
|
});
|
|
if (scenario === "batch") it("imports a large checkpoint with bounded writes and rolls back every batch on a late conflict", async () => {
|
|
const f = await fixture(160);
|
|
const binding = { companyId: f.companyId, issueId: f.issueId, runId: f.runId };
|
|
const checkpoint = captureLegacyDurablePrpCheckpoint(f.source, { runId: f.runId,
|
|
runnerInstanceId: f.native.runnerInstanceId, normalizedSessionId: f.native.normalizedSessionId },
|
|
{ version: "migration-fixture", digest: `sha256:${"a".repeat(64)}` });
|
|
const last = [...checkpoint.records].at(-1)!;
|
|
await f.db.transaction(tx => insertMigratedNativeRunnerCheckpoint(tx, binding, {
|
|
...checkpoint, records: new Map([last]),
|
|
}));
|
|
const before = await f.db.select().from(nativeRunnerCheckpoints).where(eq(nativeRunnerCheckpoints.runId, f.runId));
|
|
const parameters: number[] = [];
|
|
const payloadBytes: number[] = [];
|
|
const previousDebug = f.db.$client.options.debug;
|
|
f.db.$client.options.debug = (_connection, query, values) => {
|
|
if (query.startsWith('insert into "native_runner_checkpoints"')) {
|
|
parameters.push(values.length);
|
|
payloadBytes.push(values.reduce((sum, value) => sum + Buffer.byteLength(JSON.stringify(value)), 0));
|
|
}
|
|
};
|
|
try {
|
|
await expect(f.db.transaction(tx => insertMigratedNativeRunnerCheckpoint(tx, binding, checkpoint)))
|
|
.rejects.toThrow();
|
|
expect(await f.db.select().from(nativeRunnerCheckpoints).where(eq(nativeRunnerCheckpoints.runId, f.runId))).toEqual(before);
|
|
expect(parameters.length).toBeGreaterThan(1);
|
|
expect(Math.max(...parameters)).toBeLessThanOrEqual(320);
|
|
expect(Math.max(...payloadBytes)).toBeLessThanOrEqual(512 * 1024);
|
|
await f.db.delete(nativeRunnerCheckpoints).where(eq(nativeRunnerCheckpoints.runId, f.runId));
|
|
expect(await migrateNativeControllers(f.db, { writersStopped: true, signal: AbortSignal.timeout(30_000) }))
|
|
.toMatchObject({ imported: 1 });
|
|
const rows = await f.db.select().from(nativeRunnerCheckpoints).where(eq(nativeRunnerCheckpoints.runId, f.runId));
|
|
const cipher = createLocalEncryptedCipher("read");
|
|
for (const row of rows) expect(JSON.parse(cipher.decrypt(row.material))).toEqual({
|
|
...binding, part: row.part, contents: checkpoint.records.get(row.part),
|
|
});
|
|
const restored = await loadNativeRunnerCheckpoint(f.db, binding);
|
|
expect(restored?.records).toEqual(checkpoint.records);
|
|
expect(await readFile(f.file, "utf8")).toBe(f.source);
|
|
} finally { f.db.$client.options.debug = previousDebug; }
|
|
}, 60_000);
|
|
if (scenario === "durable_failed") it("preserves a failed durable checkpoint without inventing a missing session snapshot", async () => {
|
|
const f = await fixture();
|
|
const binding = { companyId: f.companyId, issueId: f.issueId, runId: f.runId };
|
|
const checkpoint = captureLegacyDurablePrpCheckpoint(f.source, { runId: f.runId,
|
|
runnerInstanceId: f.native.runnerInstanceId, normalizedSessionId: f.native.normalizedSessionId },
|
|
{ version: "migration-fixture", digest: `sha256:${"a".repeat(64)}` });
|
|
await f.db.transaction(tx => insertMigratedNativeRunnerCheckpoint(tx, binding, checkpoint));
|
|
await f.db.update(heartbeatRuns).set({ processPid: 12345, runnerProfileJson: { ...f.profile, sessionCheckpoint: null } })
|
|
.where(eq(heartbeatRuns.id, f.runId));
|
|
const options = { writersStopped: true as const, signal: AbortSignal.timeout(30_000) };
|
|
await f.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
|
|
await expect(migrateNativeControllers(f.db, options)).rejects.toThrow("stale_supervisor_snapshot");
|
|
await f.db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, f.runId));
|
|
const [run] = await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
const [owner] = await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
const rows = await f.db.select().from(nativeRunnerCheckpoints).where(eq(nativeRunnerCheckpoints.runId, f.runId));
|
|
const foreign = JSON.parse(f.source);
|
|
foreign.identity.normalizedSessionId = randomUUID();
|
|
const foreignCheckpoint = captureLegacyDurablePrpCheckpoint(JSON.stringify(foreign), { runId: f.runId,
|
|
runnerInstanceId: f.native.runnerInstanceId, normalizedSessionId: foreign.identity.normalizedSessionId },
|
|
{ version: "migration-fixture", digest: `sha256:${"a".repeat(64)}` });
|
|
await f.db.delete(nativeRunnerCheckpoints).where(eq(nativeRunnerCheckpoints.runId, f.runId));
|
|
await f.db.transaction(tx => insertMigratedNativeRunnerCheckpoint(tx, binding, foreignCheckpoint));
|
|
expect(await hasLocalNativeControllerObligations(f.db)).toBe(true);
|
|
await expect(migrateNativeControllers(f.db, options)).rejects.toThrow("stale_supervisor_snapshot");
|
|
await f.db.delete(nativeRunnerCheckpoints).where(eq(nativeRunnerCheckpoints.runId, f.runId));
|
|
await f.db.insert(nativeRunnerCheckpoints).values(rows);
|
|
expect(await migrateNativeControllers(f.db, options)).toMatchObject({ preservedFailed: 1, imported: 0 });
|
|
expect(await hasLocalNativeControllerObligations(f.db)).toBe(false);
|
|
expect(await migrateNativeControllers(f.db, options)).toMatchObject({ preservedFailed: 1, imported: 0 });
|
|
expect(await f.db.select().from(nativeRunnerCheckpoints).where(eq(nativeRunnerCheckpoints.runId, f.runId))).toEqual(rows);
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0]).toEqual(run);
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]).toEqual(owner);
|
|
expect((await f.db.select().from(agentTaskSessions).where(eq(agentTaskSessions.id, f.session.id)))[0].lastRunId).toBe(f.runId);
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.queuedId)))[0].status).toBe("queued");
|
|
await rm(f.paperclipHome, { recursive: true, force: true });
|
|
expect((await loadNativeRunnerCheckpoint(f.db, binding))?.records).toEqual(checkpoint.records);
|
|
const recovery = await resolveNativeControllerRecovery(f.db, { companyId: f.companyId, agentId: f.agentId,
|
|
issueId: f.issueId, normalizedSessionId: f.native.normalizedSessionId, currentRunId: f.queuedId });
|
|
expect(recovery.kind).not.toBe("recoverable");
|
|
});
|
|
if (scenario === "archive") it("preserves an already-failed legacy controller without inventing executable recovery authority", async () => {
|
|
const f = await fixture();
|
|
await rm(join(f.paths.root, "provider-pack-authority.json"));
|
|
await f.db.update(nativeRunFinalizations).set({ recoveryState: "blocked", failureCode: "native_provider_terminal_failed" })
|
|
.where(eq(nativeRunFinalizations.runId, f.runId));
|
|
const [originalRun] = await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
const [originalOwner] = await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
const storage = createStorageService(createLocalDiskStorageProvider(join(f.runtimeRoot, "durable-objects")));
|
|
const options = { storage, writersStopped: true as const, signal: AbortSignal.timeout(30_000) };
|
|
const put = vi.spyOn(storage, "putFile").mockRejectedValue(new Error("storage unavailable"));
|
|
await expect(migrateNativeControllers(f.db, options)).rejects.toThrow("storage unavailable");
|
|
put.mockRestore();
|
|
expect(await hasLocalNativeControllerObligations(f.db, storage)).toBe(true);
|
|
expect(await migrateNativeControllers(f.db, options)).toMatchObject({ archivedFailed: 1, imported: 0 });
|
|
expect(await migrateNativeControllers(f.db, options)).toMatchObject({ archivedFailed: 1, imported: 0 });
|
|
expect(await hasLocalNativeControllerObligations(f.db, storage)).toBe(false);
|
|
expect(await f.db.select().from(nativeRunnerCheckpoints).where(eq(nativeRunnerCheckpoints.runId, f.runId))).toHaveLength(0);
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0]).toEqual(originalRun);
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]).toEqual(originalOwner);
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0].executionRunId).toBe(f.queuedId);
|
|
expect((await f.db.select().from(agentTaskSessions).where(eq(agentTaskSessions.id, f.session.id)))[0].lastRunId).toBe(f.runId);
|
|
await rm(f.paperclipHome, { recursive: true, force: true });
|
|
const descriptor = legacyNativeControllerArchive({ companyId: f.companyId, issueId: f.issueId, runId: f.runId, controller: f.source });
|
|
const replacement = join(f.runtimeRoot, "archived-inspection");
|
|
expect(await restoreDurableNativeHarnessBackup({ db: f.db, storage, companyId: f.companyId,
|
|
scopeDigest: descriptor.scopeDigest, backupRoot: replacement })).toBe(true);
|
|
expect(await readFile(join(replacement, "current", "control-plane-state.json"), "utf8")).toBe(f.source);
|
|
const recovery = await resolveNativeControllerRecovery(f.db, { companyId: f.companyId, agentId: f.agentId,
|
|
issueId: f.issueId, normalizedSessionId: f.native.normalizedSessionId, currentRunId: f.queuedId });
|
|
expect(recovery.kind).not.toBe("recoverable");
|
|
});
|
|
if (scenario.startsWith("stale_")) it("archives a failed source only when its older supervisor matches scoped history", async () => {
|
|
const f = await fixture(1, true), priorId = randomUUID();
|
|
const priorSnapshot = { ...f.snapshot, identity: { ...f.snapshot.identity, runId: priorId } };
|
|
if (scenario === "stale_foreign") priorSnapshot.identity.agentId = randomUUID();
|
|
const priorProfile = { ...f.profile, nativeExecutionInput: { ...f.profile.nativeExecutionInput,
|
|
binding: { ...f.profile.nativeExecutionInput.binding, runId: priorId } }, sessionCheckpoint: priorSnapshot };
|
|
await f.db.insert(heartbeatRuns).values({ id: priorId, companyId: f.companyId, agentId: f.agentId,
|
|
nativeIssueId: f.issueId, nativeSessionId: f.native.normalizedSessionId, runnerInstanceId: f.native.runnerInstanceId,
|
|
runtimeMode: "native", status: "succeeded", createdAt: new Date(Date.now() - 60_000),
|
|
finishedAt: new Date(Date.now() - 50_000), runnerProfileJson: priorProfile });
|
|
if (scenario === "stale_queued" || scenario === "stale_overlap") {
|
|
const now = Date.now();
|
|
await f.db.update(heartbeatRuns).set({ createdAt: new Date(now - 45_000),
|
|
startedAt: new Date(now - (scenario === "stale_queued" ? 30_000 : 43_000)) }).where(eq(heartbeatRuns.id, f.runId));
|
|
await f.db.update(heartbeatRuns).set({ finishedAt: new Date(now - 40_000) }).where(eq(heartbeatRuns.id, priorId));
|
|
}
|
|
const supervisor = JSON.stringify({ schema: "paperclip.native-session-supervisor.v1",
|
|
configDigest: f.paths.configDigest, snapshot: priorSnapshot });
|
|
await mkdir(dirname(f.paths.supervisorPath), { recursive: true });
|
|
await writeFile(f.paths.supervisorPath, supervisor, { mode: 0o600 });
|
|
if (scenario === "stale_changed") await f.db.update(heartbeatRuns).set({ runnerProfileJson: {
|
|
...priorProfile, sessionCheckpoint: { ...priorSnapshot, sessionId: "different-provider-thread" },
|
|
} }).where(eq(heartbeatRuns.id, priorId));
|
|
if (scenario === "stale_success") await f.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
|
|
if (scenario === "stale_busy") await f.db.update(nativeRunFinalizations).set({ leaseOwner: "live-owner",
|
|
leaseExpiresAt: new Date(Date.now() + 60_000) }).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
if (scenario.startsWith("stale_durable")) {
|
|
const state = JSON.parse(f.source);
|
|
if (scenario === "stale_durable_runner") state.identity.runnerInstanceId = randomUUID();
|
|
if (scenario === "stale_durable_session") state.identity.normalizedSessionId = randomUUID();
|
|
const checkpoint = captureLegacyDurablePrpCheckpoint(JSON.stringify(state), { runId: f.runId,
|
|
runnerInstanceId: state.identity.runnerInstanceId, normalizedSessionId: state.identity.normalizedSessionId },
|
|
{ version: "migration-fixture", digest: `sha256:${"a".repeat(64)}` });
|
|
await f.db.transaction(tx => insertMigratedNativeRunnerCheckpoint(tx,
|
|
{ companyId: f.companyId, issueId: f.issueId, runId: f.runId }, checkpoint));
|
|
if (scenario === "stale_durable_absent") await rm(f.paths.supervisorPath);
|
|
if (scenario === "stale_durable_snapshot") await f.db.update(heartbeatRuns).set({ runnerProfileJson: {
|
|
...f.profile, sessionCheckpoint: { ...f.snapshot, identity: { ...f.snapshot.identity, agentId: randomUUID() } },
|
|
} }).where(eq(heartbeatRuns.id, f.runId));
|
|
}
|
|
const [originalRun] = await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
const [originalOwner] = await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
const storage = createStorageService(createLocalDiskStorageProvider(join(f.runtimeRoot, "durable-objects")));
|
|
const options = { storage, writersStopped: true as const, signal: AbortSignal.timeout(30_000) };
|
|
if (scenario === "stale_durable" || scenario === "stale_durable_absent") {
|
|
const binding = { companyId: f.companyId, issueId: f.issueId, runId: f.runId };
|
|
const before = await f.db.select().from(nativeRunnerCheckpoints).where(eq(nativeRunnerCheckpoints.runId, f.runId));
|
|
expect(await migrateNativeControllers(f.db, options)).toMatchObject({ imported: 1, archivedFailed: 0 });
|
|
expect(await migrateNativeControllers(f.db, options)).toMatchObject({ unchanged: 1, archivedFailed: 0 });
|
|
expect(await hasLocalNativeControllerObligations(f.db, storage)).toBe(false);
|
|
expect(await f.db.select().from(nativeRunnerCheckpoints).where(eq(nativeRunnerCheckpoints.runId, f.runId))).toEqual(before);
|
|
expect(await f.db.select().from(nativeHarnessBackups)).toHaveLength(0);
|
|
const [afterRun] = await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
expect(afterRun.runnerProfileJson?.sessionCheckpoint).toEqual(originalRun.runnerProfileJson?.sessionCheckpoint);
|
|
expect(afterRun).toEqual({ ...originalRun, runnerProfileJson: afterRun.runnerProfileJson, updatedAt: afterRun.updatedAt });
|
|
expect(afterRun.runnerProfileJson).toEqual({ ...originalRun.runnerProfileJson,
|
|
nativeControllerRecovery: expect.objectContaining({ snapshotSha256: nativeSha256(f.snapshot),
|
|
checkpoint: { runId: f.runId, runnerInstanceId: f.native.runnerInstanceId, normalizedSessionId: f.native.normalizedSessionId } }) });
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]).toEqual(originalOwner);
|
|
await rm(f.paperclipHome, { recursive: true, force: true });
|
|
expect((await loadNativeRunnerCheckpoint(f.db, binding))?.records.size).toBe(before.length);
|
|
await f.db.update(heartbeatRuns).set({ nativeIssueId: f.issueId, nativeSessionId: f.native.normalizedSessionId,
|
|
status: "running", runtimeMode: "native" }).where(eq(heartbeatRuns.id, f.queuedId));
|
|
expect((await resolveNativeControllerRecovery(f.db, { companyId: f.companyId, agentId: f.agentId,
|
|
issueId: f.issueId, normalizedSessionId: f.native.normalizedSessionId, currentRunId: f.queuedId })).kind).toBe("recoverable");
|
|
return;
|
|
}
|
|
if (!["stale_confirmed", "stale_queued"].includes(scenario)) {
|
|
await expect(migrateNativeControllers(f.db, options)).rejects.toThrow();
|
|
expect(await f.db.select().from(nativeHarnessBackups)).toHaveLength(0);
|
|
expect(await hasLocalNativeControllerObligations(f.db, storage)).toBe(true);
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0]).toEqual(originalRun);
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]).toEqual(originalOwner);
|
|
return;
|
|
}
|
|
const put = vi.spyOn(storage, "putFile").mockRejectedValue(new Error("storage unavailable"));
|
|
await expect(migrateNativeControllers(f.db, options)).rejects.toThrow("storage unavailable");
|
|
put.mockRestore();
|
|
expect(await migrateNativeControllers(f.db, options)).toMatchObject({ archivedFailed: 1, imported: 0 });
|
|
expect(await migrateNativeControllers(f.db, options)).toMatchObject({ archivedFailed: 1, imported: 0 });
|
|
expect(await hasLocalNativeControllerObligations(f.db, storage)).toBe(false);
|
|
expect(await f.db.select().from(nativeRunnerCheckpoints).where(eq(nativeRunnerCheckpoints.runId, f.runId))).toHaveLength(0);
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0]).toEqual(originalRun);
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]).toEqual(originalOwner);
|
|
expect((await f.db.select().from(agentTaskSessions).where(eq(agentTaskSessions.id, f.session.id)))[0].lastRunId).toBe(f.runId);
|
|
await rm(f.paperclipHome, { recursive: true, force: true });
|
|
const descriptor = legacyNativeControllerArchive({ companyId: f.companyId, issueId: f.issueId,
|
|
runId: f.runId, controller: f.source, supervisor });
|
|
const replacement = join(f.runtimeRoot, "archived-stale-supervisor");
|
|
expect(await restoreDurableNativeHarnessBackup({ db: f.db, storage, companyId: f.companyId,
|
|
scopeDigest: descriptor.scopeDigest, backupRoot: replacement })).toBe(true);
|
|
expect(await readFile(join(replacement, "current", "control-plane-state.json"), "utf8")).toBe(f.source);
|
|
expect(await readFile(join(replacement, "current", "supervisor.json"), "utf8")).toBe(supervisor);
|
|
expect((await resolveNativeControllerRecovery(f.db, { companyId: f.companyId, agentId: f.agentId,
|
|
issueId: f.issueId, normalizedSessionId: f.native.normalizedSessionId, currentRunId: f.queuedId })).kind).not.toBe("recoverable");
|
|
});
|
|
if (scenario.startsWith("controller_")) it("preserves failed legacy files only with proven older controller lineage", async () => {
|
|
const f = await fixture(), priorId = randomUUID();
|
|
const priorSnapshot = { ...f.snapshot, identity: { ...f.snapshot.identity, runId: priorId } };
|
|
if (scenario === "controller_foreign") priorSnapshot.identity.agentId = randomUUID();
|
|
const priorProfile = { ...f.profile, nativeExecutionInput: { ...f.profile.nativeExecutionInput,
|
|
binding: { ...f.profile.nativeExecutionInput.binding, runId: priorId } }, sessionCheckpoint: priorSnapshot };
|
|
await f.db.insert(heartbeatRuns).values({ id: priorId, companyId: f.companyId,
|
|
agentId: f.agentId,
|
|
nativeIssueId: f.issueId, nativeSessionId: f.native.normalizedSessionId,
|
|
runnerInstanceId: scenario === "controller_runner" ? randomUUID() : f.native.runnerInstanceId,
|
|
runtimeMode: "native", status: "succeeded", createdAt: new Date(Date.now() - 60_000),
|
|
finishedAt: new Date(Date.now() - 50_000), runnerProfileJson: scenario === "controller_thread"
|
|
? { ...priorProfile, sessionCheckpoint: { ...priorSnapshot, sessionId: "foreign-provider-thread" } } : priorProfile });
|
|
const state = JSON.parse(f.source);
|
|
state.identity = { ...state.identity, runId: priorId, turnId: `turn-${priorId}`, itemId: `item-${priorId}` };
|
|
if (scenario === "controller_invalid") state.schema = "unrecognized";
|
|
const source = JSON.stringify(state);
|
|
await writeFile(f.file, source, { mode: 0o600 });
|
|
if (scenario === "controller_success") await f.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
|
|
if (scenario === "controller_busy") await f.db.update(nativeRunFinalizations).set({ leaseOwner: "live-owner",
|
|
leaseExpiresAt: new Date(Date.now() + 60_000) }).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
if (scenario === "controller_durable") {
|
|
const checkpoint = captureLegacyDurablePrpCheckpoint(f.source, f.identity,
|
|
{ version: "migration-fixture", digest: `sha256:${"a".repeat(64)}` });
|
|
await f.db.transaction(tx => insertMigratedNativeRunnerCheckpoint(tx,
|
|
{ companyId: f.companyId, issueId: f.issueId, runId: f.runId }, checkpoint));
|
|
await f.db.update(heartbeatRuns).set({ runnerProfileJson: { ...f.profile, nativeControllerRecovery:
|
|
createNativeControllerRecovery(f.snapshot, { configDigest: f.paths.configDigest, processConfigDigest: null },
|
|
{ runId: f.runId, runnerInstanceId: f.native.runnerInstanceId, normalizedSessionId: f.native.normalizedSessionId }) } })
|
|
.where(eq(heartbeatRuns.id, f.runId));
|
|
}
|
|
if (scenario.startsWith("controller_observed_")) {
|
|
await f.db.update(nativeRunFinalizations).set({ phase: scenario === "controller_observed_result" ? "result_accepted" : "observed",
|
|
leaseOwner: "retired-controller", leaseExpiresAt: new Date(Date.now() + (scenario === "controller_observed_live" ? 60_000 : -60_000)),
|
|
nextAttemptAt: scenario === "controller_observed_retry" ? new Date(Date.now() + 60_000) : null,
|
|
}).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
if (scenario === "controller_observed_task") await f.db.update(issues).set({ executionRunId: f.runId }).where(eq(issues.id, f.issueId));
|
|
if (scenario !== "controller_observed_missing") {
|
|
const lease = { id: randomUUID(), companyId: f.companyId, heartbeatRunId: f.runId, issueId: f.issueId,
|
|
provider: "daytona", providerLeaseId: randomUUID(), status: "released", cleanupStatus: "success", releasedAt: new Date() };
|
|
const receipt = remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: "stopped" })!;
|
|
if (scenario === "controller_observed_stop_before_finish") {
|
|
const now = Date.now();
|
|
await f.db.update(heartbeatRuns).set({ createdAt: new Date(now - 40_000), startedAt: new Date(now - 30_000) })
|
|
.where(eq(heartbeatRuns.id, f.runId));
|
|
receipt.confirmedAt = new Date(now - 10_000).toISOString();
|
|
lease.releasedAt = new Date(now - 10_000);
|
|
}
|
|
if (scenario === "controller_observed_foreign") receipt.runId = randomUUID();
|
|
if (scenario === "controller_observed_old_receipt") receipt.confirmedAt = new Date(0).toISOString();
|
|
await f.db.insert(environmentLeases).values({ ...lease, metadata: { remoteExecutionTermination: receipt } });
|
|
if (scenario.startsWith("controller_observed_handoff")) {
|
|
const environmentId = randomUUID(), successorRunId = randomUUID(), handoffAt = new Date(Date.now() - 1000);
|
|
await f.db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" });
|
|
await f.db.insert(heartbeatRuns).values({ id: successorRunId, companyId: f.companyId, agentId: f.agentId,
|
|
nativeIssueId: f.issueId, nativeSessionId: f.native.normalizedSessionId, runtimeMode: "native", status: "succeeded", finishedAt: new Date() });
|
|
const [previous] = await f.db.update(environmentLeases).set({ environmentId, leasePolicy: "reuse_by_environment",
|
|
status: "expired", acquiredAt: new Date(handoffAt.getTime() - 1000), releasedAt: handoffAt,
|
|
metadata: { agentId: f.agentId } }).where(eq(environmentLeases.id, lease.id)).returning();
|
|
const successor = { ...previous, id: randomUUID(), heartbeatRunId: successorRunId, acquiredAt: handoffAt,
|
|
status: "released", releasedAt: new Date(), metadata: { agentId: f.agentId } };
|
|
const handoff = createReusableLeaseHandoff(previous, successor);
|
|
const termination = remoteTerminationReceipt(successor, { providerLeaseId: successor.providerLeaseId,
|
|
state: scenario === "controller_observed_handoff_stopped" ? "stopped" : "destroyed" });
|
|
await f.db.update(environmentLeases).set({ metadata: { agentId: f.agentId, reusableLeaseSuccessor: handoff } })
|
|
.where(eq(environmentLeases.id, previous.id));
|
|
await f.db.insert(environmentLeases).values({ ...successor, metadata: { agentId: f.agentId,
|
|
reusableLeasePredecessor: scenario === "controller_observed_handoff_broken" ? { ...handoff, handoffId: randomUUID() } : handoff,
|
|
remoteExecutionTermination: termination } });
|
|
if (scenario === "controller_observed_handoff_fork") await f.db.insert(environmentLeases).values({ ...previous,
|
|
id: randomUUID(), heartbeatRunId: f.queuedId, status: "active", releasedAt: null, metadata: { agentId: f.agentId } });
|
|
}
|
|
}
|
|
}
|
|
const [run] = await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
const [owner] = await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
const storage = createStorageService(createLocalDiskStorageProvider(join(f.runtimeRoot, "durable-objects")));
|
|
const options = { storage, writersStopped: true as const, signal: AbortSignal.timeout(30_000) };
|
|
if (scenario === "controller_observed_revoked") {
|
|
const put = storage.putFile.bind(storage);
|
|
vi.spyOn(storage, "putFile").mockImplementation(async args => {
|
|
const result = await put(args);
|
|
await f.db.update(environmentLeases).set({ metadata: {} }).where(eq(environmentLeases.heartbeatRunId, f.runId));
|
|
return result;
|
|
});
|
|
}
|
|
if (scenario === "controller_durable") {
|
|
expect(await migrateNativeControllers(f.db, options)).toMatchObject({ unchanged: 1, archivedFailed: 0 });
|
|
} else if (!["controller_confirmed", "controller_observed_stopped", "controller_observed_stop_before_finish", "controller_observed_handoff"].includes(scenario)) {
|
|
await expect(migrateNativeControllers(f.db, options)).rejects.toThrow();
|
|
const archives = await f.db.select().from(nativeHarnessBackups);
|
|
if (scenario === "controller_observed_revoked") {
|
|
expect(storage.putFile).toHaveBeenCalledOnce();
|
|
expect(archives).toHaveLength(1);
|
|
expect(archives[0].status).toBe("pending");
|
|
}
|
|
else expect(archives).toHaveLength(0);
|
|
} else {
|
|
const put = vi.spyOn(storage, "putFile").mockRejectedValue(new Error("storage unavailable"));
|
|
await expect(migrateNativeControllers(f.db, options)).rejects.toThrow("storage unavailable");
|
|
put.mockRestore();
|
|
expect(await migrateNativeControllers(f.db, options)).toMatchObject({ archivedFailed: 1, imported: 0 });
|
|
expect(await migrateNativeControllers(f.db, options)).toMatchObject({ archivedFailed: 1, imported: 0 });
|
|
expect(await hasLocalNativeControllerObligations(f.db, storage)).toBe(false);
|
|
expect(await f.db.select().from(nativeRunnerCheckpoints).where(eq(nativeRunnerCheckpoints.runId, f.runId))).toHaveLength(0);
|
|
await rm(f.paperclipHome, { recursive: true, force: true });
|
|
const descriptor = legacyNativeControllerArchive({ companyId: f.companyId, issueId: f.issueId, runId: f.runId, controller: source });
|
|
const replacement = join(f.runtimeRoot, "archived-older-controller");
|
|
expect(await restoreDurableNativeHarnessBackup({ db: f.db, storage, companyId: f.companyId,
|
|
scopeDigest: descriptor.scopeDigest, backupRoot: replacement })).toBe(true);
|
|
expect(await readFile(join(replacement, "current", "control-plane-state.json"), "utf8")).toBe(source);
|
|
expect((await resolveNativeControllerRecovery(f.db, { companyId: f.companyId, agentId: f.agentId,
|
|
issueId: f.issueId, normalizedSessionId: f.native.normalizedSessionId, currentRunId: f.queuedId })).kind).not.toBe("recoverable");
|
|
}
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0]).toEqual(run);
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]).toEqual(owner);
|
|
expect((await f.db.select().from(agentTaskSessions).where(eq(agentTaskSessions.id, f.session.id)))[0].lastRunId).toBe(f.runId);
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0].executionRunId)
|
|
.toBe(scenario === "controller_observed_task" ? f.runId : f.queuedId);
|
|
});
|
|
if (scenario === "success") it("does not substitute a failure archive for a successful retained session", async () => {
|
|
const f = await fixture();
|
|
await rm(join(f.paths.root, "provider-pack-authority.json"));
|
|
await f.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
|
|
const storage = createStorageService(createLocalDiskStorageProvider(join(f.runtimeRoot, "durable-objects")));
|
|
await expect(migrateNativeControllers(f.db, { storage, writersStopped: true, signal: AbortSignal.timeout(30_000) }))
|
|
.rejects.toThrow("native_runner_checkpoint_artifact_unproven");
|
|
expect(await f.db.select().from(nativeHarnessBackups)).toHaveLength(0);
|
|
expect(await hasLocalNativeControllerObligations(f.db, storage)).toBe(true);
|
|
});
|
|
});
|