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
- 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.
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
- 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.
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
- Worktree deletion already in progress
- Could not read watchdog child RSS for PID ${pid}
- Could not read CPU time for PID ${pid}
- Could not inspect process ${pid}: ${raw}
- Benchmark process group anchor changed before signaling
AI-assisted analysis of stablyai/orca@1136503c6a (2026-08-12).
Data as JSON: /api/errors/2734d58cde2332f4.
Report an issue: GitHub.