From 88f30a702d3784c14b0d15b7693d78c00a2aa2a4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:38:34 -0700 Subject: [PATCH] fix(daemon): stop the local fallback answering for sessions it does not own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../daemon/degraded-daemon-pty-provider.ts | 14 ++++++++--- .../daemon/degraded-daemon-session-routing.ts | 25 ++++++++++++++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 545f66ca892..ec85118e5b7 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -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 => 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 { - 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 { - await this.providerFor(id).sendSignal(id, signal) + await this.ownerFor(id).sendSignal(id, signal) } async getCwd(id: string): Promise { diff --git a/src/main/daemon/degraded-daemon-session-routing.ts b/src/main/daemon/degraded-daemon-session-routing.ts index 7fb5a9a00c8..48aa3870baf 100644 --- a/src/main/daemon/degraded-daemon-session-routing.ts +++ b/src/main/daemon/degraded-daemon-session-routing.ts @@ -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, @@ -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(