fix(runtime): preserve headless authority retirement

This commit is contained in:
Brennan Benson
2026-08-23 18:37:11 -07:00
parent 1c80e0c6d0
commit eb278f7ffa
12 changed files with 98 additions and 15 deletions
@@ -1,9 +1,14 @@
import { describe, expect, it } from 'vitest'
import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
import {
CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION,
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
PROTOCOL_VERSION
} from './types'
describe('foreground-confirmation daemon protocol', () => {
it('rejects daemons from before the fresh-confirmation RPC', () => {
expect(PROTOCOL_VERSION).toBe(36)
expect(PROTOCOL_VERSION).toBe(37)
expect(CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION).toBe(37)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
@@ -6,6 +6,7 @@ import {
CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION,
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION,
CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION,
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION,
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION,
@@ -19,7 +20,8 @@ import {
describe('daemon protocol version', () => {
it('ships bounded history transfer after the 2031-unsubscribe fact', () => {
expect(PROTOCOL_VERSION).toBe(36)
expect(PROTOCOL_VERSION).toBe(37)
expect(CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION).toBe(37)
expect(CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION).toBe(36)
expect(ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION).toBe(35)
expect(CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION).toBe(34)
@@ -33,7 +35,7 @@ describe('daemon protocol version', () => {
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
Array.from({ length: 35 }, (_, index) => index + 1)
Array.from({ length: 36 }, (_, index) => index + 1)
)
})
+4 -3
View File
@@ -1,6 +1,7 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
// v36 launches shells from content-addressed wrapper trees; older owners stay attachable.
export const PROTOCOL_VERSION = 36
// v37 adds fresh foreground confirmation; older owners stay attachable without that RPC.
export const PROTOCOL_VERSION = 37
export const CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION = 37
export const CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION = 36
export const ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION = 35
export const CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION = 34
@@ -30,7 +31,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
28, 29, 30, 31, 32, 33, 34, 35
28, 29, 30, 31, 32, 33, 34, 35, 36
] as const
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
@@ -5,6 +5,7 @@ import { DaemonClient } from './client'
import { DaemonPtyAdapter, LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-adapter'
import {
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION,
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
GET_SIZE_PROTOCOL_VERSION,
PROTOCOL_VERSION
@@ -613,6 +614,19 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
legacy.dispose()
})
it('does not send fresh confirmation to a stale daemon', async () => {
const request = vi.fn(async () => ({ foregroundProcess: 'zsh' }))
const legacy = createInspectionAdapter(
CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION - 1,
request
)
await expect(legacy.confirmForegroundProcess('sess-a')).resolves.toBeNull()
expect(request).not.toHaveBeenCalled()
legacy.dispose()
})
it.each([
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION - 1
+4
View File
@@ -21,6 +21,7 @@ import { CODEX_SHELL_READY_TIMEOUT_MS } from './session-shell-ready-barrier'
import {
CLEAN_DISCONNECT_PROTOCOL_VERSION,
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION,
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
@@ -1622,6 +1623,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
async confirmForegroundProcess(id: string): Promise<string | null> {
if (this.protocolVersion < CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION) {
return null
}
try {
const result = await this.client.request<{ foregroundProcess: string | null }>(
'confirmForegroundProcess',
+12
View File
@@ -41,6 +41,7 @@ function createMockSubprocess() {
getForegroundProcess(): string | null {
return this.foregroundProcess
},
confirmForegroundProcess: undefined as (() => Promise<string | null>) | undefined,
write(data: string) {
written.push(data)
},
@@ -309,6 +310,17 @@ describe('Session', () => {
})
})
describe('foreground confirmation', () => {
it('does not fall back to cached foreground evidence', async () => {
createSession({ shellReadySupported: false })
subprocess.foregroundProcess = 'codex'
subprocess.confirmForegroundProcess = vi.fn(async () => null)
await expect(session.confirmForegroundProcess()).resolves.toBeNull()
expect(subprocess.confirmForegroundProcess).toHaveBeenCalledOnce()
})
})
describe('emulator does not reply to terminal queries', () => {
// Why: daemon emulator parses in-process synchronously — before
// handleSubprocessData forwards bytes onward — so any auto-reply it
+1 -1
View File
@@ -250,7 +250,7 @@ export class Session {
}
async confirmForegroundProcess(): Promise<string | null> {
return this.subprocess.confirmForegroundProcess?.() ?? this.subprocess.getForegroundProcess()
return (await this.subprocess.confirmForegroundProcess?.()) ?? null
}
clearScrollback(): void {
+1
View File
@@ -29,6 +29,7 @@ export {
ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION,
CLEAN_DISCONNECT_PROTOCOL_VERSION,
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION,
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
+26
View File
@@ -13314,6 +13314,32 @@ describe('OrcaRuntimeService', () => {
).toBeUndefined()
})
it('retires runtime authority in a dependency-free host without hook state', async () => {
const harness = await createCommandFinishedRuntimeHarness({
ptyId: 'pty-headless-authority',
confirmForegroundProcess: async () => 'zsh',
authorityDeps: {
captureAgentHookPaneAuthorityState: undefined,
retireAgentHookPaneAuthorityIfCurrent: undefined
}
})
vi.useFakeTimers()
try {
harness.runtime.onPtyData('pty-headless-authority', '\x1b]133;D;0\x07', 100)
await vi.advanceTimersByTimeAsync(COMMAND_FINISHED_LAUNCH_AUTHORITY_SETTLE_MS)
} finally {
vi.useRealTimers()
}
expect(harness.confirmForegroundProcess).toHaveBeenCalledOnce()
expect(
harness.runtime.getAgentStatusLaunchConfigForPaneKey(harness.paneKey, {
launchToken: harness.launchToken
})
).toBeUndefined()
})
it('conditionally retires canonical AgentHookServer authority before clearing runtime launch authority', async () => {
const spawn = vi
.fn()
+20 -6
View File
@@ -2328,7 +2328,7 @@ type CommandFinishedLaunchAuthorityAttempt = Readonly<{
launchIncarnationId: PtyIncarnationId | null
incarnationId: PtyIncarnationId | null
restoredReceipt: RestoredOrchestrationAuthorityReceipt | null
hookAuthorityState: AgentHookPaneAuthorityStateReceipt
hookAuthorityState: AgentHookPaneAuthorityStateReceipt | null
}>
type OrchestrationCompatibilitySshAttachmentAuthority = Extract<
@@ -14073,8 +14073,14 @@ export class OrcaRuntimeService {
}
const paneKey =
(pty.paneKey && parsePaneKey(pty.paneKey) ? pty.paneKey : null) ?? restoredReceipt?.paneKey
const hookAuthorityState = paneKey ? this.captureAgentHookPaneAuthorityStateFn?.(paneKey) : null
if (!hookAuthorityState) {
const hookAuthoritySeamAvailable =
this.captureAgentHookPaneAuthorityStateFn !== null &&
this.retireAgentHookPaneAuthorityIfCurrentFn !== null
const hookAuthorityState =
hookAuthoritySeamAvailable && paneKey
? (this.captureAgentHookPaneAuthorityStateFn?.(paneKey) ?? null)
: null
if (hookAuthoritySeamAvailable && !hookAuthorityState) {
return
}
const attempt: CommandFinishedLaunchAuthorityAttempt = Object.freeze({
@@ -14095,7 +14101,9 @@ export class OrcaRuntimeService {
})
if (
!this.isCommandFinishedLaunchAuthorityAttemptCurrent(ptyId, attempt) ||
!this.isAgentHookPaneAuthorityStateCurrent(attempt.hookAuthorityState)
(hookAuthoritySeamAvailable &&
(!attempt.hookAuthorityState ||
!this.isAgentHookPaneAuthorityStateCurrent(attempt.hookAuthorityState)))
) {
return
}
@@ -14111,13 +14119,19 @@ export class OrcaRuntimeService {
if (!shouldRetireLaunchAuthorityOnCommandFinished(foregroundProcess)) {
return
}
if (!this.retireAgentHookPaneAuthorityIfCurrentFn?.(attempt.hookAuthorityState)) {
if (
hookAuthoritySeamAvailable &&
(!attempt.hookAuthorityState ||
!this.retireAgentHookPaneAuthorityIfCurrentFn?.(attempt.hookAuthorityState))
) {
return
}
if (!this.isCommandFinishedLaunchAuthorityAttemptCurrent(ptyId, attempt)) {
return
}
this.retirePtyAgentLaunchAuthority(ptyId, { hookAuthorityAlreadyRetired: true })
this.retirePtyAgentLaunchAuthority(ptyId, {
hookAuthorityAlreadyRetired: hookAuthoritySeamAvailable
})
}
async resolveTerminalCwd(handle: string): Promise<string | null> {
@@ -15,4 +15,8 @@ describe('isShellProcess', () => {
it('does not classify an unknown foreground process as a shell', () => {
expect(isShellProcess('node')).toBe(false)
})
it.each(['', '/'])('does not classify %j as a shell', (processName) => {
expect(isShellProcess(processName)).toBe(false)
})
})
+1 -1
View File
@@ -2,7 +2,7 @@
// renderer (agent-ready-wait, new-workspace). A bare shell is the negative
// signal for "is an agent running" because it garbles injected preambles.
const SHELL_NAMES = new Set(
'|bash|zsh|sh|fish|cmd|cmd.exe|powershell|powershell.exe|pwsh|pwsh.exe|nu|ksh|mksh|dash|ash|tcsh|csh|elvish|xonsh'.split(
'bash|zsh|sh|fish|cmd|cmd.exe|powershell|powershell.exe|pwsh|pwsh.exe|nu|ksh|mksh|dash|ash|tcsh|csh|elvish|xonsh'.split(
'|'
)
)