{"record":{"id":"87936606e8aa20d9","repo":"vercel/ai","slug":"claude-code-session-sessionid-is-already-stoppe","errorCode":null,"errorMessage":"claude-code session ${sessionId} is already stopped; cannot detach.","messagePattern":"claude-code session (.+?) is already stopped; cannot detach\\.","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/harness-claude-code/src/claude-code-harness.ts","lineNumber":1895,"sourceCode":"      return control;\n    },\n    doCompact: async (customInstructions?: string) => {\n      /*\n       * Claude Code has no SDK/control method for compaction — the supported\n       * trigger is the `/compact` slash command submitted as user input. Ride\n       * the existing user-message rail; the bridge feeds it into the streaming\n       * query input and Claude's native compaction handles the rest, emitting a\n       * `compact_boundary` + `PostCompact` we observe as a `compaction` event.\n       */\n      const text =\n        customInstructions && customInstructions.trim()\n          ? `/compact ${customInstructions.trim()}`\n          : '/compact';\n      channel.send({ type: 'user-message', text });\n    },\n    doDetach: async () => {\n      if (stopped) {\n        throw new Error(\n          `claude-code session ${sessionId} is already stopped; cannot detach.`,\n        );\n      }\n      stopped = true;\n      const lastSeenEventId = await channel.suspend();\n      const payload: HarnessV1ResumeSessionState = {\n        type: 'resume-session',\n        harnessId: 'claude-code',\n        specificationVersion: 'harness-v1',\n        data: {\n          ...(sandboxCredentialEnvironment == null\n            ? {}\n            : { sandboxCredentialEnvironment }),\n          bridge: {\n            port: bridgePort,\n            token: bridgeToken,\n            lastSeenEventId,\n            ...(sandboxId == null ? {} : { sandboxId }),","sourceCodeStart":1877,"sourceCodeEnd":1913,"githubUrl":"https://github.com/vercel/ai/blob/69428b1f8b037e4d118fb4853428d5c4e620493c/packages/harness-claude-code/src/claude-code-harness.ts#L1877-L1913","documentation":"The claude-code harness session exposes a doDetach operation on each created session. The library tracks a `stopped` flag per session and throws this error when detach is called on a session that has already been stopped (by doStop or doSuspendTurn, or after a prior detach tore things down), because detaching a stopped session has no channel left to suspend and would corrupt state. It is a guard against double teardown of the same session.","triggerScenarios":"Calling session.doDetach() after the same session was already stopped: a second doDetach, a doDetach after doStop, or a doDetach after doSuspendTurn (which also sets stopped = true).","commonSituations":"Race conditions where two parts of an app both call detach (e.g. an abort handler plus a cleanup path in a finally block); calling detach on a cached session object that a previous request already stopped; resuming work with an old session handle that was torn down earlier.","solutions":["Check the session's stopped state before calling doDetach and skip the call if it is already stopped","Wrap doDetach in try-catch and treat this error as a no-op idempotent outcome rather than a failure","Restructure code so teardown happens in exactly one place (single owner / AbortSignal-driven cleanup) instead of multiple call sites","If you need to keep working after a stop, create a new session via createClaudeCode instead of reusing the stopped one"],"exampleFix":"// before\nawait session.doDetach();\nawait session.doDetach(); // throws: already stopped; cannot detach\n\n// after\nlet detached = false;\nasync function detachOnce() {\n  if (detached) return;\n  detached = true;\n  await session.doDetach();\n}","handlingStrategy":"try-catch","validationCode":"// Track lifecycle locally before calling\nif (sessionStopped.has(sessionId)) {\n  throw new Error(`skip detach: session ${sessionId} already stopped`);\n}","typeGuard":"function canDetach(session: { stopped: boolean } | unknown): session is { stopped: false } {\n  return typeof session === 'object' && session !== null &&\n    (session as { stopped?: boolean }).stopped !== true;\n}","tryCatchPattern":"try {\n  await session.doDetach();\n} catch (err) {\n  if (err instanceof Error && err.message.includes('already stopped; cannot detach')) {\n    return; // idempotent: already torn down\n  }\n  throw err;\n}","preventionTips":["Keep a single teardown function per session and call it from exactly one place","Track stopped/detached state in a Set or WeakSet keyed by session id","Make detach idempotent by swallowing the already-stopped error","Avoid calling detach from both abort handlers and finally blocks on the same session"],"tags":["session-lifecycle","claude-code","harness","idempotency"],"backgroundTag":"session-already-stopped","analyzedSha":"69428b1f8b037e4d118fb4853428d5c4e620493c","analyzedAt":"2026-08-30T12:32:21.016Z","schemaVersion":2},"datasetVersion":"2026-08-30T13:17:10.514Z"}