{"record":{"id":"2734d58cde2332f4","repo":"stablyai/orca","slug":"agent-session-claim-unavailable","errorCode":"agent_session_claim_unavailable","errorMessage":"agent_session_claim_unavailable","messagePattern":"agent_session_claim_unavailable","errorType":"error_code","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/main/daemon/terminal-host.ts","lineNumber":58,"sourceCode":"    this.spawnSubprocess = opts.spawnSubprocess\n    this.onSessionReaped = opts.onSessionReaped\n    this.onFinalCheckpoint = opts.onFinalCheckpoint\n    this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES\n    this.killedTombstones = new TerminalHostTombstones(this.maxTombstones)\n  }\n\n  async createOrAttach(opts: CreateOrAttachOptions): Promise<CreateOrAttachResult> {\n    return await createOrAttachClaimedAgentSession({\n      options: opts,\n      owners: this.agentSessionOwners,\n      isLive: (owner) =>\n        this.agentSessionGenerations.isCurrent(\n          owner,\n          Boolean(this.sessions.get(owner.ptyId)?.isAlive)\n        ),\n      createOrAttach: async (options) => {\n        if (options.agentSessionGeneration && this.sessions.get(options.sessionId)?.isAlive) {\n          throw new Error('agent_session_claim_unavailable')\n        }\n        return await createOrAttachTerminalSession(options, {\n          sessions: this.sessions,\n          sessionTeardown: this.sessionTeardown,\n          killedTombstones: this.killedTombstones,\n          spawnSubprocess: this.spawnSubprocess,\n          creationFenced: this.creationFenced,\n          onDeadSessionRemoved: (sessionId) => this.agentSessionGenerations.forget(sessionId),\n          onSessionCreated: (sessionId, generation, isAlive) =>\n            this.agentSessionGenerations.remember(sessionId, generation, isAlive),\n          onSessionExit: (sessionId, generation) => {\n            this.agentSessionOwners.release(sessionId, generation)\n            this.agentSessionGenerations.forget(sessionId, generation)\n            this.reapSession(sessionId)\n          }\n        })\n      }\n    })","sourceCodeStart":40,"sourceCodeEnd":76,"githubUrl":"https://github.com/stablyai/orca/blob/1136503c6a231a16dce8f921f6fadb63d181e8db/src/main/daemon/terminal-host.ts#L40-L76","documentation":"Thrown by TerminalHost.createOrAttach when the claimed-agent-session ensure flow tries to spawn a new PTY under a generation token, but a session with that sessionId is already alive. It signals that an incumbent agent still owns the live PTY, so a fresh claim with the same id cannot be granted without hijacking a running terminal. The library throws (rather than silently attaching) to preserve the ownership invariant that only one generation may own a live session at a time.","triggerScenarios":"createOrAttach is called with options.agentSessionEnsure set, ClaimedAgentPtyOwnerRegistry.ensure computes a generation and invokes the spawn callback, which re-enters createOrAttach with agentSessionGeneration set; at that moment sessions.get(options.sessionId)?.isAlive is still true. Concretely: two concurrent ensure requests for the same claim/surface race, the loser's spawn path sees the winner's live session under the same sessionId.","commonSituations":"Two agent tabs or two daemon clients requesting the same claimed PTY simultaneously; a client retrying createOrAttach with an agentSessionEnsure payload before the prior session's onSessionExit has released the owner binding; a generation bookkeeping bug where agentSessionGenerations.remember/forget fell out of sync with sessions.isAlive.","solutions":["Make the caller serialize ensure requests for the same claim (single-flight / mutex keyed by claim) so only one spawn path runs at a time.","If the incumbent is genuinely live, route the second request through the attach path (attachOnly: true, command cleared) instead of re-spawning — this is what createOrAttachClaimedAgentSession does on the non-'created' disposition.","If the incumbent is stale (isAlive stale because onSessionExit did not fire), verify agentSessionGenerations.isCurrent reflects true liveness and call reapSession/release to retire the dead binding before retrying.","Audit that onSessionCreated/onSessionExit hooks keep agentSessionGenerations and agentSessionOwners consistent with sessions Map membership."],"exampleFix":"// before: two concurrent ensures race\nawait Promise.all([\n  host.createOrAttach({ sessionId, agentSessionEnsure: { claim, surface }, ... }),\n  host.createOrAttach({ sessionId, agentSessionEnsure: { claim, surface }, ... })\n])\n// after: single-flight per claim\nconst inflight = new Map<string, Promise<CreateOrAttachResult>>()\nfunction singleFlight(key: string, run: () => Promise<CreateOrAttachResult>) {\n  const existing = inflight.get(key)\n  if (existing) return existing\n  const p = run().finally(() => inflight.delete(key))\n  inflight.set(key, p)\n  return p\n}\nawait Promise.all([\n  singleFlight(claim, () => host.createOrAttach({ sessionId, agentSessionEnsure: { claim, surface }, ... })),\n  singleFlight(claim, () => host.createOrAttach({ sessionId, agentSessionEnsure: { claim, surface }, ... }))\n])","handlingStrategy":"validation","validationCode":"// Single-flight ensure requests per claim so two spawns never race.\nconst inflight = new Map<string, Promise<CreateOrAttachResult>>()\nfunction ensureSingleFlight(\n  claim: string,\n  run: () => Promise<CreateOrAttachResult>\n): Promise<CreateOrAttachResult> {\n  const existing = inflight.get(claim)\n  if (existing) return existing\n  const p = run().finally(() => inflight.delete(claim))\n  inflight.set(claim, p)\n  return p\n}","typeGuard":null,"tryCatchPattern":"// Detect the claim race and fall back to attach (the live session is owned).\ntry {\n  return await host.createOrAttach(opts)\n} catch (e) {\n  if (e instanceof Error && e.message === 'agent_session_claim_unavailable') {\n    return await host.createOrAttach({ ...opts, attachOnly: true, command: undefined, agentSessionEnsure: undefined })\n  }\n  throw e\n}","preventionTips":["Serialize createOrAttach calls that share a claim value via a per-claim mutex.","Prefer the attach path (attachOnly) when an incumbent session is known to be live.","Ensure onSessionCreated/onSessionExit keep generations and owners consistent with the sessions Map."],"tags":["concurrency","session-ownership","race-condition","terminal-host"],"backgroundTag":null,"analyzedSha":"1136503c6a231a16dce8f921f6fadb63d181e8db","analyzedAt":"2026-08-12T23:15:58.167Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}