mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
fix(codex): bound user hook trust rebase retries per host
The rebase lane ran a codex app-server session on every launch prep while a host was stuck (CLI without app-server support, or keys hooks/list cannot match). Gate the transaction on the shared capability cache and add the same 5-minute transient cooldown the grant lane uses, so sweep and legacy-cleanup retries cost plain fs reads instead of a codex session per pane spawn.
This commit is contained in:
@@ -8,6 +8,8 @@ import type { CodexUserHookTrustRebaseRequest } from './codex-user-hook-trust-re
|
||||
const resolveCodexCommandMock = vi.hoisted(() => vi.fn(() => process.execPath))
|
||||
vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock }))
|
||||
|
||||
import { codexAppServerCapabilityCache } from './codex-app-server-capability-cache'
|
||||
import { CodexAppServerUnsupportedError } from './codex-app-server-session'
|
||||
import {
|
||||
_internals,
|
||||
getMovedCodexUserHookTrust,
|
||||
@@ -26,6 +28,8 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
_internals.setSessionRunnerSync(null)
|
||||
_internals.resetRetryState()
|
||||
codexAppServerCapabilityCache.clear()
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -142,6 +146,69 @@ describe('real-home user hook trust rebasing', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('marks the host unsupported and skips further codex sessions', () => {
|
||||
const orca = command('orca-hook')
|
||||
const user = command('user-hook')
|
||||
const before = { Stop: [{ hooks: [orca] }, { hooks: [user] }] }
|
||||
const after = { Stop: [{ hooks: [user] }] }
|
||||
writeFileSync(configPath, '# config\n')
|
||||
let sessions = 0
|
||||
_internals.setSessionRunnerSync(() => {
|
||||
sessions += 1
|
||||
throw new CodexAppServerUnsupportedError('unrecognized subcommand app-server')
|
||||
})
|
||||
const args = {
|
||||
sourcePath: hooksPath,
|
||||
runtimeHomePath: root,
|
||||
tomlPath: configPath,
|
||||
beforeHooks: before,
|
||||
afterHooks: after,
|
||||
writeHooks: () => {
|
||||
throw new Error('write must not run')
|
||||
},
|
||||
restoreHooks: () => {
|
||||
throw new Error('restore must not run')
|
||||
}
|
||||
}
|
||||
|
||||
expect(() => mutateRealHomeHooksPreservingUserTrust(args)).toThrow('unrecognized subcommand')
|
||||
expect(() => mutateRealHomeHooksPreservingUserTrust(args)).toThrow('marked unsupported')
|
||||
expect(sessions).toBe(1)
|
||||
expect(codexAppServerCapabilityCache.shouldTry('native')).toBe(false)
|
||||
})
|
||||
|
||||
it('cools down after a transient session failure instead of retrying every launch prep', () => {
|
||||
const orca = command('orca-hook')
|
||||
const user = command('user-hook')
|
||||
const before = { Stop: [{ hooks: [orca] }, { hooks: [user] }] }
|
||||
const after = { Stop: [{ hooks: [user] }] }
|
||||
writeFileSync(configPath, '# config\n')
|
||||
let sessions = 0
|
||||
_internals.setSessionRunnerSync(() => {
|
||||
sessions += 1
|
||||
throw new Error('pre-mutation hooks/list reported 0 of 1 moved user hooks')
|
||||
})
|
||||
const args = {
|
||||
sourcePath: hooksPath,
|
||||
runtimeHomePath: root,
|
||||
tomlPath: configPath,
|
||||
beforeHooks: before,
|
||||
afterHooks: after,
|
||||
writeHooks: () => {
|
||||
throw new Error('write must not run')
|
||||
},
|
||||
restoreHooks: () => {
|
||||
throw new Error('restore must not run')
|
||||
}
|
||||
}
|
||||
|
||||
expect(() => mutateRealHomeHooksPreservingUserTrust(args)).toThrow('0 of 1 moved user hooks')
|
||||
expect(() => mutateRealHomeHooksPreservingUserTrust(args)).toThrow('cooling down')
|
||||
expect(sessions).toBe(1)
|
||||
// Why: a transient failure must not poison the shared capability signal.
|
||||
expect(codexAppServerCapabilityCache.shouldTry('native')).toBe(true)
|
||||
})
|
||||
|
||||
it('restores both files byte-exactly when post-mutation repair fails', () => {
|
||||
const orca = command('orca-hook')
|
||||
const user = command('user-hook')
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { HookCommandConfig, HookDefinition } from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
codexAppServerCapabilityCache,
|
||||
getCodexAppServerHostKey,
|
||||
type CodexAppServerHostKey
|
||||
} from './codex-app-server-capability-cache'
|
||||
import { runCodexUserHookTrustRebaseSessionSync } from './codex-app-server-grant-bridge'
|
||||
import { isCodexAppServerUnsupportedError } from './codex-app-server-session'
|
||||
import { CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS } from './codex-hook-trust-grant'
|
||||
import { createCodexHookTrustEntry } from './codex-hook-identity'
|
||||
import { resolveCodexTrustGrantHost } from './codex-trust-grant-host'
|
||||
import {
|
||||
@@ -22,6 +29,19 @@ type RebaseSessionRunnerSync = (
|
||||
|
||||
let runSessionSync: RebaseSessionRunnerSync = runCodexUserHookTrustRebaseSessionSync
|
||||
|
||||
// Why: launch prep re-runs the callers on every pane spawn. A host stuck
|
||||
// without a usable rebase lane (old CLI, unmatched keys) must not pay a codex
|
||||
// session each time — bound retries like the grant lane does.
|
||||
const rebaseRetryAfterByHost = new Map<CodexAppServerHostKey, number>()
|
||||
|
||||
function rememberRebaseSessionFailure(hostKey: CodexAppServerHostKey, error: unknown): void {
|
||||
if (isCodexAppServerUnsupportedError(error)) {
|
||||
codexAppServerCapabilityCache.rememberUnsupported(hostKey)
|
||||
return
|
||||
}
|
||||
rebaseRetryAfterByHost.set(hostKey, Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function entriesByHookObject(
|
||||
sourcePath: string,
|
||||
hooksByEvent: HooksByEvent
|
||||
@@ -115,6 +135,17 @@ export function mutateRealHomeHooksPreservingUserTrust(args: {
|
||||
args.writeHooks()
|
||||
return null
|
||||
}
|
||||
const hostKey = getCodexAppServerHostKey({ kind: 'native' })
|
||||
if (!codexAppServerCapabilityCache.shouldTry(hostKey)) {
|
||||
throw new Error('codex app-server is marked unsupported on this host; trust rebase skipped')
|
||||
}
|
||||
const retryAfterMs = rebaseRetryAfterByHost.get(hostKey)
|
||||
if (retryAfterMs !== undefined) {
|
||||
if (Date.now() < retryAfterMs) {
|
||||
throw new Error('Codex user hook trust rebase is cooling down after a recent failure')
|
||||
}
|
||||
rebaseRetryAfterByHost.delete(hostKey)
|
||||
}
|
||||
const snapshot = captureCodexTrustConfig(args.tomlPath)
|
||||
|
||||
const baseRequest = resolveCodexTrustGrantHost({ kind: 'native' }).buildRequest({
|
||||
@@ -124,12 +155,19 @@ export function mutateRealHomeHooksPreservingUserTrust(args: {
|
||||
})
|
||||
// Why: inspection happens before the write, so an unavailable RPC aborts
|
||||
// without shifting a user's positional trust key.
|
||||
const inspected: CodexUserHookTrustRebaseResult = runSessionSync({
|
||||
operation: 'inspect-user-hook-trust',
|
||||
invocation: baseRequest.invocation,
|
||||
hooksListCwd: baseRequest.hooksListCwd,
|
||||
moves
|
||||
})
|
||||
let inspected: CodexUserHookTrustRebaseResult
|
||||
try {
|
||||
inspected = runSessionSync({
|
||||
operation: 'inspect-user-hook-trust',
|
||||
invocation: baseRequest.invocation,
|
||||
hooksListCwd: baseRequest.hooksListCwd,
|
||||
moves
|
||||
})
|
||||
} catch (error) {
|
||||
rememberRebaseSessionFailure(hostKey, error)
|
||||
throw error
|
||||
}
|
||||
codexAppServerCapabilityCache.rememberSupported(hostKey)
|
||||
if (inspected.outcome !== 'inspected') {
|
||||
throw new Error('Unexpected Codex user hook trust inspection result')
|
||||
}
|
||||
@@ -149,6 +187,7 @@ export function mutateRealHomeHooksPreservingUserTrust(args: {
|
||||
}
|
||||
return snapshot
|
||||
} catch (error) {
|
||||
rememberRebaseSessionFailure(hostKey, error)
|
||||
if (hooksWritten) {
|
||||
return rollbackMutation(args.restoreHooks, args.tomlPath, snapshot, error)
|
||||
}
|
||||
@@ -159,5 +198,8 @@ export function mutateRealHomeHooksPreservingUserTrust(args: {
|
||||
export const _internals = {
|
||||
setSessionRunnerSync(runner: RebaseSessionRunnerSync | null): void {
|
||||
runSessionSync = runner ?? runCodexUserHookTrustRebaseSessionSync
|
||||
},
|
||||
resetRetryState(): void {
|
||||
rebaseRetryAfterByHost.clear()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user