paperclipai/paperclip · error

OpenCode session already has an active turn

Error message

OpenCode session already has an active turn

What it means

This driver enforces a single-turn invariant per OpenCode harness session: `#activeTurnId` must be null before a new turn can be submitted. `startTurn` refuses to run when a prior turn is still considered active, preventing two concurrent prompt_async submissions against the same provider session. It is a guard against interleaving agent work, which OpenCode sessions do not support.

Source

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

    return this.#events;
  }

  startEventPump(): void {
    void this.#recoverPendingRuntimeRequests()
      .catch((error) =>
        this.#emit("harness.diagnostic", {
          code: "opencode_runtime_request_recovery_failed",
          message: redact(String(error), this.#runtime.sensitiveValues),
        }),
      )
      .finally(() => this.#pumpEvents());
  }

  async startTurn(input: {
    message: NativeUserMessage;
  }): Promise<{ turnId: string }> {
    if (this.#activeTurnId !== null)
      throw new Error("OpenCode session already has an active turn");
    const turnId = `turn-${randomBytes(12).toString("hex")}`;
    this.#activeTurnId = turnId;
    this.#emit("turn.submitted", {
      envelopeSchema: this.#taskEnvelope.schema,
      text: input.message.text,
    });
    this.#emit("turn.accepted", { turnId }, { turnId });
    this.#emit("turn.started", { status: "inProgress" }, { turnId });
    const [providerID, ...modelParts] = this.#model.split("/");
    const modelID = modelParts.join("/");
    // A resumed OpenCode provider session already retains the original system
    // instructions and task envelope in its conversation. Repeating both on
    // every Paperclip continuation can overflow smaller context windows and
    // OpenCode then completes with `finish: unknown` and zero tokens. The
    // native model envelope still carries the authoritative wake delta,
    // interaction responses, completion contract, and current issue context.
    const prompt = this.#sendFullContext
      ? JSON.stringify({

View on GitHub (pinned to 01ad858492)

Solutions

  1. Wait for the current turn to finish: consume the session's `events()` iterable until a terminal turn event (`turn.completed`/`turn.aborted`) before calling `startTurn` again.
  2. If the current turn is genuinely stuck, call `await session.interrupt({})` first (no turnId) to abort the OpenCode session, let the turn state clear, then start the new turn.
  3. Check `pendingRuntimeRequests()` — an unanswered permission/question request can hold the turn open; resolve it via `resolveRuntimeRequest` before starting a new turn.
  4. Ensure each OpenCode session object is used by exactly one run/worker; create a new session via the driver factory instead of reusing a busy one.

Example fix

// before
const a = await session.startTurn({ message });
const b = await session.startTurn({ message }); // throws: turn a still active

// after
const a = await session.startTurn({ message });
for await (const evt of session.events()) {
  if (evt.type === 'turn.completed' || evt.type === 'turn.aborted') break;
}
const b = await session.startTurn({ message });
Defensive patterns

Strategy: try-catch

Validate before calling

const snap = await session.snapshot();
if (snap.activeTurnId) throw new SkipStartError(snap.activeTurnId);

Type guard

function canStartTurn(s) { return s != null && typeof s.snapshot === 'function'; }

Try / catch

try {
  await session.startTurn({ message });
} catch (e) {
  if (e.message === 'OpenCode session already has an active turn') {
    await session.interrupt({});
    await session.startTurn({ message });
  } else throw e;
}

Prevention

When it happens

Trigger: Calling `session.startTurn({ message })` while `#activeTurnId !== null` — i.e. a previous `startTurn` completed (turn accepted) but its turn has not yet reached a terminal state (turn completed/aborted) that clears `#activeTurnId`. Typical: awaiting the turn without draining `events()` and calling again, or retrying `startTurn` after a timeout while the old turn still runs.

Common situations: A supervisor loop that re-submits a prompt on a heartbeat timeout without first aborting the in-flight turn; a retry after a network blip on `prompt_async` where the caller assumes the turn failed; parallel workers sharing one persisted session via `snapshot()`/restore and both calling `startTurn`.

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/6657b5b88fd4fcd0. Report an issue: GitHub.