{"record":{"id":"8744a85fadd6ba53","repo":"vercel/ai","slug":"a-host-tool-relay-turn-is-already-active","errorCode":null,"errorMessage":"A host tool relay turn is already active.","messagePattern":"A host tool relay turn is already active\\.","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/harness-acp/src/v1/bridge/host-tool-relay.ts","lineNumber":114,"sourceCode":"    } catch (error) {\n      const status = error instanceof RelayRequestError ? error.status : 500;\n      response.writeHead(status, { 'content-type': 'application/json' });\n      response.end(\n        JSON.stringify({\n          error: error instanceof Error ? error.message : String(error),\n        }),\n      );\n    }\n  });\n  await listen({ server });\n  const address = server.address() as AddressInfo;\n\n  return {\n    url: `http://127.0.0.1:${address.port}/invoke`,\n    credential,\n    bindTurn: ({ turn }) => {\n      if (activeTurn != null && activeTurn !== turn) {\n        throw new Error('A host tool relay turn is already active.');\n      }\n      activeTurn = turn;\n    },\n    unbindTurn: ({ turn }) => {\n      if (activeTurn === turn) activeTurn = undefined;\n    },\n    updateCatalog: ({ tools: nextTools }) => {\n      const fingerprint = catalogFingerprint({ tools: nextTools });\n      if (fingerprint === state.fingerprint) {\n        return { changed: false, revision: state.revision };\n      }\n      state.tools = [...nextTools];\n      state.fingerprint = fingerprint;\n      state.revision += 1;\n      resolveCatalogChanges({ state });\n      return { changed: true, revision: state.revision };\n    },\n    waitForCatalogRefresh: ({ revision, timeoutMs }) =>","sourceCodeStart":96,"sourceCodeEnd":132,"githubUrl":"https://github.com/vercel/ai/blob/69428b1f8b037e4d118fb4853428d5c4e620493c/packages/harness-acp/src/v1/bridge/host-tool-relay.ts#L96-L132","documentation":"startHostToolRelay's returned relay can only be bound to a single active ACP prompt turn at a time. bindTurn throws this plain Error when another turn is currently bound and the incoming turn is a different one. Re-binding the same turn is allowed, and unbindTurn clears the slot, so the guard enforces one-turn-at-a-time semantics for the shared local HTTP relay.","triggerScenarios":"Calling relay.bindTurn({ turn }) while activeTurn is set to a different HostToolRelayTurn — e.g. binding a second ACP session's turn before the first session unbinds, or binding a new turn in a turn-start hook while a previous turn was never unbound (an earlier turn errored or was aborted without cleanup).","commonSituations":"Running multiple concurrent ACP prompt turns against one shared relay; a crashed or aborted turn leaking its binding because unbindTurn was never called; forgetting to unbind in a finally block when bridging host tool calls; reusing a single relay across several sessions instead of one relay per session.","solutions":["Always call relay.unbindTurn({ turn }) in a finally block when a turn ends, so the slot is freed.","Check whether a turn is already active and wait for it to complete (or abort it) before binding a new turn.","Create a separate relay via startHostToolRelay for each concurrent session/turn instead of sharing one.","If re-binding the same turn, note the guard permits it — ensure you pass the same HostToolRelayTurn object, not an equal-looking new one."],"exampleFix":"// before\nsession.onTurnStart(turn => {\n  relay.bindTurn({ turn });\n});\n// after\nsession.onTurnStart(turn => {\n  relay.bindTurn({ turn });\n});\nsession.onTurnEnd(turn => {\n  relay.unbindTurn({ turn }); // always release, even on error\n});","handlingStrategy":"try-catch","validationCode":"// Track the currently bound turn before binding\nlet boundTurn: HostToolRelayTurn | undefined;\nfunction safeBind(relay: HostToolRelay, turn: HostToolRelayTurn) {\n  if (boundTurn != null && boundTurn !== turn) {\n    throw new Error('Cannot bind: another relay turn is already active.');\n  }\n  relay.bindTurn({ turn });\n  boundTurn = turn;\n}","typeGuard":"function canBind(activeTurn: HostToolRelayTurn | undefined, next: HostToolRelayTurn): boolean {\n  return activeTurn == null || activeTurn === next;\n}","tryCatchPattern":"try {\n  relay.bindTurn({ turn });\n  await runPromptTurn(turn);\n} catch (error) {\n  if (error instanceof Error && error.message === 'A host tool relay turn is already active.') {\n    await waitForActiveTurnToEnd(); // or abort the active turn\n    relay.bindTurn({ turn });\n  } else {\n    throw error;\n  }\n} finally {\n  relay.unbindTurn({ turn });\n}","preventionTips":["Always pair bindTurn with unbindTurn in a finally block.","Use one relay instance per concurrent session instead of sharing.","Serialize turn starts behind a queue/mutex so only one bind happens at a time.","On abort/error paths, verify unbindTurn actually ran (assert activeTurn cleared in tests)."],"tags":["harness-acp","relay","concurrency","state-conflict"],"backgroundTag":"relay-turn-already-active","analyzedSha":"69428b1f8b037e4d118fb4853428d5c4e620493c","analyzedAt":"2026-08-30T12:32:21.016Z","schemaVersion":2},"datasetVersion":"2026-08-30T13:17:10.514Z"}