fix(daemon): self-heal write routing for undiscovered legacy sessions

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.
This commit is contained in:
Neil
2026-07-05 15:28:22 -07:00
parent ce680071a2
commit 6209412f2c
2 changed files with 42 additions and 1 deletions
+28
View File
@@ -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'])
+14 -1
View File
@@ -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[] {