vercel/ai · error

claude-code session ${sessionId} is already stopped; cannot

Error message

claude-code session ${sessionId} is already stopped; cannot stop.

What it means

Each claude-code harness session keeps a `stopped` flag that is set once by doStop (and by doDetach/doSuspendTurn). doStop throws this error when called on a session that is already stopped, since stopping twice has no meaningful effect and the underlying bridge channel is already being torn down. It protects the stop machinery from double-execution.

Source

Thrown at packages/harness-claude-code/src/claude-code-harness.ts:1957

              new Promise<void>(resolve => {
                stopTimer = setTimeout(resolve, 5000);
                stopTimer.unref?.();
              }),
            ]);
          }
        } finally {
          if (stopTimer) clearTimeout(stopTimer);
          try {
            await proc?.kill();
          } catch {}
          channel.close();
        }
      })();
      return stopPromise;
    },
    doStop: async () => {
      if (stopped) {
        throw new Error(
          `claude-code session ${sessionId} is already stopped; cannot stop.`,
        );
      }
      stopped = true;
      /*
       * If the bridge's channel already closed (e.g. mid-turn WS drop)
       * there is no one to acknowledge a `stop` message. Synthesize an empty
       * payload — for Claude Code the resume state structurally is `{}`
       * (the conversation lives in the workdir, captured by the sandbox
       * snapshot during the subsequent `sandboxSession.stop()`), so we
       * lose nothing by skipping the round-trip.
       */
      // Tell the channel we are tearing down so the bridge's post-stop
      // socket close finalises instead of triggering a reconnect.
      channel.beginClose();
      const data: unknown = channel.isClosed()
        ? {}
        : await new Promise<unknown>((resolve, reject) => {

View on GitHub (pinned to 69428b1f8b)

Solutions

  1. Guard doStop with a local boolean or promise so it runs only once per session
  2. Catch this error and ignore it — an already-stopped session satisfies the goal of stopping
  3. Check that the session handle you hold is still the active one; obtain a fresh session from createClaudeCode if the old one was stopped
  4. Deduplicate concurrent stop paths (abort handler vs explicit stop) behind a single teardown function

Example fix

// before
async function stop(session) {
  await session.doStop();
}
// called from both abort handler and finally -> second call throws

// after
async function stop(session) {
  if (session.stopped) return; // or track a local stopPromise
  await session.doStop();
}
Defensive patterns

Strategy: try-catch

Validate before calling

let stopRequested = false;
function requestStop(session) {
  if (stopRequested) return Promise.resolve();
  stopRequested = true;
  return session.doStop();
}

Type guard

function isAlreadyStoppedError(err: unknown): boolean {
  return err instanceof Error && /already stopped; cannot stop\.$/.test(err.message);
}

Try / catch

try {
  await session.doStop();
} catch (err) {
  if (!isAlreadyStoppedError(err)) throw err;
  // already stopped: treat as success
}

Prevention

When it happens

Trigger: Calling session.doStop() twice on the same session, calling doStop after doDetach or doSuspendTurn already set stopped = true, or concurrent doStop calls racing where the second arrives after the first set the flag.

Common situations: Cleanup code invoked both by an abort signal and a finally block; a UI stop button pressed twice while the first stop is in flight; reusing a pooled or cached session that a previous turn already stopped.

Related errors


AI-assisted analysis of vercel/ai@69428b1f8b (2026-08-30). Data as JSON: /api/errors/6fb86707a529cb1d. Report an issue: GitHub.