From 6209412f2cd7c9c7383718e4d74ed797ee824dfe Mon Sep 17 00:00:00 2001 From: Neil Date: Sun, 5 Jul 2026 15:28:22 -0700 Subject: [PATCH] fix(daemon): self-heal write routing for undiscovered legacy sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DaemonPtyRouter fans out reads (onData/hasPty/listProcesses) across every adapter, but routes writes and resizes via a sessionAdapters map that is populated only by the one-shot discoverLegacySessions()/reconcile/spawn boot passes. When a surviving previous-protocol daemon owns a session that boot discovery missed (e.g. a Windows named-pipe/startup-order race, widened by the .perf startup reordering), adapterFor() fell back to `this.current` — so output kept streaming (reads fan out) while every keystroke was silently dispatched to the wrong daemon and dropped. Symptom: adopted terminals stream live output but accept no input after a protocol-bumping update. Make adapterFor() self-heal: on a routing-map miss, ask each adapter whether it owns the pty (synchronous Set lookup), cache the winner, and route there; current-first precedence keeps the safe default. This mirrors the self-healing providerFor() already used by DegradedDaemonPtyProvider. Adds regression tests covering the undiscovered-legacy-session route and the no-owner fallback. --- src/main/daemon/daemon-pty-router.test.ts | 28 +++++++++++++++++++++++ src/main/daemon/daemon-pty-router.ts | 15 +++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 47d44aa807c..3522811b30e 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -118,6 +118,34 @@ describe('DaemonPtyRouter', () => { expect(current.write).toHaveBeenCalledWith(fresh.id, 'new\n') }) + it('routes an undiscovered legacy session to its owning daemon, not current', () => { + // Reproduces the frozen-input bug: the old daemon still owns the session + // (hasPty true, output streaming), but the one-shot discoverLegacySessions() + // boot pass never mapped it — so the routing map is empty for this id. + const current = createAdapter('current') + const legacy = createAdapter('legacy', ['orphan-session']) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + + // Deliberately NO discoverLegacySessions() / reconcile / spawn call. + router.write('orphan-session', 'input\n') + router.resize('orphan-session', 80, 24) + + expect(legacy.write).toHaveBeenCalledWith('orphan-session', 'input\n') + expect(legacy.resize).toHaveBeenCalledWith('orphan-session', 80, 24) + expect(current.write).not.toHaveBeenCalled() + }) + + it('falls back to the current adapter when no adapter owns the session', () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy') + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + + router.write('unknown-session', 'input\n') + + expect(current.write).toHaveBeenCalledWith('unknown-session', 'input\n') + expect(legacy.write).not.toHaveBeenCalled() + }) + it('drops a legacy mapping after the routed session exits', async () => { const current = createAdapter('current') const legacy = createAdapter('legacy', ['legacy-session']) diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 1f80f770cd9..a1d6a0344cb 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -250,7 +250,20 @@ export class DaemonPtyRouter implements IPtyProvider { } private adapterFor(sessionId: string): DaemonPtyAdapter { - return this.sessionAdapters.get(sessionId) ?? this.current + const routed = this.sessionAdapters.get(sessionId) + if (routed) { + return routed + } + // Why: reads fan out across all adapters, but this routing map is filled only + // by one-shot boot discovery. A legacy session it missed had its writes silently + // dropped onto `current`; self-heal by asking who actually owns the pty, then cache. + for (const adapter of this.allAdapters()) { + if (adapter.hasPty(sessionId)) { + this.sessionAdapters.set(sessionId, adapter) + return adapter + } + } + return this.current } private allAdapters(): DaemonPtyAdapter[] {