mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:12:25 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A native run that fails before any provider work gets a bounded same-run bootstrap retry. Restart recovery decides whether that retry can run. > - The retry needs the task's issue lock. A user comment or another wake can move that lock to a new run before the retry is due. > - Restart recovery then returned `issue_execution_lock_changed` without a write. The same check ran on every recovery pass and never ended. The coordinator stayed `retryable_failure`. > - The orphaned-lease sweep waits for a terminal coordinator before it preserves the workspace. So the sandbox lease stayed active, and every later run for the task was cancelled as `workspace_busy`. > - This pull request gives a lock-lost bootstrap retry the same bounded admission that a superseded remote retry already has. > - The benefit is that the retry settles, the board gets a reconciliation action, and the sandbox lease is released instead of blocking the task. ## Linked Issues or Issue Description No public issue exists. Bug description: **What happened?** On a hosted instance, a native agent run failed at startup because the provider rejected `session.open` with a rate limit. Paperclip scheduled a same-run bootstrap retry. Seconds later, a new run for the task took its issue lock, most likely woken by a user comment. From then on, every restart recovery pass for the old run returned `blocked` / `issue_execution_lock_changed` and wrote nothing. The run recorded about 1,000 recovery events over 12 hours. Its coordinator stayed `retryable_failure` with `recoveryMode: bootstrap_retry`. The scoped orphaned-lease sweep found a pending workspace export for the lease. It could not preserve the workspace, because `preserveNativeFailedWorkspace` requires `terminal_failure`. So the lease stayed `active`, and 16 later runs for the task were cancelled as `workspace_busy`. Three more runs on the same instance were stuck in the same state. A board Stop on the failed run moved the coordinator to `terminal_failure`, and the sweep then released the lease within about a minute. **Expected behavior** A bootstrap retry that cannot get its task lock back settles within a bounded number of recovery passes. The coordinator reaches `terminal_failure`, the board gets a reconciliation action, and the existing orphaned-lease sweep can preserve the workspace and release the sandbox lease. **Steps to reproduce** 1. Fail a native remote run before provider work, so its coordinator is `retryable_failure` with `recoveryMode: bootstrap_retry`. 2. Move the task's `executionRunId` to a newer run for the same task, or clear it. 3. Run `claimNativeRestartRecoveries` for the failed run. 4. Before this change, it returns `blocked` with no `retryAt`, and the coordinator stays `retryable_failure` on every pass. The new tests in `native-remote-retry-admission.test.ts` reproduce this. **Paperclip version or commit** Observed on a hosted deployment. Reproduced in tests on `master` at `10a7d039c8e`. Related: #19190 (a `session.open` model rejection no longer schedules a doomed bootstrap retry), #19131 (preserve superseded observed workspaces) and #19281 (release the leases of a cancelled legacy run). None of them changes this admission path. ## What Changed - **Restart recovery (`server/src/services/native-runtime/native-restart-recovery.ts`)**: a new condition, `lockLostBootstrapRetry`, matches a failed run whose coordinator is `retryable_failure` with `recoveryMode: bootstrap_retry` and whose issue lock is not on the run. - A newer run for the task: the existing superseded check now covers this case too. It defers with `native_retry_source_superseded` as permanent, so admission is exhausted at once. - No newer run (the lock was released): recovery defers with `issue_execution_lock_changed` through the existing `deferRemoteRetry`. That path persists a deadline, backs off and exhausts after the system recovery budget. - When admission is exhausted, the existing hold path writes `terminal_failure` / `native_restart_recovery_blocked`, keeps the provider evidence and creates the board reconciliation action. This PR adds no new terminal path. - A bootstrap retry that still holds its own lock is unchanged. The exact-checkpoint remote retry path is unchanged. - **Docs (`doc/execution-semantics.md`)**: the bootstrap retry section now states the lock-lost rule. ## Verification - New test `settles a %s bootstrap retry that lost its task lock instead of re-checking forever` in `server/src/services/native-runtime/native-remote-retry-admission.test.ts`, for the `superseded` and `released` cases. The fixture is a genuine pre-provider failure: a `session.open` rejection with no session checkpoint, no runner checkpoint row and no provider evidence. - On `master` without the fix, both cases fail: recovery returns `blocked` without a deadline, and the coordinator stays `retryable_failure`. - With the fix, `superseded` settles on the first pass, and `released` settles after `SYSTEM_RECOVERY_MAX_RETRIES` deferred passes. Each deferred pass persists its `nextAttemptAt`. Both end in `terminal_failure` with one board recovery action, and later passes open no transaction. - `npx vitest run src/services/native-runtime/native-remote-retry-admission.test.ts` (from `server/`): 92 passed. - Related suites, each run on its own with exit code 0: `native-ingress-bootstrap-retry`, `native-startup-retry`, `native-scheduled-resume-lock`, `native-restart-recovery-isolation`, `native-runner-restart-recovery`, `native-runner-restart-preparation`, `native-runner-restart-diskless`, `native-warm-transition-restart`, `native-controller-recovery`, `native-failed-workspace-recovery`, `native-session-resumption`, `native-startup-cleanup-recovery`, `heartbeat-observed-workspace-preservation`, `heartbeat/recovery`, `runtime-selection`, `remote-workspace-migration` and `native-session-executor` (1,149 tests). Two other suites each had one local failure that this change does not cause: - `heartbeat-process-recovery`: "redacts opaque environment-bound credentials from Sentry diagnostics" fails the same way on this machine without the change. - `chat-channels.integration`: "settles a shared-endpoint accepted-response backlog without retrying its own locks" failed once under full-suite load. It passes alone, with and without the change. - `pnpm run typecheck` in `server/`: passed. ## Risks - Low to medium. The change only affects failed bootstrap retries whose lock is not on the run. Before this change, such a retry could never run, so no working path is removed. - A released lock that comes back to the same run during the backoff still resumes normally, because the condition reads the current lock on each pass. - When the retry settles, the task gets a board reconciliation action. Before this change, the task was silently stuck behind `workspace_busy`. - No migration and no API change. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, code search, test runs), extended thinking, and a read-only investigation subagent for the root cause. ## 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
830 lines
62 KiB
TypeScript
830 lines
62 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { resolve } from "node:path";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { agents, companies, completionContracts, databaseWorkPending, environmentLeases, reconcileDatabaseWork, heartbeatRunEvents, heartbeatRuns, issues, issueRelations,
|
|
issueTreeHolds, issueTreeHoldMembers, issueRecoveryActions, nativeRunFinalizations, nativeRunResults, nativeRunnerCheckpoints } from "@paperclipai/db";
|
|
import { expect, it, vi } from "vitest";
|
|
import type { NativeExecutionInputV1 } from "@paperclipai/paperclip-runner";
|
|
|
|
const provider = vi.hoisted(() => ({ execute: vi.fn() }));
|
|
vi.mock("../../vendor/paperclip-runner/index.js", async importOriginal => ({
|
|
...await importOriginal<typeof import("../../vendor/paperclip-runner/index.js")>(),
|
|
executeNativeSession: provider.execute,
|
|
}));
|
|
import { DurablePrpControlPlane } from "../../vendor/paperclip-runner/index.js";
|
|
import { describeEmbeddedPostgres, nativeRestartFixture, remotePreparedWorkspace } from "../../__tests__/helpers/native-restart.js";
|
|
import { nativeRunnerCheckpointWriter } from "./native-runner-checkpoint.js";
|
|
import { DELIVERY_QUEUES, subscribeDeliveryWork } from "../delivery-work-notifications.js";
|
|
import { claimNativeSessionResumptions } from "./native-finalization-reconciler.js";
|
|
import { cancelNativeSession, executePaperclipNativeSession } from "./native-session-executor.js";
|
|
import { buildNativeCompletionContract } from "./completion-contracts.js";
|
|
import { claimNativeRestartRecoveries, currentNativeControllerIdentity, NATIVE_RESTART_RECOVERY_FAILED } from "./native-restart-recovery.js";
|
|
import { readSystemRecovery, SYSTEM_RECOVERY_MAX_RETRIES } from "../system-recovery-state.js";
|
|
import { recoveryService } from "../recovery/service.js";
|
|
import { issueService } from "../issues.js";
|
|
import { hasPendingRunCleanup } from "../execution-blocker.js";
|
|
import { createPostgresWakeQueueAdapter } from "../../modules/wake-queue/adapters/postgres.js";
|
|
import { nativeManualAdmissionHoldsExecution } from "./native-runner-ownership.js";
|
|
import { remoteTerminationReceipt } from "../remote-execution-termination.js";
|
|
import { releaseSettledNativeRunProcess } from "../native-local-process-stop.js";
|
|
import { createHeartbeatQueue } from "../heartbeat/queue.js";
|
|
import { createHeartbeatQueueTestDependencies } from "../../__tests__/helpers/heartbeat-queue.js";
|
|
import { instanceSettingsService } from "../instance-settings.js";
|
|
import { claimCancellationRequest } from "./native-cancellation-request.js";
|
|
|
|
describeEmbeddedPostgres("remote native retry execution admission", () => {
|
|
const environment = nativeRestartFixture();
|
|
|
|
async function failedRemoteRun(storage: "classic" | "remote" | "plugin") {
|
|
const { companyId, agentId, runtimeRoot } = environment.current;
|
|
const fixture = await environment.seedRun(`REMOTE-RETRY-${storage}-${randomUUID()}`);
|
|
await fixture.db.update(agents).set({ status: "active" }).where(eq(agents.id, agentId));
|
|
const controller = { bootId: randomUUID(), generation: 1, leaseOwner: randomUUID() };
|
|
await fixture.db.update(nativeRunFinalizations).set({
|
|
controllerBootId: controller.bootId, controllerGeneration: controller.generation,
|
|
leaseOwner: controller.leaseOwner, leaseExpiresAt: new Date(Date.now() + 60_000),
|
|
}).where(eq(nativeRunFinalizations.runId, fixture.runId));
|
|
const authority = new DurablePrpControlPlane({
|
|
stateDirectory: resolve(runtimeRoot, fixture.runId),
|
|
identity: { runId: fixture.runId, normalizedSessionId: fixture.native.normalizedSessionId,
|
|
runnerInstanceId: fixture.native.runnerInstanceId, environmentLeaseId: fixture.native.environmentLeaseId,
|
|
turnId: fixture.native.turnId, itemId: fixture.native.itemId },
|
|
expectedRunnerVersion: "retry-admission-fixture", expectedRunnerDigest: `sha256:${"a".repeat(64)}`,
|
|
});
|
|
authority.setCheckpointPersistence(nativeRunnerCheckpointWriter(fixture.db, {
|
|
companyId, issueId: fixture.issueId, runId: fixture.runId, authorityRunId: fixture.runId,
|
|
...controller,
|
|
}));
|
|
await authority.flushCheckpoint();
|
|
await authority.stop();
|
|
const classic = remotePreparedWorkspace();
|
|
const remote = { schema: "paperclip.remote-workspace-run.v1", workspaceId: classic.workspaceId,
|
|
leaseId: classic.leaseId, providerLeaseId: classic.providerLeaseId, remoteCwd: classic.remoteCwd,
|
|
epoch: 1, state: "prepared", snapshotId: null };
|
|
const checkpoint = { backendKind: "acpx", driverKind: "acpx_runtime", sessionId: "provider-session",
|
|
providerSessionId: "provider-session", activeTurnId: "retained-turn", semanticResult: null,
|
|
pendingRuntimeRequests: [], terminalTurns: [], identity: { companyId, agentId,
|
|
issueId: fixture.issueId, runId: fixture.runId, sessionId: fixture.native.normalizedSessionId } };
|
|
const [pluginLease] = storage === "plugin" ? await fixture.db.insert(environmentLeases).values({ companyId,
|
|
issueId: fixture.issueId, heartbeatRunId: fixture.runId, provider: "daytona", providerLeaseId: randomUUID(),
|
|
status: "active", leasePolicy: "ephemeral", metadata: { runnerTaskLease: true },
|
|
}).returning({ id: environmentLeases.id }) : [];
|
|
await fixture.db.update(heartbeatRuns).set({ status: "failed", finishedAt: new Date(),
|
|
processPid: 1386, processStartedAt: new Date("2026-11-01T12:00:00Z"),
|
|
nativePhase: "retryable_failure", errorCode: "native_session_interrupted",
|
|
runnerProfileJson: { ...fixture.preparedProfile, nativeExecutionInput: { binding: {
|
|
companyId, agentId, issueId: fixture.issueId, runId: fixture.runId } }, sessionCheckpoint: checkpoint,
|
|
...(storage === "plugin" ? { nativeWorkspaceSync: null, remoteWorkspace: null }
|
|
: storage === "classic" ? { nativeWorkspaceSync: classic } : { remoteWorkspace: remote }) },
|
|
}).where(eq(heartbeatRuns.id, fixture.runId));
|
|
await fixture.db.update(nativeRunFinalizations).set({ phase: "retryable_failure", attempt: 1,
|
|
leaseOwner: null, leaseExpiresAt: null, recoveryState: "resuming_session",
|
|
failureCode: "native_session_interrupted", nextAttemptAt: new Date(0),
|
|
failureDetail: { recoveryMode: "exact_checkpoint_resume", providerSessionEstablished: true,
|
|
checkpointExists: true, providerEventsExist: true },
|
|
}).where(eq(nativeRunFinalizations.runId, fixture.runId));
|
|
await fixture.db.update(issues).set({ executionRunId: null, checkoutRunId: null })
|
|
.where(eq(issues.id, fixture.issueId));
|
|
return { ...fixture, companyId, agentId, checkpoint, pluginLeaseId: pluginLease?.id };
|
|
}
|
|
|
|
async function manuallyHeldRemoteRun() {
|
|
const f = await failedRemoteRun("remote");
|
|
await f.db.update(issues).set({ executionRunId: f.runId, checkoutRunId: f.runId }).where(eq(issues.id, f.issueId));
|
|
await f.db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, issueId: f.issueId,
|
|
status: "succeeded", createdAt: new Date(Date.now() + 1_000) });
|
|
await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId] });
|
|
return f;
|
|
}
|
|
|
|
it.each(["background sweep", "terminal cleanup", "pending cleanup", "queue release", "agent release"] as const)
|
|
("preserves a manual native execution hold through %s", async entry => {
|
|
const f = await manuallyHeldRemoteRun();
|
|
const [before] = await f.db.select().from(issues).where(eq(issues.id, f.issueId));
|
|
if (entry === "background sweep") expect(await recoveryService(f.db, { enqueueWakeup: vi.fn() })
|
|
.sweepStaleIssueLocks({ companyId: f.companyId, issueId: f.issueId })).toMatchObject({ cleared: 0 });
|
|
if (entry === "terminal cleanup") {
|
|
await expect(issueService(f.db).assertCheckoutOwner(f.issueId, f.agentId, randomUUID()))
|
|
.rejects.toMatchObject({ status: 409 });
|
|
}
|
|
if (entry === "agent release") await expect(issueService(f.db).release(f.issueId, f.agentId, randomUUID()))
|
|
.rejects.toMatchObject({ status: 409 });
|
|
if (entry === "pending cleanup") expect(await hasPendingRunCleanup(f.db, f.companyId, f.runId)).toBe(true);
|
|
if (entry === "queue release") await createPostgresWakeQueueAdapter(f.db, {
|
|
resolveResponsibleUserId: async () => "operator", getRoutineEnv: async () => ({ routineId: null, env: null, responsibleUserId: null }),
|
|
resolveSessionBeforeForWakeup: async () => null,
|
|
}).withIssueExecutionLock({ companyId: f.companyId, runId: f.runId, now: new Date() },
|
|
async () => ({ outcome: { kind: "released" }, postCommitEffects: [] }));
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(before);
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0])
|
|
.toMatchObject({ processPid: 1386, runnerProfileJson: { sessionCheckpoint: f.checkpoint } });
|
|
});
|
|
|
|
it.each(["execution", "checkout"] as const)("preserves a manual hold referenced only by the %s binding", async pointer => {
|
|
const f = await manuallyHeldRemoteRun();
|
|
await f.db.update(issues).set(pointer === "execution" ? { checkoutRunId: null } : { executionRunId: null })
|
|
.where(eq(issues.id, f.issueId));
|
|
expect(await recoveryService(f.db, { enqueueWakeup: vi.fn() }).sweepStaleIssueLocks({ companyId: f.companyId, issueId: f.issueId }))
|
|
.toMatchObject({ cleared: 0 });
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0])
|
|
.toMatchObject(pointer === "execution" ? { executionRunId: f.runId, checkoutRunId: null } : { executionRunId: null, checkoutRunId: f.runId });
|
|
});
|
|
|
|
it("rechecks a newly committed manual hold under the issue lock before background cleanup", async () => {
|
|
const f = await manuallyHeldRemoteRun();
|
|
const [hold] = await f.db.select({ failureCode: nativeRunFinalizations.failureCode,
|
|
failureDetail: nativeRunFinalizations.failureDetail }).from(nativeRunFinalizations)
|
|
.where(eq(nativeRunFinalizations.runId, f.runId));
|
|
await f.db.update(nativeRunFinalizations).set({ failureCode: null, failureDetail: null })
|
|
.where(eq(nativeRunFinalizations.runId, f.runId));
|
|
const beforeStaleLockClear = vi.fn(async (issueId: string) => {
|
|
if (issueId === f.issueId) await f.db.update(nativeRunFinalizations).set(hold)
|
|
.where(eq(nativeRunFinalizations.runId, f.runId));
|
|
});
|
|
expect(await recoveryService(f.db, { enqueueWakeup: vi.fn(), beforeStaleLockClear })
|
|
.sweepStaleIssueLocks({ companyId: f.companyId, issueId: f.issueId })).toMatchObject({ cleared: 0 });
|
|
expect(beforeStaleLockClear).toHaveBeenCalledExactlyOnceWith(f.issueId);
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0])
|
|
.toMatchObject({ executionRunId: f.runId, checkoutRunId: f.runId });
|
|
});
|
|
|
|
it("defers a new wake behind the manual owner without clearing it or dispatching", async () => {
|
|
const f = await manuallyHeldRemoteRun();
|
|
await f.db.delete(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
|
|
const [agent] = await f.db.select().from(agents).where(eq(agents.id, f.agentId));
|
|
const dependencies = createHeartbeatQueueTestDependencies(f.db);
|
|
dependencies.getAgent.mockResolvedValue(agent);
|
|
dependencies.instanceSettings.getExperimental.mockImplementation(() => instanceSettingsService(f.db).getExperimental());
|
|
dependencies.wakeQueue.admitWakeBehindIssueExecution.mockResolvedValue({ kind: "deferred" });
|
|
await createHeartbeatQueue(f.db, dependencies).enqueueWakeup(f.agentId, { source: "assignment", reason: "issue_assigned",
|
|
requestedByActorType: "system", payload: { issueId: f.issueId }, contextSnapshot: { issueId: f.issueId } });
|
|
expect(dependencies.executeRun).not.toHaveBeenCalled();
|
|
expect(dependencies.wakeQueue.admitWakeBehindIssueExecution).toHaveBeenCalledOnce();
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0])
|
|
.toMatchObject({ executionRunId: f.runId, checkoutRunId: f.runId });
|
|
});
|
|
|
|
it("retains the hold until every exact remote lease is confirmed stopped, then permits ordinary checkout", async () => {
|
|
const f = await manuallyHeldRemoteRun();
|
|
const scope = { companyId: f.companyId, runId: f.runId, issueId: f.issueId };
|
|
expect(await nativeManualAdmissionHoldsExecution(f.db, scope)).toBe(true);
|
|
expect(await nativeManualAdmissionHoldsExecution(f.db, { ...scope, companyId: randomUUID() })).toBe(false);
|
|
expect(await nativeManualAdmissionHoldsExecution(f.db, { ...scope, runId: randomUUID() })).toBe(false);
|
|
expect(await nativeManualAdmissionHoldsExecution(f.db, { ...scope, issueId: randomUUID() })).toBe(false);
|
|
const leases = await f.db.insert(environmentLeases).values([1, 2].map(index => ({ companyId: f.companyId,
|
|
issueId: f.issueId, heartbeatRunId: f.runId, provider: "daytona", providerLeaseId: `fixture-${index}-${randomUUID()}`,
|
|
status: "released", releasedAt: new Date(), cleanupStatus: "success", metadata: {},
|
|
}))).returning();
|
|
expect(await nativeManualAdmissionHoldsExecution(f.db, scope)).toBe(true);
|
|
for (let index = 0; index < leases.length; index += 1) {
|
|
const lease = leases[index];
|
|
await f.db.update(environmentLeases).set({ metadata: { remoteExecutionTermination: remoteTerminationReceipt(lease,
|
|
{ providerLeaseId: lease.providerLeaseId, state: "stopped" }) } }).where(eq(environmentLeases.id, lease.id));
|
|
expect(await nativeManualAdmissionHoldsExecution(f.db, scope)).toBe(index < leases.length - 1);
|
|
}
|
|
expect(await releaseSettledNativeRunProcess(f.db, f.runId)).toBe("released");
|
|
expect(await hasPendingRunCleanup(f.db, f.companyId, f.runId)).toBe(false);
|
|
expect(await recoveryService(f.db, { enqueueWakeup: vi.fn() }).sweepStaleIssueLocks({ companyId: f.companyId, issueId: f.issueId }))
|
|
.toMatchObject({ cleared: 1 });
|
|
const actorRunId = randomUUID();
|
|
await f.db.insert(heartbeatRuns).values({ id: actorRunId, companyId: f.companyId, agentId: f.agentId,
|
|
status: "running", invocationSource: "manual", contextSnapshot: { issueId: f.issueId } });
|
|
expect(await issueService(f.db).assertCheckoutOwner(f.issueId, f.agentId, actorRunId))
|
|
.toMatchObject({ executionRunId: actorRunId, checkoutRunId: actorRunId });
|
|
});
|
|
|
|
it.each(["ordinary terminal", "accepted result"] as const)("does not preserve unrelated %s ownership", async state => {
|
|
const f = await manuallyHeldRemoteRun();
|
|
if (state === "ordinary terminal") await f.db.update(nativeRunFinalizations).set({ failureDetail: null })
|
|
.where(eq(nativeRunFinalizations.runId, f.runId));
|
|
else {
|
|
const [run] = await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
const [result] = await f.db.insert(nativeRunResults).values({ companyId: f.companyId, issueId: f.issueId,
|
|
runId: f.runId, completionContractId: run.completionContractId!, serverFingerprint: randomUUID(),
|
|
canonicalSha256: "a".repeat(64), schemaStatus: "accepted", resultJson: {} }).returning();
|
|
await f.db.update(nativeRunFinalizations).set({ resultId: result.id }).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
}
|
|
expect(await nativeManualAdmissionHoldsExecution(f.db, { companyId: f.companyId, runId: f.runId, issueId: f.issueId })).toBe(false);
|
|
expect(await recoveryService(f.db, { enqueueWakeup: vi.fn() }).sweepStaleIssueLocks({ companyId: f.companyId, issueId: f.issueId }))
|
|
.toMatchObject({ cleared: 1 });
|
|
});
|
|
|
|
it("admits an explicit correlated Stop for the preserved manual execution", async () => {
|
|
const f = await manuallyHeldRemoteRun();
|
|
const requestId = randomUUID();
|
|
expect(await claimCancellationRequest(f.db, f.runId, f.companyId, requestId, "operator"))
|
|
.toMatchObject({ resultJson: { startupCancellation: { cancellationRequestId: requestId, retryCancellation: true } } });
|
|
const stop = await cancelNativeSession(f.runId, "Operator Stop", { db: f.db, scope: "run", cancellationRequestId: requestId });
|
|
expect(stop).toMatchObject({ dispatched: false, auditId: expect.any(String) });
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0])
|
|
.toMatchObject({ phase: "terminal_failure", failureCode: "native_retry_cancelled" });
|
|
expect(await nativeManualAdmissionHoldsExecution(f.db, { companyId: f.companyId, runId: f.runId, issueId: f.issueId })).toBe(true);
|
|
expect(await recoveryService(f.db, { enqueueWakeup: vi.fn() }).sweepStaleIssueLocks({ companyId: f.companyId, issueId: f.issueId }))
|
|
.toMatchObject({ cleared: 0 });
|
|
expect(await cancelNativeSession(f.runId, "Repeat Operator Stop", { db: f.db, scope: "run", cancellationRequestId: requestId }))
|
|
.toMatchObject({ auditId: stop.auditId });
|
|
await f.db.update(nativeRunFinalizations).set({ failureCode: "native_restart_recovery_blocked" })
|
|
.where(eq(nativeRunFinalizations.runId, f.runId));
|
|
expect(await cancelNativeSession(f.runId, "Resume recorded Stop", { db: f.db, scope: "run", cancellationRequestId: requestId }))
|
|
.toMatchObject({ auditId: stop.auditId });
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0])
|
|
.toMatchObject({ failureCode: "native_retry_cancelled" });
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0])
|
|
.toMatchObject({ executionRunId: f.runId, checkoutRunId: f.runId });
|
|
});
|
|
|
|
it.each(["classic", "remote", "plugin"] as const)("reclaims the exact failed %s execution before remote inspection", async storage => {
|
|
const fixture = await failedRemoteRun(storage);
|
|
expect(await claimNativeSessionResumptions({ db: fixture.db, runnerInstanceId: "ordinary-resumer",
|
|
runIds: [fixture.runId] })).toEqual([]);
|
|
const [claim] = await claimNativeRestartRecoveries({ db: fixture.db,
|
|
controller: environment.current.successor, restartKind: "hard", runIds: [fixture.runId] });
|
|
expect(claim).toMatchObject({ kind: "reattach_remote_runner", runId: fixture.runId, providerAttempt: 2 });
|
|
expect((await fixture.db.select().from(issues).where(eq(issues.id, fixture.issueId)))[0])
|
|
.toMatchObject({ executionRunId: fixture.runId, status: "in_progress", assigneeAgentId: fixture.agentId });
|
|
expect((await fixture.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId)))[0])
|
|
.toMatchObject({ status: "running", processPid: 1386,
|
|
runnerProfileJson: { sessionCheckpoint: fixture.checkpoint } });
|
|
expect((await fixture.db.select().from(nativeRunFinalizations)
|
|
.where(eq(nativeRunFinalizations.runId, fixture.runId)))[0])
|
|
.toMatchObject({ attempt: 2, phase: "observed", recoveryState: "awaiting_runner_reattach" });
|
|
});
|
|
|
|
it.each(["company", "run", "issue"] as const)("does not use a plugin lease from another %s", async scope => {
|
|
const f = await failedRemoteRun("plugin");
|
|
if (!f.pluginLeaseId) throw new Error("Expected a plugin allocation");
|
|
const foreignId = randomUUID();
|
|
if (scope === "company") {
|
|
await f.db.insert(companies).values({ id: foreignId, name: "Other company", issuePrefix: foreignId.slice(0, 8) });
|
|
await f.db.update(environmentLeases).set({ companyId: foreignId }).where(eq(environmentLeases.id, f.pluginLeaseId));
|
|
} else if (scope === "run") {
|
|
await f.db.insert(heartbeatRuns).values({ id: foreignId, companyId: f.companyId, agentId: f.agentId, status: "failed" });
|
|
await f.db.update(environmentLeases).set({ heartbeatRunId: foreignId }).where(eq(environmentLeases.id, f.pluginLeaseId));
|
|
} else {
|
|
await f.db.insert(issues).values({ id: foreignId, companyId: f.companyId, title: "Other task" });
|
|
await f.db.update(environmentLeases).set({ issueId: foreignId }).where(eq(environmentLeases.id, f.pluginLeaseId));
|
|
}
|
|
const onClaim = vi.fn();
|
|
expect(await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId], onClaim }))
|
|
.toMatchObject([{ kind: "blocked", runId: f.runId, reason: "issue_execution_lock_changed" }]);
|
|
expect(onClaim).not.toHaveBeenCalled();
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0])
|
|
.toMatchObject({ executionRunId: null, checkoutRunId: null });
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0])
|
|
.toMatchObject({ phase: "retryable_failure", attempt: 1 });
|
|
});
|
|
|
|
it.each((["remote", "plugin"] as const).flatMap(storage => (["done", "cancelled", "blocked", "paused-agent", "pending-agent-approval", "reassigned",
|
|
"dependency", "tree-pause", "native-stop", "operator-stop", "execution-owner", "checkout-owner",
|
|
"queued-input", "newer-completed-input", "wrong-checkpoint", "missing-authority"] as const).map(gate => [storage, gate] as const)))
|
|
("preserves a %s retry held by %s", async (storage, gate) => {
|
|
const f = await failedRemoteRun(storage);
|
|
const { db } = f;
|
|
if (["done", "cancelled", "blocked"].includes(gate)) {
|
|
await db.update(issues).set({ status: gate }).where(eq(issues.id, f.issueId));
|
|
} else if (gate === "paused-agent" || gate === "pending-agent-approval") {
|
|
await db.update(agents).set({ status: gate === "paused-agent" ? "paused" : "pending_approval" }).where(eq(agents.id, f.agentId));
|
|
} else if (gate === "reassigned") {
|
|
const nextAgent = randomUUID();
|
|
await db.insert(agents).values({ id: nextAgent, companyId: f.companyId, name: "Next owner", status: "active" });
|
|
await db.update(issues).set({ assigneeAgentId: nextAgent }).where(eq(issues.id, f.issueId));
|
|
} else if (gate === "dependency") {
|
|
const dependency = randomUUID();
|
|
await db.insert(issues).values({ id: dependency, companyId: f.companyId, title: "Required input", status: "todo" });
|
|
await db.insert(issueRelations).values({ companyId: f.companyId, issueId: dependency, relatedIssueId: f.issueId, type: "blocks" });
|
|
} else if (gate === "tree-pause") {
|
|
const holdId = randomUUID();
|
|
await db.insert(issueTreeHolds).values({ id: holdId, companyId: f.companyId, rootIssueId: f.issueId, mode: "pause" });
|
|
await db.insert(issueTreeHoldMembers).values({ holdId, companyId: f.companyId, issueId: f.issueId,
|
|
issueTitle: "Paused work", issueStatus: "in_progress" });
|
|
} else if (gate === "native-stop" || gate === "operator-stop") {
|
|
await db.update(heartbeatRuns).set({ resultJson: gate === "native-stop"
|
|
? { nativeCancellation: { scope: "run", dispatchState: "pending" } }
|
|
: { cancellation: { source: "operator", expected: true, initiator: { type: "user" },
|
|
reason: "Stop", recordedAt: new Date().toISOString() } } }).where(eq(heartbeatRuns.id, f.runId));
|
|
} else if (["execution-owner", "checkout-owner", "queued-input", "newer-completed-input"].includes(gate)) {
|
|
const nextRun = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: nextRun, companyId: f.companyId, agentId: f.agentId,
|
|
issueId: f.issueId, contextSnapshot: { issueId: f.issueId, wakeReason: "issue_comment" },
|
|
invocationSource: "manual", createdAt: new Date(Date.now() + 1_000),
|
|
status: gate === "queued-input" ? "queued" : gate === "newer-completed-input" ? "succeeded" : "failed" });
|
|
if (gate === "execution-owner" || gate === "checkout-owner") await db.update(issues)
|
|
.set(gate === "execution-owner" ? { executionRunId: nextRun } : { checkoutRunId: nextRun }).where(eq(issues.id, f.issueId));
|
|
} else if (gate === "wrong-checkpoint") {
|
|
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
await db.update(heartbeatRuns).set({ runnerProfileJson: { ...run.runnerProfileJson,
|
|
sessionCheckpoint: { ...f.checkpoint, identity: { ...f.checkpoint.identity, runId: randomUUID() } },
|
|
} }).where(eq(heartbeatRuns.id, f.runId));
|
|
} else if (gate === "missing-authority") {
|
|
await db.delete(nativeRunnerCheckpoints).where(and(eq(nativeRunnerCheckpoints.companyId, f.companyId),
|
|
eq(nativeRunnerCheckpoints.runId, f.runId)));
|
|
}
|
|
const beforeTask = (await db.select().from(issues).where(eq(issues.id, f.issueId)))[0];
|
|
const beforeRun = (await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0];
|
|
const beforeOwner = (await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0];
|
|
const dispositions = await claimNativeRestartRecoveries({ db,
|
|
controller: environment.current.successor, restartKind: "hard", runIds: [f.runId] });
|
|
expect(dispositions.every(value => value.kind === "blocked")).toBe(true);
|
|
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(beforeTask);
|
|
const [afterRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
const [afterOwner] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
if (["done", "cancelled", "newer-completed-input", "execution-owner", "checkout-owner", "queued-input"].includes(gate)) {
|
|
expect(afterOwner).toMatchObject({ phase: "terminal_failure", attempt: 1, nextAttemptAt: null,
|
|
failureDetail: { systemRecovery: { state: "exhausted" } } });
|
|
expect(afterRun).toMatchObject({ status: "failed", processPid: beforeRun.processPid,
|
|
runnerProfileJson: beforeRun.runnerProfileJson });
|
|
return;
|
|
}
|
|
expect(afterRun).toEqual(beforeRun);
|
|
expect(afterOwner.failureDetail).toMatchObject(beforeOwner.failureDetail!);
|
|
expect({ ...afterOwner, nextAttemptAt: beforeOwner.nextAttemptAt, updatedAt: beforeOwner.updatedAt,
|
|
recoveryHistory: beforeOwner.recoveryHistory, failureDetail: beforeOwner.failureDetail }).toEqual(beforeOwner);
|
|
if (gate === "execution-owner") expect(afterOwner).toEqual(beforeOwner);
|
|
else expect(afterOwner.nextAttemptAt!.getTime()).toBeGreaterThan(beforeOwner.nextAttemptAt!.getTime());
|
|
});
|
|
|
|
it.each(["remote", "plugin"] as const)("waits for the exact old %s controller lease and keeps future retries quiet", async storage => {
|
|
const f = await failedRemoteRun(storage);
|
|
const now = new Date(), deadline = new Date(now.getTime() + 60_000);
|
|
await f.db.update(nativeRunFinalizations).set({ nextAttemptAt: deadline })
|
|
.where(eq(nativeRunFinalizations.runId, f.runId));
|
|
expect(await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", now, runIds: [f.runId] })).toEqual([]);
|
|
await f.db.update(nativeRunFinalizations).set({ nextAttemptAt: new Date(0), leaseOwner: "old-owner", leaseExpiresAt: deadline })
|
|
.where(eq(nativeRunFinalizations.runId, f.runId));
|
|
expect(await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", now, runIds: [f.runId] })).toMatchObject([{ kind: "awaiting_evidence", reason: "remote_controller_lease_live" }]);
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0].executionRunId).toBeNull();
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].status).toBe("failed");
|
|
});
|
|
|
|
it.each(["remote", "plugin"] as const)("grants two competing controllers only one original %s execution", async storage => {
|
|
const f = await failedRemoteRun(storage);
|
|
const claims = (await Promise.all([environment.current.successor,
|
|
{ ...environment.current.successor, bootId: randomUUID() }].map(controller =>
|
|
claimNativeRestartRecoveries({ db: f.db, controller, restartKind: "hard", runIds: [f.runId] })))).flat();
|
|
expect(claims.filter(claim => claim.kind === "reattach_remote_runner")).toHaveLength(1);
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0])
|
|
.toMatchObject({ controllerGeneration: 2, attempt: 2 });
|
|
expect(await f.db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.runId, f.runId),
|
|
eq(heartbeatRunEvents.eventType, "native.recovery.execution_reclaimed")))).toHaveLength(1);
|
|
});
|
|
|
|
it.each(["remote", "plugin"] as const)("rolls back the %s task, owner, run, and recovery event together", async storage => {
|
|
const f = await failedRemoteRun(storage);
|
|
await expect(f.db.transaction(async tx => {
|
|
const claims = await claimNativeRestartRecoveries({ db: tx as unknown as typeof f.db,
|
|
controller: environment.current.successor, restartKind: "hard", runIds: [f.runId] });
|
|
expect(claims).toMatchObject([{ kind: "reattach_remote_runner" }]);
|
|
throw new Error("rollback admission");
|
|
})).rejects.toThrow("rollback admission");
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0].executionRunId).toBeNull();
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].status).toBe("failed");
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0])
|
|
.toMatchObject({ phase: "retryable_failure", controllerGeneration: 1, leaseOwner: null, attempt: 1 });
|
|
expect(await f.db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.runId, f.runId),
|
|
eq(heartbeatRunEvents.eventType, "native.recovery.execution_reclaimed")))).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
["succeeded", "2026-11-01T12:00:00.000600Z", 1, true],
|
|
["succeeded", "2026-11-01T12:00:00.000000Z", 1, true],
|
|
["failed", "2026-11-01T12:00:00.000000Z", 1, true],
|
|
["failed", "2026-11-01T12:00:00.000600Z", 1, true],
|
|
["succeeded", "2026-11-01T12:00:00.000599Z", 1, false],
|
|
["failed", "2026-11-01T12:00:00.000601Z", -1, true],
|
|
["failed", "2026-11-01T12:00:00.000600Z", -1, false],
|
|
] as const)("uses canonical source order for %s input at %s with ID offset %s", async (status, timestamp, offset, superseded) => {
|
|
const f = await failedRemoteRun("remote");
|
|
const idHex = (BigInt(`0x${f.runId.replaceAll("-", "")}`) + BigInt(offset)).toString(16).padStart(32, "0");
|
|
const nextId = `${idHex.slice(0, 8)}-${idHex.slice(8, 12)}-${idHex.slice(12, 16)}-${idHex.slice(16, 20)}-${idHex.slice(20)}`;
|
|
const sourceTimestamp = timestamp.endsWith(".000000Z") ? timestamp : "2026-11-01T12:00:00.000600Z";
|
|
await f.db.update(heartbeatRuns).set({ createdAt: sql`${sourceTimestamp}::timestamptz` })
|
|
.where(eq(heartbeatRuns.id, f.runId));
|
|
await f.db.insert(heartbeatRuns).values({ id: nextId, companyId: f.companyId, agentId: f.agentId,
|
|
issueId: f.issueId, invocationSource: "manual", status, createdAt: sql`${timestamp}::timestamptz` });
|
|
const [disposition] = await claimNativeRestartRecoveries({ db: f.db,
|
|
controller: environment.current.successor, restartKind: "hard", runIds: [f.runId] });
|
|
expect(disposition).toMatchObject(superseded
|
|
? { kind: "blocked", reason: "native_retry_source_superseded" }
|
|
: { kind: "reattach_remote_runner", providerAttempt: 2 });
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0].executionRunId)
|
|
.toBe(superseded ? null : f.runId);
|
|
});
|
|
|
|
it.each((["remote", "plugin"] as const).flatMap(storage => (["released", "retained"] as const).map(lock => [storage, lock] as const)))
|
|
("exhausts the existing attempt budget after repeated %s snapshot failures with a %s task lock", async (storage, lock) => {
|
|
const f = await failedRemoteRun(storage);
|
|
if (lock === "retained") await f.db.update(issues).set({ executionRunId: f.runId }).where(eq(issues.id, f.issueId));
|
|
const [issue] = await f.db.select().from(issues).where(eq(issues.id, f.issueId));
|
|
const [contract] = await f.db.select().from(completionContracts).where(eq(completionContracts.issueId, f.issueId));
|
|
const execution: NativeExecutionInputV1 = {
|
|
schema: "paperclip.native-execution-input.v1",
|
|
binding: { companyId: f.companyId, runId: f.runId, issueId: f.issueId, agentId: f.agentId, executionWorkspaceId: f.runId },
|
|
provider: { kind: "codex", model: "gpt-5.6-luna" },
|
|
task: { identifier: issue.identifier!, title: issue.title, description: null, prompt: "Continue retained work.", workMode: "standard" },
|
|
workspace: { cwd: environment.current.runtimeRoot, repoUrl: null, repoRef: null, branchName: null },
|
|
session: { normalizedSessionId: f.native.normalizedSessionId, driverKind: "codex_app_server", protocolVersion: 1,
|
|
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } },
|
|
completionContract: { id: contract.id, sha256: contract.canonicalSha256, schemaVersion: "paperclip.completion-contract.v1",
|
|
contract: buildNativeCompletionContract(issue, { revision: contract.revision }) },
|
|
interactionResponses: [], credentialBindings: [],
|
|
};
|
|
await f.db.update(heartbeatRuns).set({ runnerProfileJson: sql`coalesce(${heartbeatRuns.runnerProfileJson}, '{}'::jsonb)
|
|
|| ${JSON.stringify({ nativeExecutionInput: execution })}::jsonb` }).where(eq(heartbeatRuns.id, f.runId));
|
|
provider.execute.mockReset().mockRejectedValue(new Error("PRP command session.snapshot failed: original provider unavailable"));
|
|
const controller = await currentNativeControllerIdentity();
|
|
for (const attempt of [2, 3]) {
|
|
await f.db.update(nativeRunFinalizations).set({ nextAttemptAt: new Date(0) }).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
const [claim] = await claimNativeRestartRecoveries({ db: f.db, controller, restartKind: "hard", runIds: [f.runId] });
|
|
expect(claim).toMatchObject({ kind: "reattach_remote_runner", providerAttempt: attempt });
|
|
if (claim.kind !== "reattach_remote_runner") throw new Error("Expected exact remote recovery claim");
|
|
await expect(executePaperclipNativeSession({ db: f.db, execution, runnerInstanceId: f.native.runnerInstanceId,
|
|
leaseOwner: claim.leaseOwner, restartRecovery: claim,
|
|
backend: { descriptor: async () => { throw new Error("Unexpected descriptor call"); },
|
|
openSession: async () => { throw new Error("Unexpected session launch"); } },
|
|
})).rejects.toThrow("PRP command session.snapshot failed");
|
|
const [owner] = await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
expect(owner).toMatchObject({ attempt, phase: attempt === 3 ? "terminal_failure" : "retryable_failure",
|
|
failureCode: attempt === 3 ? "native_session_retry_exhausted" : "native_session_interrupted",
|
|
recoveryState: attempt === 3 ? "blocked" : "resuming_session", leaseOwner: null });
|
|
expect(owner.nextAttemptAt === null).toBe(attempt === 3);
|
|
if (lock === "released") await f.db.update(issues).set({ executionRunId: null }).where(eq(issues.id, f.issueId));
|
|
}
|
|
expect(provider.execute).toHaveBeenCalledTimes(2);
|
|
expect(await claimNativeRestartRecoveries({ db: f.db, controller, restartKind: "hard", runIds: [f.runId] })).toEqual([]);
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({ status: "blocked", executionRunId: null });
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0])
|
|
.toMatchObject({ status: "failed", nativePhase: "terminal_failure", errorCode: "native_session_interrupted",
|
|
processPid: 1386, runnerProfileJson: { sessionCheckpoint: f.checkpoint } });
|
|
});
|
|
|
|
it.each((["remote", "plugin"] as const).flatMap(storage => (["released", "retained"] as const).map(lock => [storage, lock] as const)))
|
|
("does not admit another failed %s retry when the persisted provider budget is already exhausted with a %s task lock", async (storage, lock) => {
|
|
const f = await failedRemoteRun(storage);
|
|
if (lock === "retained") await f.db.update(issues).set({ executionRunId: f.runId }).where(eq(issues.id, f.issueId));
|
|
await f.db.update(nativeRunFinalizations).set({ attempt: 3 }).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
expect(await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId] })).toMatchObject([{ kind: "blocked", reason: "execution_recovery_budget_exhausted" }]);
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0].executionRunId).toBeNull();
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0])
|
|
.toMatchObject({ phase: "terminal_failure", attempt: 3, leaseOwner: null, nextAttemptAt: null,
|
|
failureCode: "native_restart_recovery_blocked", failureDetail: { reason: "execution_recovery_budget_exhausted" } });
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0])
|
|
.toMatchObject({ status: "failed", nativePhase: "terminal_failure", processPid: 1386,
|
|
runnerProfileJson: { sessionCheckpoint: f.checkpoint } });
|
|
expect(await f.db.select().from(issueRecoveryActions).where(and(eq(issueRecoveryActions.sourceIssueId, f.issueId),
|
|
eq(issueRecoveryActions.kind, "active_run_watchdog"))))
|
|
.toMatchObject([{ ownerType: "board", evidence: { runId: f.runId, reason: "execution_recovery_budget_exhausted" } }]);
|
|
expect(await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId] })).toEqual([]);
|
|
});
|
|
|
|
it("persists a blocked retry deadline and records only changed hold reasons", async () => {
|
|
const f = await failedRemoteRun("remote");
|
|
await f.db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agentId));
|
|
const now = new Date();
|
|
const attempt = (at: Date) => claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId], now: at });
|
|
const [first] = await attempt(now);
|
|
expect(first).toMatchObject({ kind: "blocked", reason: "native_retry_admission_ineligible", transitionUnchanged: false });
|
|
if (!("retryAt" in first) || !first.retryAt) throw new Error("missing first retry deadline");
|
|
expect(first.retryAt.getTime() - now.getTime()).toBeGreaterThanOrEqual(24_000);
|
|
expect(first.retryAt.getTime() - now.getTime()).toBeLessThanOrEqual(36_000);
|
|
expect(await attempt(new Date(first.retryAt.getTime() - 1))).toEqual([]);
|
|
const [second] = await attempt(first.retryAt);
|
|
expect(second).toMatchObject({ kind: "blocked", transitionUnchanged: true });
|
|
if (!("retryAt" in second) || !second.retryAt) throw new Error("missing second retry deadline");
|
|
expect(second.retryAt.getTime() - first.retryAt.getTime()).toBeGreaterThanOrEqual(48_000);
|
|
let [owner] = await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
expect(owner).toMatchObject({ phase: "retryable_failure", attempt: 1,
|
|
nextAttemptAt: second.retryAt, recoveryHistory: [{ reason: "native_retry_admission_ineligible" }] });
|
|
await f.db.update(agents).set({ status: "active" }).where(eq(agents.id, f.agentId));
|
|
await f.db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, issueId: f.issueId,
|
|
status: "succeeded", createdAt: new Date(now.getTime() + 1_000) });
|
|
expect(await attempt(second.retryAt)).toMatchObject([{ kind: "blocked", reason: "native_retry_source_superseded" }]);
|
|
[owner] = await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
expect(owner.recoveryHistory).toMatchObject([{ reason: "native_retry_admission_ineligible" }, { reason: "native_retry_source_superseded" }]);
|
|
});
|
|
|
|
it("registers recovery work intent before writing a blocked retry deadline", async () => {
|
|
const f = await failedRemoteRun("remote");
|
|
await f.db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agentId));
|
|
const transaction = f.db.transaction.bind(f.db);
|
|
let writes = 0;
|
|
const interception = vi.spyOn(f.db, "transaction").mockImplementation((async (callback, options) => transaction(async tx => {
|
|
const update = tx.update.bind(tx);
|
|
const updates = vi.spyOn(tx, "update").mockImplementation((table => {
|
|
if (Object.is(table, nativeRunFinalizations)) {
|
|
writes += 1;
|
|
expect(databaseWorkPending(f.db, DELIVERY_QUEUES.nativeProcessSessions)).toBe(true);
|
|
}
|
|
return update(table);
|
|
}) as typeof tx.update);
|
|
try { return await callback(tx); } finally { updates.mockRestore(); }
|
|
}, options)) as typeof f.db.transaction);
|
|
try {
|
|
expect(await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId] })).toMatchObject([{ kind: "blocked" }]);
|
|
expect(writes).toBe(1);
|
|
} finally { interception.mockRestore(); }
|
|
});
|
|
|
|
it.each(["done", "cancelled", "superseded"] as const)("settles a permanently %s remote retry without recurring work", async gate => {
|
|
for (const lock of ["released", "retained"]) {
|
|
const f = await failedRemoteRun("remote");
|
|
if (lock === "retained") await f.db.update(issues).set({ executionRunId: f.runId, checkoutRunId: f.runId })
|
|
.where(eq(issues.id, f.issueId));
|
|
if (gate === "superseded") await f.db.insert(heartbeatRuns).values({ companyId: f.companyId,
|
|
agentId: f.agentId, issueId: f.issueId, status: "succeeded", createdAt: new Date(Date.now() + 1_000) });
|
|
else await f.db.update(issues).set({ status: gate }).where(eq(issues.id, f.issueId));
|
|
const onClaim = vi.fn();
|
|
const environmentWake = vi.fn(), stopWatchingEnvironment = subscribeDeliveryWork(f.db, DELIVERY_QUEUES.nativeEnvironmentSettlement, environmentWake);
|
|
const [disposition] = await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId], onClaim });
|
|
expect(disposition).toMatchObject({ kind: "blocked" });
|
|
expect(disposition).not.toHaveProperty("retryAt");
|
|
expect(onClaim).not.toHaveBeenCalled();
|
|
expect(environmentWake).not.toHaveBeenCalled();
|
|
stopWatchingEnvironment();
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({
|
|
executionRunId: lock === "retained" ? f.runId : null, checkoutRunId: lock === "retained" ? f.runId : null });
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0])
|
|
.toMatchObject({ phase: "terminal_failure", nextAttemptAt: null, attempt: 1 });
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0])
|
|
.toMatchObject({ processPid: 1386, runnerProfileJson: { sessionCheckpoint: f.checkpoint } });
|
|
const wake = vi.fn(), unsubscribe = subscribeDeliveryWork(f.db, DELIVERY_QUEUES.nativeProcessSessions, wake);
|
|
const transaction = vi.spyOn(f.db, "transaction");
|
|
try {
|
|
for (const elapsed of [30_000, 60_000, 3_600_000]) expect(await claimNativeRestartRecoveries({ db: f.db,
|
|
controller: environment.current.successor, restartKind: "hard", runIds: [f.runId],
|
|
now: new Date(Date.now() + elapsed), onClaim })).toEqual([]);
|
|
expect(transaction).not.toHaveBeenCalled();
|
|
expect(wake).not.toHaveBeenCalled();
|
|
} finally { transaction.mockRestore(); unsubscribe(); }
|
|
}
|
|
});
|
|
|
|
it.each(["superseded", "released"] as const)("settles a %s bootstrap retry that lost its task lock instead of re-checking forever", async lock => {
|
|
const f = await failedRemoteRun("remote");
|
|
// A bootstrap retry follows a pre-provider failure: no checkpoint, provider session or provider events.
|
|
const [run] = await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
const { sessionCheckpoint: _checkpoint, ...profile } = run.runnerProfileJson ?? {};
|
|
await f.db.update(heartbeatRuns).set({ errorCode: "provider_session_open_rejected", runnerProfileJson: profile })
|
|
.where(eq(heartbeatRuns.id, f.runId));
|
|
await f.db.delete(nativeRunnerCheckpoints)
|
|
.where(and(eq(nativeRunnerCheckpoints.companyId, f.companyId), eq(nativeRunnerCheckpoints.runId, f.runId)));
|
|
await f.db.update(nativeRunFinalizations).set({ failureCode: "provider_session_open_rejected", failureDetail: {
|
|
recoveryMode: "bootstrap_retry", providerSessionEstablished: false, checkpointExists: false, providerEventsExist: false,
|
|
} }).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
if (lock === "superseded") {
|
|
const [newer] = await f.db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, issueId: f.issueId,
|
|
status: "running", createdAt: new Date(Date.now() + 1_000) }).returning({ id: heartbeatRuns.id });
|
|
await f.db.update(issues).set({ executionRunId: newer.id }).where(eq(issues.id, f.issueId));
|
|
}
|
|
const onClaim = vi.fn();
|
|
const attempt = (now: Date) => claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId], now, onClaim });
|
|
let now = new Date();
|
|
let passes = 0;
|
|
for (; passes <= SYSTEM_RECOVERY_MAX_RETRIES + 1; passes++) {
|
|
const [disposition] = await attempt(now);
|
|
expect(disposition).toMatchObject({ kind: "blocked" });
|
|
const [coordinator] = await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
if (coordinator.phase === "terminal_failure") break;
|
|
if (!("retryAt" in disposition) || !disposition.retryAt) throw new Error("a deferred bootstrap retry must persist its deadline");
|
|
expect(coordinator.nextAttemptAt).toEqual(disposition.retryAt);
|
|
now = disposition.retryAt;
|
|
}
|
|
expect(passes).toBe(lock === "superseded" ? 0 : SYSTEM_RECOVERY_MAX_RETRIES);
|
|
expect(onClaim).not.toHaveBeenCalled();
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0])
|
|
.toMatchObject({ phase: "terminal_failure", nextAttemptAt: null, attempt: 1, failureCode: "native_restart_recovery_blocked" });
|
|
expect(await f.db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)))
|
|
.toMatchObject([{ ownerType: "board", wakePolicy: null }]);
|
|
const transaction = vi.spyOn(f.db, "transaction");
|
|
try {
|
|
for (const elapsed of [30_000, 3_600_000]) expect(await attempt(new Date(now.getTime() + elapsed))).toEqual([]);
|
|
expect(transaction).not.toHaveBeenCalled();
|
|
} finally { transaction.mockRestore(); }
|
|
});
|
|
|
|
it.each(["paused", "Stop", "missing-authority"] as const)("bounds %s holds and preserves the provider and own task lock for manual reconciliation", async gate => {
|
|
const f = await failedRemoteRun("remote");
|
|
if (gate === "paused") await f.db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agentId));
|
|
if (gate === "Stop") await f.db.update(heartbeatRuns).set({ resultJson: {
|
|
nativeCancellation: { scope: "run", dispatchState: "pending" },
|
|
} }).where(eq(heartbeatRuns.id, f.runId));
|
|
if (gate === "missing-authority") await f.db.delete(nativeRunnerCheckpoints)
|
|
.where(and(eq(nativeRunnerCheckpoints.companyId, f.companyId), eq(nativeRunnerCheckpoints.runId, f.runId)));
|
|
await f.db.update(issues).set({ executionRunId: f.runId }).where(eq(issues.id, f.issueId));
|
|
await f.db.update(nativeRunFinalizations).set({ attempt: 3 }).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
let now = new Date();
|
|
const onClaim = vi.fn();
|
|
const environmentWake = vi.fn(), stopWatchingEnvironment = subscribeDeliveryWork(f.db, DELIVERY_QUEUES.nativeEnvironmentSettlement, environmentWake);
|
|
for (let index = 0; index <= SYSTEM_RECOVERY_MAX_RETRIES; index += 1) {
|
|
const [disposition] = await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId], now, onClaim });
|
|
const [owner] = await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
const policy = readSystemRecovery(owner.failureDetail);
|
|
expect(policy).not.toBeNull();
|
|
expect(owner.attempt).toBe(3);
|
|
if (index < SYSTEM_RECOVERY_MAX_RETRIES) {
|
|
expect(owner.phase).toBe("retryable_failure");
|
|
expect(policy).toMatchObject({ state: "awaiting_evidence" });
|
|
expect(disposition).toHaveProperty("retryAt", owner.nextAttemptAt);
|
|
now = owner.nextAttemptAt!;
|
|
} else {
|
|
expect(owner).toMatchObject({ phase: "terminal_failure", nextAttemptAt: null });
|
|
expect(policy).toMatchObject({ state: "exhausted" });
|
|
expect(disposition).not.toHaveProperty("retryAt");
|
|
}
|
|
}
|
|
expect(onClaim).not.toHaveBeenCalled();
|
|
expect(environmentWake).not.toHaveBeenCalled();
|
|
stopWatchingEnvironment();
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({ executionRunId: f.runId });
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0])
|
|
.toMatchObject({ processPid: 1386, runnerProfileJson: { sessionCheckpoint: f.checkpoint } });
|
|
expect(await f.db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)))
|
|
.toMatchObject([{ ownerType: "board", wakePolicy: null,
|
|
evidence: { automaticAdmissionExhausted: true, executorStopEvidence: "unverified", providerPid: 1386, checkpointPreserved: true },
|
|
nextAction: expect.stringContaining("original executor stop and state remain unverified") }]);
|
|
expect(await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId], now: new Date(now.getTime() + 3_600_000) })).toEqual([]);
|
|
});
|
|
|
|
it("settles the old admission policy while preserving a newer active execution and its binding", async () => {
|
|
const f = await failedRemoteRun("remote");
|
|
const newerRunId = randomUUID();
|
|
await f.db.insert(heartbeatRuns).values({ id: newerRunId, companyId: f.companyId, agentId: f.agentId,
|
|
issueId: f.issueId, status: "running", invocationSource: "manual", createdAt: new Date(Date.now() + 1_000) });
|
|
await f.db.update(issues).set({ executionRunId: newerRunId, checkoutRunId: newerRunId }).where(eq(issues.id, f.issueId));
|
|
const [task] = await f.db.select().from(issues).where(eq(issues.id, f.issueId));
|
|
const [newer] = await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, newerRunId));
|
|
const environmentWake = vi.fn(), unsubscribe = subscribeDeliveryWork(f.db, DELIVERY_QUEUES.nativeEnvironmentSettlement, environmentWake);
|
|
const onClaim = vi.fn();
|
|
try {
|
|
expect(await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId], onClaim }))
|
|
.toMatchObject([{ kind: "blocked", reason: "native_retry_source_superseded" }]);
|
|
expect(onClaim).not.toHaveBeenCalled();
|
|
expect(environmentWake).not.toHaveBeenCalled();
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toEqual(task);
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, newerRunId)))[0]).toEqual(newer);
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0])
|
|
.toMatchObject({ phase: "terminal_failure", nextAttemptAt: null, failureDetail: { systemRecovery: { state: "exhausted" } } });
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0])
|
|
.toMatchObject({ processPid: 1386, runnerProfileJson: { sessionCheckpoint: f.checkpoint } });
|
|
const transactions = vi.spyOn(f.db, "transaction");
|
|
try {
|
|
expect(await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId], now: new Date(Date.now() + 3_600_000) })).toEqual([]);
|
|
expect(transactions).not.toHaveBeenCalled();
|
|
} finally { transactions.mockRestore(); }
|
|
} finally { unsubscribe(); }
|
|
});
|
|
|
|
it("resumes the original provider when eligibility returns before admission backoff exhausts", async () => {
|
|
const f = await failedRemoteRun("remote");
|
|
await f.db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agentId));
|
|
await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId] });
|
|
const [owner] = await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
await f.db.update(agents).set({ status: "active" }).where(eq(agents.id, f.agentId));
|
|
expect(await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId], now: owner.nextAttemptAt! }))
|
|
.toMatchObject([{ kind: "reattach_remote_runner", providerAttempt: 2, runId: f.runId }]);
|
|
expect((await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0])
|
|
.toMatchObject({ status: "running", processPid: 1386, runnerProfileJson: { sessionCheckpoint: f.checkpoint } });
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0].executionRunId).toBe(f.runId);
|
|
});
|
|
|
|
it("does not settle permanently ineligible work while the original controller is active", async () => {
|
|
const f = await failedRemoteRun("remote");
|
|
await f.db.update(issues).set({ status: "done" }).where(eq(issues.id, f.issueId));
|
|
const [before] = await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
const wake = vi.fn(), unsubscribe = subscribeDeliveryWork(f.db, DELIVERY_QUEUES.nativeProcessSessions, wake);
|
|
try {
|
|
expect(await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId], liveRunExecutions: new Set([f.runId]) }))
|
|
.toMatchObject([{ kind: "awaiting_evidence", reason: "current_controller_preparing" }]);
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]).toEqual(before);
|
|
expect(wake).not.toHaveBeenCalled();
|
|
expect(await f.db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]);
|
|
} finally { unsubscribe(); }
|
|
});
|
|
|
|
it("keeps a retry deadline that changed after discovery read-only and quiet", async () => {
|
|
const f = await failedRemoteRun("remote");
|
|
await f.db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agentId));
|
|
const deadline = new Date(Date.now() + 60_000);
|
|
const transaction = f.db.transaction.bind(f.db);
|
|
const interception = vi.spyOn(f.db, "transaction").mockImplementationOnce((async (callback, options) => {
|
|
await f.db.update(nativeRunFinalizations).set({ nextAttemptAt: deadline }).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
return transaction(callback, options);
|
|
}) as typeof f.db.transaction);
|
|
const wake = vi.fn(), unsubscribe = subscribeDeliveryWork(f.db, DELIVERY_QUEUES.nativeProcessSessions, wake);
|
|
try {
|
|
expect(await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId] })).toMatchObject([{ kind: "blocked", retryAt: deadline, transitionUnchanged: true }]);
|
|
const [owner] = await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
expect(owner).toMatchObject({ nextAttemptAt: deadline, recoveryHistory: [], phase: "retryable_failure", attempt: 1 });
|
|
expect(wake).not.toHaveBeenCalled();
|
|
} finally { interception.mockRestore(); unsubscribe(); }
|
|
});
|
|
|
|
it("rolls back work intent when the locked retry deadline update matches no row", async () => {
|
|
const f = await failedRemoteRun("remote");
|
|
await f.db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agentId));
|
|
const name = `retry_noop_${f.runId.replaceAll("-", "")}`;
|
|
await f.db.execute(sql.raw(`create function ${name}() returns trigger language plpgsql as $$begin return null; end$$`));
|
|
await f.db.execute(sql.raw(`create trigger ${name} before update on native_run_finalizations for each row
|
|
when (old.run_id = '${f.runId}'::uuid) execute function ${name}()`));
|
|
const [before] = await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
try {
|
|
const now = new Date();
|
|
await expect(claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId], now })).resolves.toEqual([{ kind: "awaiting_evidence",
|
|
runId: f.runId, reason: NATIVE_RESTART_RECOVERY_FAILED, retryAt: new Date(now.getTime() + 30_000) }]);
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]).toEqual(before);
|
|
await reconcileDatabaseWork(f.db, DELIVERY_QUEUES.nativeProcessSessions);
|
|
expect(databaseWorkPending(f.db, DELIVERY_QUEUES.nativeProcessSessions)).toBe(false);
|
|
} finally {
|
|
await f.db.execute(sql.raw(`drop trigger ${name} on native_run_finalizations`));
|
|
await f.db.execute(sql.raw(`drop function ${name}()`));
|
|
}
|
|
});
|
|
|
|
it("does not publish or log a deferred transition when an ineligible retry still has a live lease", async () => {
|
|
const f = await failedRemoteRun("remote");
|
|
const now = new Date(), leaseDeadline = new Date(now.getTime() + 60_000);
|
|
await f.db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agentId));
|
|
await f.db.update(nativeRunFinalizations).set({ leaseOwner: "live-controller", leaseExpiresAt: leaseDeadline })
|
|
.where(eq(nativeRunFinalizations.runId, f.runId));
|
|
const [before] = await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
const wake = vi.fn(), unsubscribe = subscribeDeliveryWork(f.db, DELIVERY_QUEUES.nativeProcessSessions, wake);
|
|
try {
|
|
for (let index = 0; index < 2; index += 1) {
|
|
expect(await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId], now })).toMatchObject([{ kind: "blocked",
|
|
retryAt: leaseDeadline, transitionUnchanged: true }]);
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0]).toEqual(before);
|
|
expect(wake).not.toHaveBeenCalled();
|
|
}
|
|
} finally { unsubscribe(); }
|
|
});
|
|
|
|
it.each(["released", "retained"] as const)("keeps exhausted %s retries fenced behind live owners, Stops and newer input", async lock => {
|
|
for (const gate of ["live-owner", "Stop", "newer-input", "missing-authority"]) {
|
|
const f = await failedRemoteRun("remote");
|
|
if (lock === "retained") await f.db.update(issues).set({ executionRunId: f.runId }).where(eq(issues.id, f.issueId));
|
|
await f.db.update(nativeRunFinalizations).set({ attempt: 3,
|
|
...(gate === "live-owner" ? { leaseOwner: "live-controller", leaseExpiresAt: new Date(Date.now() + 60_000) } : {}),
|
|
}).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
if (gate === "Stop") await f.db.update(heartbeatRuns).set({ resultJson: {
|
|
nativeCancellation: { scope: "run", dispatchState: "pending" },
|
|
} }).where(eq(heartbeatRuns.id, f.runId));
|
|
if (gate === "newer-input") await f.db.insert(heartbeatRuns).values({ companyId: f.companyId,
|
|
agentId: f.agentId, issueId: f.issueId, status: "succeeded", createdAt: new Date(Date.now() + 1_000) });
|
|
if (gate === "missing-authority") await f.db.delete(nativeRunnerCheckpoints)
|
|
.where(and(eq(nativeRunnerCheckpoints.companyId, f.companyId), eq(nativeRunnerCheckpoints.runId, f.runId)));
|
|
const onClaim = vi.fn();
|
|
expect((await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId], onClaim }))[0].kind).toBe(gate === "live-owner" ? "awaiting_evidence" : "blocked");
|
|
expect(onClaim).not.toHaveBeenCalled();
|
|
expect((await f.db.select().from(issues).where(eq(issues.id, f.issueId)))[0].executionRunId).toBe(lock === "retained" ? f.runId : null);
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0])
|
|
.toMatchObject({ phase: gate === "newer-input" ? "terminal_failure" : "retryable_failure", attempt: 3 });
|
|
const actions = await f.db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
|
|
if (gate === "newer-input") expect(actions).toMatchObject([{ ownerType: "board", wakePolicy: null }]);
|
|
else expect(actions).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("preserves the charged attempt when replacing an active remote controller", async () => {
|
|
const f = await failedRemoteRun("remote");
|
|
await f.db.update(issues).set({ executionRunId: f.runId }).where(eq(issues.id, f.issueId));
|
|
await f.db.update(heartbeatRuns).set({ status: "running", nativePhase: "observed" }).where(eq(heartbeatRuns.id, f.runId));
|
|
await f.db.update(nativeRunFinalizations).set({ attempt: 2, phase: "observed", failureCode: null, failureDetail: null })
|
|
.where(eq(nativeRunFinalizations.runId, f.runId));
|
|
expect(await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId] })).toMatchObject([{ kind: "reattach_remote_runner", providerAttempt: 2 }]);
|
|
expect((await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0])
|
|
.toMatchObject({ phase: "observed", attempt: 2, controllerGeneration: 2 });
|
|
});
|
|
|
|
it("ignores another company's newer input when reclaiming this company's execution", async () => {
|
|
const f = await failedRemoteRun("remote");
|
|
const foreignCompany = randomUUID(), foreignAgent = randomUUID();
|
|
await f.db.insert(companies).values({ id: foreignCompany, name: "Other company", issuePrefix: "FOREIGN" });
|
|
await f.db.insert(agents).values({ id: foreignAgent, companyId: foreignCompany, name: "Other agent", status: "active" });
|
|
await f.db.insert(heartbeatRuns).values({ companyId: foreignCompany, agentId: foreignAgent,
|
|
status: "queued", contextSnapshot: { issueId: f.issueId }, createdAt: new Date(Date.now() + 1_000) });
|
|
expect(await claimNativeRestartRecoveries({ db: f.db, controller: environment.current.successor,
|
|
restartKind: "hard", runIds: [f.runId] })).toMatchObject([{ kind: "reattach_remote_runner" }]);
|
|
});
|
|
});
|