Continuations

Implement interrupt-and-resume flows and protect their replay state.

Continuations

A continuation lets the host stop an invocation at an interruption and resume the same logical run later. Use it for approvals, authentication, and other decisions made outside the sandbox.

Concept

An interruption does not leave a worker suspended. The invocation ends and returns a continuation token. When the host supplies that token and a resolution, run starts a new invocation and replays the source.

Suspension is a host operation, not a rejected promise inside the guest. It discards the guest context without executing guest catch or finally handlers. On resume, those handlers follow normal JavaScript semantics as the source replays and the interrupted host call receives its resolution. Put cleanup for work owned by the host in the host function; guest finally is not a suspension or cancellation hook.

Continuations do not turn run into a complete durable execution system. The host application remains responsible for storing tokens, collecting decisions, scheduling resumed work, authorizing callers, and handling retries.

Configuration

The simplest configuration uses a shared signing secret:

import { createRunner } from 'run';

const runner = createRunner({
  continuationSecret: process.env.RUN_CONTINUATION_SECRET!,
  continuationAudience: 'message-approval-v1',
});

The secret must contain at least 32 bytes. Every process that can create or resume a continuation must use the same secret and audience.

The convenience run() function reads RUN_CONTINUATION_SECRET automatically. Continuation configuration is not needed for an invocation that completes without interruption, but a secret or codec must be available before a run can interrupt or resume.

Implementation

First, define a host function that interrupts before performing an action:

import { getHostFunctionContext } from 'run';

const hostFunctions = {
  messages: {
    send: async (recipient: string, body: string) => {
      const context = getHostFunctionContext();

      if (context.resume === undefined) {
        context.interrupt({
          kind: 'approval',
          recipient,
          body,
        });
      }

      if (context.resume.resolution !== true) {
        return { sent: false };
      }

      await messageService.send({ recipient, body });
      return { sent: true };
    },
  },
};

context.interrupt() uses an internal throw to leave the host function. Do not catch it inside the host function.

Next, start the run and handle the interrupted result:

const input = {
  source: `
    return await messages.send(
      "ada@example.com",
      "The report is ready."
    );
  `,
  hostFunctions,
  continuationContext: {
    tenantId: 'tenant_123',
    userId: 'user_456',
  },
};

const result = await runner.run(input);

if (result.status === 'interrupted') {
  await approvalStore.create({
    id: 'approval_789',
    continuation: result.continuation,
    interruptions: result.interruptions,
    tenantId: 'tenant_123',
    userId: 'user_456',
  });
}

The host presents the interruption payload to an authorized actor. When a decision is available, load the same token and resume with resolutions for one or more pending interruptions. This example resolves all of them:

const approval = await approvalStore.get('approval_789');

const resumed = await runner.run({
  ...input,
  continuation: approval.continuation,
  resolutions: approval.interruptions.map(interruption => ({
    interruptionId: interruption.id,
    value: true,
  })),
});

if (resumed.status === 'completed') {
  console.log(resumed.value);
}

The original source, asynchronous and synchronous host function names, module loader identity, audience, and continuation context must match when the run resumes. A mismatch rejects the continuation before the interrupted host function is invoked.

Host function implementations are not compared. Deployments must preserve compatible behavior for interrupted host functions while old continuations remain valid.

Several host functions can interrupt during the same invocation. The result contains the full batch, and the host can resolve any non-empty subset of it. Unresolved calls keep their IDs and payloads and remain pending without being invoked again. Resolved calls can advance independently and reach new host calls or interruptions. If the run remains interrupted, save the returned continuation and pending interruptions for the next resume. Apply subsequent resolutions to that updated continuation so completed work is preserved.

Each supplied resolution must identify a different pending interruption. Empty batches, duplicate IDs, and unknown IDs are rejected. Partial resumption does not cancel or detach outstanding calls; a program using Promise.race should still drain those calls before returning.

The continuation ledger records asynchronous calls, synchronous calls, and module normalization and loading in their aggregate request order. Replay uses recorded synchronous and module outcomes without invoking those integrations again. Calls reached for the first time after the recorded frontier dispatch normally and are added to a later continuation if the run interrupts again.

A module loader needs a non-empty, stable identity to create or resume a continuation. Change the identity whenever its resolution rules, module graph, or configuration changes incompatibly.

Signing

Signed continuations are self-contained tokens protected with HMAC. The signature prevents modification, but the token is not encrypted. Source, host function arguments, results, errors, and interruption payloads may be visible to anyone who receives it.

Use createSignedContinuationCodec() when the application needs custom expiration or signing-key rotation:

import { createRunner, createSignedContinuationCodec } from 'run';

const runner = createRunner({
  continuationAudience: 'message-approval-v1',
  continuationCodec: createSignedContinuationCodec({
    secret: process.env.RUN_CONTINUATION_SECRET_CURRENT!,
    verificationSecrets: [process.env.RUN_CONTINUATION_SECRET_PREVIOUS!],
    maxAgeMs: 15 * 60 * 1000,
  }),
});

New tokens are signed with secret. Values in verificationSecrets are used only to verify older tokens during a key rotation.

Signed tokens expire after one hour by default. They can be replayed until they expire, so the host must still prevent a decision from being submitted more times than the application permits.

Storage

Use createStoredContinuationCodec() when replay state should remain in host-controlled storage or a continuation should be consumed at most once. The token returned to the caller is a random storage key rather than the complete signed state.

import {
  createRunner,
  createStoredContinuationCodec,
  type ContinuationStorage,
} from 'run';

const storage: ContinuationStorage = {
  async set(key, value, context) {
    await continuationStore.set(key, value, {
      deadlineMs: context?.deadlineMs,
      signal: context?.abortSignal,
    });
  },
  async acquire(key, claimId, context) {
    return continuationStore.claim(key, {
      claimId,
      claimMs: 30_000,
      deadlineMs: context?.deadlineMs,
      signal: context?.abortSignal,
    });
  },
  async consume(key, claimId) {
    await continuationStore.deleteIfClaimedBy(key, claimId);
  },
  async release(key, claimId) {
    await continuationStore.releaseIfClaimedBy(key, claimId);
  },
};

const runner = createRunner({
  continuationAudience: 'message-approval-v1',
  continuationCodec: createStoredContinuationCodec({
    storage,
    maxAgeMs: 15 * 60 * 1000,
  }),
});

These methods implement a temporary claim. acquire() must atomically allow only one caller to claim a continuation without deleting it. consume() and release() must only modify a record when the supplied claim identifier matches its current claim.

The codec manages this lifecycle automatically:

  1. It calls acquire() before decoding.
  2. It calls release() when decoding is cancelled or replay validation fails.
  3. It calls consume() after replay validation succeeds, before the worker resumes execution.

Claims must expire automatically after a bounded interval. If the host process crashes before cleanup, expiry makes the continuation available for another attempt instead of leaving it permanently unavailable.

Storage implementations should honor the supplied abort signal and operation deadline during set() and acquire(). Cleanup operations intentionally do not receive the aborted context.

Scope

Use continuationAudience to separate applications or resume endpoints. Use continuationContext to bind a logical run to serializable tenant, principal, or policy information:

const continuationContext = {
  tenantId,
  userId,
  policyVersion,
};

const result = await runner.run({
  source,
  hostFunctions,
  continuationContext,
});

Pass the same context when resuming. A mismatch is rejected before replayed results are returned.

Scope validation does not authorize the person submitting a resolution. The resume endpoint must authenticate the caller and verify that the caller may approve the requested action.

Treat every continuation token as a bearer capability. Store it behind access controls, transmit it over an authenticated channel, and do not write the raw token to logs.