{"record":{"id":"191fb4c8c203386b","repo":"vercel/ai","slug":"harnessid-acp-session-sessionid-is-stopped-191fb4","errorCode":null,"errorMessage":"${harnessId} ACP session ${sessionId} is stopped; cannot detach.","messagePattern":"(.+?) ACP session (.+?) is stopped; cannot detach\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/harness-acp/src/v1/acp-v1-harness.ts","lineNumber":1616,"sourceCode":"      const lastSeenEventId = await channel.suspend();\n      return {\n        type: 'continue-turn',\n        harnessId,\n        specificationVersion: 'harness-v1',\n        data: createLifecycleData({\n          bridge: {\n            port: bridgePort,\n            token: bridgeToken,\n            lastSeenEventId,\n            ...(sandboxId == null ? {} : { sandboxId }),\n            stateDir: bridgeStateDir,\n          },\n        }),\n      };\n    },\n    doDetach: async () => {\n      if (stopped) {\n        throw new Error(\n          `${harnessId} ACP session ${sessionId} is stopped; cannot detach.`,\n        );\n      }\n      if (turnInFlight) {\n        throw new Error(\n          `${harnessId} ACP session ${sessionId} has an in-flight turn; suspend it instead.`,\n        );\n      }\n      stopped = true;\n      const lastSeenEventId = await channel.suspend();\n      return {\n        type: 'resume-session',\n        harnessId,\n        specificationVersion: 'harness-v1',\n        data: createLifecycleData({\n          bridge: {\n            port: bridgePort,\n            token: bridgeToken,","sourceCodeStart":1598,"sourceCodeEnd":1634,"githubUrl":"https://github.com/vercel/ai/blob/69428b1f8b037e4d118fb4853428d5c4e620493c/packages/harness-acp/src/v1/acp-v1-harness.ts#L1598-L1634","documentation":"doDetach ends the session's attachment to the bridge and returns a resume-session handle; it is only valid while the session is still live. Once the session is stopped (stop/destroy issued or a prior suspend/detach), the bridge is being torn down and detach throws.","triggerScenarios":"Calling detach on a session already stopped by stop/destroy, or after a previous suspendTurn/detach call set stopped=true.","commonSituations":"Shutdown handlers calling both suspend and then detach; calling detach on a stale session reference after the process was terminated; re-running cleanup logic twice (e.g. in both a signal handler and a finally block).","solutions":["Call detach exactly once, before any stop/destroy, and keep the returned resume-session handle for later resumption.","Guard cleanup code with an idempotency flag so stop/detach are not both invoked.","If the session is already stopped, discard the handle — there is nothing detachable left; use the earlier suspend/detach data to resume.","Only resume from persisted lifecycle data instead of trying to detach a dead session."],"exampleFix":"// before\nawait session.suspendTurn();\nawait session.detach(); // throws: session stopped\n// after\nconst handle = await session.detach(); // detach directly when no turn is in flight","handlingStrategy":"validation","validationCode":"let detachedOrStopped = false;\nasync function safeDetach(session) {\n  if (detachedOrStopped) throw new Error('session no longer detachable');\n  detachedOrStopped = true;\n  return session.detach();\n}","typeGuard":null,"tryCatchPattern":"try {\n  await session.detach();\n} catch (e) {\n  if (e instanceof Error && e.message.includes('is stopped; cannot detach')) {\n    // reuse handle from the earlier suspend/detach call\n  } else throw e;\n}","preventionTips":["Detach exactly once per session, before any stop/destroy","Centralize teardown in one idempotent cleanup function","Keep the returned resume-session handle; never detach a stopped session to 'get' one"],"tags":["acp","detach","session-lifecycle","stopped-session"],"backgroundTag":"session-already-stopped","analyzedSha":"69428b1f8b037e4d118fb4853428d5c4e620493c","analyzedAt":"2026-08-30T12:32:21.016Z","schemaVersion":2},"datasetVersion":"2026-08-30T13:17:10.514Z"}