mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
fix(runtime): preserve headless authority retirement
This commit is contained in:
@@ -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)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
'|'
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user