stablyai/orca · error

agent_session_claim_unavailable

agent_session_claim_unavailable

Error message

agent_session_claim_unavailable

What it means

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.

Source

Thrown at src/main/daemon/terminal-host.ts:58

    this.spawnSubprocess = opts.spawnSubprocess
    this.onSessionReaped = opts.onSessionReaped
    this.onFinalCheckpoint = opts.onFinalCheckpoint
    this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES
    this.killedTombstones = new TerminalHostTombstones(this.maxTombstones)
  }

  async createOrAttach(opts: CreateOrAttachOptions): Promise<CreateOrAttachResult> {
    return await createOrAttachClaimedAgentSession({
      options: opts,
      owners: this.agentSessionOwners,
      isLive: (owner) =>
        this.agentSessionGenerations.isCurrent(
          owner,
          Boolean(this.sessions.get(owner.ptyId)?.isAlive)
        ),
      createOrAttach: async (options) => {
        if (options.agentSessionGeneration && this.sessions.get(options.sessionId)?.isAlive) {
          throw new Error('agent_session_claim_unavailable')
        }
        return await createOrAttachTerminalSession(options, {
          sessions: this.sessions,
          sessionTeardown: this.sessionTeardown,
          killedTombstones: this.killedTombstones,
          spawnSubprocess: this.spawnSubprocess,
          creationFenced: this.creationFenced,
          onDeadSessionRemoved: (sessionId) => this.agentSessionGenerations.forget(sessionId),
          onSessionCreated: (sessionId, generation, isAlive) =>
            this.agentSessionGenerations.remember(sessionId, generation, isAlive),
          onSessionExit: (sessionId, generation) => {
            this.agentSessionOwners.release(sessionId, generation)
            this.agentSessionGenerations.forget(sessionId, generation)
            this.reapSession(sessionId)
          }
        })
      }
    })

View on GitHub (pinned to 1136503c6a)

Solutions

  1. 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.
  2. 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.
  3. 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.
  4. Audit that onSessionCreated/onSessionExit hooks keep agentSessionGenerations and agentSessionOwners consistent with sessions Map membership.

Example fix

// before: two concurrent ensures race
await Promise.all([
  host.createOrAttach({ sessionId, agentSessionEnsure: { claim, surface }, ... }),
  host.createOrAttach({ sessionId, agentSessionEnsure: { claim, surface }, ... })
])
// after: single-flight per claim
const inflight = new Map<string, Promise<CreateOrAttachResult>>()
function singleFlight(key: string, run: () => Promise<CreateOrAttachResult>) {
  const existing = inflight.get(key)
  if (existing) return existing
  const p = run().finally(() => inflight.delete(key))
  inflight.set(key, p)
  return p
}
await Promise.all([
  singleFlight(claim, () => host.createOrAttach({ sessionId, agentSessionEnsure: { claim, surface }, ... })),
  singleFlight(claim, () => host.createOrAttach({ sessionId, agentSessionEnsure: { claim, surface }, ... }))
])
Defensive patterns

Strategy: validation

Validate before calling

// Single-flight ensure requests per claim so two spawns never race.
const inflight = new Map<string, Promise<CreateOrAttachResult>>()
function ensureSingleFlight(
  claim: string,
  run: () => Promise<CreateOrAttachResult>
): Promise<CreateOrAttachResult> {
  const existing = inflight.get(claim)
  if (existing) return existing
  const p = run().finally(() => inflight.delete(claim))
  inflight.set(claim, p)
  return p
}

Try / catch

// Detect the claim race and fall back to attach (the live session is owned).
try {
  return await host.createOrAttach(opts)
} catch (e) {
  if (e instanceof Error && e.message === 'agent_session_claim_unavailable') {
    return await host.createOrAttach({ ...opts, attachOnly: true, command: undefined, agentSessionEnsure: undefined })
  }
  throw e
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of stablyai/orca@1136503c6a (2026-08-12). Data as JSON: /api/errors/2734d58cde2332f4. Report an issue: GitHub.