fix(resource-manager): distinguish unprovable ownership from proven absence

Adversarial review of the previous commit found it committed the same class of
error it was fixing: it collapsed "no agent owns this" and "this provider cannot
tell me" into one boolean `false`, and both destructive paths read that as proof.

A daemon generation below the claim protocol, an older SSH relay, or the
in-process local fallback all list no owners for a session that may well have
one. `pty.ts` already encodes the rule at :613 — "only providers that serialize
claims may make listing absence authoritative" — and the new IPC row ignored it.
So after upgrading with a legacy daemon still holding a live agent terminal,
bulk cleanup would have destroyed it: exactly #8459, one layer down.

`hasAgentOwner: boolean` is now `agentOwnership: 'present' | 'absent' | 'unknown'`,
derived via `providesAgentSessionOwnerListings`. Only `absent` authorizes
destruction, so `unknown` protects and confirms.

Second defect, found independently by four review lenses: the deferred-SSH
bindings reached the bulk selector but not `mergeSnapshotAndSessions`, because
the merge call site re-listed the binding fields instead of reusing the object.
A deferred SSH session therefore rendered `bound: false`, and its single-row kill
skipped confirmation while bulk cleanup correctly spared it. The call site now
spreads `resourceSessionBindings`, and a parity test fails if any binding field
is re-listed inline — the drift itself is now impossible to reintroduce quietly.

The e2e ownership assertion was also weak: it checked only that a boolean
arrived. It now asserts the exact arm, and that the live local provider reports
`absent` rather than `unknown`, so a degenerate all-unknown implementation fails.
This commit is contained in:
OrcaWin
2026-07-27 11:34:02 -07:00
parent 7a393d1c98
commit 53a1db4b64
16 changed files with 313 additions and 74 deletions
+41 -3
View File
@@ -5736,6 +5736,8 @@ describe('registerPtyHandlers', () => {
{ id: 'agent-pty', cwd: '/workspace', title: 'codex', agentSessionOwners: [owner] },
{ id: 'plain-pty', cwd: '/tmp', title: 'zsh' }
]),
// Why: this provider serializes claims, so its silence about an owner is authoritative.
providesAgentSessionOwnerListings: () => true,
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
@@ -5743,11 +5745,47 @@ describe('registerPtyHandlers', () => {
const sessions = (await handlers.get('pty:listSessions')!(null, undefined)) as {
id: string
hasAgentOwner: boolean
agentOwnership: string
}[]
expect(sessions.find((s) => s.id === 'agent-pty')?.hasAgentOwner).toBe(true)
expect(sessions.find((s) => s.id === 'plain-pty')?.hasAgentOwner).toBe(false)
expect(sessions.find((s) => s.id === 'agent-pty')?.agentOwnership).toBe('present')
expect(sessions.find((s) => s.id === 'plain-pty')?.agentOwnership).toBe('absent')
})
it('reports unknown ownership when the provider cannot serialize claims', async () => {
registerPtyHandlers(mainWindow as never)
// Why: a legacy daemon generation or older SSH relay lists no owners for a session that may
// have one. Reporting that silence as 'absent' is what let live agent sessions be killed (#8459).
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => [{ id: 'legacy-pty', cwd: '/workspace', title: 'zsh' }]),
providesAgentSessionOwnerListings: () => false,
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const sessions = (await handlers.get('pty:listSessions')!(null, undefined)) as {
id: string
agentOwnership: string
}[]
expect(sessions.find((s) => s.id === 'legacy-pty')?.agentOwnership).toBe('unknown')
})
it('kills app-scoped SSH PTY ids through the parsed provider when ownership is not rebuilt', async () => {
+10 -5
View File
@@ -5514,7 +5514,7 @@ export function registerPtyHandlers(
registeredPtyProviders(),
({ provider, connectionId }) =>
connectionId === null ? provider.listProcesses() : provider.listProcesses().catch(() => []),
({ connectionId }, sessions) => {
({ provider, connectionId }, sessions) => {
for (const rawSession of sessions) {
const session = admission.admit(rawSession)
// Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider.
@@ -5523,10 +5523,15 @@ export function registerPtyHandlers(
id: session.id,
cwd: session.cwd,
title: session.title,
// Why: an agent bound to this session is proof work is running, independent of whether
// this renderer has a tab binding yet. Without it the UI can only guess, and guessed
// "orphan" force-killed live agent sessions (#8459).
hasAgentOwner: (session.agentSessionOwners?.length ?? 0) > 0
// Why: the renderer's binding map is empty during restore, so ownership is the only
// liveness evidence it has. Absence is authoritative only from a provider that
// serializes claims — otherwise it is 'unknown', never 'absent' (#8459).
agentOwnership:
(session.agentSessionOwners?.length ?? 0) > 0
? 'present'
: provider.providesAgentSessionOwnerListings?.(session.id) === true
? 'absent'
: 'unknown'
})
}
}
@@ -45,7 +45,7 @@ function makeSession(overrides: Partial<UnifiedSessionRow>): UnifiedSessionRow {
pid: 100,
label: 'zsh',
bound: true,
hasAgentOwner: false,
agentOwnership: 'absent' as const,
tabId: 'tab-1',
cpu: 1,
memory: 100,
@@ -931,11 +931,11 @@ export function ResourceUsageStatusSegment({
() =>
open
? mergeSnapshotAndSessions(resourceSnapshot, sessions, {
tabsByWorktree,
ptyIdsByTabId,
terminalLayoutsByTabId,
// Why spread: the rows and the bulk selector must classify from the identical binding
// inputs. Re-listing them here let the row path miss deferred SSH sessions, so their
// single-row kill skipped confirmation while bulk cleanup correctly spared them (#8459).
...resourceSessionBindings,
runtimePaneTitlesByTabId,
workspaceSessionReady,
repoDisplayNameById,
repoConnectionIdById,
repoRuntimeScopedById,
@@ -947,11 +947,8 @@ export function ResourceUsageStatusSegment({
open,
resourceSnapshot,
sessions,
tabsByWorktree,
ptyIdsByTabId,
terminalLayoutsByTabId,
resourceSessionBindings,
runtimePaneTitlesByTabId,
workspaceSessionReady,
repoDisplayNameById,
repoConnectionIdById,
repoRuntimeScopedById,
@@ -7,6 +7,7 @@ import type {
WorktreeMemory
} from '../../../../shared/types'
import { mergeSnapshotAndSessions, UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions'
import { requiresKillConfirmation } from './resource-session-kill-confirmation'
import type { DaemonSession, MergeContext } from './resource-usage-merge-types'
function emptyAppMemory() {
@@ -156,7 +157,7 @@ describe('mergeSnapshotAndSessions', () => {
sessions: [{ sessionId: 'pty-1', paneKey: null, pid: 999, cpu: 0.1, memory: 50_000_000 }]
}
const ds: DaemonSession[] = [
{ id: 'pty-1', cwd: '/Users/me/Triton', title: 'shell', hasAgentOwner: false }
{ id: 'pty-1', cwd: '/Users/me/Triton', title: 'shell', agentOwnership: 'absent' as const }
]
const out = mergeSnapshotAndSessions(makeSnapshot([wt]), ds, baseCtx())
expect(out[0].worktrees[0].sessions).toHaveLength(1)
@@ -182,24 +183,66 @@ describe('mergeSnapshotAndSessions', () => {
sessions: [{ sessionId: 'pty-agent', paneKey: null, pid: 999, cpu: 0.1, memory: 50_000_000 }]
}
const ds: DaemonSession[] = [
{ id: 'pty-agent', cwd: '/Users/me/Triton', title: 'codex', hasAgentOwner: true }
{
id: 'pty-agent',
cwd: '/Users/me/Triton',
title: 'codex',
agentOwnership: 'present' as const
}
]
const out = mergeSnapshotAndSessions(makeSnapshot([wt]), ds, baseCtx())
expect(out[0].worktrees[0].sessions[0]).toMatchObject({
sessionId: 'pty-agent',
hasAgentOwner: true
agentOwnership: 'present' as const
})
})
it('binds a deferred SSH row so its single-row kill cannot skip confirmation', () => {
// Why: the bulk selector already excluded these, but the rendered row took its own path.
// An unbound row with no agent skips the dialog entirely — the same #8459 defect, one click over.
const sessionId = 'orca::/remote/Stingray@@deferred1'
const out = mergeSnapshotAndSessions(
null,
[{ id: sessionId, cwd: '', title: 'orca/Stingray', agentOwnership: 'absent' as const }],
baseCtx({
tabsByWorktree: { 'orca::/remote/Stingray': [makeTab('tab-ssh')] },
deferredSshSessionIdsByTabId: { 'tab-ssh': sessionId },
repoConnectionIdById: new Map([['orca', 'ssh-conn-1']])
})
)
const row = out[0].worktrees[0].sessions[0]
expect(row).toMatchObject({ sessionId, bound: true, tabId: 'tab-ssh' })
expect(requiresKillConfirmation(row)).toBe(true)
})
it('treats a snapshot row the daemon never listed as unknown ownership, not absent', () => {
const wt: WorktreeMemory = {
worktreeId: 'orca::/Users/me/Triton',
worktreeName: 'Triton',
repoId: 'orca',
repoName: 'ORCA',
cpu: 0.1,
memory: 50_000_000,
history: [],
sessions: [{ sessionId: 'pty-only-local', paneKey: null, pid: 7, cpu: 0.1, memory: 1 }]
}
const out = mergeSnapshotAndSessions(makeSnapshot([wt]), [], baseCtx())
expect(out[0].worktrees[0].sessions[0]).toMatchObject({ agentOwnership: 'unknown' })
expect(requiresKillConfirmation(out[0].worktrees[0].sessions[0])).toBe(true)
})
it('@@ parse: an SSH-style session id resolves to its worktree group', () => {
const ds: DaemonSession[] = [
{
id: 'orca::/remote/Stingray@@abcd1234',
cwd: '',
title: 'orca/Stingray',
hasAgentOwner: false
agentOwnership: 'absent' as const
}
]
const ctx = baseCtx({
@@ -240,7 +283,7 @@ describe('mergeSnapshotAndSessions', () => {
id: 'orca::/local/Triton@@deadbeef',
cwd: '/local/Triton',
title: 'orca/Triton',
hasAgentOwner: false
agentOwnership: 'absent' as const
}
]
const ctx = baseCtx({
@@ -260,7 +303,12 @@ describe('mergeSnapshotAndSessions', () => {
it('tab walk wins over @@ parse when they disagree', () => {
const tabId = 'tab-xyz'
const ds: DaemonSession[] = [
{ id: 'orca::/wrong/path@@feedface', cwd: '', title: 'orca', hasAgentOwner: false }
{
id: 'orca::/wrong/path@@feedface',
cwd: '',
title: 'orca',
agentOwnership: 'absent' as const
}
]
const ctx = baseCtx({
tabsByWorktree: {
@@ -278,7 +326,12 @@ describe('mergeSnapshotAndSessions', () => {
const tabId = 'tab-restored'
const sessionId = 'orca::/Users/me/Triton@@deferred'
const ds: DaemonSession[] = [
{ id: sessionId, cwd: '/Users/me/Triton', title: 'orca/Triton', hasAgentOwner: false }
{
id: sessionId,
cwd: '/Users/me/Triton',
title: 'orca/Triton',
agentOwnership: 'absent' as const
}
]
const restoredTab = { ...makeTab(tabId, 'Restored'), ptyId: sessionId }
const ctx = baseCtx({
@@ -319,7 +372,7 @@ describe('mergeSnapshotAndSessions', () => {
id: 'remote-repo::/remote/Stingray@@1234',
cwd: '',
title: 'remote/Stingray',
hasAgentOwner: false
agentOwnership: 'absent' as const
}
]
const ctx = baseCtx({
@@ -362,10 +415,20 @@ describe('mergeSnapshotAndSessions', () => {
id: 'runtime-repo::/runtime/Wt@@future-runtime',
cwd: '',
title: 'runtime/Wt',
hasAgentOwner: false
agentOwnership: 'absent' as const
},
{ id: 'ssh-repo::/remote/Wt@@ssh-session', cwd: '', title: 'ssh/Wt', hasAgentOwner: false },
{ id: 'opaque-local-orphan', cwd: '', title: 'orphan shell', hasAgentOwner: false }
{
id: 'ssh-repo::/remote/Wt@@ssh-session',
cwd: '',
title: 'ssh/Wt',
agentOwnership: 'absent' as const
},
{
id: 'opaque-local-orphan',
cwd: '',
title: 'orphan shell',
agentOwnership: 'absent' as const
}
]
const ctx = baseCtx({
repoConnectionIdById: new Map<string, string | null>([
@@ -409,7 +472,7 @@ describe('mergeSnapshotAndSessions', () => {
// to. The chip should stay off; the row still surfaces in the
// unattributed bucket with `—` cells because we have no sample.
const ds: DaemonSession[] = [
{ id: 'opaque-id-without-prefix', cwd: '', title: 'shell', hasAgentOwner: false }
{ id: 'opaque-id-without-prefix', cwd: '', title: 'shell', agentOwnership: 'absent' as const }
]
const out = mergeSnapshotAndSessions(null, ds, baseCtx())
expect(out).toHaveLength(1)
@@ -463,7 +526,12 @@ describe('mergeSnapshotAndSessions', () => {
it('remote-orphan interaction state: null metrics + bound=false', () => {
const ds: DaemonSession[] = [
{ id: 'orca::/remote/Wt@@deadbeef', cwd: '', title: 'orca/Wt', hasAgentOwner: false }
{
id: 'orca::/remote/Wt@@deadbeef',
cwd: '',
title: 'orca/Wt',
agentOwnership: 'absent' as const
}
]
const out = mergeSnapshotAndSessions(null, ds, baseCtx())
const session = out[0].worktrees[0].sessions[0]
@@ -478,7 +546,7 @@ describe('mergeSnapshotAndSessions', () => {
it('uses repoDisplayNameById to humanize new project groups when available', () => {
const ds: DaemonSession[] = [
{ id: 'stably-ai/orca::/remote/Wt@@1', cwd: '', title: '', hasAgentOwner: false }
{ id: 'stably-ai/orca::/remote/Wt@@1', cwd: '', title: '', agentOwnership: 'absent' as const }
]
const ctx = baseCtx({
repoDisplayNameById: new Map([['stably-ai/orca', 'ORCA']])
@@ -143,9 +143,10 @@ export function mergeSnapshotAndSessions(
const index = buildResourceSessionBindingIndex(ctx)
const boundPtyIds = index.boundPtyIds
// Why: the daemon list is the only place agent ownership is reported. Snapshot-derived rows
// describe the same sessions by id, so carry it across rather than letting them report `false`.
const agentOwnedSessionIds = new Set(
daemonSessions.filter((session) => session.hasAgentOwner).map((session) => session.id)
// describe the same sessions by id, so carry it across rather than inventing an answer; a
// session the daemon never listed is 'unknown', not 'absent'.
const ownershipBySessionId = new Map(
daemonSessions.map((session) => [session.id, session.agentOwnership])
)
function isRepoRemote(repoId: string): boolean {
@@ -207,7 +208,7 @@ export function mergeSnapshotAndSessions(
pid: s.pid,
label: resolveSnapshotSessionLabel(s, wt.worktreeId, ctx),
bound: ctx.workspaceSessionReady && boundPtyIds.has(s.sessionId),
hasAgentOwner: agentOwnedSessionIds.has(s.sessionId),
agentOwnership: ownershipBySessionId.get(s.sessionId) ?? 'unknown',
tabId,
cpu: s.cpu,
memory: s.memory,
@@ -295,7 +296,7 @@ export function mergeSnapshotAndSessions(
pid: 0,
label: resolveDaemonSessionLabel(session, worktreeId, tabId, ctx),
bound: ctx.workspaceSessionReady && boundPtyIds.has(session.id),
hasAgentOwner: session.hasAgentOwner,
agentOwnership: session.agentOwnership,
tabId,
cpu: null,
memory: null,
@@ -83,9 +83,14 @@ describe('resource session bindings', () => {
it('counts only daemon sessions without live or restorable tab bindings', () => {
const count = countUnboundDaemonSessions(
[
{ id: 'pty-live', cwd: '/workspace', title: 'live', hasAgentOwner: false },
{ id: 'pty-restored', cwd: '/workspace', title: 'restored', hasAgentOwner: false },
{ id: 'pty-orphan', cwd: '/tmp', title: 'orphan', hasAgentOwner: false }
{ id: 'pty-live', cwd: '/workspace', title: 'live', agentOwnership: 'absent' as const },
{
id: 'pty-restored',
cwd: '/workspace',
title: 'restored',
agentOwnership: 'absent' as const
},
{ id: 'pty-orphan', cwd: '/tmp', title: 'orphan', agentOwnership: 'absent' as const }
],
{
ptyIdsByTabId: { 'tab-live': ['pty-live'] },
@@ -112,7 +117,14 @@ describe('resource session bindings', () => {
expect(buildResourceSessionBindingIndex(inputs).boundPtyIds.has('pty-deferred')).toBe(true)
expect(
selectUnboundDaemonSessions(
[{ id: 'pty-deferred', cwd: '/workspace', title: 'agent', hasAgentOwner: false }],
[
{
id: 'pty-deferred',
cwd: '/workspace',
title: 'agent',
agentOwnership: 'absent' as const
}
],
inputs
)
).toEqual([])
@@ -138,8 +150,8 @@ describe('resource session bindings', () => {
workspaceSessionReady: true
}
const sessions = [
{ id: 'pty-agent', cwd: '/workspace', title: 'codex', hasAgentOwner: true },
{ id: 'pty-shell', cwd: '/tmp', title: 'shell', hasAgentOwner: false }
{ id: 'pty-agent', cwd: '/workspace', title: 'codex', agentOwnership: 'present' as const },
{ id: 'pty-shell', cwd: '/tmp', title: 'shell', agentOwnership: 'absent' as const }
]
expect(selectUnboundDaemonSessions(sessions, inputs).map((s) => s.id)).toEqual(['pty-shell'])
@@ -155,10 +167,15 @@ describe('resource session bindings', () => {
workspaceSessionReady: true
}
const sessions = [
{ id: 'pty-live', cwd: '/workspace', title: 'live', hasAgentOwner: false },
{ id: 'pty-deferred', cwd: '/workspace', title: 'deferred', hasAgentOwner: false },
{ id: 'pty-agent', cwd: '/workspace', title: 'codex', hasAgentOwner: true },
{ id: 'pty-orphan', cwd: '/tmp', title: 'orphan', hasAgentOwner: false }
{ id: 'pty-live', cwd: '/workspace', title: 'live', agentOwnership: 'absent' as const },
{
id: 'pty-deferred',
cwd: '/workspace',
title: 'deferred',
agentOwnership: 'absent' as const
},
{ id: 'pty-agent', cwd: '/workspace', title: 'codex', agentOwnership: 'present' as const },
{ id: 'pty-orphan', cwd: '/tmp', title: 'orphan', agentOwnership: 'absent' as const }
]
expect(selectUnboundDaemonSessions(sessions, inputs).map((s) => s.id)).toEqual(['pty-orphan'])
@@ -167,8 +184,28 @@ describe('resource session bindings', () => {
)
})
it('never offers a session whose ownership could not be established', () => {
// Why: a legacy daemon generation or an older SSH relay cannot serialize claims, so it reports
// no owners for a session that may well have one. Unknown must never read as absent (#8459).
const inputs = {
ptyIdsByTabId: {},
tabsByWorktree: {},
terminalLayoutsByTabId: {},
workspaceSessionReady: true
}
const sessions = [
{ id: 'pty-legacy', cwd: '/workspace', title: 'shell', agentOwnership: 'unknown' as const },
{ id: 'pty-known', cwd: '/tmp', title: 'shell', agentOwnership: 'absent' as const }
]
expect(selectUnboundDaemonSessions(sessions, inputs).map((s) => s.id)).toEqual(['pty-known'])
expect(countUnboundDaemonSessions(sessions, inputs)).toBe(1)
})
it('offers nothing while the workspace session is still loading', () => {
const sessions = [{ id: 'pty-any', cwd: '/tmp', title: 'shell', hasAgentOwner: false }]
const sessions = [
{ id: 'pty-any', cwd: '/tmp', title: 'shell', agentOwnership: 'absent' as const }
]
const inputs = {
ptyIdsByTabId: {},
tabsByWorktree: {},
@@ -1,4 +1,5 @@
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types'
import { mayDestroyWithoutOwnerEvidence } from '../../../../shared/pty-listed-session'
import type { DaemonSession } from './resource-usage-merge-types'
export type ResourceSessionBindingInputs = {
@@ -97,9 +98,11 @@ export function selectUnboundDaemonSessions(
return []
}
const { boundPtyIds } = buildResourceSessionBindingIndex(inputs)
// Why hasAgentOwner short-circuits: an agent holding the session is positive proof work is
// running. This renderer's binding map is empty during restore, so it cannot decide alone.
return sessions.filter((session) => !boundPtyIds.has(session.id) && !session.hasAgentOwner)
// Why the ownership check: this renderer's binding map is empty during restore, so it cannot
// decide alone. Only proven absence of an owner qualifies — 'unknown' protects.
return sessions.filter(
(session) => !boundPtyIds.has(session.id) && mayDestroyWithoutOwnerEvidence(session)
)
}
export function countUnboundDaemonSessions(
@@ -0,0 +1,51 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const SEGMENT_PATH = resolve(__dirname, 'ResourceUsageStatusSegment.tsx')
/**
* Both destructive paths — bulk "kill orphans" and a single row's kill — must classify from the
* same binding inputs. When the merge call site re-listed the fields instead of reusing the
* object, it silently omitted deferred SSH sessions: bulk cleanup spared them while the row's
* kill destroyed them with no confirmation. Same bug, one click over (#8459).
*/
describe('resource session classification parity', () => {
it('feeds the row merge the same binding inputs as the bulk selector', () => {
const source = readFileSync(SEGMENT_PATH, 'utf8')
const mergeCall = source.slice(
source.indexOf('mergeSnapshotAndSessions(resourceSnapshot'),
source.indexOf('worktreeById\n })')
)
expect(mergeCall).toContain('...resourceSessionBindings')
// Any of these appearing inline means the call site is re-deriving bindings and can drift.
for (const field of [
'ptyIdsByTabId,',
'tabsByWorktree,',
'terminalLayoutsByTabId,',
'deferredSshSessionIdsByTabId,',
'workspaceSessionReady,'
]) {
expect(mergeCall).not.toContain(field)
}
})
it('keeps every binding source in the one object both paths read', () => {
const source = readFileSync(SEGMENT_PATH, 'utf8')
const bindings = source.slice(
source.indexOf('const resourceSessionBindings = useMemo'),
source.indexOf('const popoverBodyRef')
)
for (const field of [
'ptyIdsByTabId',
'tabsByWorktree',
'terminalLayoutsByTabId',
'deferredSshSessionIdsByTabId',
'workspaceSessionReady'
]) {
expect(bindings).toContain(field)
}
})
})
@@ -8,7 +8,7 @@ import {
import type { DaemonSession } from './resource-usage-merge-types'
function session(id: string): DaemonSession {
return { id, cwd: '/workspace', title: id, hasAgentOwner: false }
return { id, cwd: '/workspace', title: id, agentOwnership: 'absent' as const }
}
describe('resource session inventory', () => {
@@ -9,7 +9,7 @@ function row(overrides: Partial<UnifiedSessionRow> = {}): UnifiedSessionRow {
pid: 0,
label: 'zsh',
bound: false,
hasAgentOwner: false,
agentOwnership: 'absent',
tabId: null,
cpu: null,
memory: null,
@@ -24,10 +24,16 @@ describe('resource session kill confirmation', () => {
})
it('confirms before killing an agent-owned session that has no binding', () => {
expect(requiresKillConfirmation(row({ bound: false, hasAgentOwner: true }))).toBe(true)
expect(requiresKillConfirmation(row({ bound: false, agentOwnership: 'present' }))).toBe(true)
})
it('skips the prompt only when the session is both unbound and unclaimed', () => {
it('confirms when ownership could not be established at all', () => {
// Why: a provider that cannot serialize claims reports no owners for a session that may have
// one. Treating that silence as proof of absence is the #8459 defect.
expect(requiresKillConfirmation(row({ bound: false, agentOwnership: 'unknown' }))).toBe(true)
})
it('skips the prompt only on proven absence with no binding', () => {
expect(requiresKillConfirmation(row())).toBe(false)
})
})
@@ -1,13 +1,14 @@
import { mayDestroyWithoutOwnerEvidence } from '../../../../shared/pty-listed-session'
import type { UnifiedSessionRow } from './resource-usage-merge-types'
/**
* Whether killing this session must ask first.
*
* Skipping the prompt is only safe when Orca can show what would be lost — a bound row has a tab
* the user can look at. Absence of a binding is not evidence the session is idle, and agent
* ownership is positive evidence it is not: an agent holds live work with no tab of its own.
* the user can look at. Absence of a binding is not evidence the session is idle, and only proven
* absence of an agent owner is evidence no work is running; an unprovable owner asks.
* Resource Manager force-killed exactly those sessions with no prompt (#8459).
*/
export function requiresKillConfirmation(session: UnifiedSessionRow): boolean {
return session.bound || session.hasAgentOwner
return session.bound || !mayDestroyWithoutOwnerEvidence(session)
}
@@ -4,7 +4,10 @@ import type {
TerminalTab,
Worktree
} from '../../../../shared/types'
import type { PtyListedSession } from '../../../../shared/pty-listed-session'
import type {
AgentOwnershipEvidence,
PtyListedSession
} from '../../../../shared/pty-listed-session'
/** `null` === "no local sample" (e.g. SSH PTY); UI renders as em-dash. */
export type Metric = number | null
@@ -18,8 +21,8 @@ export type UnifiedSessionRow = {
pid: number
label: string
bound: boolean
/** An agent holds this session. Destroying it discards live work, so it always confirms. */
hasAgentOwner: boolean
/** Ownership as the provider could establish it; anything but `absent` means confirm first. */
agentOwnership: AgentOwnershipEvidence
tabId: string | null
cpu: Metric
memory: Metric
@@ -58,6 +61,8 @@ export type MergeContext = {
ptyIdsByTabId: Record<string, string[]>
/** From useAppStore: persisted per-leaf PTY wake hints for deferred reattach. */
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot>
/** From useAppStore: SSH sessions known live but not yet reattached; no other binding sees them. */
deferredSshSessionIdsByTabId?: Record<string, string>
/** From useAppStore: per-tab live pane titles (for label resolution). */
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
/** From useAppStore: false until renderer state can distinguish bound/orphan. */
@@ -5,7 +5,7 @@ import type { DaemonSession } from './resource-usage-merge-types'
import { useResourceSessionInventory } from './use-resource-session-inventory'
function session(id: string): DaemonSession {
return { id, cwd: '/workspace', title: id, hasAgentOwner: false }
return { id, cwd: '/workspace', title: id, agentOwnership: 'absent' as const }
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
+21 -4
View File
@@ -1,3 +1,13 @@
/**
* Whether an agent holds a listed session.
*
* `unknown` is not a variant of `absent`. A provider that cannot serialize claims — a legacy
* daemon generation below the claim protocol, an older SSH relay, the in-process local fallback —
* reports no owners for a session that may well have one. Only a provider whose
* `providesAgentSessionOwnerListings` is true may make listing absence authoritative.
*/
export type AgentOwnershipEvidence = 'present' | 'absent' | 'unknown'
/**
* One row of `pty:listSessions`. Shared so the main handler, both preload surfaces, and the
* renderer cannot drift on which evidence the UI is allowed to see.
@@ -7,9 +17,16 @@ export type PtyListedSession = {
cwd: string
title: string
/**
* An agent holds this session, so work is running regardless of whether the calling renderer
* has a tab binding for it yet. Positive ownership evidence the renderer cannot derive alone —
* dropping it at this boundary is what let Resource Manager force-kill live sessions (#8459).
* Agent ownership as the listing provider could establish it. Destructive actions must treat
* anything other than `absent` as live work: discarding this distinction is what let Resource
* Manager force-kill live agent sessions (#8459).
*/
hasAgentOwner: boolean
agentOwnership: AgentOwnershipEvidence
}
/** Only proven absence authorizes destroying a session without asking. */
export function mayDestroyWithoutOwnerEvidence(session: {
agentOwnership: AgentOwnershipEvidence
}): boolean {
return session.agentOwnership === 'absent'
}
@@ -150,18 +150,28 @@ test.describe('Resource Manager unbound-session safety', () => {
'A live warm-reattached session was unbound after restore completed; orphan cleanup would target it'
).toBe(true)
// The ownership field must survive the real IPC boundary, not just the unit-test mock:
// a structured-clone drop or a preload contract mismatch would surface here as undefined.
const ownershipReported = await secondLaunch.page.evaluate(async (expected: string) => {
// Ownership evidence must survive the real IPC boundary, not just the unit-test mock: a
// structured-clone drop or preload contract mismatch would surface here as undefined.
// Asserting the exact arm matters — a stub returning a constant would satisfy a typeof check.
const ownership = await secondLaunch.page.evaluate(async (expected: string) => {
const sessions = await window.api.pty.listSessions()
return sessions
.filter((s) => s.id === expected)
.map((s) => typeof s.hasAgentOwner === 'boolean')
return sessions.filter((s) => s.id === expected).map((s) => s.agentOwnership)
}, ptyId)
expect(
ownershipReported,
'pty:listSessions dropped hasAgentOwner across the real IPC boundary'
).toEqual([true])
ownership,
'pty:listSessions did not report a valid agentOwnership arm across the real IPC boundary'
).toHaveLength(1)
expect(
['present', 'absent', 'unknown'],
'agentOwnership crossed IPC as an unrecognized value'
).toContain(ownership[0])
// A plain shell under the live local provider must be PROVEN unowned, not merely unknown —
// otherwise the tri-state would be reporting "unknown" for everything and proving nothing.
expect(
ownership[0],
'The live local provider reported non-authoritative ownership for its own session'
).toBe('absent')
} finally {
if (firstApp) {
await session.close(firstApp).catch(() => {})