diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 17cfd44cc18..33945cfe8c2 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -5319,7 +5319,7 @@ "id": "terminal-provider.daemon-startup-degraded-contract", "title": "Daemon startup reconcile and degraded fallback preserve provider identity", "maturity": "experimental", - "protection": "none", + "protection": "partial", "owner": "terminal-provider", "layer": "provider-contract", "surfaces": [ @@ -5327,26 +5327,75 @@ "degraded daemon", "fallback PTY", "provider ownership", - "startup restore" + "startup restore", + "stable-pane reopen" ], "platforms": ["macos", "linux", "windows"], "providers": ["daemon", "local"], - "coveredPlatforms": [], - "coveredProviders": [], - "coverageNotes": "Registered gap on main. The fail-closed degraded-daemon hardening and its contracts exist only on the pending reliability stack. It registers here with its owning split PR.", + "coveredPlatforms": ["macos"], + "coveredProviders": ["daemon", "local"], + "coverageNotes": "Deterministic provider contracts cover complete, incomplete, conflicting, refused, and identity-invalidated owner inventories; degraded fallback/current/legacy routing; repeated stale-binding classification; and fresh-session reattach without local fallback during unresolved ownership. Main IPC contracts cover exact persisted-binding retirement and one fresh replacement after confirmed absence. Live Linux, Windows, WSL, SSH, paired-runtime, and packaged-upgrade journeys remain uncollected.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6830", "https://github.com/stablyai/orca/pull/6866", - "https://github.com/stablyai/orca/pull/7002" + "https://github.com/stablyai/orca/pull/7002", + "https://github.com/stablyai/orca/pull/12776" + ], + "invariant": "Daemon startup reconciliation must preserve valid live daemon sessions and reap only true orphans. A persisted binding stays fail-closed while any possible owner is incomplete, conflicting, refusing attach, or identity-invalidated, but complete authoritative absence from every configured provider must retire that exact stale binding and let retry or reopen converge without attaching it through local fallback or duplicating a PTY.", + "oracle": "Hold exact fallback, current-daemon, and legacy-daemon inventories behind controlled promises. No attach may settle or dispatch until every provider answers. Complete zero-candidate inventories must report SessionNotFound; incomplete, duplicate, refused, and identity-invalidated attempts must remain owner-unverified. In degraded mode, repeated stale attempts scan each eligible provider once per attempt and dispatch no attach, then one explicit fresh spawn and exact reattach use only the recorded fresh route. Main IPC must compare-and-swap retire only the matching persisted PTY/incarnation, emit one synthetic exit, and create one different fresh PTY.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/ipc/pty.test.ts --reporter=dot" + ], + "testFiles": [ + "src/main/daemon/daemon-session-owner-resolution.test.ts", + "src/main/daemon/daemon-pty-router.test.ts", + "src/main/daemon/degraded-daemon-pty-provider.test.ts", + "src/main/ipc/pty.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/daemon/daemon-session-owner-resolution.test.ts", + "assertions": [ + "controlled concurrent inventories wait for fallback, current, and legacy authority before classifying exact persisted PTYs absent", + "incomplete inventory, duplicate claims, owner refusal, and identity replacement stay fail-closed with exact list and spawn counts" + ] + }, + { + "file": "src/main/daemon/daemon-pty-router.test.ts", + "assertions": [ + "complete current and legacy daemon inventories prove liveness absence with one list call per adapter", + "one unavailable daemon inventory keeps the same missing PTY liveness unknown" + ] + }, + { + "file": "src/main/daemon/degraded-daemon-pty-provider.test.ts", + "assertions": [ + "repeated complete absence never dispatches the stale id to fallback or either daemon", + "one explicit fresh fallback PTY reattaches by its exact PTY and incarnation without another inventory", + "a mapped fallback-owned PTY reports live without borrowing fallback authority for unknown ids or scanning daemon inventories" + ] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "confirmed absence retires the exact persisted binding once and creates one differently identified fresh PTY", + "unverified ownership retains the binding and creates no fresh PTY" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-06", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/ipc/pty.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 5.44, + "summary": "Four files passed 535 owner-resolution, router, degraded-provider, and IPC lifecycle tests. With only the resolver fix disabled, the unchanged four-file gate failed four stale-absence, identity-retry, and router-liveness assertions; restoring it passed 535 of 535." + } ], - "invariant": "Daemon startup reconciliation must preserve valid live daemon sessions, reap only true orphans, and degraded mode must not route an existing-looking daemon session through local fallback unless the caller explicitly marks a fresh degraded-mode spawn.", - "oracle": "The current provider-contract corpus covers valid current-worktree ids, folder/floating workspace ids, invalid removed-worktree ids, mixed live/orphan dry-run reconciliation, hyphenated worktree ids, malformed ids, daemon sessions discovered after restart, router discovery before existing-session spawn, router fail-closed behavior when legacy ownership cannot be listed or a known session has exited, degraded daemon fallback for fresh sessions, fail-closed behavior for unknown restored ids, benign inspection defaults for unknown ownership, and synthetic exits on daemon restart. Prior-worktree aliases and renamed-worktree startup wiring are promotion gaps, not current proof.", - "commands": [], - "testFiles": [], - "assertionRefs": [], - "evidenceRuns": [], "runtimeBudget": { - "p95Seconds": 20, + "p95Seconds": 45, "scope": "provider contract unit/integration test" }, "flakeHistory": { @@ -5354,22 +5403,22 @@ "evidence": "Focused provider contract tests now run locally; needs soak history before promotion." }, "redGreenEvidence": { - "status": "partial", - "evidence": "Tests assert discovered daemon sessions route to the daemon, fresh degraded-mode PTYs route to fallback only when marked new, router spawn discovers uncached existing sessions before choosing an adapter, router spawn fails closed instead of falling through to current when legacy listing fails or a known session has exited, targeted hasPty discovery caches legacy ownership before later write/resize-style operations, real daemon adapter listProcesses discovery seeds targeted hasPty liveness, folder and floating terminal workspace ids survive startup reconcile when valid, restored worktree-scoped and legacy/non-scoped ids do not fall back through spawn after ownership is lost or unknown, operations on unknown or exited existing ids fail closed, startup reconcile can dry-run orphan detection without killing live sessions, and process inspection returns benign defaults for unknown ownership. Production startup reconcile wiring remains unproven." + "status": "complete", + "evidence": "The byte-identical owner-resolver blob d35d2795d56e7e32c1ca99af6726fe6b92181881 is present in v1.4.176-rc.0@ddf64199fa, prod-release-1.4.176@8ddf575fe6, and origin/main@cb960408f2. It returns TerminalSessionOwnerUnverifiedError for both controlled complete-empty persisted PTYs and leaves degraded stale bindings unrecoverable. The candidate passes 535 of 535 registered tests. Restoring only the old predicate makes the unchanged four-file gate fail four of 535 tests; restoring the fix passes 535 of 535." }, "performanceBudget": { "required": true, - "evidence": "Reconcile must not add startup-blocking scans beyond the explicit daemon session inventory and must not leak global listing into hot paths." + "evidence": "One unresolved attempt performs one concurrent listProcesses call per eligible provider, concurrent pane attempts coalesce onto that inventory, and no polling, sleep, timer, subprocess, listener, or retry loop is added. Confirmed absence is not cached; repeated explicit attempts remain bounded to one fanout each. Reattaching the newly recorded fresh route adds no inventory or daemon spawn." }, "promotionCriteria": [ "Wire reconcileOnStartup or mark the production wiring gap explicitly.", "Cover priorWorktreeIds so renamed worktrees are not falsely reaped." ], "knownGaps": [ - "No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.", "Production startup reconcile wiring remains unproven.", "Prior-worktree aliases and renamed-worktree startup reconcile are not covered by the current executable corpus.", - "Real daemon restart behavior is still covered only by lower-level synthetic exit and provider-contract tests." + "Real daemon restart behavior is still covered only by lower-level synthetic exit and provider-contract tests.", + "No live Linux, Windows, WSL, SSH, headed/headless paired-runtime, folder-workspace, or mixed-version packaged-upgrade journey was run for stale-owner recovery." ], "demotionRule": "Cannot promote while restored daemon ids or routing operations can silently route to local fallback." }, diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 1930224c2d5..11b6c53dee0 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -526,12 +526,24 @@ describe('DaemonPtyRouter', () => { it('does not report absence while any possible daemon owner is unavailable', async () => { const current = createAdapter('current') const legacy = createAdapter('legacy') - vi.mocked(legacy.probePtyLiveness).mockResolvedValue(null) + vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('legacy inventory unavailable')) const router = new DaemonPtyRouter({ current, legacy: [legacy] }) await expect(router.probePtyLiveness('unknown-session')).resolves.toBeNull() }) + it('reports absence after every possible daemon owner returns a complete inventory', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy') + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + + await expect(router.probePtyLiveness('missing-session')).resolves.toBe(false) + expect(current.listProcesses).toHaveBeenCalledOnce() + expect(legacy.listProcesses).toHaveBeenCalledOnce() + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + }) + it('routes an attach to a legacy session created after startup inventory', async () => { const current = createAdapter('current') const legacySessions = ['legacy-at-startup'] diff --git a/src/main/daemon/daemon-session-owner-resolution.test.ts b/src/main/daemon/daemon-session-owner-resolution.test.ts index 277c0750a84..55dc6142658 100644 --- a/src/main/daemon/daemon-session-owner-resolution.test.ts +++ b/src/main/daemon/daemon-session-owner-resolution.test.ts @@ -21,31 +21,103 @@ function provider( } describe('DaemonSessionOwnerResolver', () => { - it('coalesces concurrent all-provider inventories', async () => { - let release!: () => void - const gate = new Promise((resolve) => { - release = resolve + it('coalesces complete multi-provider absence without dispatching an attach', async () => { + let releaseFallback!: (processes: PtyProcessInfo[]) => void + let releaseCurrent!: (processes: PtyProcessInfo[]) => void + let releaseLegacy!: (processes: PtyProcessInfo[]) => void + const fallbackGate = new Promise((resolve) => { + releaseFallback = resolve }) - const firstInventory = vi.fn(async () => { - await gate - return [] + const currentGate = new Promise((resolve) => { + releaseCurrent = resolve }) - const secondInventory = vi.fn(async () => { - await gate - return [] + const legacyGate = new Promise((resolve) => { + releaseLegacy = resolve }) - const first = provider(firstInventory) - const second = provider(secondInventory) - const resolver = new DaemonSessionOwnerResolver([first, second], new Map()) + const fallbackInventory = vi.fn(() => fallbackGate) + const currentInventory = vi.fn(() => currentGate) + const legacyInventory = vi.fn(() => legacyGate) + const fallback = provider(fallbackInventory) + const current = provider(currentInventory) + const legacy = provider(legacyInventory) + const resolver = new DaemonSessionOwnerResolver([fallback, current, legacy], new Map()) - const resolutions = Promise.all([resolver.resolve('one'), resolver.resolve('two')]) - await vi.waitFor(() => expect(firstInventory).toHaveBeenCalledOnce()) - expect(secondInventory).toHaveBeenCalledOnce() - release() + const resolutions = [ + resolver.spawnAttachOnly({ + sessionId: 'pty-persisted-alpha', + expectedIncarnationId: 'incarnation-persisted-alpha', + attachOnly: true, + cols: 80, + rows: 24 + }), + resolver.spawnAttachOnly({ + sessionId: 'pty-persisted-beta', + expectedIncarnationId: 'incarnation-persisted-beta', + attachOnly: true, + cols: 80, + rows: 24 + }) + ] + let settled = 0 + for (const resolution of resolutions) { + void resolution.then( + () => { + settled += 1 + }, + () => { + settled += 1 + } + ) + } - await expect(resolutions).resolves.toEqual([{ kind: 'unknown' }, { kind: 'unknown' }]) - expect(firstInventory).toHaveBeenCalledOnce() - expect(secondInventory).toHaveBeenCalledOnce() + expect(fallbackInventory).toHaveBeenCalledOnce() + expect(currentInventory).toHaveBeenCalledOnce() + expect(legacyInventory).toHaveBeenCalledOnce() + releaseFallback([]) + releaseCurrent([]) + for (let iteration = 0; iteration < 10; iteration += 1) { + await Promise.resolve() + } + expect(settled).toBe(0) + expect(fallback.spawn).not.toHaveBeenCalled() + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + releaseLegacy([]) + + const results = await Promise.allSettled(resolutions) + expect(results).toEqual([ + expect.objectContaining({ + status: 'rejected', + reason: expect.any(SessionNotFoundError) + }), + expect.objectContaining({ + status: 'rejected', + reason: expect.any(SessionNotFoundError) + }) + ]) + expect(settled).toBe(2) + expect(fallbackInventory).toHaveBeenCalledOnce() + expect(currentInventory).toHaveBeenCalledOnce() + expect(legacyInventory).toHaveBeenCalledOnce() + expect(fallback.spawn).not.toHaveBeenCalled() + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + + await expect( + resolver.spawnAttachOnly({ + sessionId: 'pty-persisted-alpha', + expectedIncarnationId: 'incarnation-persisted-alpha', + attachOnly: true, + cols: 80, + rows: 24 + }) + ).rejects.toBeInstanceOf(SessionNotFoundError) + expect(fallbackInventory).toHaveBeenCalledTimes(2) + expect(currentInventory).toHaveBeenCalledTimes(2) + expect(legacyInventory).toHaveBeenCalledTimes(2) + expect(fallback.spawn).not.toHaveBeenCalled() + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() }) it('bounds restore inventory requests across many panes and daemon generations', async () => { @@ -312,6 +384,10 @@ describe('DaemonSessionOwnerResolver', () => { const resolver = new DaemonSessionOwnerResolver([first, second], new Map()) await expect(resolver.resolve('session')).resolves.toEqual({ kind: 'unknown' }) + expect(first.listProcesses).toHaveBeenCalledOnce() + expect(second.listProcesses).toHaveBeenCalledOnce() + expect(first.spawn).not.toHaveBeenCalled() + expect(second.spawn).not.toHaveBeenCalled() }) it('reports confirmed absence from a sole owner', async () => { @@ -337,19 +413,19 @@ describe('DaemonSessionOwnerResolver', () => { }) it('preserves an unresolved owner when any provider inventory fails', async () => { - const resolver = new DaemonSessionOwnerResolver( - [ - provider(async () => []), - provider(async () => { - throw new Error('offline') - }) - ], - new Map() - ) + const reachable = provider(async () => []) + const unreachable = provider(async () => { + throw new Error('offline') + }) + const resolver = new DaemonSessionOwnerResolver([reachable, unreachable], new Map()) await expect( resolver.spawnAttachOnly({ sessionId: 'missing', attachOnly: true, cols: 80, rows: 24 }) ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + expect(reachable.listProcesses).toHaveBeenCalledOnce() + expect(unreachable.listProcesses).toHaveBeenCalledOnce() + expect(reachable.spawn).not.toHaveBeenCalled() + expect(unreachable.spawn).not.toHaveBeenCalled() }) it('does not turn a raced owner refusal into absence while another provider is unresolved', async () => { @@ -372,6 +448,8 @@ describe('DaemonSessionOwnerResolver', () => { await expect( resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 }) ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + expect(candidate.listProcesses).toHaveBeenCalledOnce() + expect(candidate.spawn).not.toHaveBeenCalled() }) it('does not turn a post-inventory owner refusal into aggregate absence', async () => { @@ -389,6 +467,8 @@ describe('DaemonSessionOwnerResolver', () => { await expect( resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 }) ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + expect(candidate.listProcesses).toHaveBeenCalledOnce() + expect(candidate.spawn).toHaveBeenCalledOnce() }) it('pre-routes every uniquely inventoried session for serialized restores', async () => { @@ -429,6 +509,52 @@ describe('DaemonSessionOwnerResolver', () => { expect(inventory).toHaveBeenCalledTimes(2) }) + it('fails closed across identity replacement then confirms absence on retry', async () => { + let releaseCurrent!: (processes: PtyProcessInfo[]) => void + let releaseLegacy!: (processes: PtyProcessInfo[]) => void + const currentGate = new Promise((resolve) => { + releaseCurrent = resolve + }) + const legacyGate = new Promise((resolve) => { + releaseLegacy = resolve + }) + const currentInventory = vi + .fn<() => Promise>() + .mockReturnValueOnce(currentGate) + .mockResolvedValueOnce([]) + const legacyInventory = vi + .fn<() => Promise>() + .mockReturnValueOnce(legacyGate) + .mockResolvedValueOnce([]) + const current = provider(currentInventory) + const legacy = provider(legacyInventory) + const resolver = new DaemonSessionOwnerResolver([current, legacy], new Map()) + const attach = { + sessionId: 'pty-persisted-after-identity-change', + expectedIncarnationId: 'incarnation-before-identity-change', + attachOnly: true, + cols: 80, + rows: 24 + } as const + + const staleAttempt = resolver.spawnAttachOnly(attach) + expect(currentInventory).toHaveBeenCalledOnce() + expect(legacyInventory).toHaveBeenCalledOnce() + resolver.invalidateProvider(current) + releaseCurrent([]) + releaseLegacy([]) + + await expect(staleAttempt).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + + await expect(resolver.spawnAttachOnly(attach)).rejects.toBeInstanceOf(SessionNotFoundError) + expect(currentInventory).toHaveBeenCalledTimes(2) + expect(legacyInventory).toHaveBeenCalledTimes(2) + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + }) + it('restores a proven route when identity changes during exact reattach', async () => { const routes = new Map() let resolver!: DaemonSessionOwnerResolver diff --git a/src/main/daemon/daemon-session-owner-resolution.ts b/src/main/daemon/daemon-session-owner-resolution.ts index d35d2795d56..1906ebfb35b 100644 --- a/src/main/daemon/daemon-session-owner-resolution.ts +++ b/src/main/daemon/daemon-session-owner-resolution.ts @@ -223,10 +223,10 @@ export class DaemonSessionOwnerResolver { this.recordRoute(sessionId, provider, process?.incarnationId) return { kind: 'owner', provider } } - if (!inventory.complete || providers.size > 1 || this.providers.length !== 1) { - return { kind: 'unknown' } + if (inventory.complete && providers.size === 0 && this.providers.length > 0) { + return { kind: 'absent' } } - return { kind: 'absent' } + return { kind: 'unknown' } } private inventory(allowCached = true): Promise> { diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index af7c30d1c5d..e8a8e4d1214 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -4,7 +4,7 @@ import { DEGRADED_DAEMON_RECOVERY_RETRY_MS } from './degraded-daemon-fresh-spawn import type { DaemonPtyAdapter } from './daemon-pty-adapter' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' -import { TerminalSessionOwnerUnverifiedError } from './daemon-errors' +import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors' type ProviderMock = IPtyProvider & { probePtyLiveness: (id: string) => Promise @@ -200,6 +200,66 @@ it('keeps an attach unresolved when a legacy inventory listing fails', async () expect(current.spawn).not.toHaveBeenCalled() }) +it('confirms repeated stale-binding absence without falling back or spawning', async () => { + const current = createDaemonAdapter('daemon-current') + const legacy = createDaemonAdapter('daemon-legacy') + const fallback = createProvider('local-fallback') + vi.mocked(fallback.spawn).mockImplementation(async (opts) => + opts.attachOnly + ? { + id: opts.sessionId!, + incarnationId: 'incarnation-fresh-local', + isReattach: true + } + : { id: 'pty-fresh-local', incarnationId: 'incarnation-fresh-local' } + ) + const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) + const attach = { + sessionId: 'pty-persisted-missing', + expectedIncarnationId: 'incarnation-persisted-missing', + attachOnly: true, + cols: 80, + rows: 24 + } as const + + await expect(provider.spawn(attach)).rejects.toBeInstanceOf(SessionNotFoundError) + await expect(provider.spawn(attach)).rejects.toBeInstanceOf(SessionNotFoundError) + + expect(fallback.listProcesses).toHaveBeenCalledTimes(2) + expect(current.listProcesses).toHaveBeenCalledTimes(2) + expect(legacy.listProcesses).toHaveBeenCalledTimes(2) + expect(fallback.spawn).not.toHaveBeenCalled() + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + + const fresh = await provider.spawn({ cols: 80, rows: 24 }) + await expect( + provider.spawn({ + sessionId: fresh.id, + expectedIncarnationId: fresh.incarnationId, + attachOnly: true, + cols: 80, + rows: 24 + }) + ).resolves.toMatchObject({ + id: 'pty-fresh-local', + incarnationId: 'incarnation-fresh-local', + isReattach: true + }) + + expect(fallback.spawn).toHaveBeenCalledTimes(2) + expect(vi.mocked(fallback.spawn).mock.calls[0]?.[0]).not.toHaveProperty('sessionId') + expect(vi.mocked(fallback.spawn).mock.calls[1]?.[0]).toMatchObject({ + sessionId: 'pty-fresh-local', + attachOnly: true + }) + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + expect(fallback.listProcesses).toHaveBeenCalledTimes(2) + expect(current.listProcesses).toHaveBeenCalledTimes(2) + expect(legacy.listProcesses).toHaveBeenCalledTimes(2) +}) + it('rejects completion inspection instead of borrowing the fallback provider', async () => { const provider = new DegradedDaemonPtyProvider({ current: createDaemonAdapter('daemon'), @@ -415,7 +475,7 @@ describe('DegradedDaemonPtyProvider', () => { const legacy = createDaemonAdapter('legacy') const fallback = createProvider('fallback', ['unknown-session']) const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) - vi.mocked(legacy.probePtyLiveness).mockResolvedValue(null) + vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('legacy inventory unavailable')) await expect(provider.probePtyLiveness('unknown-session')).resolves.toBeNull() expect(fallback.probePtyLiveness).not.toHaveBeenCalled() @@ -424,6 +484,19 @@ describe('DegradedDaemonPtyProvider', () => { await expect(provider.probePtyLiveness('unknown-session')).resolves.toBe(true) }) + it('reports a routed fallback PTY live before probing daemon absence', async () => { + const current = createDaemonAdapter('current') + const legacy = createDaemonAdapter('legacy') + const fallback = createProvider('fallback') + const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) + const fresh = await provider.spawn({ cols: 80, rows: 24 }) + + await expect(provider.probePtyLiveness(fresh.id)).resolves.toBe(true) + expect(fallback.hasPty).toHaveBeenCalledWith(fresh.id) + expect(current.listProcesses).not.toHaveBeenCalled() + expect(legacy.listProcesses).not.toHaveBeenCalled() + }) + it('routes authoritative recovery snapshots to the owning daemon', async () => { const current = createDaemonAdapter('daemon', ['daemon-session']) const fallback = createProvider('fallback') diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 6b1c738b316..d1a9e90a2eb 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -95,6 +95,10 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } async probePtyLiveness(id: string): Promise { + const mapped = this.sessionProviders.get(id) + if (mapped && (mapped.hasPty?.(id) ?? true)) { + return true + } return await this.ownerRecovery.probe(id) }