fix(pty): bound kill verdict state and expose fence gaps

This commit is contained in:
Merge Sim
2026-08-31 15:52:13 -07:00
parent 6f44adb44b
commit d6dbc3b486
4 changed files with 76 additions and 8 deletions
+14 -1
View File
@@ -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
+19 -4
View File
@@ -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
}
})
@@ -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<T>(): { promise: Promise<T>; 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), {
@@ -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)
}
}