paperclipai/paperclip · error · Error

ACPX runtime host already has an active turn

Error message

ACPX runtime host already has an active turn

What it means

The ACPX runtime host enforces one active turn at a time. startTurn() throws this error when a turn is already running (#activeTurn is set), preventing interleaved or concurrent turns on the same host.

Source

Thrown at packages/paperclip-runner/src/drivers/acpx/runtime-host.ts:607

  }

  async controlGoal(
    action: "set" | "pause" | "resume" | "clear",
    objective?: string,
  ): Promise<AcpxRuntimeGoalSnapshot | null> {
    if (!this.#runtime.controlGoal) {
      throw new Error("ACPX runtime does not expose session goal controls");
    }
    return await this.#runtime.controlGoal(action, objective);
  }

  startTurn(input: AcpxRuntimeTurnInput): AcpxRuntimeTurn {
    if (this.#closed || this.#closingStarted) {
      throw new Error("ACPX runtime host is closing");
    }
    if (this.#activeTurn) {
      throw new Error("ACPX runtime host already has an active turn");
    }
    const requestId = boundedRequestId(input.requestId);
    const text = boundedTurnText(input.text);
    const turn = this.#runtime.startTurn({
      text,
      requestId,
      ...(input.signal ? { signal: input.signal } : {}),
      ...(input.onElicitation ? { onElicitation: input.onElicitation } : {}),
    });
    this.#activeTurn = turn;
    void turn.result
      .finally(() => {
        // Once shutdown owns this turn, retain its cancellation handle until
        // runtime cleanup succeeds. The result may settle while cleanup is
        // failing, and a later close must still be able to retry cancellation.
        if (this.#activeTurn === turn && !this.#closingStarted) {
          this.#activeTurn = null;
        }
      })

View on GitHub (pinned to 01ad858492)

Solutions

  1. Serialize turns: await the previous turn's completion before calling startTurn
  2. Maintain a turn queue that submits the next turn only when the host is idle
  3. If a turn is stuck, abort it (via its signal) or close and reopen the host to clear #activeTurn
  4. Guard concurrent callers with a mutex/promise chain around startTurn

Example fix

// before
host.startTurn({ text: "a" });
host.startTurn({ text: "b" }); // throws
// after
await chain = chain.then(() => runTurn(host, "b"));
async function runTurn(h, text) {
  const turn = h.startTurn({ text });
  await turn.done;
}
Defensive patterns

Strategy: validation

Validate before calling

if (host.hasActiveTurn?.()) throw new Error("Wait for current turn");

Type guard

function isIdle(host) {
  return !host.hasActiveTurn?.();
}

Try / catch

let chain = Promise.resolve();
try {
  chain = chain.then(() => host.startTurn({ text }));
} catch (err) {
  if (err.message.includes("already has an active turn")) {
    await currentTurnDone;
    return host.startTurn({ text });
  }
  throw err;
}

Prevention

When it happens

Trigger: Calling startTurn() while a previous turn has not completed; concurrent callers issuing turns in parallel; a stuck/never-completing active turn blocking all subsequent turns.

Common situations: UI double-submits a prompt; an event handler fires while a prior turn is in flight; a crashed turn never cleared #activeTurn so all later turns fail.

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