fix(cleanup): stop reading an unreachable terminal as an idle one

Workspace cleanup asked main two questions per terminal —
`pty:hasChildProcesses` and `pty:getForegroundProcess` — and both coerce a
degraded read to `false`/`null` before it crosses the IPC boundary. A
disconnected SSH provider short-circuits to those values, and the daemon
adapter catches its own dead-socket request and answers `null`. Nothing
throws, so `probeTerminalLiveness`'s `unknown` arm never ran for the case it
exists for, and a worktree whose agent was still working presented as an
idle, unblocked cleanup row.

Read `pty:inspectProcess` instead. It is the one inspection that carries
`unavailable` across the boundary, and it is a single round trip instead of
two. An `unavailable` answer, or a rejection, now yields `unverifiable` and
the `terminal-liveness-unknown` blocker — unknown falls toward "do not clean
up", which is the conservative arm here.

The inspection read moves to a store-free module so the cleanup store can
call it without a store->runtime import cycle; the type declaration stays put.
This commit is contained in:
Brennan Benson
2026-08-28 08:38:07 -07:00
parent 7eefcdaac3
commit 570cb71cbb
8 changed files with 318 additions and 145 deletions
@@ -4,11 +4,20 @@ import type { RuntimeTerminalSend } from '../../../shared/runtime-types'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { isTerminalInputTooLargeWithDeferredMeasurement } from '../../../shared/terminal-input'
import { useAppStore } from '../store'
import { RuntimeRpcCallError, callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
import {
getRemoteRuntimePtyEnvironmentId,
getRemoteRuntimeTerminalHandle
} from './runtime-terminal-stream'
import {
inspectRuntimeTerminalProcess,
isTerminalGoneError
} from './runtime-terminal-process-inspection'
// Why: the inspection read moved to a store-free module so the cleanup store can
// call it without a store->runtime import cycle. Re-exported so callers keep one
// import site.
export { inspectRuntimeTerminalProcess }
export type RuntimeTerminalProcessInspection = {
foregroundProcess: string | null
@@ -31,26 +40,6 @@ export function isRemoteRuntimePtyId(ptyId: string): boolean {
return ptyId.startsWith(REMOTE_PTY_ID_PREFIX)
}
function isTerminalGoneError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
const code =
error instanceof RuntimeRpcCallError
? error.code
: error && typeof error === 'object' && 'code' in error
? String((error as { code?: unknown }).code)
: ''
return (
code === 'no_connected_pty' ||
code === 'terminal_handle_stale' ||
code === 'terminal_exited' ||
code === 'terminal_gone' ||
message.includes('terminal_handle_stale') ||
message.includes('terminal_exited') ||
message.includes('terminal_gone') ||
message.includes('no_connected_pty')
)
}
export function recordRuntimeTerminalInputForPtyId(ptyId: string, timestamp = Date.now()): void {
const state = useAppStore.getState()
for (const [tabId, layout] of Object.entries(state.terminalLayoutsByTabId)) {
@@ -71,35 +60,6 @@ export function recordRuntimeTerminalInputForPtyId(ptyId: string, timestamp = Da
}
}
export async function inspectRuntimeTerminalProcess(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
ptyId: string
): Promise<RuntimeTerminalProcessInspection> {
const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
const target = ownerEnvironmentId
? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const)
: getActiveRuntimeTarget(settings)
const terminal = getRemoteRuntimeTerminalHandle(ptyId)
if (target.kind !== 'environment' || !terminal) {
return window.api.pty.inspectProcess(ptyId)
}
try {
const result = await callRuntimeRpc<{ process: RuntimeTerminalProcessInspection }>(
target,
'terminal.inspectProcess',
{ terminal },
{ timeoutMs: 15_000 }
)
return result.process
} catch (error) {
if (isTerminalGoneError(error)) {
return { foregroundProcess: null, hasChildProcesses: false, unavailable: true }
}
throw error
}
}
/**
* Forces a fresh, uncached foreground scan for a pane whose cached inspection
* is suspect (issue #11064: the cached read can flap to the shell for a live
@@ -0,0 +1,56 @@
import { RuntimeRpcCallError, callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { RuntimeTerminalProcessInspection } from './runtime-terminal-inspection'
import {
getRemoteRuntimePtyEnvironmentId,
getRemoteRuntimeTerminalHandle
} from './runtime-terminal-stream'
export function isTerminalGoneError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
const code =
error instanceof RuntimeRpcCallError
? error.code
: error && typeof error === 'object' && 'code' in error
? String((error as { code?: unknown }).code)
: ''
return (
code === 'no_connected_pty' ||
code === 'terminal_handle_stale' ||
code === 'terminal_exited' ||
code === 'terminal_gone' ||
message.includes('terminal_handle_stale') ||
message.includes('terminal_exited') ||
message.includes('terminal_gone') ||
message.includes('no_connected_pty')
)
}
export async function inspectRuntimeTerminalProcess(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
ptyId: string
): Promise<RuntimeTerminalProcessInspection> {
const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
const target = ownerEnvironmentId
? ({ kind: 'environment', environmentId: ownerEnvironmentId } as const)
: getActiveRuntimeTarget(settings)
const terminal = getRemoteRuntimeTerminalHandle(ptyId)
if (target.kind !== 'environment' || !terminal) {
return window.api.pty.inspectProcess(ptyId)
}
try {
const result = await callRuntimeRpc<{ process: RuntimeTerminalProcessInspection }>(
target,
'terminal.inspectProcess',
{ terminal },
{ timeoutMs: 15_000 }
)
return result.process
} catch (error) {
if (isTerminalGoneError(error)) {
return { foregroundProcess: null, hasChildProcesses: false, unavailable: true }
}
throw error
}
}
@@ -241,7 +241,7 @@ async function enrichWorkspaceCleanupCandidate(
const terminalProbe = await probeTerminalLiveness(state, tabs)
if (terminalProbe === 'running') {
blockers.push('running-terminal')
} else if (terminalProbe === 'unknown') {
} else if (terminalProbe === 'unverifiable') {
blockers.push('terminal-liveness-unknown')
}
@@ -301,28 +301,20 @@ describe('workspace cleanup enrichment performance', () => {
let probeGate = deferred<void>()
const active: Record<ProbePhase, number> = { stream: 0, final: 0 }
const peak: Record<ProbePhase, number> = { stream: 0, final: 0 }
const hasChildProcesses = vi.fn(async () => {
const inspectProcess = vi.fn(async () => {
const callPhase = phase
const callGate = probeGate
active[callPhase] += 1
peak[callPhase] = Math.max(peak[callPhase], active[callPhase])
await callGate.promise
active[callPhase] -= 1
return false
return { foregroundProcess: 'zsh', hasChildProcesses: false }
})
;(
globalThis.window as unknown as {
api: {
pty: {
hasChildProcesses: typeof hasChildProcesses
getForegroundProcess: ReturnType<typeof vi.fn>
}
}
api: { pty: { inspectProcess: typeof inspectProcess } }
}
).api.pty = {
hasChildProcesses,
getForegroundProcess: vi.fn().mockResolvedValue('zsh')
}
).api.pty = { inspectProcess }
const store = createCleanupTestStore()
store.setState({ tabsByWorktree, ptyIdsByTabId } as Partial<AppState>)
@@ -356,7 +348,7 @@ describe('workspace cleanup enrichment performance', () => {
const finalScan = await scanPromise
expect(peak.final).toBe(WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY)
expect(hasChildProcesses).toHaveBeenCalledTimes(candidateCount * 2)
expect(inspectProcess).toHaveBeenCalledTimes(candidateCount * 2)
expect(finalScan.candidates.map((candidate) => candidate.worktreeId)).toEqual(
finalCandidates.map((candidate) => candidate.worktreeId)
)
@@ -393,26 +385,18 @@ describe('workspace cleanup enrichment performance', () => {
const probeGate = deferred<void>()
let active = 0
let peak = 0
const hasChildProcesses = vi.fn(async () => {
const inspectProcess = vi.fn(async () => {
active += 1
peak = Math.max(peak, active)
await probeGate.promise
active -= 1
return false
return { foregroundProcess: 'zsh', hasChildProcesses: false }
})
;(
globalThis.window as unknown as {
api: {
pty: {
hasChildProcesses: typeof hasChildProcesses
getForegroundProcess: ReturnType<typeof vi.fn>
}
}
api: { pty: { inspectProcess: typeof inspectProcess } }
}
).api.pty = {
hasChildProcesses,
getForegroundProcess: vi.fn().mockResolvedValue('zsh')
}
).api.pty = { inspectProcess }
const store = createCleanupTestStore()
store.setState({ tabsByWorktree, ptyIdsByTabId } as Partial<AppState>)
@@ -438,7 +422,7 @@ describe('workspace cleanup enrichment performance', () => {
probeGate.resolve()
await scanPromise
expect(hasChildProcesses).toHaveBeenCalledTimes(candidateCount)
expect(inspectProcess).toHaveBeenCalledTimes(candidateCount)
})
it('rebuilds focused removal enrichment from state changed after the broad scan', async () => {
@@ -450,20 +434,14 @@ describe('workspace cleanup enrichment performance', () => {
: Promise.resolve({ scannedAt: NOW, candidates: [candidate], errors: [] })
)
installWorkspaceCleanupApi(scan)
const hasChildProcesses = vi.fn().mockResolvedValue(false)
const inspectProcess = vi
.fn()
.mockResolvedValue({ foregroundProcess: 'zsh', hasChildProcesses: false })
;(
globalThis.window as unknown as {
api: {
pty: {
hasChildProcesses: typeof hasChildProcesses
getForegroundProcess: ReturnType<typeof vi.fn>
}
}
api: { pty: { inspectProcess: typeof inspectProcess } }
}
).api.pty = {
hasChildProcesses,
getForegroundProcess: vi.fn().mockResolvedValue('zsh')
}
).api.pty = { inspectProcess }
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
const store = createCleanupTestStore(removeWorktree)
@@ -480,6 +458,6 @@ describe('workspace cleanup enrichment performance', () => {
focusedScan.resolve({ scannedAt: NOW, candidates: [candidate], errors: [] })
await expect(removal).resolves.toMatchObject({ removedIds: [candidate.worktreeId] })
expect(hasChildProcesses).toHaveBeenCalledWith('post-scan-pty')
expect(inspectProcess).toHaveBeenCalledWith('post-scan-pty')
})
})
@@ -6,6 +6,7 @@ import {
import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import { getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency'
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-process-inspection'
const RECENT_VISIBLE_CONTEXT_MS = 24 * 60 * 60 * 1000
const VIEWED_FROM_CLEANUP_MS = 2 * 60 * 60 * 1000
@@ -158,7 +159,7 @@ export function hasWorkingTitleAgent(
export async function probeTerminalLiveness(
state: AppState,
tabs: { id: string; title: string }[]
): Promise<'idle' | 'running' | 'unknown'> {
): Promise<'idle' | 'running' | 'unverifiable'> {
const ptyChecks = tabs.flatMap((tab) =>
(state.ptyIdsByTabId[tab.id] ?? []).map((ptyId) => ({ tab, ptyId }))
)
@@ -166,15 +167,20 @@ export async function probeTerminalLiveness(
return 'idle'
}
let unknown = false
let unverifiable = false
for (const { tab, ptyId } of ptyChecks) {
try {
const [hasChildProcesses, foregroundProcess] = await Promise.all([
window.api.pty.hasChildProcesses(ptyId),
window.api.pty.getForegroundProcess(ptyId)
])
const processName = normalizeProcessName(foregroundProcess)
if (!hasChildProcesses && (!processName || SHELL_PROCESS_NAMES.has(processName))) {
// Why: the standalone hasChildProcesses/getForegroundProcess handlers coerce a
// degraded read to false/null below IPC, so an unreachable host arrived here as
// an idle shell and cleanup dropped its blocker. inspectProcess is the only read
// that carries `unavailable` across the boundary; loss of contact is never idle.
const inspection = await inspectRuntimeTerminalProcess(state.settings, ptyId)
if (inspection.unavailable === true) {
unverifiable = true
continue
}
const processName = normalizeProcessName(inspection.foregroundProcess)
if (!inspection.hasChildProcesses && (!processName || SHELL_PROCESS_NAMES.has(processName))) {
continue
}
if (
@@ -186,11 +192,11 @@ export async function probeTerminalLiveness(
}
return 'running'
} catch {
unknown = true
unverifiable = true
}
}
return unknown ? 'unknown' : 'idle'
return unverifiable ? 'unverifiable' : 'idle'
}
function hasIdleAgentTitleForPty(
@@ -528,7 +528,11 @@ describe('workspace cleanup removal and protection', () => {
api: {
pty: {
hasChildProcesses: vi.fn().mockResolvedValue(true),
getForegroundProcess: vi.fn().mockResolvedValue('codex')
getForegroundProcess: vi.fn().mockResolvedValue('codex'),
inspectProcess: vi.fn().mockResolvedValue({
foregroundProcess: 'codex',
hasChildProcesses: true
})
}
}
}
@@ -556,7 +560,11 @@ describe('workspace cleanup removal and protection', () => {
hasChildProcesses: vi.fn(async (ptyId: string) => ptyId === 'pty-running'),
getForegroundProcess: vi.fn(async (ptyId: string) =>
ptyId === 'pty-running' ? 'codex' : 'zsh'
)
),
inspectProcess: vi.fn(async (ptyId: string) => ({
foregroundProcess: ptyId === 'pty-running' ? 'codex' : 'zsh',
hasChildProcesses: ptyId === 'pty-running'
}))
}
}
}
@@ -15,6 +15,11 @@ import {
} from './workspace-cleanup-slice-test-harness'
import { WorkspaceCleanupScanSupersededError } from './workspace-cleanup-broad-scan-registry'
type PtyProcessProbe = { foregroundProcess: string | null; hasChildProcesses: boolean }
/** What main answers for a pty sitting at an idle shell prompt. */
const IDLE_SHELL_PROBE: PtyProcessProbe = { foregroundProcess: 'zsh', hasChildProcesses: false }
describe('workspace cleanup scan progress', () => {
it('joins duplicate broad cleanup scans', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
@@ -172,18 +177,14 @@ describe('workspace cleanup scan progress', () => {
return pending.promise
})
installWorkspaceCleanupApi(scan)
const hasChildProcesses = vi.fn().mockResolvedValue(false)
const getForegroundProcess = vi.fn().mockResolvedValue('zsh')
const inspectProcess = vi.fn().mockResolvedValue(IDLE_SHELL_PROBE)
;(
globalThis.window as unknown as {
api: {
pty?: {
hasChildProcesses: typeof hasChildProcesses
getForegroundProcess: typeof getForegroundProcess
}
pty?: { inspectProcess: typeof inspectProcess }
}
}
).api.pty = { hasChildProcesses, getForegroundProcess }
).api.pty = { inspectProcess }
const store = createCleanupTestStore()
store.setState({
tabsByWorktree: {
@@ -208,8 +209,7 @@ describe('workspace cleanup scan progress', () => {
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1)
})
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
expect(inspectProcess).toHaveBeenCalledTimes(1)
onProgress?.({
scanId: 'scan-1',
@@ -225,8 +225,7 @@ describe('workspace cleanup scan progress', () => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2)
})
expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(2)
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
expect(inspectProcess).toHaveBeenCalledTimes(1)
pending.resolve({
scannedAt: NOW,
@@ -235,8 +234,7 @@ describe('workspace cleanup scan progress', () => {
})
await scanPromise
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
expect(inspectProcess).toHaveBeenCalledTimes(1)
})
it('updates count-only append progress without replacing existing candidate rows', async () => {
@@ -291,7 +289,7 @@ describe('workspace cleanup scan progress', () => {
it('keeps append progress rows when terminal enrichment resolves out of order', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
const terminalProbe = deferred<boolean>()
const terminalProbe = deferred<PtyProcessProbe>()
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' })
const laterCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/later' })
@@ -300,18 +298,14 @@ describe('workspace cleanup scan progress', () => {
return pending.promise
})
installWorkspaceCleanupApi(scan)
const hasChildProcesses = vi.fn().mockReturnValue(terminalProbe.promise)
const getForegroundProcess = vi.fn().mockResolvedValue('zsh')
const inspectProcess = vi.fn().mockReturnValue(terminalProbe.promise)
;(
globalThis.window as unknown as {
api: {
pty?: {
hasChildProcesses: typeof hasChildProcesses
getForegroundProcess: typeof getForegroundProcess
}
pty?: { inspectProcess: typeof inspectProcess }
}
}
).api.pty = { hasChildProcesses, getForegroundProcess }
).api.pty = { inspectProcess }
const store = createCleanupTestStore()
store.setState({
tabsByWorktree: {
@@ -333,7 +327,7 @@ describe('workspace cleanup scan progress', () => {
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(inspectProcess).toHaveBeenCalledTimes(1)
})
onProgress?.({
@@ -348,7 +342,7 @@ describe('workspace cleanup scan progress', () => {
await Promise.resolve()
expect(store.getState().workspaceCleanupProgress).toBeNull()
terminalProbe.resolve(false)
terminalProbe.resolve(IDLE_SHELL_PROBE)
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2)
})
@@ -366,7 +360,7 @@ describe('workspace cleanup scan progress', () => {
it('drains queued progress before publishing the final scan result', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
const terminalProbe = deferred<boolean>()
const terminalProbe = deferred<PtyProcessProbe>()
let scanSettled = false
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' })
@@ -376,18 +370,14 @@ describe('workspace cleanup scan progress', () => {
return pending.promise
})
installWorkspaceCleanupApi(scan)
const hasChildProcesses = vi.fn().mockReturnValue(terminalProbe.promise)
const getForegroundProcess = vi.fn().mockResolvedValue('zsh')
const inspectProcess = vi.fn().mockReturnValue(terminalProbe.promise)
;(
globalThis.window as unknown as {
api: {
pty?: {
hasChildProcesses: typeof hasChildProcesses
getForegroundProcess: typeof getForegroundProcess
}
pty?: { inspectProcess: typeof inspectProcess }
}
}
).api.pty = { hasChildProcesses, getForegroundProcess }
).api.pty = { inspectProcess }
const store = createCleanupTestStore()
store.setState({
tabsByWorktree: {
@@ -414,7 +404,7 @@ describe('workspace cleanup scan progress', () => {
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(inspectProcess).toHaveBeenCalledTimes(1)
})
pending.resolve({
@@ -425,14 +415,14 @@ describe('workspace cleanup scan progress', () => {
await Promise.resolve()
expect(scanSettled).toBe(false)
terminalProbe.resolve(false)
terminalProbe.resolve(IDLE_SHELL_PROBE)
await scanPromise
expect(scanSettled).toBe(true)
expect(
store.getState().workspaceCleanupScan?.candidates.map((candidate) => candidate.worktreeId)
).toEqual(['repo1::/tmp/final'])
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(inspectProcess).toHaveBeenCalledTimes(1)
expect(store.getState().workspaceCleanupProgress).toMatchObject({
scannedWorktreeCount: 1,
totalWorktreeCount: 1
@@ -442,7 +432,7 @@ describe('workspace cleanup scan progress', () => {
it('ignores stale progress without replacing the active scan progress queue', async () => {
const firstPending = deferred<WorkspaceCleanupScanResult>()
const secondPending = deferred<WorkspaceCleanupScanResult>()
const terminalProbe = deferred<boolean>()
const terminalProbe = deferred<PtyProcessProbe>()
const progressCallbacks: ((progress: WorkspaceCleanupScanProgress) => void)[] = []
let secondScanSettled = false
const firstCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/first' })
@@ -455,18 +445,14 @@ describe('workspace cleanup scan progress', () => {
: firstPending.promise
})
installWorkspaceCleanupApi(scan)
const hasChildProcesses = vi.fn().mockReturnValue(terminalProbe.promise)
const getForegroundProcess = vi.fn().mockResolvedValue('zsh')
const inspectProcess = vi.fn().mockReturnValue(terminalProbe.promise)
;(
globalThis.window as unknown as {
api: {
pty?: {
hasChildProcesses: typeof hasChildProcesses
getForegroundProcess: typeof getForegroundProcess
}
pty?: { inspectProcess: typeof inspectProcess }
}
}
).api.pty = { hasChildProcesses, getForegroundProcess }
).api.pty = { inspectProcess }
const store = createCleanupTestStore()
store.setState({
tabsByWorktree: {
@@ -495,7 +481,7 @@ describe('workspace cleanup scan progress', () => {
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(inspectProcess).toHaveBeenCalledTimes(1)
})
progressCallbacks[0]?.({
scanId: 'scan-1',
@@ -515,7 +501,7 @@ describe('workspace cleanup scan progress', () => {
await Promise.resolve()
expect(secondScanSettled).toBe(false)
terminalProbe.resolve(false)
terminalProbe.resolve(IDLE_SHELL_PROBE)
await secondScan
firstPending.resolve({ scannedAt: NOW - 1, candidates: [firstCandidate], errors: [] })
// Why: the first fleet scan was superseded when the second started.
@@ -0,0 +1,179 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '../types'
import { enrichWorkspaceCleanupCandidates } from './workspace-cleanup-candidate-enrichment'
import { probeTerminalLiveness } from './workspace-cleanup-local-evidence'
import { WORKTREE_ID, makeCandidate, makeState } from './workspace-cleanup-slice-test-harness'
const TAB_ID = 'tab-1'
const PTY_ID = 'pty-1'
type PtyInspection = {
foregroundProcess: string | null
hasChildProcesses: boolean
unavailable?: true
}
/**
* Models the three answers main really gives for ONE pty read. The standalone
* `hasChildProcesses`/`getForegroundProcess` handlers coerce a degraded read to
* `false`/`null` before it crosses IPC; only `inspectProcess` carries evidence.
*/
function installPtyApi(inspection: {
hasChildProcesses: boolean
foregroundProcess: string | null
inspectProcess: () => Promise<PtyInspection>
}) {
const calls = { hasChildProcesses: 0, getForegroundProcess: 0, inspectProcess: 0 }
;(globalThis as { window: unknown }).window = {
api: {
pty: {
hasChildProcesses: vi.fn(async () => {
calls.hasChildProcesses += 1
return inspection.hasChildProcesses
}),
getForegroundProcess: vi.fn(async () => {
calls.getForegroundProcess += 1
return inspection.foregroundProcess
}),
inspectProcess: vi.fn(async () => {
calls.inspectProcess += 1
return inspection.inspectProcess()
}),
confirmForegroundProcess: vi.fn(async () => null)
}
}
}
return calls
}
/** A degraded read: main answered, but the answer carries no process evidence. */
function installDegradedPtyApi(inspection: () => Promise<PtyInspection>) {
// Why: the standalone reads are SWALLOWED below IPC (a disconnected SSH
// provider short-circuits to false/null; the daemon adapter catches its own
// dead-socket request and returns null). Nothing throws at the caller.
return installPtyApi({
hasChildProcesses: false,
foregroundProcess: null,
inspectProcess: inspection
})
}
function stateWithOnePty(overrides: Partial<AppState> = {}): AppState {
return makeState({
tabsByWorktree: { [WORKTREE_ID]: [{ id: TAB_ID, title: 'zsh' }] } as never,
ptyIdsByTabId: { [TAB_ID]: [PTY_ID] },
...overrides
})
}
async function enrichOne(state: AppState) {
const [candidate] = await enrichWorkspaceCleanupCandidates([makeCandidate()], state, {
applyDismissals: false
})
return candidate!
}
afterEach(() => {
delete (globalThis as { window?: unknown }).window
vi.restoreAllMocks()
})
describe('a degraded terminal read is unverifiable, never idle', () => {
it('reports unverifiable when the host answers without process evidence', async () => {
installDegradedPtyApi(async () => ({
foregroundProcess: null,
hasChildProcesses: false,
unavailable: true
}))
await expect(
probeTerminalLiveness(stateWithOnePty(), [{ id: TAB_ID, title: 'zsh' }])
).resolves.toBe('unverifiable')
})
it('blocks cleanup on an unavailable read instead of letting the row look clean', async () => {
installDegradedPtyApi(async () => ({
foregroundProcess: null,
hasChildProcesses: false,
unavailable: true
}))
const candidate = await enrichOne(stateWithOnePty())
expect(candidate.blockers).toContain('terminal-liveness-unknown')
expect(candidate.blockers).not.toContain('running-terminal')
expect(candidate.tier).toBe('protected')
expect(candidate.selectedByDefault).toBe(false)
})
it('reports unverifiable when contact with the host is lost mid-read', async () => {
// Why: a dead daemon socket rejects `inspectProcess` while the standalone
// reads still resolve false/null — a swallowing adapter, not a thrower.
installDegradedPtyApi(async () => {
throw new Error('terminal_gone')
})
const candidate = await enrichOne(stateWithOnePty())
expect(candidate.blockers).toContain('terminal-liveness-unknown')
expect(candidate.tier).toBe('protected')
})
it('reads the evidence-carrying handler, not the coerced ones', async () => {
const calls = installDegradedPtyApi(async () => ({
foregroundProcess: null,
hasChildProcesses: false,
unavailable: true
}))
await probeTerminalLiveness(stateWithOnePty(), [{ id: TAB_ID, title: 'zsh' }])
expect(calls.inspectProcess).toBe(1)
expect(calls.hasChildProcesses).toBe(0)
expect(calls.getForegroundProcess).toBe(0)
})
})
describe('verified reads keep their existing verdicts', () => {
it('still permits cleanup for a genuinely idle shell', async () => {
installPtyApi({
hasChildProcesses: false,
foregroundProcess: 'zsh',
inspectProcess: async () => ({ foregroundProcess: 'zsh', hasChildProcesses: false })
})
const candidate = await enrichOne(stateWithOnePty())
expect(candidate.blockers).toEqual([])
expect(candidate.tier).toBe('ready')
expect(candidate.selectedByDefault).toBe(true)
})
it('still blocks cleanup for a live agent', async () => {
installPtyApi({
hasChildProcesses: true,
foregroundProcess: 'claude',
inspectProcess: async () => ({ foregroundProcess: 'claude', hasChildProcesses: true })
})
const candidate = await enrichOne(stateWithOnePty())
expect(candidate.blockers).toContain('running-terminal')
expect(candidate.tier).toBe('protected')
})
it('still permits cleanup for a worktree with no terminals at all', async () => {
installPtyApi({
hasChildProcesses: false,
foregroundProcess: null,
inspectProcess: async () => {
throw new Error('should not be asked about a worktree with no ptys')
}
})
const candidate = await enrichOne(makeState())
expect(candidate.blockers).toEqual([])
expect(candidate.tier).toBe('ready')
})
})