fix(agent-status): retire panes whose agent process is gone (STA-4612)

Agent status can hold `working` on a pane where no work is outstanding, and
nothing closes the gap. A pane's Claude state is a join of a lead turn and three
latches — the subagent roster, the background-task gate and the session-cron gate
— and each is set by a hook and cleared only by another hook. Claude Code emits
no terminating hook on `/exit`, `/clear`, Ctrl+C, crash, SIGKILL or terminal
close, so every one of those latches is a claim with no owner and no expiry. The
join is also materialised at ingest time and persisted, so a stale `working`
survives restart and blocks hibernation, which requires `done`.

Registering `SessionEnd` is not the fix: it covers roughly a third of exit paths
(measured on 2.1.231/2.1.233; upstream anthropics/claude-code#17885 and #6428 are
both closed as not planned). Nor is a TTL — `AGENT_STATUS_STALE_AFTER_MS` only
decays the sidebar dot at read time while the stored row stays non-terminal.

So the backstop is built from evidence Orca already owns.

A session id that changes means the conversation was replaced. On the first hook
of the new session — whatever that hook is — the previous session's own claims
are void: its session crons and its one-shot subagents. Deliberately not voided:
the background-task gate (a background shell is an OS process that survives
`/clear`, and the previous inventory is positive evidence it was running), and
`confirmedTeammate` rows (persistent in-process teammates a lead swap cannot
end). The lead record is left to the incoming event's own fold.

A certified process exit retires the pane. Orca already does this on every
attributable PTY exit — `clearProviderPtyState` resolves the pane key and calls
`clearPaneState` — but that resolution depends on the spawn-time `ptyPaneKey`
mapping, which a restored or reattached PTY may never rebuild. Those panes keep
their row and latches for good. `onPtyExit` knows the keys teardown could not
resolve, so it reconciles them from its own records. The certificate is
`exitCode >= 0 || hostExitConfirmed || providerExitObserved`: a synthetic `-1`
from a failed stop is not a death (the PTY can have survived it), while a real
exit can also report `-1`, so neither the code nor the SSH surface predicate is
sufficient alone. `providerExitObserved` is additive and separate from
`hostExitConfirmed`, which also drives the liveness verdict and the SSH surface
decision.

A confirmed shell foreground is the `/exit` case: the agent died, the shell
lived. That already dropped the row, but through `agentStatus:drop`, which by its
own contract preserves a live pane's caches — so every latch survived and the
next event resolved the pane back to `working`. It now routes through the
reconciler instead, gated on a per-pane accepted-status generation rather than
row identity: the confirming process read can take seconds, and `updatedAt`
cannot order two writes inside one millisecond (the store deliberately admits
equal timestamps).

Cold start generalises the same way. The startup sweep required a restored
subagent roster, so a stranded lead row, background-task gate or cron gate — the
shapes with no child event left to reap them — were never candidates.

Hibernation needs no change: with the above, those rows become genuinely `done`
and the lockout resolves through the front door. A `restoredUnconfirmed` bypass
in the planner would let it reclaim the heap of an agent that may be working.

Not included: folding `background_tasks` from a child-attributed `SubagentStop`.
Writing its test surfaced #11838's deliberate assertion that child inventories
are not authoritative for lead-owned background work, and the listener says the
same — "background_tasks is trusted only where unambiguous". An empty list on a
`SubagentStop` does not prove the lead's shell ended, so the fold would have
cleared a gate on evidence that establishes nothing.

STA-4119's live-side question — whether a genuinely live background shell should
hold the lead row after the lead turn ends — is untouched. This change extends
gate-clearing to zero new triggers.
This commit is contained in:
Brennan Benson
2026-08-17 18:50:05 -07:00
parent f8e728bb8b
commit 9999e548dc
21 changed files with 788 additions and 67 deletions
@@ -0,0 +1,112 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { makePaneKey } from '../../shared/stable-pane-id'
import { AgentHookServer } from './server'
const LEAF = '11111111-1111-4111-8111-111111111111'
const PANE = makePaneKey('tab-1', LEAF)
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'orca-ended-process-'))
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
async function startServer(): Promise<AgentHookServer> {
const server = new AgentHookServer()
await server.start({ env: 'production', userDataPath: dir })
return server
}
function claudeRow(server: AgentHookServer, state: 'working' | 'waiting' | 'done'): void {
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: null,
payload: { state, prompt: 'review the PR', agentType: 'claude' }
})
}
function paneState(server: AgentHookServer): string {
return server.getStatusSnapshotForPane(PANE)[0]?.state ?? 'missing'
}
// The teardown that normally clears a dead pane depends on the spawn-time ptyPaneKey mapping, which
// a restored PTY may never rebuild — leaving the row and its latches with nothing left to retire
// them. See PLAN-STA-4612 §4.2.
describe('reconcileEndedProcessForPaneKeys', () => {
it('retires a working row whose process is certifiably dead', async () => {
const server = await startServer()
try {
claudeRow(server, 'working')
expect(paneState(server)).toBe('working')
expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(1)
expect(paneState(server)).toBe('missing')
} finally {
server.stop()
}
})
it('retires a waiting row — a wait whose owner is dead is not a wait', async () => {
const server = await startServer()
try {
claudeRow(server, 'waiting')
expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(1)
expect(paneState(server)).toBe('missing')
} finally {
server.stop()
}
})
it('clears Claude latches even when the stored row already reads done', async () => {
// An interrupted lead suppresses the gate while leaving the latch set, so a row can read `done`
// with a latch that would re-gate `working` on the pane's very next event.
const server = await startServer()
try {
claudeRow(server, 'done')
server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.add(PANE)
server._getStateForTests().claudeActiveSessionCronPaneKeys.add(PANE)
expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(1)
expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(false)
expect(server._getStateForTests().claudeActiveSessionCronPaneKeys.has(PANE)).toBe(false)
} finally {
server.stop()
}
})
it('is a no-op for a pane with nothing to retire', async () => {
const server = await startServer()
try {
expect(server.reconcileEndedProcessForPaneKeys([PANE])).toBe(0)
} finally {
server.stop()
}
})
it('drops the session owner so a rebound pane is not read as a session replacement', async () => {
const server = await startServer()
try {
claudeRow(server, 'working')
server._getStateForTests().claudeSessionOwnerByPaneKey.set(PANE, 'session-a')
server.reconcileEndedProcessForPaneKeys([PANE])
expect(server._getStateForTests().claudeSessionOwnerByPaneKey.has(PANE)).toBe(false)
} finally {
server.stop()
}
})
})
@@ -211,7 +211,10 @@ describe('restored subagent liveness sweep', () => {
}
})
it('does not probe a hydrated Claude pane without restored rows', async () => {
it('reaps a hydrated non-terminal row with no roster at all (STA-4612)', async () => {
// A restored roster is only one shape of stranded claim. A lead row left `working` with no
// children has nothing left to retire it — no child event, no inventory, no hook — so it holds
// the pane working for good and locks its agent out of hibernation.
const server = await restartWithInFlightSubagent({ subagents: [] })
const probeLiveLocalPty = vi.fn(() => false)
try {
@@ -220,9 +223,42 @@ describe('restored subagent liveness sweep', () => {
probeLiveLocalPty,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(1)
expect(probeLiveLocalPty).toHaveBeenCalled()
expect(paneStatus(server).state).toBe('missing')
} finally {
server.stop()
}
})
it('keeps a hydrated non-terminal row with no roster when its PTY is still live', async () => {
const server = await restartWithInFlightSubagent({ subagents: [] })
try {
expect(
await sweepWith(server, {
probeLiveLocalPty: () => true,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(probeLiveLocalPty).not.toHaveBeenCalled()
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('keeps a hydrated non-terminal row with no roster when the probe cannot prove either state', async () => {
const server = await restartWithInFlightSubagent({ subagents: [] })
try {
expect(
await sweepWith(server, {
probeLiveLocalPty: () => null,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
+50 -2
View File
@@ -2606,6 +2606,40 @@ export class AgentHookServer {
this.notifyStatusChangeListeners()
}
/** Retire panes whose owning process is certifiably dead.
*
* The ordinary teardown already does this: every attributable PTY exit reaches
* `clearProviderPtyState`, which resolves the pane key and calls `clearPaneState`. But that
* resolution depends on the spawn-time `ptyPaneKey` mapping, which a restored/reattached PTY may
* never rebuild — so those panes keep a `working` row and its latches for good, with no hook left
* to retire them. This is the same operation reached from the runtime's own pane-key knowledge,
* so a dead pane is cleaned up identically however its keys were resolved. */
reconcileEndedProcessForPaneKeys(paneKeys: Iterable<string>): number {
let cleared = 0
for (const paneKey of paneKeys) {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
if (!this.hasLiveClaimsForPaneKey(resolvedPaneKey)) {
continue
}
this.clearPaneState(resolvedPaneKey)
cleared += 1
}
return cleared
}
/** Anything a dead pane could still be asserting: a row, or a Claude latch that would re-gate one
* through `resolveClaudePaneState` on the pane's next event even after the row reads `done`. */
private hasLiveClaimsForPaneKey(paneKey: string): boolean {
return (
this.state.lastStatusByPaneKey.has(paneKey) ||
this.state.claudeSubagentRosterByPaneKey.has(paneKey) ||
this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) ||
this.state.claudeActiveSessionCronPaneKeys.has(paneKey) ||
this.state.claudeLeadStateByPaneKey.has(paneKey) ||
this.state.claudeSessionOwnerByPaneKey.has(paneKey)
)
}
/** Clear statuses proven to belong to one lost SSH transport. */
clearStatusEntriesForConnection(connectionId: string): void {
const normalizedConnectionId = connectionId.trim()
@@ -2636,6 +2670,7 @@ export class AgentHookServer {
this.state.claudeLeadStateByPaneKey.delete(paneKey)
this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
this.state.claudeActiveSessionCronPaneKeys.delete(paneKey)
this.state.claudeSessionOwnerByPaneKey.delete(paneKey)
}
}
}
@@ -2823,9 +2858,15 @@ export class AgentHookServer {
enriched.payload.agentType === 'claude' &&
enriched.connectionId === null &&
isLocalExecutionHost(enriched.worktreeId) &&
claudeRosterHasRestoredSnapshotSubagent(
// Why: a restored roster is only one shape of stranded claim. A lead row left non-terminal,
// or a background-task/cron latch nothing will refresh, strands the pane just as
// permanently — and unlike the roster case there is no child event left to reap it.
(claudeRosterHasRestoredSnapshotSubagent(
this.state.claudeSubagentRosterByPaneKey.get(paneKey)
) &&
) ||
enriched.payload.state !== 'done' ||
this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) ||
this.state.claudeActiveSessionCronPaneKeys.has(paneKey)) &&
!this.runtimeObservedStatusPaneKeys.has(paneKey)
) {
candidates.push({ paneKey, entry: enriched })
@@ -2853,6 +2894,13 @@ export class AgentHookServer {
continue
}
if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) {
// Why: the roster reap only speaks for restored child rows. A pane whose PTY is provably
// gone and whose claim is a lead row or a latch has nothing for it to reap, so retire the
// pane the same way an observed exit would — otherwise the widened candidate set is inert.
if (this.hasLiveClaimsForPaneKey(paneKey)) {
this.clearPaneState(paneKey)
changedPanes += 1
}
continue
}
changedPanes += 1
+3
View File
@@ -2624,6 +2624,9 @@ void app.whenReady().then(async () => {
agentHookServer.attestCompatibilityAuthority(candidate),
retireAgentHookCompatibilityAuthority: (paneKey) =>
agentHookServer.retirePaneAuthority(paneKey),
reconcileAgentStatusForEndedProcess: (paneKeys) => {
agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys)
},
canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null,
// Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only.
getAdditionalAiVaultCodexHomePaths: () =>
+4 -39
View File
@@ -6,13 +6,9 @@ import type {
} from '../../shared/agent-status-types'
import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent'
import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent'
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
import { agentHookServer } from '../agent-hooks/server'
import { ampHookService } from '../amp/hook-service'
import {
clearMigrationUnsupportedPtysByTabPrefix,
clearMigrationUnsupportedPtysForPaneKey,
getMigrationUnsupportedPtySnapshot
} from '../agent-hooks/migration-unsupported-pty-state'
import { getMigrationUnsupportedPtySnapshot } from '../agent-hooks/migration-unsupported-pty-state'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
import { geminiHookService } from '../gemini/hook-service'
@@ -27,10 +23,10 @@ import { devinHookService } from '../devin/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
import { registerAgentPaneAuthorityIpcHandlers } from './agent-pane-authority-ipc'
import { registerAgentStatusRowTeardownIpcHandlers } from './agent-status-row-teardown-ipc'
import { createAgentPaneAuthorityOwnership } from './agent-pane-authority-ownership'
import {
enrichAgentStatusIpcPayload,
isValidAgentStatusDropTabId,
type AgentStatusRuntimeEnrichment
} from './agent-status-ipc-boundary'
@@ -70,38 +66,7 @@ export function registerAgentHookHandlers(
ipcMain.removeHandler('agentStatus:inferInterrupt')
ipcMain.removeHandler('agentStatus:inferQuestionAnswered')
ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot')
// Why: agentStatus:drop is sent fire-and-forget from the renderer via
// ipcRenderer.send(); we listen with ipcMain.on (not handle) so we don't
// round-trip a response. Removing first keeps re-registration safe even
// though the module-level registered guard already prevents re-entry today.
ipcMain.removeAllListeners('agentStatus:drop')
ipcMain.removeAllListeners('agentStatus:dropByTabPrefix')
ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => {
if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) {
return
}
try {
// Why: dropStatusEntry (not clearPaneState) is correct here — the user is
// dismissing a status row, not tearing down a PTY. clearPaneState would also
// wipe the per-pane prompt/tool caches, which the next hook event for that
// (still-alive) pane needs to render a coherent row.
agentHookServer.dropStatusEntry(paneKey)
clearMigrationUnsupportedPtysForPaneKey(paneKey)
} catch (err) {
console.warn('[agent-hooks] dropStatusEntry failed:', err)
}
})
ipcMain.on('agentStatus:dropByTabPrefix', (_event, tabId: unknown) => {
if (!isValidAgentStatusDropTabId(tabId)) {
return
}
try {
agentHookServer.dropStatusEntriesByTabPrefix(tabId)
clearMigrationUnsupportedPtysByTabPrefix(tabId)
} catch (err) {
console.warn('[agent-hooks] dropStatusEntriesByTabPrefix failed:', err)
}
})
registerAgentStatusRowTeardownIpcHandlers()
registerAgentPaneAuthorityIpcHandlers({
ownsPty: createAgentPaneAuthorityOwnership({
getPtyIdForPaneKey: dependencies.getPtyIdForPaneKey,
@@ -0,0 +1,64 @@
import { ipcMain } from 'electron'
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
import {
clearMigrationUnsupportedPtysByTabPrefix,
clearMigrationUnsupportedPtysForPaneKey
} from '../agent-hooks/migration-unsupported-pty-state'
import { isValidAgentStatusDropTabId } from './agent-status-ipc-boundary'
/**
* The three renderer-initiated ways a status row goes away. All fire-and-forget
* (`ipcRenderer.send` → `ipcMain.on`), so none round-trips a response; removing the
* listeners first keeps re-registration safe.
*
* They are NOT interchangeable. A dismissal keeps the pane's per-pane caches because its agent may
* still be alive; a confirmed process exit must take them too, or a surviving Claude latch resolves
* the pane's next event straight back to `working`.
*/
export function registerAgentStatusRowTeardownIpcHandlers(): void {
ipcMain.removeAllListeners('agentStatus:drop')
ipcMain.removeAllListeners('agentStatus:reconcileEndedProcess')
ipcMain.removeAllListeners('agentStatus:dropByTabPrefix')
ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => {
if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) {
return
}
try {
// Why: dropStatusEntry (not clearPaneState) is correct here — the user is
// dismissing a status row, not tearing down a PTY. clearPaneState would also
// wipe the per-pane prompt/tool caches, which the next hook event for that
// (still-alive) pane needs to render a coherent row.
agentHookServer.dropStatusEntry(paneKey)
clearMigrationUnsupportedPtysForPaneKey(paneKey)
} catch (err) {
console.warn('[agent-hooks] dropStatusEntry failed:', err)
}
})
ipcMain.on('agentStatus:reconcileEndedProcess', (_event, paneKey: unknown) => {
if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) {
return
}
try {
// Why: a process-table-confirmed agent exit is exactly the case the dismissal above excludes
// — the pane's agent is NOT still alive — so its latches must go with the row (STA-4612).
agentHookServer.reconcileEndedProcessForPaneKeys([paneKey])
clearMigrationUnsupportedPtysForPaneKey(paneKey)
} catch (err) {
console.warn('[agent-hooks] reconcileEndedProcessForPaneKeys failed:', err)
}
})
ipcMain.on('agentStatus:dropByTabPrefix', (_event, tabId: unknown) => {
if (!isValidAgentStatusDropTabId(tabId)) {
return
}
try {
agentHookServer.dropStatusEntriesByTabPrefix(tabId)
clearMigrationUnsupportedPtysByTabPrefix(tabId)
} catch (err) {
console.warn('[agent-hooks] dropStatusEntriesByTabPrefix failed:', err)
}
})
}
@@ -289,7 +289,9 @@ describe('registerPtyHandlers', () => {
await Promise.resolve()
expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, {
providerExitObserved: true
})
expect(
mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')
).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]])
@@ -342,7 +344,9 @@ describe('registerPtyHandlers', () => {
await expect(stopPromise).resolves.toBe(true)
expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, {
providerExitObserved: true
})
expect(
mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')
).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]])
@@ -436,7 +436,9 @@ describe('registerPtyHandlers', () => {
await Promise.resolve()
expect(killSpy).toHaveBeenCalled()
expect(runtime.onPtyExit).toHaveBeenCalledWith(spawnResult.id, -1, spawnResult.incarnationId)
expect(runtime.onPtyExit).toHaveBeenCalledWith(spawnResult.id, -1, spawnResult.incarnationId, {
providerExitObserved: true
})
const listed = await getLocalPtyProvider().listProcesses()
expect(listed.some((info) => info.id === spawnResult.id)).toBe(false)
})
@@ -284,7 +284,9 @@ describe('registerPtyHandlers', () => {
await handlers.get('pty:kill')!(null, { id: 'local-pty' })
expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined)
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0, undefined, {
providerExitObserved: true
})
expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual(
[['pty:exit', { id: 'local-pty', code: 0 }]]
)
@@ -526,7 +528,9 @@ describe('registerPtyHandlers', () => {
seq: 13,
rawLength: 'daemon output'.length
})
expect(runtime.onPtyExit).toHaveBeenCalledWith(result.id, 0, undefined)
expect(runtime.onPtyExit).toHaveBeenCalledWith(result.id, 0, undefined, {
providerExitObserved: true
})
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:exit', {
id: result.id,
code: 0
+8 -2
View File
@@ -2599,7 +2599,9 @@ export function registerPtyHandlers(
clearProviderPtyState(id)
ptyOwnership.delete(id)
markClaudePtyExited(id)
runtime?.onPtyExit(id, code, incarnationId)
// Why: the provider's physical exit callback is a death certificate even when node-pty
// reports the code as -1; only the synthesized failed/unverified stop calls omit this.
runtime?.onPtyExit(id, code, incarnationId, { providerExitObserved: true })
},
onData: (id, data, timestamp, sequenceChars, transformed) =>
runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed)
@@ -4144,7 +4146,11 @@ export function registerPtyHandlers(
clearProviderPtyState(payload.id)
ptyOwnership.delete(payload.id)
markClaudePtyExited(payload.id)
runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId)
// Why: same physical-exit certificate as the LocalPtyProvider branch above — this is the
// daemon/WSL callback, and on macOS it is the branch that actually runs.
runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId, {
providerExitObserved: true
})
}
sendPtyExitToRenderer(payload)
})
+46 -13
View File
@@ -3594,6 +3594,9 @@ export class OrcaRuntimeService {
}) => AgentHookAuthorityAttestation | null)
| null
private readonly retireAgentHookCompatibilityAuthorityFn: ((paneKey: string) => void) | null
private readonly reconcileAgentStatusForEndedProcessFn:
| ((paneKeys: Iterable<string>) => void)
| null
private readonly canRecoverPersistentLocalPtysFn: () => boolean
private readonly buildAgentHookPtyEnv: (() => Record<string, string>) | null
private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus
@@ -3674,6 +3677,7 @@ export class OrcaRuntimeService {
terminalProvenance: 'current_runtime' | 'restored'
}) => AgentHookAuthorityAttestation | null
retireAgentHookCompatibilityAuthority?: (paneKey: string) => void
reconcileAgentStatusForEndedProcess?: (paneKeys: Iterable<string>) => void
canRecoverPersistentLocalPtys?: () => boolean
// Why: codex-home paths for the Agent Session History scan must be sourced
// here, not via the window-only registerCoreHandlers path — that path never
@@ -3717,6 +3721,7 @@ export class OrcaRuntimeService {
deps?.attestAgentHookCompatibilityAuthority ?? null
this.retireAgentHookCompatibilityAuthorityFn =
deps?.retireAgentHookCompatibilityAuthority ?? null
this.reconcileAgentStatusForEndedProcessFn = deps?.reconcileAgentStatusForEndedProcess ?? null
this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true)
// Why: configure the shared AiVault scan cache from a serve-mode-reachable
// seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions
@@ -13781,6 +13786,27 @@ export class OrcaRuntimeService {
}
}
/** Every pane key this PTY could be addressed by. Independent of launch authority: an ordinary
* restored PTY has neither a launch token nor a receipt, and those are exactly the panes whose
* spawn-time `ptyPaneKey` mapping teardown could not resolve. */
private collectPaneKeysForPty(ptyId: string): Set<string> {
const paneKeys = new Set<string>()
const pty = this.ptysById.get(ptyId)
if (pty?.paneKey && parsePaneKey(pty.paneKey)) {
paneKeys.add(pty.paneKey)
}
const receipt = this.restoredOrchestrationAuthorityByPtyId.get(ptyId)
if (receipt?.paneKey && parsePaneKey(receipt.paneKey)) {
paneKeys.add(receipt.paneKey)
}
for (const leaf of this.getLeavesForPty(ptyId)) {
if (isValidTerminalTabId(leaf.tabId) && isTerminalLeafId(leaf.leafId)) {
paneKeys.add(makePaneKey(leaf.tabId, leaf.leafId))
}
}
return paneKeys
}
private retirePtyAgentLaunchAuthority(ptyId: string): void {
const pty = this.ptysById.get(ptyId)
if (!pty) {
@@ -13790,21 +13816,10 @@ export class OrcaRuntimeService {
if (!pty.launchToken && !receipt) {
return
}
const paneKeys = this.collectPaneKeysForPty(ptyId)
this.restoredOrchestrationAuthorityByPtyId.delete(ptyId)
pty.launchToken = null
pty.launchIncarnationId = null
const paneKeys = new Set<string>()
if (pty.paneKey && parsePaneKey(pty.paneKey)) {
paneKeys.add(pty.paneKey)
}
if (receipt?.paneKey && parsePaneKey(receipt.paneKey)) {
paneKeys.add(receipt.paneKey)
}
for (const leaf of this.getLeavesForPty(ptyId)) {
if (isValidTerminalTabId(leaf.tabId) && isTerminalLeafId(leaf.leafId)) {
paneKeys.add(makePaneKey(leaf.tabId, leaf.leafId))
}
}
for (const paneKey of paneKeys) {
this.retireAgentHookCompatibilityAuthorityFn?.(paneKey)
}
@@ -15024,7 +15039,13 @@ export class OrcaRuntimeService {
ptyId: string,
exitCode: number,
exitIncarnationId?: PtyIncarnationId,
options?: { hostExitConfirmed?: boolean }
options?: {
hostExitConfirmed?: boolean
/** The provider's own physical-exit callback fired. Separate from `hostExitConfirmed`, which
* also drives the SSH surface decision and the liveness verdict: node-pty reports real exits
* as -1, so the numeric code alone cannot tell a dead process from a failed stop. */
providerExitObserved?: boolean
}
): void {
const pty = this.ptysById.get(ptyId)
if (exitIncarnationId && pty?.incarnationId && exitIncarnationId !== pty.incarnationId) {
@@ -15035,6 +15056,9 @@ export class OrcaRuntimeService {
pty?.connectionId != null &&
exitCode < 0 &&
options?.hostExitConfirmed !== true
// Why: collect before retirePtyAgentLaunchAuthority, which deletes the restored-authority
// receipt a receipt-only pane's key comes from.
const exitPaneKeys = this.collectPaneKeysForPty(ptyId)
if (preservesAbnormalSshSurface) {
if (this.getPtyLivenessVerdict(ptyId)?.status !== 'unverifiable') {
this.markPtyLivenessUnverifiable(ptyId, SSH_EXIT_UNCONFIRMED_REASON)
@@ -15043,6 +15067,15 @@ export class OrcaRuntimeService {
} else {
this.retirePtyAgentLaunchAuthority(ptyId)
}
// Why: a synthetic -1 from a failed or unverified stop is not a death certificate (the PTY can
// have survived it), while a real exit can also report -1 — so neither the numeric code nor the
// SSH surface predicate is sufficient on its own. Decided separately from
// preservesAbnormalSshSurface, which a host-confirmed negative SSH exit would otherwise skip.
const processDeathCertified =
exitCode >= 0 || options?.hostExitConfirmed === true || options?.providerExitObserved === true
if (processDeathCertified && exitPaneKeys.size > 0) {
this.reconcileAgentStatusForEndedProcessFn?.(exitPaneKeys)
}
const incarnationId =
exitIncarnationId ??
pty?.incarnationId ??
@@ -0,0 +1,71 @@
/**
* A PTY that exits without a resolvable spawn-time pane key never reaches
* `agentHookServer.clearPaneState`, so its row and every Claude latch outlive the process with
* nothing left to retire them (STA-4612). `onPtyExit` knows the pane keys that teardown could not
* resolve, so it reconciles them — but only on a real death certificate.
*/
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import { makePaneKey } from '../../shared/stable-pane-id'
const LEAF = '11111111-1111-4111-8111-111111111111'
const PANE = makePaneKey('tab-1', LEAF)
const PTY = 'wt-1__pty-1'
type RuntimeInternals = {
ptysById: Map<string, { ptyId: string; paneKey: string; worktreeId: string; tabId: string }>
}
function runtimeWithBoundPane(
reconcile: (paneKeys: Iterable<string>) => void,
options: { connectionId?: string } = {}
): OrcaRuntimeService {
const runtime = new OrcaRuntimeService(null, undefined, {
reconcileAgentStatusForEndedProcess: reconcile
})
const ptys = (runtime as unknown as RuntimeInternals).ptysById
ptys.set(PTY, {
ptyId: PTY,
paneKey: PANE,
worktreeId: 'wt-1',
tabId: 'tab-1',
...(options.connectionId ? { connectionId: options.connectionId } : {})
} as never)
return runtime
}
function reconciledPaneKeys(calls: Iterable<string>[]): string[] {
return calls.flatMap((keys) => [...keys])
}
describe('onPtyExit agent-status reconciliation', () => {
it('reconciles on a normal zero exit code', () => {
const reconcile = vi.fn()
runtimeWithBoundPane(reconcile).onPtyExit(PTY, 0)
expect(reconciledPaneKeys(reconcile.mock.calls.map(([keys]) => keys))).toContain(PANE)
})
it('reconciles a physical -1 exit reported by the provider callback', () => {
// node-pty forwards real exits as -1 (local-pty-provider-shutdown.test.ts pins this), so the
// numeric code alone cannot separate a dead process from a failed stop.
const reconcile = vi.fn()
runtimeWithBoundPane(reconcile).onPtyExit(PTY, -1, undefined, { providerExitObserved: true })
expect(reconciledPaneKeys(reconcile.mock.calls.map(([keys]) => keys))).toContain(PANE)
})
it('reconciles a host-confirmed negative exit', () => {
const reconcile = vi.fn()
runtimeWithBoundPane(reconcile).onPtyExit(PTY, -1, undefined, { hostExitConfirmed: true })
expect(reconciledPaneKeys(reconcile.mock.calls.map(([keys]) => keys))).toContain(PANE)
})
it('does NOT reconcile a synthetic -1 from a failed stop, whose PTY may have survived', () => {
const reconcile = vi.fn()
runtimeWithBoundPane(reconcile).onPtyExit(PTY, -1)
expect(reconcile).not.toHaveBeenCalled()
})
})
+3
View File
@@ -30,6 +30,9 @@ export type AgentStatusApi = {
getMigrationUnsupportedSnapshot: () => Promise<MigrationUnsupportedPtyEntry[]>
/** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */
drop: (paneKey: string) => void
/** Retire a pane whose agent process is proven gone — clears the row AND the per-pane caches a
* dismissal deliberately keeps. Not `drop`: that one is a user dismissal of a live pane's row. */
reconcileEndedProcess: (paneKey: string) => void
/** Drop every cached hook status under one terminal tab prefix. Fire-and-forget. */
dropByTabPrefix: (tabId: string) => void
/** Permanently retire one pane's hook authority while siblings stay live. */
+3
View File
@@ -5025,6 +5025,9 @@ const api = {
drop: (paneKey: string): void => {
ipcRenderer.send('agentStatus:drop', paneKey)
},
reconcileEndedProcess: (paneKey: string): void => {
ipcRenderer.send('agentStatus:reconcileEndedProcess', paneKey)
},
/** Drop all cached hook statuses under one terminal tab prefix; fired on explicit tab close even without a local row. */
dropByTabPrefix: (tabId: string): void => {
ipcRenderer.send('agentStatus:dropByTabPrefix', tabId)
@@ -328,6 +328,43 @@ describe('connectPanePty', () => {
shellForeground: true
})
expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter')
// Why: `agentStatus:drop` deliberately preserves a live pane's per-pane caches, so a
// process-confirmed agent exit must go through the reconcile route instead — otherwise a
// surviving Claude latch resolves the pane's next event straight back to 'working' (STA-4612).
expect(window.api.agentStatus.reconcileEndedProcess).toHaveBeenCalledWith(paneKey)
})
it('does NOT reconcile when the process check could not confirm a shell', async () => {
vi.useFakeTimers()
const { connectPanePty } = await import('./pty-connection')
vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null)
vi.mocked(window.api.agentStatus.reconcileEndedProcess).mockClear()
const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null }
const ptyId = 'pty-unconfirmed-shell'
const transport = createMockTransport(ptyId)
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
dataCallbackRef.current = callbacks.onData ?? null
return { id: ptyId }
})
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
connectPanePty(
createPane(1) as never,
createManager(1) as never,
createDeps({ isVisibleRef: { current: false } }) as never
)
await vi.advanceTimersByTimeAsync(20)
await flushAsyncTicks()
mockStoreState.agentLaunchConfigByPaneKey[paneKey] = {
launchConfig: { agentArgs: '', agentEnv: {} },
identity: { agentType: 'droid' }
}
dataCallbackRef.current?.('\x1b]133;D;0\x07')
await vi.advanceTimersByTimeAsync(350)
expect(window.api.agentStatus.reconcileEndedProcess).not.toHaveBeenCalled()
})
it('disarms stale TUI modes in the emulator after a confirmed return to shell', async () => {
@@ -72,7 +72,8 @@ export function installTerminalTestGlobals(): void {
restoreTerminalFit: vi.fn().mockResolvedValue({ restored: true })
},
agentStatus: {
inferInterrupt: vi.fn().mockResolvedValue(false)
inferInterrupt: vi.fn().mockResolvedValue(false),
reconcileEndedProcess: vi.fn()
}
},
dispatchEvent: vi.fn(),
@@ -6,6 +6,7 @@ import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anc
import { installTerminalImeCompositionRoute } from './terminal-ime-composition-route'
import { detectAgentStatusFromTitle, agentTypeToIconAgent, isClaudeAgent } from '@/lib/agent-status'
import { reportWorkerTerminalUserInput } from '@/lib/worker-terminal-takeover-report'
import { getAcceptedAgentStatusGeneration } from '@/store/slices/agent-status'
import { resolvePaneTitleDecision } from './terminal-title-evidence'
import { blocksCodexPaneInput } from '../codex-restart-notice-state'
import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership'
@@ -1930,6 +1931,25 @@ export function connectPanePty(
return false
})
})
/** The pane's foreground was proven to be a shell, so its agent exited. Unlike a user dismissal,
* this must also retire the main-side per-pane caches — a surviving Claude latch resolves the
* next event straight back to `working`.
*
* Ordered by the pane's accepted-status generation rather than by row identity: the confirming
* process read can take seconds, and an unrelated field moving in that window is not evidence the
* agent is alive — while a genuinely newer row can share the anchor's millisecond, so a timestamp
* cannot separate the two. A relaunch inside the window is already handled by onCommandStarted,
* which discards the armed drop. */
const reconcileEndedProcessIfPaneQuiet = (armedGeneration: number): void => {
if (getAcceptedAgentStatusGeneration(cacheKey) !== armedGeneration) {
return
}
// Why: main-side only. The renderer row and launch config are already owned by the deferred
// drop above; what that path cannot reach is the hook server's per-pane Claude latches, which
// `agentStatus:drop` deliberately preserves for a still-live pane. Main echoes its own clear
// back through the pane-status-cleared channel, so both sides stay consistent.
window.api?.agentStatus?.reconcileEndedProcess?.(cacheKey)
}
const dropCommandFinishedStatusIfSameTurn = (
entry: AgentStatusEntry | undefined,
options?: { allowInferredInterrupt?: boolean }
@@ -2107,12 +2127,23 @@ export function connectPanePty(
}
}
let deferredCommandFinishedStatusDrop: (() => void) | null = null
/** Armed alongside the deferred drop; runs only on a CONFIRMED shell foreground, where the
* process table proved the agent gone. The unavailable-inspection path deliberately does not
* reconcile — no proof, no teardown. */
let deferredConfirmedShellReconcile: (() => void) | null = null
let visibleForegroundSamplePending = false
let visibleForegroundSampleSettled = false
const settleDeferredCommandFinishedStatusDrop = (): void => {
const settleDeferredCommandFinishedStatusDrop = (
options: { confirmedShell?: boolean } = {}
): void => {
const dropStatus = deferredCommandFinishedStatusDrop
const reconcile = deferredConfirmedShellReconcile
deferredCommandFinishedStatusDrop = null
deferredConfirmedShellReconcile = null
dropStatus?.()
if (options.confirmedShell) {
reconcile?.()
}
}
const isForegroundTrackingAllowed = (id: string): boolean => {
if (isRemoteRuntimePtyId(id) || parseAppSshPtyId(id) !== null) {
@@ -2159,9 +2190,11 @@ export function connectPanePty(
useAppStore.getState().clearAgentLaunchConfig(cacheKey)
return
}
settleDeferredCommandFinishedStatusDrop()
settleDeferredCommandFinishedStatusDrop({ confirmedShell: true })
},
onCommandFinishedUnavailable: settleDeferredCommandFinishedStatusDrop,
// Why: inspection that could not answer is not shell proof — settle the drop the pane already
// armed, but never tear the pane's caches down on it.
onCommandFinishedUnavailable: () => settleDeferredCommandFinishedStatusDrop(),
onVisibleForegroundSettled: (outcome) => {
visibleForegroundSamplePending = false
visibleForegroundSampleSettled = outcome !== 'inconclusive'
@@ -2199,9 +2232,14 @@ export function connectPanePty(
if (shouldDeferStatusDrop) {
// Why: keep the concrete pane identity routable while the local process
// check distinguishes a leaked nested-shell D from a genuine agent exit.
// Anchor the generation once, here — not per rung of the confirm ladder, which can span
// seconds — so the gate tolerates churn across the whole window.
const armedGeneration = getAcceptedAgentStatusGeneration(cacheKey)
deferredCommandFinishedStatusDrop = dropStatus
deferredConfirmedShellReconcile = () => reconcileEndedProcessIfPaneQuiet(armedGeneration)
return
}
deferredConfirmedShellReconcile = null
deferredCommandFinishedStatusDrop = null
dropStatus()
}
@@ -2266,6 +2304,7 @@ export function connectPanePty(
// Why: a new command invalidates cleanup waiting on the previous D; only
// a later confirmed shell boundary may retire this pane's live identity.
deferredCommandFinishedStatusDrop = null
deferredConfirmedShellReconcile = null
visibleForegroundSamplePending = false
visibleForegroundSampleSettled = false
// Why: typed commands can be aliases, so they only widen the bounded
@@ -9551,6 +9590,7 @@ export function connectPanePty(
}
commandLifecycle.dispose()
deferredCommandFinishedStatusDrop = null
deferredConfirmedShellReconcile = null
visibleForegroundSamplePending = false
visibleForegroundSampleSettled = false
paneForegroundAgentTracker.dispose()
@@ -1290,6 +1290,28 @@ function buildAgentStatusBatchPatch(
return patch as Partial<AppState>
}
/** Per-pane counter of ACCEPTED status writes. Deliberately module-local, not store state: nothing
* renders from it, and it exists only so a deferred drop can ask "did this pane report again while
* I was confirming?" — a question `updatedAt` cannot answer, since the accept rule admits equal
* timestamps and a real event can share the anchor's millisecond. */
const acceptedAgentStatusGenerationByPaneKey = new Map<string, number>()
function bumpAcceptedAgentStatusGeneration(paneKey: string): void {
acceptedAgentStatusGenerationByPaneKey.set(
paneKey,
(acceptedAgentStatusGenerationByPaneKey.get(paneKey) ?? 0) + 1
)
}
/** Reads 0 for a pane that has never reported, so an armed drop compares equal rather than undefined. */
export function getAcceptedAgentStatusGeneration(paneKey: string): number {
return acceptedAgentStatusGenerationByPaneKey.get(paneKey) ?? 0
}
export function forgetAcceptedAgentStatusGeneration(paneKey: string): void {
acceptedAgentStatusGenerationByPaneKey.delete(paneKey)
}
export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusSlice> = (
storeSet,
storeGet
@@ -1974,6 +1996,10 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
if (existing && updatedAt < existing.updatedAt) {
return s
}
// Why: `updatedAt` cannot order two writes inside one millisecond — and this very check
// admits equal timestamps — so a deferred process-exit drop needs a token that is ordered by
// construction to tell "the pane reported again" from "an unrelated field moved".
bumpAcceptedAgentStatusGeneration(paneKey)
// Why: terminalTitle labels the pane itself, not the turn, so a missing title means "no update" —
// preserve the prior value to avoid flicker (unlike tool/prompt fields, which clear on a fresh turn).
const effectiveTitle = terminalTitle ?? existing?.terminalTitle
@@ -2704,6 +2730,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
if (typeof window !== 'undefined') {
window.api?.agentStatus?.drop?.(paneKey)
}
forgetAcceptedAgentStatusGeneration(paneKey)
},
dropAgentStatusByTabPrefix: (tabIdPrefix, opts) => {
+1
View File
@@ -940,6 +940,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
onLegacyWorkerTerminalRecovery: () => noopUnsubscribe,
getMigrationUnsupportedSnapshot: () => Promise.resolve([]),
drop: () => {},
reconcileEndedProcess: () => {},
dropByTabPrefix: () => {},
retirePaneAuthority: () => {},
restorePaneAuthority: () => {},
@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest'
import {
createHookListenerState,
normalizeHookPayload,
type HookListenerState
} from './agent-hook-listener'
import { makePaneKey } from './stable-pane-id'
const LEAF_ID = '44444444-4444-4444-8444-444444444444'
const SESSION_A = 'session-a'
const SESSION_B = 'session-b'
function claudeEvent(
state: HookListenerState,
paneKey: string,
payload: Record<string, unknown>
): ReturnType<typeof normalizeHookPayload> {
return normalizeHookPayload(state, 'claude', { paneKey, payload }, 'production')
}
/** Close a turn for `sessionId`, carrying whatever inventory the caller wants folded. */
function stop(
state: HookListenerState,
paneKey: string,
sessionId: string,
extra: Record<string, unknown> = {}
): ReturnType<typeof normalizeHookPayload> {
return claudeEvent(state, paneKey, {
hook_event_name: 'Stop',
session_id: sessionId,
...extra
})
}
// A replacement session is the only evidence Orca gets for the exits Claude emits no terminating
// hook for (/clear, relaunch, resume). See PLAN-STA-4612 §4.1.
describe('Claude session replacement voids the replaced session claims', () => {
it('voids a session cron gate held by the previous session', () => {
const state = createHookListenerState()
const paneKey = makePaneKey('cron-gate', LEAF_ID)
stop(state, paneKey, SESSION_A, { session_crons: [{ id: 'cron-1' }] })
expect(state.claudeActiveSessionCronPaneKeys.has(paneKey)).toBe(true)
const replaced = stop(state, paneKey, SESSION_B)
expect(state.claudeActiveSessionCronPaneKeys.has(paneKey)).toBe(false)
expect(replaced?.payload.state).toBe('done')
})
it('voids a one-shot subagent the previous session was still running', () => {
const state = createHookListenerState()
const paneKey = makePaneKey('roster-void', LEAF_ID)
claudeEvent(state, paneKey, {
hook_event_name: 'UserPromptSubmit',
session_id: SESSION_A,
prompt: 'go'
})
claudeEvent(state, paneKey, {
hook_event_name: 'SubagentStart',
session_id: SESSION_A,
agent_id: 'achild-0000000000000001'
})
expect(state.claudeSubagentRosterByPaneKey.get(paneKey)?.size).toBe(1)
const replaced = stop(state, paneKey, SESSION_B)
expect(state.claudeSubagentRosterByPaneKey.has(paneKey)).toBe(false)
expect(replaced?.payload.state).toBe('done')
expect(replaced?.payload.subagents ?? []).toEqual([])
})
it('keeps a confirmed teammate across the replacement — a lead swap cannot end an in-process teammate', () => {
const state = createHookListenerState()
const paneKey = makePaneKey('teammate-kept', LEAF_ID)
claudeEvent(state, paneKey, {
hook_event_name: 'SubagentStart',
session_id: SESSION_A,
agent_id: 'arev-0000000000000001',
agent_type: 'rev'
})
claudeEvent(state, paneKey, {
hook_event_name: 'TeammateIdle',
session_id: SESSION_A,
teammate_name: 'rev'
})
claudeEvent(state, paneKey, {
hook_event_name: 'SubagentStart',
session_id: SESSION_A,
agent_id: 'arev-0000000000000001',
agent_type: 'rev'
})
const tracked = state.claudeSubagentRosterByPaneKey.get(paneKey)?.get('arev-0000000000000001')
expect(tracked?.confirmedTeammate).toBe(true)
expect(tracked?.state).toBe('working')
stop(state, paneKey, SESSION_B)
expect(
state.claudeSubagentRosterByPaneKey.get(paneKey)?.get('arev-0000000000000001')?.state
).toBe('working')
})
it('KEEPS a running background-shell gate across the replacement', () => {
// /clear keeps the Claude process alive, so a background shell can outlive the conversation and
// the previous inventory is positive evidence it was running. Retiring it here would decide the
// open live-shell product question inside this change. See PLAN-STA-4612 §6.3.
const state = createHookListenerState()
const paneKey = makePaneKey('bg-gate-kept', LEAF_ID)
stop(state, paneKey, SESSION_A, {
background_tasks: [{ type: 'bash', status: 'running', id: 'bash_1' }]
})
expect(state.claudeRunningNonAgentTaskPaneKeys.has(paneKey)).toBe(true)
const replaced = claudeEvent(state, paneKey, {
hook_event_name: 'PostToolUse',
session_id: SESSION_B
})
expect(state.claudeRunningNonAgentTaskPaneKeys.has(paneKey)).toBe(true)
expect(replaced?.payload.state).toBe('working')
})
it('voids nothing when the session id is unchanged', () => {
const state = createHookListenerState()
const paneKey = makePaneKey('same-session', LEAF_ID)
stop(state, paneKey, SESSION_A, { session_crons: [{ id: 'cron-1' }] })
const same = stop(state, paneKey, SESSION_A, { session_crons: [{ id: 'cron-1' }] })
expect(state.claudeActiveSessionCronPaneKeys.has(paneKey)).toBe(true)
expect(same?.payload.state).toBe('working')
})
it('voids nothing when a CHILD event carries a foreign session id', () => {
// Claude has no equivalent of the Codex child-session guard, so a child payload's own
// session_id reaches the same extraction path as the lead's.
const state = createHookListenerState()
const paneKey = makePaneKey('child-session', LEAF_ID)
stop(state, paneKey, SESSION_A, { session_crons: [{ id: 'cron-1' }] })
claudeEvent(state, paneKey, {
hook_event_name: 'PostToolUse',
session_id: 'child-session',
agent_id: 'achild-0000000000000009'
})
expect(state.claudeActiveSessionCronPaneKeys.has(paneKey)).toBe(true)
expect(state.claudeSessionOwnerByPaneKey.get(paneKey)).toBe(SESSION_A)
})
it('voids nothing when the event carries no session id', () => {
const state = createHookListenerState()
const paneKey = makePaneKey('no-session', LEAF_ID)
stop(state, paneKey, SESSION_A, { session_crons: [{ id: 'cron-1' }] })
stop(state, paneKey, '')
expect(state.claudeActiveSessionCronPaneKeys.has(paneKey)).toBe(true)
expect(state.claudeSessionOwnerByPaneKey.get(paneKey)).toBe(SESSION_A)
})
it('leaves the lead-state record to the incoming fold rather than deleting it', () => {
const state = createHookListenerState()
const paneKey = makePaneKey('lead-untouched', LEAF_ID)
stop(state, paneKey, SESSION_A)
claudeEvent(state, paneKey, {
hook_event_name: 'UserPromptSubmit',
session_id: SESSION_B,
prompt: 'new conversation'
})
expect(state.claudeLeadStateByPaneKey.get(paneKey)?.state).toBe('working')
})
})
+82
View File
@@ -142,6 +142,10 @@ export type HookListenerState = {
claudeRunningNonAgentTaskPaneKeys: Set<string>
/** Panes whose latest authoritative Claude cron inventory still has a scheduled job. */
claudeActiveSessionCronPaneKeys: Set<string>
/** Claude `session_id` that last reported on the pane from a LEAD event. A different id means the
* conversation was replaced (/clear, relaunch, resume), so claims the old session owned are void
* even when no SessionStart arrives — the backstop for the exits that emit no terminating hook. */
claudeSessionOwnerByPaneKey: Map<string, string>
/** Live thread-spawn children per Codex pane. */
codexSubagentRosterByPaneKey: Map<string, CodexSubagentRoster>
/** Incremental parent/child rollout cursors for Codex collaboration v2. */
@@ -182,6 +186,7 @@ export function createHookListenerState(): HookListenerState {
claudeUnconfirmedRestoredStatusPaneKeys: new Set(),
claudeRunningNonAgentTaskPaneKeys: new Set(),
claudeActiveSessionCronPaneKeys: new Set(),
claudeSessionOwnerByPaneKey: new Map(),
codexSubagentRosterByPaneKey: new Map(),
codexSubagentTranscriptByPaneKey: new Map(),
codexLeadStateByPaneKey: new Map()
@@ -199,6 +204,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string):
state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey)
state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
state.claudeActiveSessionCronPaneKeys.delete(paneKey)
state.claudeSessionOwnerByPaneKey.delete(paneKey)
state.codexSubagentRosterByPaneKey.delete(paneKey)
state.codexSubagentTranscriptByPaneKey.delete(paneKey)
state.codexLeadStateByPaneKey.delete(paneKey)
@@ -246,6 +252,7 @@ export function movePaneCacheState(
movePaneScopedSetEntries(state.claudeUnconfirmedRestoredStatusPaneKeys, fromPaneKey, toPaneKey)
movePaneScopedSetEntries(state.claudeRunningNonAgentTaskPaneKeys, fromPaneKey, toPaneKey)
movePaneScopedSetEntries(state.claudeActiveSessionCronPaneKeys, fromPaneKey, toPaneKey)
movePaneScopedMapEntries(state.claudeSessionOwnerByPaneKey, fromPaneKey, toPaneKey)
movePaneScopedMapEntries(state.codexSubagentRosterByPaneKey, fromPaneKey, toPaneKey)
movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey)
movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey)
@@ -291,6 +298,7 @@ export function clearAllListenerCaches(state: HookListenerState): void {
state.claudeUnconfirmedRestoredStatusPaneKeys.clear()
state.claudeRunningNonAgentTaskPaneKeys.clear()
state.claudeActiveSessionCronPaneKeys.clear()
state.claudeSessionOwnerByPaneKey.clear()
state.codexSubagentRosterByPaneKey.clear()
state.codexSubagentTranscriptByPaneKey.clear()
state.codexLeadStateByPaneKey.clear()
@@ -2627,6 +2635,9 @@ function updateClaudeRunningNonAgentTask(
state: HookListenerState,
paneKey: string,
hasRunningNonAgentTask: boolean,
/** Lead-turn property. Pass `false` from any non-lead fold: an interrupt clears the gate even when
* the inventory positively reports a running shell, which is a live-shell judgement no new call
* site may inherit by copying this signature. */
interrupted: boolean
): void {
if (hasRunningNonAgentTask && !interrupted) {
@@ -2910,6 +2921,76 @@ function buildClaudeCachedLeadStatusPayload(
})
}
/** Lead events that may re-anchor a pane's owning session. Allow-list, not a deny-list: a payload we
* can't attribute (unknown name, child event missing its agent_id) must void nothing. */
const CLAUDE_SESSION_OWNER_EVENTS: ReadonlySet<string> = new Set([
'SessionStart',
'UserPromptSubmit',
'Stop',
'StopFailure',
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'PermissionRequest'
])
/** A pane whose `session_id` changed is running a different conversation, so claims the previous one
* owned are void — the hook-independent backstop for /clear, relaunch and resume, which emit no
* terminating hook (SessionEnd covers about a third of exit paths).
*
* Voids only what the replaced session provably owned. Deliberately NOT voided:
* - `claudeRunningNonAgentTaskPaneKeys`: a background shell is an OS process that survives /clear,
* and the previous inventory is positive evidence it was running. Only a fresh inventory or a
* certified process death may retire it.
* - `confirmedTeammate` roster rows: persistent in-process teammates a lead replacement can't end.
* - `claudeLeadStateByPaneKey`: the caller's own fold overwrites it anyway. */
function voidClaimsOfReplacedClaudeSession(
state: HookListenerState,
eventName: unknown,
eventAgentId: string | undefined,
paneKey: string,
hookPayload: Record<string, unknown>
): void {
if (
eventAgentId !== undefined ||
typeof eventName !== 'string' ||
!CLAUDE_SESSION_OWNER_EVENTS.has(eventName)
) {
return
}
const sessionId = readString(hookPayload, 'session_id')
if (!sessionId) {
return
}
const previousOwner = state.claudeSessionOwnerByPaneKey.get(paneKey)
state.claudeSessionOwnerByPaneKey.set(paneKey, sessionId)
if (previousOwner === undefined || previousOwner === sessionId) {
return
}
// Why: a compact restart mints a SessionStart mid-turn under the same conversation; the existing
// handler already fails closed on non-idle sources, and this must not undercut it.
if (
eventName === 'SessionStart' ||
hookPayload.trigger === 'manual' ||
hookPayload.trigger === 'auto'
) {
return
}
state.claudeActiveSessionCronPaneKeys.delete(paneKey)
const roster = state.claudeSubagentRosterByPaneKey.get(paneKey)
if (!roster) {
return
}
for (const [id, tracked] of roster) {
if (tracked.confirmedTeammate !== true) {
roster.delete(id)
}
}
if (roster.size === 0) {
state.claudeSubagentRosterByPaneKey.delete(paneKey)
}
}
function normalizeClaudeEvent(
state: HookListenerState,
eventName: unknown,
@@ -2925,6 +3006,7 @@ function normalizeClaudeEvent(
) {
return normalizeClaudeSubagentLifecycleEvent(state, eventName, paneKey, hookPayload)
}
voidClaimsOfReplacedClaudeSession(state, eventName, eventAgentId, paneKey, hookPayload)
if (eventName === 'SessionStart') {
// Why: SessionStart is the only signal a resumed session emits before its first prompt
// (STA-3386). Land it as a session-boundary 'done' row: 'working' would show a phantom