paperclipai/paperclip · error

stale OpenCode turn

Error message

stale OpenCode turn

What it means

`interrupt` aborts the OpenCode session via `/session/{id}/abort`. If the caller passes a `turnId` that does not match the currently active turn id tracked by the driver, the driver treats the request as targeting a turn that no longer exists and throws instead of aborting, protecting the live turn from being cancelled by stale bookkeeping.

Source

Thrown at packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts:604

          tools: { question: true },
          ...(this.#sendFullContext
            ? { system: this.#systemInstructions }
            : {}),
          parts: [{ type: "text", text: prompt }],
        }),
      },
    );
    this.#sendFullContext = false;
    return { turnId };
  }

  async interrupt(input: { turnId?: string; reason?: string }): Promise<void> {
    if (
      input.turnId &&
      this.#activeTurnId &&
      input.turnId !== this.#activeTurnId
    )
      throw new Error("stale OpenCode turn");
    await api(
      this.#fetch,
      this.#runtime,
      `/session/${encodeURIComponent(this.#providerSessionId)}/abort`,
      { method: "POST" },
    );
  }

  pendingRuntimeRequests(): HarnessRuntimeRequest[] {
    return [...this.#pendingRuntimeRequests.values()].map(({ request }) =>
      structuredClone(request),
    );
  }

  async resolveRuntimeRequest(input: {
    requestId: string;
    turnId: string;
    resolution: HarnessRuntimeRequestResolution;

View on GitHub (pinned to 01ad858492)

Solutions

  1. Fetch the current active turn id via `await session.snapshot()` (`activeTurnId` field) and pass that to `interrupt`.
  2. If you intend to abort regardless of turn bookkeeping, call `interrupt({})` without a `turnId` — the guard is skipped.
  3. If the turn already ended, no interrupt is needed; drop the stale turn id and skip the abort call.
  4. After restoring a session from a persisted snapshot, resynchronize turn ids from live events before issuing turn-scoped calls.

Example fix

// before
await session.interrupt({ turnId: staleTurnId }); // throws 'stale OpenCode turn'

// after
const snap = await session.snapshot();
if (snap.activeTurnId) await session.interrupt({ turnId: snap.activeTurnId });
else await session.interrupt({});
Defensive patterns

Strategy: validation

Validate before calling

const snap = await session.snapshot();
const isValid = !input.turnId || input.turnId === snap.activeTurnId;
if (!isValid) return; // nothing to interrupt

Type guard

function isCurrentTurn(snap, turnId) { return turnId === undefined || turnId === snap.activeTurnId; }

Try / catch

try {
  await session.interrupt({ turnId });
} catch (e) {
  if (e.message === 'stale OpenCode turn') {
    const snap = await session.snapshot();
    if (snap.activeTurnId) await session.interrupt({ turnId: snap.activeTurnId });
  } else throw e;
}

Prevention

When it happens

Trigger: Calling `session.interrupt({ turnId })` with a turnId from an earlier, already-finished turn while a newer turn is active (`input.turnId !== this.#activeTurnId`). Happens when cached turn ids are reused after reconnect, or when a persisted snapshot's `activeTurnId` is out of date relative to the live session.

Common situations: A retry worker holds a stale turn id after the original turn already completed; two processes restored the same session from `snapshot()` and hold different views of the active turn; UI shows an old run whose turn id was superseded and the user clicks 'Stop'.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10). Data as JSON: /api/errors/168015259281a27b. Report an issue: GitHub.