fix(runtime): settle recovery lease and connection lifecycles (#18167)

## Thinking Path

> - Paperclip manages AI agents and their work.
> - A remote runner can survive a controller restart.
> - Recovery renews its execution lease during slow preparation.
> - The native executor then shares that renewal timer.
> - Preparation kept its reference until all finalization finished. A
tick after lease release treated completion as ownership loss and
detached the retained runner.
> - A Linux continuity test also found that buffered wire events could
arrive before their connection was registered. Cleanup then kept a
completed promise under an undefined key and never finished.
> - This pull request transfers renewal ownership after execution
acquires its reference. It also registers each connection before wire
callbacks can run.
> - Recovered turns can complete, retain the warm runner, and settle
connection cleanup.

## Linked Issues or Issue Description

**What happened?**

A recovered remote run could complete, release its coordinator lease,
then detach during instruction collection. The next turn could find the
old runner still using the ingress port. Recovery then stopped because
it could not prove process ownership.

**Expected behavior**

Preparation keeps the lease alive until execution owns renewal.
Execution stops renewal before releasing the lease. Actual ownership
loss still detaches the runner. Buffered wire callbacks must use the
registered connection, and cleanup must finish.

**Steps to reproduce**

1. Recover an in-flight remote runner after a controller restart.
2. Let the recovered turn complete.
3. Hold instruction collection open through the next lease renewal tick.
4. Send a follow-up that needs the retained runner.

Related: #17439 introduced remote controller recovery. #18011 preserves
checkpoint authority through warm preparation. This change fixes the
lifetime of preparation's renewal reference.

## What Changed

- Pass an internal lease-acquired callback from recovery through
heartbeat dispatch.
- Release preparation's renewal reference after the executor registers
its own. Keep final cleanup for exits before dispatch.
- Add a PostgreSQL regression for completion while finalization is still
pending. Cover callback forwarding, acquire-before-release ordering, and
failure before dispatch.
- Register the authority connection before subscribing to wire events.
Handle an immediate close before accepting buffered data.
- Add regressions for a message or close delivered during subscription.
- Extend Linux continuity coverage with preparation renewal and delayed
instruction collection after recovery.
- Update the continuity guide and session verification recipes.

## Verification

- The new recovery regression failed before the fix because renewal
detached the runner after lease release. The 76 recovery, renewal, and
dispatch tests then passed. The final ordering and early-exit checks
passed again after replacing fixed sleeps with controlled interval
ticks.
- Both buffered-wire regressions failed before the connection fix. All
170 control-plane tests passed afterward. Existing tests cover stale
authority, ownership loss, pending operations, and closed ingress.
- Linux ARM64 acceptance passed at
`a7daefacceb8bd994d0b79d284cdf54f3e3829d1`, with Node 24.19.0,
PostgreSQL, the real Rust runner, TLS ingress, and a deterministic Codex
protocol fixture. The test interrupted the first turn, recovered it,
held finalization open for a renewal interval, and completed two
follow-ups. It retained one provider process, three turns in one
history, and the same runner identity. Final suspension and connection
cleanup completed. The ordinary warm-reuse case also passed three turns
with one provider process.
- Earlier Linux attempts completed the turns but hung during cleanup. A
debugger found a fulfilled promise keyed by an undefined connection. The
regression reproduces that defect. One later setup attempt timed out
while staging an unstripped 373 MB debug binary. The passing run used
the same binary with debug symbols removed.
- Reproduce Linux acceptance with the `same-server` case in
`doc/testing-native-remote-continuity.md`.
- `pnpm -r typecheck`, `pnpm build`, map validation, and `git diff
--check` passed at the final revision. The full local `pnpm test:run`
command was attempted, but the serial run had not finished after 13
minutes. It was stopped after the equivalent CI lanes passed. It is not
counted as a full local pass.
- Greptile and the Paperclip review both scored the final revision 5/5
with no findings. All Runner verification lanes passed. The first CI
attempt hit timing failures in voice recovery, agent YAML configuration,
and the task-title outage browser test. The full YAML and voice-recovery
files passed locally (88 and 19 tests). These paths were unchanged and
passed on the prior revision. All three passed on the single retry.
Final current-head status: 55 successful checks, one neutral check, and
two skipped Storybook checks. No pending or failed checks remain.
- Live Daytona/model acceptance and recovery of the hosted task remain
unverified. This PR does not deploy the change.

## Risks

- Releasing preparation before execution starts renewal could leave a
gap in authority. The callback runs after the executor registers its
reference.
- Preparation errors must not leak renewal. The recovery promise retains
idempotent final cleanup.
- Wire callbacks can run during subscription. Message and close
regressions check that registration is complete before either callback
runs.
- No schema, API, model, permission, lease duration, or retry policy
changes.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository tools, code
execution, and browser diagnostics. The exact serving model identifier
and context window are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-29 11:18:53 -05:00
1 parent cc6a3296e5
commit 6f278dd6d7
14 files changed
+173 -8

No files matched your search

+8
View File
@@ -14,6 +14,14 @@ Recovery can restart a provider to refresh its tool contract; process reuse is
required for ordinary healthy follow-ups.
Each recovery case includes another healthy turn after the recovered turn and
asserts that it does not start another runner or provider process.
The same-server case also starts preparation lease renewal during recovery and
holds instruction collection open past a renewal tick after coordinator release.
The executor must acquire renewal before preparation releases its reference, then
stop renewal before releasing the coordinator. Completion must not detach the
retained runner or strand the next turn.
Buffered transport messages and close notifications can arrive synchronously on
subscription. The controller must register the connection before subscribing so
completed message processing can drain and the suspended session can retire.
It does not qualify a live model or a sandbox provider API.
Per-turn plugin continuation uses a stopped checkpoint on a replacement lease.
+7
View File
@@ -286,6 +286,13 @@ session-control access. Deployed diagnostic usefulness remains unverified.
## `session`
Scoped source evidence: restart preparation transfers its lease renewal to the
native executor after the executor acquires its own reference. Completion stops
renewal before coordinator release, while real ownership loss retains the detach
guard. This changes no actor permission or session-reset authority. Follow the
[session recipe](../feature-map/runs-adapters.md#session) for recovery followed by
ordinary turns; deterministic runner checks do not establish live provider access.
Plugin suspension receipts remain required through recovery and admission, then
are removed when the admitted session becomes live. A later warm run therefore
does not inherit an obsolete stopped-run identity. Repository-free fresh plugin
+9
View File
@@ -85,6 +85,15 @@ mutations. Qualify a useful model task separately from saving the account.
### `session`
Recover an in-flight remote runner after controller restart, then let it finish
with slow instruction collection. Send two follow-ups without resetting its
session. Verify that completion does not detach the retained runner and that
provider history survives with one turn per wake. Preparation must retain its
lease until execution owns renewal; a genuine ownership loss must still detach.
The [remote continuity regression](../../doc/testing-native-remote-continuity.md)
uses a real Linux runner and deterministic provider. Live provider acceptance is
a separate check.
For a plugin computer, complete a task, let its warm session suspend, then send
two follow-ups within the next warm window. Verify the first resumes the saved
session and the second keeps the same provider history without an identity
@@ -2438,6 +2438,31 @@ it.each([false, true])(
},
);
it.each(["message", "close"] as const)("settles a wire that delivers buffered %s during subscription", async buffered => {
const root = mkdtempSync(resolve(tmpdir(), "runner-buffered-subscription-"));
const core = new DurablePrpControlPlane({ stateDirectory: root, identity, expectedRunnerVersion, expectedRunnerDigest });
let closed = () => {};
try {
core.attachWireConnection({
onJson: listener => { if (buffered === "message") listener({ invalid: "buffered frame" }); },
onClose: listener => {
closed = () => listener({ message: "closed" });
if (buffered === "close") closed();
},
close: () => closed(),
sendJson: () => {},
});
await new Promise<void>(resolveTurn => setImmediate(resolveTurn));
// The invalid frame closes its wire. A completed callback must not leave a
// ghost connection or a settled promise that makes drain spin forever.
expect(() => core.retireSemanticToolCallbacks()).not.toThrow();
await core.drainPendingConnectionProcessing();
} finally {
await core.stop();
rmSync(root, { recursive: true, force: true });
}
});
it("refuses a processing-drain absence claim while local or remote ingress remains admitted", async () => {
const root = mkdtempSync(
resolve(tmpdir(), "runner-processing-drain-ingress-"),
@@ -1715,6 +1715,7 @@ class AuthorityConnection {
readonly wire: PrpWireConnection;
#closed = false;
#onClose: () => void;
#onJson: (value: unknown) => void;
constructor(input: {
wire: PrpWireConnection;
@@ -1723,8 +1724,12 @@ class AuthorityConnection {
}) {
this.wire = input.wire;
this.#onClose = input.onClose;
this.wire.onJson(input.onJson);
this.#onJson = input.onJson;
}
start(): void {
this.wire.onClose(() => this.#markClosed());
if (!this.#closed) this.wire.onJson(this.#onJson);
}
sendJson(value: unknown): void {
@@ -2558,6 +2563,9 @@ export class DurablePrpControlPlane {
},
});
this.#connections.add(connection);
// A wire can synchronously deliver buffered data or close on subscription.
// Both callbacks must see the registered connection and its exact identity.
connection.start();
return {
isAuthenticated: () => connection.secureChannel !== null,
};
+2 -1
View File
@@ -1791,6 +1791,7 @@ export function heartbeatService(
runOptions: {
nativeLeaseOwner?: string;
nativeRestartRecovery?: NativeRestartRecoveryClaim;
onNativeExecutionLeaseAcquired?: () => Promise<void>;
} = {},
) {
return withRunExecutionTiming(runId, () => executeRunAttempt(runId, runOptions));
@@ -1798,7 +1799,7 @@ export function heartbeatService(
async function executeRunAttempt(
runId: string,
runOptions: { nativeLeaseOwner?: string; nativeRestartRecovery?: NativeRestartRecoveryClaim },
runOptions: { nativeLeaseOwner?: string; nativeRestartRecovery?: NativeRestartRecoveryClaim; onNativeExecutionLeaseAcquired?: () => Promise<void> },
) {
const attemptStartedAtMs = Date.now();
let attestedQuestionResponseAtMs: number | null = null;
+63 -1
View File
@@ -11,7 +11,8 @@ import { createHeartbeatRunState } from "./run-state.js";
import type { HotRestartIntent } from "../hot-restart.js";
import type { NativeRestartRecoveryClaim, NativeRestartRecoveryDisposition } from "../native-runtime/index.js";
import { NATIVE_OWNERSHIP_UNVERIFIED_ERROR_CODE } from "../native-runtime/native-runner-ownership.js";
import { claimNativeRestartRecoveries } from "../native-runtime/native-restart-recovery.js";
import { claimNativeRestartRecoveries, currentNativeControllerIdentity } from "../native-runtime/native-restart-recovery.js";
import { startNativeSessionExecutionLeaseRenewal } from "../native-runtime/native-execution-lease.js";
import { allowLegacyShutdownWorkspaceCleanup, beginLegacyShutdownWorkspaceSettlement, finishLegacyShutdownWorkspaceSettlement, maintainLegacyShutdownWorkspaceSettlement, legacyShutdownWorkspaceResourceProtected, sweepLegacyShutdownWorkspaceSettlements } from "../legacy-shutdown-workspace-settlement.js";
import { adapterExecutionControls, createAdapterExecutionControl } from "../adapter-execution-control.js";
import { logger } from "../../middleware/logger.js";
@@ -682,6 +683,67 @@ describePostgres("heartbeat recovery module ownership", () => {
await vi.waitFor(() => expect(deps.activeRunExecutionPromises.size).toBe(0));
});
it.each(["completion", "failure_before_dispatch"] as const)("stops preparation renewal after recovered %s", async outcome => {
const run = await seedRun();
const deps = callbacks(db);
const controller = await currentNativeControllerIdentity();
const claim: NativeRestartRecoveryClaim = { kind: "reattach_remote_runner", runId: run.id,
leaseOwner: randomUUID(), controllerGeneration: 2, providerAttempt: 1, restartKind: "hard", recoveryRequestId: null,
remote: { providerLeaseId: randomUUID(), remoteCwd: "/workspace" } };
const scope = { companyId: run.companyId, issueId: run.issueId!, controller, remote: true };
await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: run.issueId }).where(eq(heartbeatRuns.id, run.id));
const expiresAt = new Date(Date.now() + 60_000);
await db.insert(nativeRunFinalizations).values({ runId: run.id, companyId: run.companyId, issueId: run.issueId!, phase: "observed", attempt: 1,
controllerBootId: controller.bootId, controllerPid: controller.pid,
controllerProcessStartedAt: controller.processStartedAt,
leaseOwner: claim.leaseOwner, leaseExpiresAt: expiresAt });
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
native.claims.mockImplementationOnce(async input => {
await input.onClaim?.(claim, scope);
return [claim];
});
let released!: () => void, finish!: () => void;
const leaseReleased = new Promise<void>(resolve => { released = resolve; });
const finalization = new Promise<void>(resolve => { finish = resolve; });
deps.executeRun.mockImplementationOnce(async (_runId, options) => {
if (outcome === "failure_before_dispatch") {
released();
throw new Error("workspace preparation failed");
}
const execution = startNativeSessionExecutionLeaseRenewal({ db, ...scope, runId: run.id,
leaseOwner: claim.leaseOwner, attempt: claim.providerAttempt,
onLost: () => native.detach() });
try {
// The executor takes its reference before preparation releases its own.
await options?.onNativeExecutionLeaseAcquired?.();
await execution.stop();
await db.update(nativeRunFinalizations).set({ leaseOwner: null, leaseExpiresAt: null })
.where(eq(nativeRunFinalizations.runId, run.id));
released();
// Workspace/result settlement may still be pending after lease release.
await finalization;
} finally { await execution.stop(); }
});
try {
await createHeartbeatRecovery(db, deps).recoverNativeRunsAfterRestart(new Date(), [run.id]);
await leaseReleased;
if (outcome === "failure_before_dispatch") await Promise.all([...deps.activeRunExecutionPromises]);
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(21_000);
expect(deps.activeRunExecutionPromises.size).toBe(outcome === "completion" ? 1 : 0);
expect(native.detach).not.toHaveBeenCalled();
expect(deps.executeRun).toHaveBeenCalledOnce();
const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, run.id));
expect(coordinator.leaseExpiresAt).toEqual(outcome === "completion" ? null : expiresAt);
} finally {
finish();
await Promise.all([...deps.activeRunExecutionPromises]);
vi.useRealTimers();
await db.delete(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, run.id));
await db.delete(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
}
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
it.each(["bootstrap_incomplete", "blocked"] as const)("retries durable remote takeover and preserves its %s disposition", async kind => {
const run = await seedRun();
const deps = callbacks(db);
+5 -1
View File
@@ -121,6 +121,7 @@ export interface HeartbeatRecoveryDependencies extends Pick<HeartbeatRetryDepend
executeRun: (runId: string, options?: {
nativeLeaseOwner?: string;
nativeRestartRecovery?: NativeRestartRecoveryClaim;
onNativeExecutionLeaseAcquired?: () => Promise<void>;
}) => Promise<void>;
scheduleNativeSessionResumeDispatch: (runId: string, nextAttemptAt: Date) => void;
cancelHeartbeatNativeRun: (input: {
@@ -1052,13 +1053,16 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
const execution = executeRun(claim.runId, {
nativeLeaseOwner: claim.leaseOwner,
nativeRestartRecovery: claim,
...(preparationRenewal ? { onNativeExecutionLeaseAcquired: () => preparationRenewal.stop() } : {}),
}).catch((error) => {
logger.error(
{ err: error, runId: claim.runId, disposition: claim.kind },
"native restart recovery execution failed",
);
}).finally(() => preparationRenewal?.stop().catch(() => undefined));
preparationRenewals.delete(claim.runId); // Execution now owns this reference.
// The executor releases preparation only after acquiring its own renewal.
// The finalizer above also covers exits before execution can acquire it.
preparationRenewals.delete(claim.runId);
activeRunExecutionPromises.add(execution);
void execution.finally(() =>
activeRunExecutionPromises.delete(execution),
@@ -118,6 +118,16 @@ describe.skipIf(!support.supported)("heartbeat runtime execution boundary", () =
expect(input.instructions.releaseInstructionCopy).toHaveBeenCalledOnce();
}
it("passes preparation renewal ownership to native dispatch", async () => {
const { input } = await fixture(true);
const acquired = vi.fn(async () => undefined);
input.runtime.runOptions.onNativeExecutionLeaseAcquired = acquired;
await executeHeartbeatRuntime(db, input);
expect(executePaperclipNativeSession).toHaveBeenCalledWith(expect.objectContaining({ onExecutionLeaseAcquired: acquired }));
// Preparation is still protected until the native executor claims its lease.
expect(acquired).not.toHaveBeenCalled();
});
it.each([false, true])("preserves the dispatch gate's early return and cleanup (native: %s)", async native => {
const { input, adapter } = await fixture(native);
input.control.dispatchResolvedInteractionContinuationWithAtomicGate = async () => ({ dispatched: false });
@@ -593,6 +593,7 @@ export async function executeHeartbeatRuntime(db: Db, input: HeartbeatRuntimeExe
runnerInstanceId: nativeRunnerInstanceId,
leaseOwner: runOptions.nativeLeaseOwner,
restartRecovery: runOptions.nativeRestartRecovery,
onExecutionLeaseAcquired: runOptions.onNativeExecutionLeaseAcquired,
backend:
options.nativeSessionBackendFactory?.(nativeExecution),
useRunnerd: agent.adapterType === "paperclip_runner",
@@ -137,7 +137,7 @@ export interface HeartbeatRuntimeSelectionInput {
resolvedConfig: Record<string, unknown>;
managedAiRuntime: Awaited<ReturnType<typeof prepareManagedAiRuntime>> | undefined;
};
recovery: { nativeLeaseOwner?: string; nativeRestartRecovery?: NativeRestartRecoveryClaim };
recovery: { nativeLeaseOwner?: string; nativeRestartRecovery?: NativeRestartRecoveryClaim; onNativeExecutionLeaseAcquired?: () => Promise<void> };
trace: { providerTraceRequested: boolean; providerTraceCapture: { metadata: { id: string } } | null };
issuesSvc: Pick<ReturnType<typeof issueService>, "addComment">;
runtimeEnv: Record<string, string | undefined>;
@@ -421,12 +421,33 @@ describeEmbeddedPostgres("native runner restart recovery with real processes", (
const priorProcess = await runner.execute({ command: "sh", args: ["-c", `cat '${remoteCwd}'/.paperclip-runtime/paperclip-runner/sessions/*/runner/runner-process.identity`] });
await fixture.db.update(nativeRunFinalizations).set({ leaseExpiresAt: new Date(Date.now() - 1) })
.where(eq(nativeRunFinalizations.runId, fixture.runId));
let preparation: NativeExecutionLeaseRenewal | undefined;
const [claim] = await claimNativeRestartRecoveries({ db: fixture.db,
controller: await currentNativeControllerIdentity(), restartKind: "hard", runIds: [fixture.runId] });
controller: await currentNativeControllerIdentity(), restartKind: "hard", runIds: [fixture.runId],
onClaim: (claim, scope) => {
preparation = startNativeSessionExecutionLeaseRenewal({ db: fixture.db, ...scope,
runId: claim.runId, leaseOwner: claim.leaseOwner, attempt: claim.providerAttempt,
onLost: () => detachNativeSessionsForRestart([claim.runId], claim.leaseOwner) });
} });
if (!claim || claim.kind !== "reattach_remote_runner") {
throw new Error(`same-server claim missing: ${claim && "reason" in claim ? claim.reason : claim?.kind}`);
}
const result = await executePaperclipNativeSession({ ...firstOptions, leaseOwner: claim.leaseOwner, restartRecovery: claim });
const result = await executePaperclipNativeSession({ ...firstOptions, leaseOwner: claim.leaseOwner, restartRecovery: claim,
onExecutionLeaseAcquired: () => preparation!.stop(),
instructionWorkingCopy: {
hasChanges: async () => {
// Completion has released the coordinator, but the startup still
// owns its session. A leftover preparation timer would detach it.
const [settled] = await fixture.db.select().from(nativeRunFinalizations)
.where(eq(nativeRunFinalizations.runId, fixture.runId));
expect(settled.leaseOwner).toBeNull();
await new Promise(resolve => setTimeout(resolve, 21_000));
return false;
},
collectStopped: async () => undefined,
},
}).finally(() => preparation?.stop().catch(() => undefined));
expect(logs.filter(line => line.includes("controller authority detached for restart"))).toHaveLength(1);
const recoveredCalls = await runner.execute({ command: "cat", args: [`${remoteCwd}/provider-calls.log`] });
expect(recoveredCalls.stdout.split("\n").filter(line => line === "turn/start")).toHaveLength(1);
const nextProcess = await runner.execute({ command: "sh", args: ["-c", `cat '${remoteCwd}'/.paperclip-runtime/paperclip-runner/sessions/*/runner/runner-process.identity`] });
@@ -10373,10 +10373,15 @@ describe("native session bounded recovery", () => {
});
const stop = vi.fn(async () => undefined);
const renew = vi.spyOn(nativeLeaseRenewal, "startNativeSessionExecutionLeaseRenewal").mockReturnValue({ stop });
const releasePreparation = vi.fn(async () => {
expect(renew).toHaveBeenCalledOnce();
expect(stop).not.toHaveBeenCalled();
});
let reached!: () => void, release!: () => void;
const claiming = new Promise<void>(resolve => { reached = resolve; });
const preparation = new Promise<void>(resolve => { release = resolve; });
const running = executePaperclipNativeSession({ db, execution: fresh, runnerInstanceId: "fresh-runner",
onExecutionLeaseAcquired: releasePreparation,
runnerExecutionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", environmentId: "fresh-environment", leaseId: "fresh-lease" },
onEvent: async event => {
if (event.payload?.span === "native.coordinator.claim") { reached(); await preparation; }
@@ -10388,6 +10393,7 @@ describe("native session bounded recovery", () => {
db, runId: fresh.binding.runId, attempt: 1, remote: true,
}));
expect(stop).not.toHaveBeenCalled();
expect(releasePreparation).toHaveBeenCalledOnce();
release();
expect(await running).toBe(failure);
expect(stop).toHaveBeenCalledOnce();
@@ -7791,6 +7791,8 @@ export async function executePaperclipNativeSession(input: {
turnTimeoutMs?: number;
leaseOwner?: string;
restartRecovery?: NativeRestartRecoveryClaim;
/** Release recovery preparation's renewal after execution owns its reference. */
onExecutionLeaseAcquired?: () => Promise<void>;
onSpawn?: (meta: {
pid: number;
processGroupId: number | null;
@@ -8395,7 +8397,7 @@ async function executePaperclipNativeSessionWithinScope(
})
.where(eq(heartbeatRuns.id, coordinator.runId));
return nextAttempt;
}).then(claimedAttempt => {
}).then(async claimedAttempt => {
const startup = nativeSessionStartups.get(input.execution.binding.runId);
if (!startup) throw new Error("native_session_startup_owner_missing");
startup.leaseOwner = leaseOwner;
@@ -8410,6 +8412,7 @@ async function executePaperclipNativeSessionWithinScope(
? detachNativeSessionsForRestart([input.execution.binding.runId], leaseOwner)
: cancelNativeSession(input.execution.binding.runId, "native session execution lease lost"),
});
await input.onExecutionLeaseAcquired?.();
return claimedAttempt;
}),
{ parentName: "task.prepare" },