fix(daemon): stop the local fallback answering for sessions it does not own

Closing a held daemon's pane reported success while the agent kept running.
Unrouted ids resolve to the in-process fallback, whose shutdown returns
silently for an id it has never heard of and whose write and resize are
no-ops — and while a daemon is held nothing ever enumerates its sessions, so
every one of them is unrouted. The pane vanished, the orphan outlived the
app, and typing into a stuck terminal disappeared without a word.

Only attach was fenced against that route. Extend the same rule to the
operations that change or feed a session: route to the fallback only when it
genuinely owns the pty, and otherwise say the session cannot be reached.

The error type is load-bearing. pty:kill treats "Session not found" as proof
the pty is already gone and synthesizes an exit, so reusing that error would
have reproduced the lie one layer down. TerminalSessionOwnerUnverifiedError
means "still there, we cannot reach its host", which is reported as a failed
close and keeps ownership for a retry.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-11 21:38:34 -07:00
co-authored by Orca
parent f5abf2aa7b
commit 88f30a702d
2 changed files with 34 additions and 5 deletions
@@ -14,6 +14,7 @@ import type {
import {
adoptOwningProvider,
attachDaemonOwnedSession,
ownerForDaemonOwnedOperation,
findDaemonAdapter,
listProviderSessionIds
} from './degraded-daemon-session-routing'
@@ -89,6 +90,11 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
attach = (id: string): ReturnType<IPtyProvider['attach']> =>
attachDaemonOwnedSession(this.providerFor(id), this.fallback, id)
/** Routing for anything that changes or feeds a session; see ownerForDaemonOwnedOperation. */
private ownerFor(id: string): IPtyProvider {
return ownerForDaemonOwnedOperation(this.providerFor(id), this.fallback, id)
}
hasPty(id: string): boolean {
const mapped = this.sessionProviders.get(id)
return mapped ? (mapped.hasPty?.(id) ?? true) : this.findProviderForExistingSession(id) !== null
@@ -109,11 +115,11 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
)?.providesAgentSessionOwnerListings?.(ptyId) === true
write(id: string, data: string): void {
this.providerFor(id).write(id, data)
this.ownerFor(id).write(id, data)
}
resize(id: string, cols: number, rows: number): void {
this.providerFor(id).resize(id, cols, rows)
this.ownerFor(id).resize(id, cols, rows)
}
pauseProducer(id: string): void {
@@ -132,14 +138,14 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
id: string,
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
): Promise<void> {
await this.providerFor(id).shutdown(id, opts)
await this.ownerFor(id).shutdown(id, opts)
if (!opts.keepHistory) {
this.sessionProviders.delete(id)
}
}
async sendSignal(id: string, signal: string): Promise<void> {
await this.providerFor(id).sendSignal(id, signal)
await this.ownerFor(id).sendSignal(id, signal)
}
async getCwd(id: string): Promise<string> {
@@ -1,6 +1,6 @@
import type { IPtyProvider } from '../providers/types'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import { SessionNotFoundError } from './daemon-errors'
import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors'
export function listProviderSessionIds(
sessionProviders: ReadonlyMap<string, IPtyProvider>,
@@ -26,6 +26,29 @@ export async function attachDaemonOwnedSession(
return await owner.attach(sessionId)
}
/**
* Session operations that must never be answered by the in-process fallback on another
* provider's behalf. An unknown id resolves to the fallback, whose shutdown returns
* silently and whose write/resize are no-ops — so a daemon-owned session reads as closed
* while its agent keeps running, and typing into it disappears. Route there only when the
* fallback genuinely owns the pty; otherwise say the session cannot be reached.
*
* Why not SessionNotFoundError: pty:kill treats "Session not found" as proof the pty is
* already gone and synthesizes an exit, which is the same lie by another route. This one
* means "still there, we just cannot reach its host", so the kill is reported as failed and
* ownership is kept for a retry.
*/
export function ownerForDaemonOwnedOperation(
owner: IPtyProvider,
fallback: IPtyProvider,
sessionId: string
): IPtyProvider {
if (owner === fallback && fallback.hasPty?.(sessionId) !== true) {
throw new TerminalSessionOwnerUnverifiedError(sessionId)
}
return owner
}
/** Probes providers for an id absent from the routing map and adopts the
* first proven owner into the map. */
export function adoptOwningProvider(