From d6dbc3b486ad2a6068e16e2d6adaa4a845241d4d Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Mon, 31 Aug 2026 15:52:13 -0700 Subject: [PATCH] fix(pty): bound kill verdict state and expose fence gaps --- src/main/ipc/pty/ipc/kill-sessions.test.ts | 15 +++++++- src/main/ipc/pty/ipc/kill-sessions.ts | 23 +++++++++-- .../use-resource-session-inventory.test.tsx | 38 ++++++++++++++++++- .../use-resource-session-inventory.ts | 8 +++- 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/main/ipc/pty/ipc/kill-sessions.test.ts b/src/main/ipc/pty/ipc/kill-sessions.test.ts index 284e96b36b1..30974302f70 100644 --- a/src/main/ipc/pty/ipc/kill-sessions.test.ts +++ b/src/main/ipc/pty/ipc/kill-sessions.test.ts @@ -61,6 +61,7 @@ describe('killPtySessions input bounds', () => { expect(results).toEqual([ { id: 'session-1', + fenceUnavailable: true, verdict: 'unverifiable', treeUnverified: true, reason: 'descendant tree could not be verified' @@ -91,7 +92,18 @@ describe('killPtySessions input bounds', () => { ) expect(shutdown).toHaveBeenCalledTimes(2) - expect(results.every((result) => result.verdict === 'exited')).toBe(true) + expect(results).toEqual([ + { + id: 'legacy', + verdict: 'exited', + fenceUnavailable: true + }, + { + id: 'current', + incarnationId: 'incarnation-current', + verdict: 'exited' + } + ]) expect(provider.supportsIncarnationFence).toHaveBeenCalledWith({ sessionId: 'legacy' }) expect(provider.supportsIncarnationFence).toHaveBeenCalledWith({ sessionId: 'current' }) }) @@ -132,6 +144,7 @@ describe('killPtySessions input bounds', () => { expect(results).toEqual([ { id: 'session-1', + fenceUnavailable: true, verdict: 'unverifiable', reason: 'descendant tree could not be verified', treeUnverified: true diff --git a/src/main/ipc/pty/ipc/kill-sessions.ts b/src/main/ipc/pty/ipc/kill-sessions.ts index 47172b10a29..70a8df24d2f 100644 --- a/src/main/ipc/pty/ipc/kill-sessions.ts +++ b/src/main/ipc/pty/ipc/kill-sessions.ts @@ -97,7 +97,14 @@ export async function killPtySessions( if (shutdownResult?.fenceUnavailable) { return { ...ref, verdict: 'refused', reason: 'incarnation fence unavailable' } } - return { ...ref, verdict: 'unverifiable' as const, reason: 'pending verification' } + return { + ...ref, + verdict: 'unverifiable' as const, + reason: 'pending verification', + // Older daemon/relay peers ignore the additive fence field. Preserve + // today's kill behavior but expose that the identity door was absent. + ...(!fenceCapable ? { fenceUnavailable: true as const } : {}) + } } catch (error) { return { ...ref, @@ -150,9 +157,17 @@ export async function killPtySessions( } const shutdownResult = shutdownResults.get(result.id) const treeUnverified = Boolean(shutdownResult?.treeUnverified) + const cleanResult = + result.reason === 'pending verification' + ? (() => { + const copy = { ...result } + delete copy.reason + return copy + })() + : result if (!survivor && treeUnverified) { return { - ...result, + ...cleanResult, verdict: 'unverifiable' as const, reason: 'descendant tree could not be verified', treeUnverified: true @@ -160,13 +175,13 @@ export async function killPtySessions( } return survivor ? { - ...result, + ...cleanResult, verdict: 'live' as const, reason: 'session still running', ...(treeUnverified ? { treeUnverified: true } : {}) } : { - ...result, + ...cleanResult, verdict: 'exited' as const } }) diff --git a/src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx b/src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx index 5112e613672..f30f38da256 100644 --- a/src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx +++ b/src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx @@ -5,8 +5,14 @@ import type { DaemonSession } from './resource-usage-merge-types' import { notifyDaemonSessionInventoryInvalidated } from './daemon-session-inventory-invalidation' import { useResourceSessionInventory } from './use-resource-session-inventory' -function session(id: string): DaemonSession { - return { id, cwd: '/workspace', title: id, agentOwnership: 'absent' as const } +function session(id: string, incarnationId?: string): DaemonSession { + return { + id, + cwd: '/workspace', + title: id, + agentOwnership: 'absent' as const, + ...(incarnationId ? { incarnationId } : {}) + } } function deferred(): { promise: Promise; resolve: (value: T) => void } { @@ -272,6 +278,34 @@ describe('useResourceSessionInventory', () => { expect(listSessions).toHaveBeenCalledTimes(2) }) + it('drops verdicts from retired incarnations while retaining the live id', async () => { + listSessions + .mockResolvedValueOnce([session('reused', 'incarnation-old')]) + .mockResolvedValueOnce([session('reused', 'incarnation-new')]) + const { result } = renderHook(() => useResourceSessionInventory(true)) + await waitFor(() => expect(result.current.sessionInventory.count).toBe(1)) + + act(() => { + result.current.setSessionVerdict( + 'reused', + 'unverifiable', + 'inventory unavailable', + 'incarnation-old' + ) + }) + expect(result.current.sessionInventory.sessions[0]).toMatchObject({ + incarnationId: 'incarnation-old', + killVerdict: 'unverifiable' + }) + + await act(async () => { + await result.current.refreshSessions() + }) + expect(result.current.sessionInventory.sessions[0]).toEqual( + session('reused', 'incarnation-new') + ) + }) + it('ignores inventory invalidation before session restore is ready and after unmount', async () => { listSessions.mockResolvedValue([session('one')]) const { unmount } = renderHook(({ ready }) => useResourceSessionInventory(ready), { diff --git a/src/renderer/src/components/status-bar/use-resource-session-inventory.ts b/src/renderer/src/components/status-bar/use-resource-session-inventory.ts index 89d178bd91c..d1f04ab768a 100644 --- a/src/renderer/src/components/status-bar/use-resource-session-inventory.ts +++ b/src/renderer/src/components/status-bar/use-resource-session-inventory.ts @@ -89,9 +89,15 @@ export function useResourceSessionInventory(ready: boolean): ResourceSessionInve } } knownSessionIdsRef.current = new Set(liveSessions.map(({ id }) => id)) + // Keep at most one verdict generation per listed PTY. Session ids can be + // recycled with a new incarnation while the inventory stays present; retaining + // every historical key would grow this map for the lifetime of the workspace. + const currentVerdictKeys = new Set( + liveSessions.map((session) => verdictKey(session.id, session.incarnationId)) + ) for (const key of verdictsRef.current.keys()) { const id = key.split('\0', 1)[0] - if (!knownSessionIdsRef.current.has(id)) { + if (!knownSessionIdsRef.current.has(id) || !currentVerdictKeys.has(key)) { verdictsRef.current.delete(key) } }