mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
fix(codex): make quota probes credential-safe
Codex OAuth uses rotating refresh tokens, and Orca's quota probes spawned real codex app-server processes inside live credential homes, hard-killed them at a 10s deadline (cold starts run 10-25s), re-probed every inactive account on each switch, and deselected accounts on torn auth.json reads. - arm the RPC read deadline only after initialize responds (30s/40s boot budget), and terminate probes via stdin EOF + SIGTERM with a bounded drain before any hard kill; resolve only once the child exits - serialize Orca-spawned codex processes per credential home (probe vs probe, probe vs commit-message/PR-fields/branch-name/model-discovery) - keep the inactive-probe debounce across account switches and stagger inactive probes; the active account still refreshes immediately - grade credential reads (present/missing/unreadable/no-credential) and require absence to outlive a grace window before deselecting
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CODEX_CREDENTIAL_ABSENCE_GRACE_MS,
|
||||
CodexCredentialAbsenceGrace
|
||||
} from './codex-credential-absence-grace'
|
||||
|
||||
const VALID_AUTH = JSON.stringify({
|
||||
auth_mode: 'chatgpt',
|
||||
tokens: { access_token: 'a', id_token: 'b', refresh_token: 'c' }
|
||||
})
|
||||
|
||||
describe('CodexCredentialAbsenceGrace', () => {
|
||||
let dir = ''
|
||||
let authPath = ''
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'codex-absence-grace-'))
|
||||
authPath = join(dir, 'auth.json')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('treats a torn mid-write read as transient until it outlives the grace window', () => {
|
||||
const grace = new CodexCredentialAbsenceGrace()
|
||||
writeFileSync(authPath, '{"tokens":{"acc', 'utf-8')
|
||||
|
||||
expect(grace.assess(authPath, 1_000)).toEqual({ state: 'unreadable', durable: false })
|
||||
expect(grace.assess(authPath, 1_000 + CODEX_CREDENTIAL_ABSENCE_GRACE_MS - 1)).toEqual({
|
||||
state: 'unreadable',
|
||||
durable: false
|
||||
})
|
||||
expect(grace.assess(authPath, 1_000 + CODEX_CREDENTIAL_ABSENCE_GRACE_MS)).toEqual({
|
||||
state: 'unreadable',
|
||||
durable: true
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the absence clock when a transient failure heals', () => {
|
||||
const grace = new CodexCredentialAbsenceGrace()
|
||||
writeFileSync(authPath, '{"tokens":{"acc', 'utf-8')
|
||||
expect(grace.assess(authPath, 1_000).durable).toBe(false)
|
||||
|
||||
writeFileSync(authPath, VALID_AUTH, 'utf-8')
|
||||
expect(grace.assess(authPath, 2_000)).toEqual({ state: 'present', durable: true })
|
||||
|
||||
// A later absence starts a fresh grace window instead of inheriting the old clock.
|
||||
rmSync(authPath)
|
||||
expect(grace.assess(authPath, 100_000)).toEqual({ state: 'missing', durable: false })
|
||||
expect(grace.assess(authPath, 100_000 + CODEX_CREDENTIAL_ABSENCE_GRACE_MS)).toEqual({
|
||||
state: 'missing',
|
||||
durable: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reports settled credential-free JSON as durable immediately', () => {
|
||||
const grace = new CodexCredentialAbsenceGrace()
|
||||
writeFileSync(authPath, '{}', 'utf-8')
|
||||
expect(grace.assess(authPath, 1_000)).toEqual({ state: 'no-credential', durable: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import {
|
||||
readStoredCodexCredentialState,
|
||||
type StoredCodexCredentialState
|
||||
} from './managed-codex-auth-readiness'
|
||||
|
||||
export const CODEX_CREDENTIAL_ABSENCE_GRACE_MS = 5_000
|
||||
|
||||
export type CodexCredentialAbsenceVerdict = {
|
||||
state: StoredCodexCredentialState
|
||||
/** For 'missing'/'unreadable': true once the absence outlived the grace window. */
|
||||
durable: boolean
|
||||
}
|
||||
|
||||
// Why: codex rotates auth.json in place, so one missing/unreadable read can be
|
||||
// a write in progress. Deselecting the managed account on that snapshot logs
|
||||
// the user out over a race; absence must persist across a grace window first.
|
||||
// Valid JSON (with or without a credential) is a settled file and needs none.
|
||||
export class CodexCredentialAbsenceGrace {
|
||||
private readonly firstAbsenceAtByPath = new Map<string, number>()
|
||||
|
||||
constructor(private readonly graceMs = CODEX_CREDENTIAL_ABSENCE_GRACE_MS) {}
|
||||
|
||||
assess(authPath: string, now = Date.now()): CodexCredentialAbsenceVerdict {
|
||||
const state = readStoredCodexCredentialState(authPath)
|
||||
const key = normalizeRuntimePathForComparison(authPath)
|
||||
if (state === 'present' || state === 'no-credential') {
|
||||
this.firstAbsenceAtByPath.delete(key)
|
||||
return { state, durable: true }
|
||||
}
|
||||
// Why: rotation replaces auth.json inside an existing home; the home
|
||||
// directory itself being gone is structural, not a write in flight.
|
||||
if (state === 'missing' && !existsSync(dirname(authPath))) {
|
||||
this.firstAbsenceAtByPath.delete(key)
|
||||
return { state, durable: true }
|
||||
}
|
||||
const firstAbsenceAt = this.firstAbsenceAtByPath.get(key)
|
||||
if (firstAbsenceAt === undefined) {
|
||||
this.firstAbsenceAtByPath.set(key, now)
|
||||
return { state, durable: false }
|
||||
}
|
||||
return { state, durable: now - firstAbsenceAt >= this.graceMs }
|
||||
}
|
||||
}
|
||||
@@ -85,19 +85,40 @@ function isManagedHostCodexHome(
|
||||
)
|
||||
}
|
||||
|
||||
export function hasStoredCodexCredential(authPath: string): boolean {
|
||||
export type StoredCodexCredentialState = 'present' | 'missing' | 'unreadable' | 'no-credential'
|
||||
|
||||
// Why: callers deciding whether to deselect an account must tell a settled
|
||||
// logout ('no-credential') apart from a rotation in progress ('unreadable') or
|
||||
// an absent file ('missing') — collapsing them to false logs users out on races.
|
||||
export function readStoredCodexCredentialState(authPath: string): StoredCodexCredentialState {
|
||||
let raw: string
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(authPath, 'utf8'))
|
||||
if (!isRecord(parsed) || Object.keys(parsed).length === 0) {
|
||||
return false
|
||||
}
|
||||
const auth = parsed as StoredCodexAuth
|
||||
return auth.auth_mode == null
|
||||
raw = readFileSync(authPath, 'utf8')
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
// Why: Windows auth.json rotation can surface transient EPERM/EBUSY reads.
|
||||
return code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : 'unreadable'
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
// Torn JSON means a write is in flight, not that the credential is gone.
|
||||
return 'unreadable'
|
||||
}
|
||||
if (!isRecord(parsed) || Object.keys(parsed).length === 0) {
|
||||
return 'no-credential'
|
||||
}
|
||||
const auth = parsed as StoredCodexAuth
|
||||
const hasCredential =
|
||||
auth.auth_mode == null
|
||||
? hasCredentialWithoutDeclaredMode(auth)
|
||||
: hasCredentialForDeclaredMode(auth)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return hasCredential ? 'present' : 'no-credential'
|
||||
}
|
||||
|
||||
export function hasStoredCodexCredential(authPath: string): boolean {
|
||||
return readStoredCodexCredentialState(authPath) === 'present'
|
||||
}
|
||||
|
||||
function hasCredentialForDeclaredMode(auth: StoredCodexAuth): boolean {
|
||||
|
||||
@@ -2389,11 +2389,27 @@ describe('CodexRuntimeHomeService', () => {
|
||||
expect(store.updateSettings).not.toHaveBeenCalled()
|
||||
|
||||
rmSync(join(managedHomePath1, 'auth.json'), { force: true })
|
||||
// Why: the first missing read may be a rotation in flight — the selection
|
||||
// survives the grace window and the launch still targets the account home.
|
||||
const absenceObservedAt = Date.now()
|
||||
expect(
|
||||
service.prepareForCodexLaunch(undefined, undefined, {
|
||||
unavailableManagedHomePath: managedHomePath1
|
||||
})
|
||||
).toBeNull()
|
||||
).toBe(managedHomePath1)
|
||||
expect(store.updateSettings).not.toHaveBeenCalled()
|
||||
|
||||
// Once the absence outlives the grace window it is durable and deselects.
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => absenceObservedAt + 6_000)
|
||||
try {
|
||||
expect(
|
||||
service.prepareForCodexLaunch(undefined, undefined, {
|
||||
unavailableManagedHomePath: managedHomePath1
|
||||
})
|
||||
).toBeNull()
|
||||
} finally {
|
||||
nowSpy.mockRestore()
|
||||
}
|
||||
expect(store.getSettings().activeCodexManagedAccountId).toBeNull()
|
||||
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime).toEqual({
|
||||
host: null,
|
||||
@@ -2408,6 +2424,66 @@ describe('CodexRuntimeHomeService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not deselect the account when a transient unreadable auth.json heals', async () => {
|
||||
const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system')
|
||||
writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8')
|
||||
const accountAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed')
|
||||
const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', accountAuth)
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
codexSystemDefaultRealHomeEnabled: true,
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'user@example.com',
|
||||
managedHomePath,
|
||||
providerAccountId: 'acct-user',
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: 'acct-user',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
],
|
||||
activeCodexManagedAccountId: 'account-1',
|
||||
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
|
||||
})
|
||||
)
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
// A mid-write read observes torn JSON: no deselect, and the launch still
|
||||
// targets the account home so codex re-reads the settled file itself.
|
||||
writeFileSync(join(managedHomePath, 'auth.json'), '{"tokens":{"acc', 'utf-8')
|
||||
expect(
|
||||
service.prepareForCodexLaunch(undefined, undefined, {
|
||||
unavailableManagedHomePath: managedHomePath
|
||||
})
|
||||
).toBe(managedHomePath)
|
||||
expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1')
|
||||
expect(store.updateSettings).not.toHaveBeenCalled()
|
||||
|
||||
// The write completes; even past the grace window the selection is intact.
|
||||
writeFileSync(join(managedHomePath, 'auth.json'), accountAuth, 'utf-8')
|
||||
const healedAt = Date.now()
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => healedAt + 60_000)
|
||||
try {
|
||||
expect(
|
||||
service.prepareForCodexLaunch(undefined, undefined, {
|
||||
unavailableManagedHomePath: managedHomePath
|
||||
})
|
||||
).toBe(managedHomePath)
|
||||
} finally {
|
||||
nowSpy.mockRestore()
|
||||
}
|
||||
expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1')
|
||||
expect(store.updateSettings).not.toHaveBeenCalled()
|
||||
expect(warnSpy).not.toHaveBeenCalledWith(
|
||||
'[codex-runtime-home] Active managed account credential remained unavailable, clearing selection'
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores mismatched runtime auth when the active managed auth is missing', async () => {
|
||||
const runtimeAuthPath = getRuntimeCodexAuthPath()
|
||||
const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system')
|
||||
|
||||
@@ -77,7 +77,7 @@ import {
|
||||
codexAuthMatchesSystemDefaultIdentity
|
||||
} from './codex-auth-identity'
|
||||
import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-migration'
|
||||
import { hasStoredCodexCredential } from './managed-codex-auth-readiness'
|
||||
import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace'
|
||||
import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility'
|
||||
import {
|
||||
hasRecordedLegacySharedCodexPane,
|
||||
@@ -179,6 +179,8 @@ export class CodexRuntimeHomeService {
|
||||
// provenance so a later deselect/rollback never adopts stale shared bytes.
|
||||
private lastHostAccountUsedSelfContainedHome = false
|
||||
private sharedAuthRefreshBlockedByManagedTransition = false
|
||||
// Why: transient auth.json read/parse failures must not deselect an account.
|
||||
private readonly credentialAbsenceGrace = new CodexCredentialAbsenceGrace()
|
||||
|
||||
constructor(private readonly store: Store) {
|
||||
this.safeRecoverInterruptedRuntimeAuthOperation()
|
||||
@@ -307,11 +309,15 @@ export class CodexRuntimeHomeService {
|
||||
if (
|
||||
unavailableManagedHomePath &&
|
||||
normalizeRuntimePathForComparison(unavailableManagedHomePath) ===
|
||||
normalizeRuntimePathForComparison(perAccountHome) &&
|
||||
!hasStoredCodexCredential(join(perAccountHome, 'auth.json'))
|
||||
normalizeRuntimePathForComparison(perAccountHome)
|
||||
) {
|
||||
this.clearSelfContainedManagedSelection(account, 'credential remained unavailable')
|
||||
return null
|
||||
const absence = this.credentialAbsenceGrace.assess(join(perAccountHome, 'auth.json'))
|
||||
if (absence.state !== 'present' && absence.durable) {
|
||||
this.clearSelfContainedManagedSelection(account, 'credential remained unavailable')
|
||||
return null
|
||||
}
|
||||
// Why: a transient missing/unreadable auth.json is usually codex rotating
|
||||
// it; keep the selection and launch — the CLI re-reads the settled file.
|
||||
}
|
||||
// Why: link the user's real ~/.codex resources and mirror config into THIS
|
||||
// home (never symlinking into or mutating ~/.codex), so the per-account home
|
||||
@@ -732,7 +738,16 @@ export class CodexRuntimeHomeService {
|
||||
}
|
||||
|
||||
const activeAuthPath = join(activeAccount.managedHomePath, 'auth.json')
|
||||
if (!existsSync(activeAuthPath)) {
|
||||
const authAbsence = this.credentialAbsenceGrace.assess(activeAuthPath)
|
||||
if (authAbsence.state === 'missing' || authAbsence.state === 'unreadable') {
|
||||
if (authAbsence.state === 'unreadable' || !authAbsence.durable) {
|
||||
// Why: mid-rotation reads look missing/unreadable for a moment; skip
|
||||
// this sync without deselecting and let a settled read decide later.
|
||||
console.warn(
|
||||
'[codex-runtime-home] Active managed account auth.json unavailable, keeping selection through grace window'
|
||||
)
|
||||
return
|
||||
}
|
||||
console.warn(
|
||||
'[codex-runtime-home] Active managed account is missing auth.json, restoring system default'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveCodexHomeProcessLockKey,
|
||||
resolveCodexHomeProcessLockKeyForSpawnEnv,
|
||||
withCodexHomeProcessLock
|
||||
} from './codex-home-process-lock'
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('withCodexHomeProcessLock', () => {
|
||||
it('serializes runs that share a lock key', async () => {
|
||||
const events: string[] = []
|
||||
const firstGate = deferred()
|
||||
|
||||
const first = withCodexHomeProcessLock('home-a', async () => {
|
||||
events.push('first:start')
|
||||
await firstGate.promise
|
||||
events.push('first:end')
|
||||
return 1
|
||||
})
|
||||
const second = withCodexHomeProcessLock('home-a', async () => {
|
||||
events.push('second:start')
|
||||
return 2
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(events).toEqual(['first:start'])
|
||||
|
||||
firstGate.resolve()
|
||||
await expect(first).resolves.toBe(1)
|
||||
await expect(second).resolves.toBe(2)
|
||||
expect(events).toEqual(['first:start', 'first:end', 'second:start'])
|
||||
})
|
||||
|
||||
it('runs different lock keys concurrently', async () => {
|
||||
const events: string[] = []
|
||||
const firstGate = deferred()
|
||||
|
||||
const first = withCodexHomeProcessLock('home-a', async () => {
|
||||
events.push('a:start')
|
||||
await firstGate.promise
|
||||
})
|
||||
const second = withCodexHomeProcessLock('home-b', async () => {
|
||||
events.push('b:start')
|
||||
})
|
||||
|
||||
await second
|
||||
expect(events).toEqual(['a:start', 'b:start'])
|
||||
firstGate.resolve()
|
||||
await first
|
||||
})
|
||||
|
||||
it('releases the lock after a rejected run', async () => {
|
||||
await expect(
|
||||
withCodexHomeProcessLock('home-a', async () => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
).rejects.toThrow('boom')
|
||||
|
||||
await expect(withCodexHomeProcessLock('home-a', async () => 'after')).resolves.toBe('after')
|
||||
})
|
||||
|
||||
it('keys explicit and default host homes consistently', () => {
|
||||
const previousCodexHome = process.env.CODEX_HOME
|
||||
delete process.env.CODEX_HOME
|
||||
try {
|
||||
const defaultKey = resolveCodexHomeProcessLockKey(null)
|
||||
expect(resolveCodexHomeProcessLockKey(join(homedir(), '.codex'))).toBe(defaultKey)
|
||||
expect(resolveCodexHomeProcessLockKeyForSpawnEnv(undefined)).toBe(defaultKey)
|
||||
expect(resolveCodexHomeProcessLockKey('/somewhere/else')).not.toBe(defaultKey)
|
||||
} finally {
|
||||
if (previousCodexHome !== undefined) {
|
||||
process.env.CODEX_HOME = previousCodexHome
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keys a WSL UNC probe home and a WSL spawn env to the same lock', () => {
|
||||
const probeKey = resolveCodexHomeProcessLockKey('\\\\wsl$\\Ubuntu\\home\\user\\.codex')
|
||||
const spawnKey = resolveCodexHomeProcessLockKeyForSpawnEnv(
|
||||
{ CODEX_HOME: '/home/user/.codex' },
|
||||
'Ubuntu'
|
||||
)
|
||||
expect(spawnKey).toBe(probeKey)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
|
||||
// Why: Codex OAuth uses rotating refresh tokens stored in each home's auth.json.
|
||||
// Two Orca-spawned codex processes refreshing the same home concurrently can
|
||||
// consume one rotation twice and permanently invalidate the stored credential,
|
||||
// so Orca's own spawns (quota probes, commit-message runs) serialize per home.
|
||||
// User terminal panes are intentionally not serialized here.
|
||||
|
||||
const lockTails = new Map<string, Promise<unknown>>()
|
||||
|
||||
export function resolveCodexHomeProcessLockKey(codexHomePath?: string | null): string {
|
||||
const home = codexHomePath ?? process.env.CODEX_HOME ?? join(homedir(), '.codex')
|
||||
return normalizeRuntimePathForComparison(home)
|
||||
}
|
||||
|
||||
export function resolveCodexHomeProcessLockKeyForSpawnEnv(
|
||||
env: NodeJS.ProcessEnv | undefined,
|
||||
wslDistro?: string | null
|
||||
): string {
|
||||
const codexHome = env?.CODEX_HOME ?? null
|
||||
if (wslDistro) {
|
||||
// Why: WSL spawns carry a Linux CODEX_HOME; key it through the same UNC
|
||||
// normalization the probe's \\wsl$ home path uses so both lanes collide.
|
||||
// Without an explicit home the distro default is unknowable from the host;
|
||||
// a sentinel still serializes same-distro default spawns with each other.
|
||||
return normalizeRuntimePathForComparison(
|
||||
`//wsl$/${wslDistro}${codexHome ?? '/.orca-default-codex-home'}`
|
||||
)
|
||||
}
|
||||
return resolveCodexHomeProcessLockKey(codexHome)
|
||||
}
|
||||
|
||||
export function withCodexHomeProcessLock<T>(lockKey: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prior = lockTails.get(lockKey) ?? Promise.resolve()
|
||||
const run = prior.then(fn)
|
||||
// Why: keep the queue alive past a failed run so later entrants still start.
|
||||
const tail = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
lockTails.set(lockKey, tail)
|
||||
void tail.then(() => {
|
||||
if (lockTails.get(lockKey) === tail) {
|
||||
lockTails.delete(lockKey)
|
||||
}
|
||||
})
|
||||
return run
|
||||
}
|
||||
@@ -34,13 +34,24 @@ function makeRpcChild() {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
stderr: EventEmitter
|
||||
stdin: { write: ReturnType<typeof vi.fn> }
|
||||
stdin: { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> }
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
exitCode: number | null
|
||||
}
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.stdin = { write: vi.fn() }
|
||||
child.kill = vi.fn()
|
||||
// Why: like the real app-server, the fake dies on stdin EOF or a signal —
|
||||
// the graceful shutdown path resolves only once the child reports exit.
|
||||
const exitNow = (): void => {
|
||||
child.exitCode = 0
|
||||
child.emit('exit', 0, null)
|
||||
}
|
||||
child.stdin = { write: vi.fn(), end: vi.fn(exitNow) }
|
||||
child.exitCode = null
|
||||
child.kill = vi.fn(() => {
|
||||
exitNow()
|
||||
return true
|
||||
})
|
||||
return child
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { childSpawnMock, readFileMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({
|
||||
childSpawnMock: vi.fn(),
|
||||
readFileMock: vi.fn(),
|
||||
resolveCodexCommandMock: vi.fn(),
|
||||
ptySpawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: childSpawnMock
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: readFileMock
|
||||
}))
|
||||
|
||||
vi.mock('../codex-cli/command', () => ({
|
||||
resolveCodexCommand: resolveCodexCommandMock
|
||||
}))
|
||||
|
||||
vi.mock('node-pty', () => ({
|
||||
spawn: ptySpawnMock
|
||||
}))
|
||||
|
||||
// Auth gate is covered by codex-auth-presence.test.ts; these tests assume a signed-in Codex.
|
||||
vi.mock('./codex-auth-presence', () => ({
|
||||
probeCodexAuthPresence: vi.fn(() => 'present')
|
||||
}))
|
||||
|
||||
import { fetchCodexRateLimits } from './codex-fetcher'
|
||||
import { probeCodexAuthPresence } from './codex-auth-presence'
|
||||
|
||||
function makeRpcChild() {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
stderr: EventEmitter
|
||||
stdin: { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> }
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
exitCode: number | null
|
||||
}
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
// Why: like the real app-server, the fake dies on stdin EOF or a signal —
|
||||
// the graceful shutdown path resolves only once the child reports exit.
|
||||
const exitNow = (): void => {
|
||||
child.exitCode = 0
|
||||
child.emit('exit', 0, null)
|
||||
}
|
||||
child.stdin = { write: vi.fn(), end: vi.fn(exitNow) }
|
||||
child.exitCode = null
|
||||
child.kill = vi.fn(() => {
|
||||
exitNow()
|
||||
return true
|
||||
})
|
||||
return child
|
||||
}
|
||||
|
||||
function respondToRpcRateLimitRead(
|
||||
rpcChild: ReturnType<typeof makeRpcChild>,
|
||||
rateLimits: unknown
|
||||
): void {
|
||||
rpcChild.stdin.write.mockImplementation((line: string) => {
|
||||
const msg = JSON.parse(line) as { id?: number; method?: string }
|
||||
if (msg.method === 'initialize') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
if (msg.method === 'account/rateLimits/read') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { rateLimits } })}\n`)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('fetchCodexRateLimits probe shutdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
resolveCodexCommandMock.mockReturnValue('codex')
|
||||
vi.mocked(probeCodexAuthPresence).mockResolvedValue('present')
|
||||
readFileMock.mockRejectedValue(new Error('no auth fixture'))
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('gives a cold app-server the full boot budget before any deadline fires', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
rpcChild.stdin.write.mockImplementation((line: string) => {
|
||||
const msg = JSON.parse(line) as { id?: number; method?: string }
|
||||
if (msg.method === 'initialize') {
|
||||
// Why: cold starts (auth refresh included) are documented at 10-25s;
|
||||
// the old 10s deadline killed them mid-auth.
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
|
||||
)
|
||||
}, 20_000)
|
||||
}
|
||||
if (msg.method === 'account/rateLimits/read') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: {
|
||||
rateLimits: { primary: { usedPercent: 3 }, secondary: { usedPercent: 4 } }
|
||||
}
|
||||
})}\n`
|
||||
)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
|
||||
await vi.advanceTimersByTimeAsync(19_999)
|
||||
expect(rpcChild.kill).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2)
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
session: { usedPercent: 3 },
|
||||
weekly: { usedPercent: 4 }
|
||||
})
|
||||
})
|
||||
|
||||
it('arms the read deadline after initialize and drains SIGTERM before SIGKILL', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
const rpcChild = makeRpcChild()
|
||||
// Stubborn child: it ignores stdin EOF, survives SIGTERM, dies on SIGKILL.
|
||||
rpcChild.stdin.end = vi.fn()
|
||||
rpcChild.kill = vi.fn((signal?: string) => {
|
||||
if (signal === 'SIGKILL') {
|
||||
rpcChild.exitCode = 0
|
||||
rpcChild.emit('exit', 0, null)
|
||||
}
|
||||
return true
|
||||
})
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
rpcChild.stdin.write.mockImplementation((line: string) => {
|
||||
const msg = JSON.parse(line) as { id?: number; method?: string }
|
||||
if (msg.method === 'initialize') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
// The rate-limits read never answers, so the request deadline expires.
|
||||
})
|
||||
|
||||
try {
|
||||
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(9_999)
|
||||
expect(rpcChild.kill).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(rpcChild.stdin.end).toHaveBeenCalledTimes(1)
|
||||
expect(rpcChild.kill).toHaveBeenCalledWith('SIGTERM')
|
||||
expect(rpcChild.kill).not.toHaveBeenCalledWith('SIGKILL')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(rpcChild.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
status: 'error',
|
||||
error: 'RPC timeout'
|
||||
})
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not spawn a second probe for the same home while one is still running', async () => {
|
||||
const firstChild = makeRpcChild()
|
||||
const secondChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild)
|
||||
respondToRpcRateLimitRead(secondChild, {
|
||||
primary: { usedPercent: 3 },
|
||||
secondary: { usedPercent: 4 }
|
||||
})
|
||||
|
||||
const first = fetchCodexRateLimits({
|
||||
allowPtyFallback: false,
|
||||
codexHomePath: '/managed/home-a'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const second = fetchCodexRateLimits({
|
||||
allowPtyFallback: false,
|
||||
codexHomePath: '/managed/home-a'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(childSpawnMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstChild.emit('close')
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(childSpawnMock).toHaveBeenCalledTimes(2)
|
||||
await expect(second).resolves.toMatchObject({ status: 'ok' })
|
||||
await first
|
||||
})
|
||||
})
|
||||
@@ -20,13 +20,25 @@ function makeRpcChild(rateLimitResetCredits?: unknown) {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
stderr: EventEmitter
|
||||
stdin: { write: ReturnType<typeof vi.fn> }
|
||||
stdin: { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> }
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
exitCode: number | null
|
||||
}
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.kill = vi.fn()
|
||||
child.exitCode = null
|
||||
// Why: like the real app-server, the fake dies on stdin EOF or a signal —
|
||||
// the graceful shutdown path resolves only once the child reports exit.
|
||||
const exitNow = (): void => {
|
||||
child.exitCode = 0
|
||||
child.emit('exit', 0, null)
|
||||
}
|
||||
child.kill = vi.fn(() => {
|
||||
exitNow()
|
||||
return true
|
||||
})
|
||||
child.stdin = {
|
||||
end: vi.fn(exitNow),
|
||||
write: vi.fn((line: string) => {
|
||||
const message = JSON.parse(line) as { id?: number; method?: string }
|
||||
if (message.method === 'initialize') {
|
||||
|
||||
@@ -43,13 +43,24 @@ function makeRpcChild() {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
stderr: EventEmitter
|
||||
stdin: { write: ReturnType<typeof vi.fn> }
|
||||
stdin: { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> }
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
exitCode: number | null
|
||||
}
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.stdin = { write: vi.fn() }
|
||||
child.kill = vi.fn()
|
||||
// Why: like the real app-server, the fake dies on stdin EOF or a signal —
|
||||
// the graceful shutdown path resolves only once the child reports exit.
|
||||
const exitNow = (): void => {
|
||||
child.exitCode = 0
|
||||
child.emit('exit', 0, null)
|
||||
}
|
||||
child.stdin = { write: vi.fn(), end: vi.fn(exitNow) }
|
||||
child.exitCode = null
|
||||
child.kill = vi.fn(() => {
|
||||
exitNow()
|
||||
return true
|
||||
})
|
||||
return child
|
||||
}
|
||||
|
||||
@@ -333,7 +344,8 @@ describe('fetchCodexRateLimits', () => {
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
// Why: without an initialize response only the 30s boot deadline fires.
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
|
||||
@@ -36,9 +36,19 @@ import {
|
||||
createAuthFilesystemOperation,
|
||||
type SharedAuthFilesystemOperation
|
||||
} from './auth-filesystem-operation'
|
||||
import { terminateCodexProbeChild } from './codex-probe-termination'
|
||||
import {
|
||||
resolveCodexHomeProcessLockKey,
|
||||
withCodexHomeProcessLock
|
||||
} from '../codex-cli/codex-home-process-lock'
|
||||
|
||||
const RPC_TIMEOUT_MS = 10_000
|
||||
const WSL_RPC_TIMEOUT_MS = 25_000
|
||||
// Why: cold app-server starts (fresh token refresh included) are documented at
|
||||
// 10-25s; the request deadline is armed only after initialize responds, so
|
||||
// these boot budgets are what protect a slow cold start from a mid-auth kill.
|
||||
const RPC_INIT_TIMEOUT_MS = 30_000
|
||||
const WSL_RPC_INIT_TIMEOUT_MS = 40_000
|
||||
const PTY_TIMEOUT_MS = 15_000
|
||||
// Why: codex ≥0.145 renders a '›' composer with placeholder text after it, so a
|
||||
// prompt-anchored send can never fire; nudge /status after a short boot grace.
|
||||
@@ -619,7 +629,8 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
const wslCodex = options?.codexHomePath
|
||||
? buildWslCodexCommand(options.codexHomePath, codexArgs, { isolateRpcStdio: true })
|
||||
: null
|
||||
// Why: cold WSL startup can exceed the host RPC budget.
|
||||
// Why: cold WSL startup can exceed the host budgets.
|
||||
const initTimeoutMs = wslCodex ? WSL_RPC_INIT_TIMEOUT_MS : RPC_INIT_TIMEOUT_MS
|
||||
const rpcTimeoutMs = wslCodex ? WSL_RPC_TIMEOUT_MS : RPC_TIMEOUT_MS
|
||||
const codexCommand = wslCodex ? 'codex' : resolveCodexCommand()
|
||||
// Why: launch .cmd/.bat files through cmd.exe /c; shell:true triggers DEP0190.
|
||||
@@ -659,7 +670,13 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
resolved = true
|
||||
cleanupListeners()
|
||||
if (options?.kill) {
|
||||
child.kill()
|
||||
// Why: resolve only after the child is gone so the per-home lock can't
|
||||
// release while a draining process may still rewrite auth.json.
|
||||
void terminateCodexProbeChild(child).then(
|
||||
() => resolve(result),
|
||||
() => resolve(result)
|
||||
)
|
||||
return
|
||||
}
|
||||
resolve(result)
|
||||
}
|
||||
@@ -676,19 +693,25 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
options.signal.addEventListener('abort', onAbort, { once: true })
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
settle(
|
||||
{
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: 'RPC timeout',
|
||||
status: 'error'
|
||||
},
|
||||
{ kill: true }
|
||||
)
|
||||
}, rpcTimeoutMs)
|
||||
function armRpcDeadline(deadlineMs: number): void {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
settle(
|
||||
{
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: 'RPC timeout',
|
||||
status: 'error'
|
||||
},
|
||||
{ kill: true }
|
||||
)
|
||||
}, deadlineMs)
|
||||
}
|
||||
armRpcDeadline(initTimeoutMs)
|
||||
|
||||
function sendRpc(method: string, params?: unknown): number {
|
||||
const id = ++rpcId
|
||||
@@ -728,6 +751,8 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
}
|
||||
|
||||
if (msg.id === initId) {
|
||||
// Why: the boot/auth-refresh budget ends here; the read gets its own deadline.
|
||||
armRpcDeadline(rpcTimeoutMs)
|
||||
// Initialize succeeded — send `initialized`, then request rate limits.
|
||||
sendNotification('initialized')
|
||||
rateLimitsId = sendRpc('account/rateLimits/read')
|
||||
@@ -1198,9 +1223,14 @@ export async function fetchCodexRateLimits(
|
||||
}
|
||||
}
|
||||
|
||||
// Why: probes spawn a real codex process inside the live credential home;
|
||||
// the per-home lock keeps Orca's own spawns (probe vs probe, probe vs
|
||||
// commit-message run) from refreshing one auth.json concurrently.
|
||||
const homeLockKey = resolveCodexHomeProcessLockKey(options?.codexHomePath)
|
||||
|
||||
// Path B: try RPC
|
||||
try {
|
||||
const rpcResult = await fetchViaRpc(options)
|
||||
const rpcResult = await withCodexHomeProcessLock(homeLockKey, () => fetchViaRpc(options))
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
@@ -1238,7 +1268,7 @@ export async function fetchCodexRateLimits(
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
const ptyResult = await fetchViaPty(options)
|
||||
const ptyResult = await withCodexHomeProcessLock(homeLockKey, () => fetchViaPty(options))
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CODEX_PROBE_SHUTDOWN_DRAIN_MS, terminateCodexProbeChild } from './codex-probe-termination'
|
||||
|
||||
function makeFakeChild() {
|
||||
const emitter = new EventEmitter()
|
||||
const child = {
|
||||
exitCode: null as number | null,
|
||||
signalCode: null as NodeJS.Signals | null,
|
||||
stdin: { end: vi.fn() },
|
||||
kill: vi.fn((_signal?: NodeJS.Signals) => true),
|
||||
once: emitter.once.bind(emitter),
|
||||
off: emitter.off.bind(emitter),
|
||||
exit(code = 0) {
|
||||
child.exitCode = code
|
||||
emitter.emit('exit')
|
||||
}
|
||||
}
|
||||
return child
|
||||
}
|
||||
|
||||
describe('terminateCodexProbeChild', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('requests shutdown and never hard-kills a child that drains in time', async () => {
|
||||
const child = makeFakeChild()
|
||||
const done = terminateCodexProbeChild(child, { platform: 'darwin' })
|
||||
|
||||
expect(child.stdin.end).toHaveBeenCalledTimes(1)
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(CODEX_PROBE_SHUTDOWN_DRAIN_MS - 1)
|
||||
child.exit()
|
||||
await done
|
||||
|
||||
expect(child.kill).toHaveBeenCalledTimes(1)
|
||||
expect(child.kill).not.toHaveBeenCalledWith('SIGKILL')
|
||||
})
|
||||
|
||||
it('escalates to SIGKILL only after the drain window elapses', async () => {
|
||||
const child = makeFakeChild()
|
||||
const done = terminateCodexProbeChild(child, { platform: 'linux' })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(CODEX_PROBE_SHUTDOWN_DRAIN_MS)
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
|
||||
child.exit()
|
||||
await done
|
||||
expect(child.kill.mock.calls.map((call) => call[0])).toEqual(['SIGTERM', 'SIGKILL'])
|
||||
})
|
||||
|
||||
it('resolves within the hard-kill bound even if the child never reports exit', async () => {
|
||||
const child = makeFakeChild()
|
||||
let settled = false
|
||||
const done = terminateCodexProbeChild(child, { platform: 'linux' }).then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(CODEX_PROBE_SHUTDOWN_DRAIN_MS + 1_000)
|
||||
await done
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('sends no signal during the drain window on Windows where kill is always forceful', async () => {
|
||||
const child = makeFakeChild()
|
||||
const done = terminateCodexProbeChild(child, { platform: 'win32' })
|
||||
|
||||
expect(child.stdin.end).toHaveBeenCalledTimes(1)
|
||||
expect(child.kill).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(CODEX_PROBE_SHUTDOWN_DRAIN_MS)
|
||||
expect(child.kill).toHaveBeenCalledTimes(1)
|
||||
expect(child.kill).toHaveBeenCalledWith()
|
||||
|
||||
child.exit()
|
||||
await done
|
||||
})
|
||||
|
||||
it('does nothing for a child that already exited', async () => {
|
||||
const child = makeFakeChild()
|
||||
child.exit()
|
||||
|
||||
await terminateCodexProbeChild(child, { platform: 'darwin' })
|
||||
|
||||
expect(child.stdin.end).not.toHaveBeenCalled()
|
||||
expect(child.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
// Why: Codex OAuth uses rotating refresh tokens. Hard-killing a probe's
|
||||
// app-server mid-refresh can strand auth.json between rotations and permanently
|
||||
// invalidate the stored credential, so termination always requests shutdown
|
||||
// first and only escalates after a bounded drain window.
|
||||
|
||||
export const CODEX_PROBE_SHUTDOWN_DRAIN_MS = 5_000
|
||||
const HARD_KILL_EXIT_WAIT_MS = 1_000
|
||||
|
||||
export type TerminatableProbeChild = {
|
||||
exitCode: number | null
|
||||
signalCode?: NodeJS.Signals | null
|
||||
kill: (signal?: NodeJS.Signals) => boolean
|
||||
once: (event: 'exit' | 'close' | 'error', listener: () => void) => unknown
|
||||
off: (event: 'exit' | 'close' | 'error', listener: () => void) => unknown
|
||||
stdin?: { end: () => void } | null
|
||||
}
|
||||
|
||||
export type TerminateCodexProbeOptions = {
|
||||
drainMs?: number
|
||||
hardKillWaitMs?: number
|
||||
// Why: injectable so the Windows branch is testable from POSIX CI hosts.
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
function hasExited(child: TerminatableProbeChild): boolean {
|
||||
return child.exitCode != null || child.signalCode != null
|
||||
}
|
||||
|
||||
function waitForExit(child: TerminatableProbeChild, timeoutMs: number): Promise<boolean> {
|
||||
if (hasExited(child)) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const settle = (exited: boolean): void => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
child.off('exit', onGone)
|
||||
child.off('close', onGone)
|
||||
child.off('error', onGone)
|
||||
resolve(exited)
|
||||
}
|
||||
const onGone = (): void => settle(true)
|
||||
// Why: 'error' covers children that never spawned and so never emit 'exit'.
|
||||
child.once('exit', onGone)
|
||||
child.once('close', onGone)
|
||||
child.once('error', onGone)
|
||||
timer = setTimeout(() => settle(false), timeoutMs)
|
||||
})
|
||||
}
|
||||
|
||||
export async function terminateCodexProbeChild(
|
||||
child: TerminatableProbeChild,
|
||||
options?: TerminateCodexProbeOptions
|
||||
): Promise<void> {
|
||||
if (hasExited(child)) {
|
||||
return
|
||||
}
|
||||
const platform = options?.platform ?? process.platform
|
||||
// Why: stdin EOF is the graceful stop for a stdio JSON-RPC server and the
|
||||
// only non-forceful request Windows has; SIGTERM backs it up where signals
|
||||
// are real (Node's kill() on Windows is always TerminateProcess).
|
||||
try {
|
||||
child.stdin?.end()
|
||||
} catch {
|
||||
// stdin may already be destroyed; the signal/kill path still applies.
|
||||
}
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
} catch {
|
||||
// Already-exited children can race the kill; the exit wait handles it.
|
||||
}
|
||||
}
|
||||
if (await waitForExit(child, options?.drainMs ?? CODEX_PROBE_SHUTDOWN_DRAIN_MS)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
child.kill()
|
||||
} else {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
} catch {
|
||||
// Already-exited children can race the kill; the exit wait handles it.
|
||||
}
|
||||
await waitForExit(child, options?.hardKillWaitMs ?? HARD_KILL_EXIT_WAIT_MS)
|
||||
}
|
||||
@@ -1987,6 +1987,51 @@ describe('RateLimitService', () => {
|
||||
expect(service.getState().inactiveCodexAccounts).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the inactive Codex debounce across an account switch instead of re-probing', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setInactiveCodexAccountsResolver(() => [
|
||||
{ id: 'account-b', managedHomePath: '/tmp/account-b/home' }
|
||||
])
|
||||
vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 10))
|
||||
|
||||
await service.fetchInactiveCodexAccountsOnOpen()
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The switch triggers exactly one fetch: the newly active account's.
|
||||
await service.refreshForCodexAccountChange('account-a')
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Why: re-opening the switcher inside the debounce window must not spawn
|
||||
// codex in every inactive credential home again.
|
||||
await service.fetchInactiveCodexAccountsOnOpen()
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('staggers inactive Codex probes instead of bursting every account at once', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const service = new RateLimitService()
|
||||
service.setInactiveCodexAccountsResolver(() => [
|
||||
{ id: 'account-a', managedHomePath: '/tmp/account-a/home' },
|
||||
{ id: 'account-b', managedHomePath: '/tmp/account-b/home' }
|
||||
])
|
||||
vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 5))
|
||||
|
||||
const fetchOnOpen = service.fetchInactiveCodexAccountsOnOpen()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_999)
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
|
||||
await fetchOnOpen
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves Gemini buckets through getState after fetch', async () => {
|
||||
const service = new RateLimitService()
|
||||
|
||||
|
||||
@@ -90,6 +90,10 @@ const RATE_LIMITED_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000
|
||||
// Why: statusline posts arrive on every turn; skip renderer pushes for identical windows so streaming sessions don't spam state updates.
|
||||
const LIVE_CLAUDE_INGEST_DEDUPE_MS = 30 * 1000
|
||||
const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open
|
||||
// Why: each inactive Codex probe spawns a real codex process inside that
|
||||
// account's live credential home; pace them out instead of bursting every
|
||||
// account the moment the switcher opens.
|
||||
const INACTIVE_CODEX_PROBE_STAGGER_MS = 2_000
|
||||
const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000
|
||||
|
||||
// Why: inactive account arrays are derived from provider caches on demand in getState()/pushToRenderer().
|
||||
@@ -132,6 +136,23 @@ function normalizeClaudeConfigDir(dir: string | null | undefined): string | null
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
function delayUnlessAborted(ms: number, signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
function isSameUsageWindow(
|
||||
a: ProviderRateLimits['session'],
|
||||
b: ProviderRateLimits['session']
|
||||
@@ -393,7 +414,10 @@ export class RateLimitService {
|
||||
this.activeFailureStreakByProvider.codex = 0
|
||||
this.inactiveCodexAccountsGeneration += 1
|
||||
this.pruneInactiveCodexState()
|
||||
this.lastInactiveCodexFetchAt = 0
|
||||
// Why: the switch must NOT reset the inactive-fetch debounce — re-probing
|
||||
// every inactive account per switch spawns codex in each credential home
|
||||
// and endangers rotating refresh tokens; the switcher shows the cached
|
||||
// snapshot (seeded above for the outgoing account) until the debounce ends.
|
||||
// Why: clear the old Codex view immediately, else the previous account's limits show under the newly selected identity until the next poll.
|
||||
this.updateState({
|
||||
...this.state,
|
||||
@@ -611,6 +635,7 @@ export class RateLimitService {
|
||||
}
|
||||
this.pushToRenderer()
|
||||
|
||||
let staggerNextProbe = false
|
||||
try {
|
||||
for (const account of accounts) {
|
||||
if (
|
||||
@@ -625,6 +650,23 @@ export class RateLimitService {
|
||||
this.pushToRenderer()
|
||||
continue
|
||||
}
|
||||
if (staggerNextProbe) {
|
||||
await delayUnlessAborted(INACTIVE_CODEX_PROBE_STAGGER_MS, signal)
|
||||
// Why: the account set can change while the stagger delay runs.
|
||||
if (
|
||||
signal.aborted ||
|
||||
fetchGeneration !== this.inactiveCodexAccountsGeneration ||
|
||||
!this.isCurrentInactiveCodexAccount(account.id)
|
||||
) {
|
||||
this.inactiveCodexFetching.delete(account.id)
|
||||
if (!this.isCurrentInactiveCodexAccount(account.id)) {
|
||||
this.inactiveCodexCache.delete(account.id)
|
||||
}
|
||||
this.pushToRenderer()
|
||||
continue
|
||||
}
|
||||
}
|
||||
staggerNextProbe = true
|
||||
try {
|
||||
// Why: point fetchCodexRateLimits at the managed home directly, avoiding materializing credentials into the shared runtime location.
|
||||
// Why: no PTY fallback — the switcher preview shouldn't spawn hidden PTYs per account (can crash ConPTY on Windows); RPC-only is enough.
|
||||
|
||||
@@ -47,6 +47,10 @@ import type { SourceControlAiOperation } from '../../shared/source-control-ai-ty
|
||||
import { formatLinkedIssueTemplateValue } from '../../shared/source-control-ai-action-variables'
|
||||
import { renderSourceControlActionCommandTemplate } from '../../shared/source-control-ai-actions'
|
||||
import { resolveCliCommand } from '../codex-cli/command'
|
||||
import {
|
||||
resolveCodexHomeProcessLockKeyForSpawnEnv,
|
||||
withCodexHomeProcessLock
|
||||
} from '../codex-cli/codex-home-process-lock'
|
||||
import {
|
||||
getSpawnArgsForWindows,
|
||||
UnsafeWindowsBatchArgumentsError,
|
||||
@@ -312,121 +316,132 @@ export async function discoverCommitMessageModelsLocal(
|
||||
return toModelDiscoveryCapability(spec)
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let child: ChildProcess
|
||||
const spawnEnv = env ?? process.env
|
||||
try {
|
||||
const planned = planModelDiscovery(spec, agentCommandOverride)
|
||||
if (!planned.ok) {
|
||||
resolve({ success: false, error: planned.error })
|
||||
const runDiscovery = (): Promise<DiscoverCommitMessageModelsResult> =>
|
||||
new Promise((resolve) => {
|
||||
let child: ChildProcess
|
||||
const spawnEnv = env ?? process.env
|
||||
try {
|
||||
const planned = planModelDiscovery(spec, agentCommandOverride)
|
||||
if (!planned.ok) {
|
||||
resolve({ success: false, error: planned.error })
|
||||
return
|
||||
}
|
||||
if (process.platform === 'win32' && options.wslDistro) {
|
||||
child = wslAwareSpawn(planned.plan.binary, planned.plan.args, {
|
||||
cwd: options.cwd,
|
||||
env: buildWslLauncherEnv(env),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
wslDistro: options.wslDistro,
|
||||
useWslLoginShell: true
|
||||
})
|
||||
} else {
|
||||
const resolvedBinary =
|
||||
process.platform === 'win32'
|
||||
? resolveCliCommand(planned.plan.binary, {
|
||||
pathEnv: spawnEnv.PATH ?? spawnEnv.Path ?? null
|
||||
})
|
||||
: planned.plan.binary
|
||||
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, planned.plan.args)
|
||||
child = spawn(spawnCmd, spawnArgs, {
|
||||
env: spawnEnv,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[commit-message] Failed to spawn model discovery:', error)
|
||||
resolve({
|
||||
success: false,
|
||||
error: `${spec.label} model discovery could not be started. Check the agent CLI configuration and try again.`
|
||||
})
|
||||
return
|
||||
}
|
||||
if (process.platform === 'win32' && options.wslDistro) {
|
||||
child = wslAwareSpawn(planned.plan.binary, planned.plan.args, {
|
||||
cwd: options.cwd,
|
||||
env: buildWslLauncherEnv(env),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
wslDistro: options.wslDistro,
|
||||
useWslLoginShell: true
|
||||
})
|
||||
} else {
|
||||
const resolvedBinary =
|
||||
process.platform === 'win32'
|
||||
? resolveCliCommand(planned.plan.binary, {
|
||||
pathEnv: spawnEnv.PATH ?? spawnEnv.Path ?? null
|
||||
})
|
||||
: planned.plan.binary
|
||||
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, planned.plan.args)
|
||||
child = spawn(spawnCmd, spawnArgs, {
|
||||
env: spawnEnv,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[commit-message] Failed to spawn model discovery:', error)
|
||||
resolve({
|
||||
success: false,
|
||||
error: `${spec.label} model discovery could not be started. Check the agent CLI configuration and try again.`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let outputLimitExceeded = false
|
||||
let settled = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let detachChildListeners = (): void => {}
|
||||
const finish = (result: DiscoverCommitMessageModelsResult): void => {
|
||||
if (settled) {
|
||||
return
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let outputLimitExceeded = false
|
||||
let settled = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let detachChildListeners = (): void => {}
|
||||
const finish = (result: DiscoverCommitMessageModelsResult): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
detachChildListeners()
|
||||
resolve(result)
|
||||
}
|
||||
settled = true
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
detachChildListeners()
|
||||
resolve(result)
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
killProcessTree(child)
|
||||
finish({
|
||||
success: false,
|
||||
error: `${spec.label} model discovery timed out after ${GENERATION_TIMEOUT_MS / 1000}s.`
|
||||
})
|
||||
}, GENERATION_TIMEOUT_MS)
|
||||
|
||||
const onData = (chunk: Buffer, append: (text: string) => void): void => {
|
||||
if (stdout.length + stderr.length + chunk.byteLength > MAX_AGENT_OUTPUT_BYTES) {
|
||||
outputLimitExceeded = true
|
||||
timer = setTimeout(() => {
|
||||
killProcessTree(child)
|
||||
finish({ success: false, error: `${spec.label} returned too much model data.` })
|
||||
return
|
||||
}
|
||||
append(chunk.toString('utf-8'))
|
||||
}
|
||||
|
||||
const onStdoutData = (chunk: Buffer): void => onData(chunk, (text) => (stdout += text))
|
||||
const onStderrData = (chunk: Buffer): void => onData(chunk, (text) => (stderr += text))
|
||||
const onError = (error: Error): void => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
finish({
|
||||
success: false,
|
||||
error: `${spec.modelDiscovery?.binary ?? spec.binary} not found on PATH. Install ${spec.label} to discover models.`
|
||||
error: `${spec.label} model discovery timed out after ${GENERATION_TIMEOUT_MS / 1000}s.`
|
||||
})
|
||||
return
|
||||
}
|
||||
finish({
|
||||
success: false,
|
||||
error: `${spec.label} model discovery failed to start. Check the agent CLI configuration and try again.`
|
||||
})
|
||||
}
|
||||
const onClose = (code: number | null): void => {
|
||||
if (outputLimitExceeded) {
|
||||
finish({ success: false, error: `${spec.label} returned too much model data.` })
|
||||
return
|
||||
}
|
||||
if (code !== 0) {
|
||||
finish(finalizeModelDiscoveryOutput(spec, stdout, stderr, code))
|
||||
return
|
||||
}
|
||||
finish(finalizeModelDiscoveryOutput(spec, stdout, stderr, code))
|
||||
}
|
||||
}, GENERATION_TIMEOUT_MS)
|
||||
|
||||
child.stdout?.on('data', onStdoutData)
|
||||
child.stderr?.on('data', onStderrData)
|
||||
child.on('error', onError)
|
||||
child.on('close', onClose)
|
||||
detachChildListeners = () => {
|
||||
child.stdout?.off?.('data', onStdoutData)
|
||||
child.stderr?.off?.('data', onStderrData)
|
||||
child.off?.('error', onError)
|
||||
child.off?.('close', onClose)
|
||||
}
|
||||
})
|
||||
const onData = (chunk: Buffer, append: (text: string) => void): void => {
|
||||
if (stdout.length + stderr.length + chunk.byteLength > MAX_AGENT_OUTPUT_BYTES) {
|
||||
outputLimitExceeded = true
|
||||
killProcessTree(child)
|
||||
finish({ success: false, error: `${spec.label} returned too much model data.` })
|
||||
return
|
||||
}
|
||||
append(chunk.toString('utf-8'))
|
||||
}
|
||||
|
||||
const onStdoutData = (chunk: Buffer): void => onData(chunk, (text) => (stdout += text))
|
||||
const onStderrData = (chunk: Buffer): void => onData(chunk, (text) => (stderr += text))
|
||||
const onError = (error: Error): void => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
finish({
|
||||
success: false,
|
||||
error: `${spec.modelDiscovery?.binary ?? spec.binary} not found on PATH. Install ${spec.label} to discover models.`
|
||||
})
|
||||
return
|
||||
}
|
||||
finish({
|
||||
success: false,
|
||||
error: `${spec.label} model discovery failed to start. Check the agent CLI configuration and try again.`
|
||||
})
|
||||
}
|
||||
const onClose = (code: number | null): void => {
|
||||
if (outputLimitExceeded) {
|
||||
finish({ success: false, error: `${spec.label} returned too much model data.` })
|
||||
return
|
||||
}
|
||||
if (code !== 0) {
|
||||
finish(finalizeModelDiscoveryOutput(spec, stdout, stderr, code))
|
||||
return
|
||||
}
|
||||
finish(finalizeModelDiscoveryOutput(spec, stdout, stderr, code))
|
||||
}
|
||||
|
||||
child.stdout?.on('data', onStdoutData)
|
||||
child.stderr?.on('data', onStderrData)
|
||||
child.on('error', onError)
|
||||
child.on('close', onClose)
|
||||
detachChildListeners = () => {
|
||||
child.stdout?.off?.('data', onStdoutData)
|
||||
child.stderr?.off?.('data', onStderrData)
|
||||
child.off?.('error', onError)
|
||||
child.off?.('close', onClose)
|
||||
}
|
||||
})
|
||||
|
||||
if (agentId === 'codex') {
|
||||
// Why: discovery spawns a real codex process in the selected home; keep it
|
||||
// off an auth.json a quota probe may be refreshing at the same time.
|
||||
return withCodexHomeProcessLock(
|
||||
resolveCodexHomeProcessLockKeyForSpawnEnv(env, options.wslDistro),
|
||||
runDiscovery
|
||||
)
|
||||
}
|
||||
return runDiscovery()
|
||||
}
|
||||
|
||||
export async function discoverCommitMessageModelsRemote(
|
||||
@@ -716,6 +731,56 @@ async function runLocalPlan(
|
||||
})
|
||||
}
|
||||
|
||||
type LocalGenerationTarget = Extract<CommitMessageGenerationTarget, { kind: 'local' }>
|
||||
|
||||
function runLocalPlanForAgent(
|
||||
agentId: string,
|
||||
plan: CommitMessagePlan,
|
||||
target: LocalGenerationTarget,
|
||||
emptyResultName: string,
|
||||
operation: TextGenerationOperation
|
||||
): Promise<InternalTextGenerationResult> {
|
||||
const run = (): Promise<InternalTextGenerationResult> =>
|
||||
runLocalPlan(plan, target.cwd, target.env, emptyResultName, operation, target.wslDistro)
|
||||
if (agentId !== 'codex') {
|
||||
// Why: no extra promise hops here — cancellation timing for non-codex
|
||||
// agents must stay byte-identical to a direct runLocalPlan call.
|
||||
return run()
|
||||
}
|
||||
return runCodexLocalPlanUnderHomeLock(run, target, operation)
|
||||
}
|
||||
|
||||
// Why: codex rewrites rotating OAuth tokens in its home's auth.json; the
|
||||
// per-home lock keeps this run from racing Orca's own quota probes there.
|
||||
async function runCodexLocalPlanUnderHomeLock(
|
||||
run: () => Promise<InternalTextGenerationResult>,
|
||||
target: LocalGenerationTarget,
|
||||
operation: TextGenerationOperation
|
||||
): Promise<InternalTextGenerationResult> {
|
||||
const laneKey = localLaneKey(operation, target.cwd)
|
||||
let canceledWhileQueued = false
|
||||
const queuedCancelToken = (): void => {
|
||||
canceledWhileQueued = true
|
||||
}
|
||||
// Why: Stop must work while this run waits behind a probe holding the lock.
|
||||
cancelTokensByLane.set(laneKey, queuedCancelToken)
|
||||
try {
|
||||
return await withCodexHomeProcessLock(
|
||||
resolveCodexHomeProcessLockKeyForSpawnEnv(target.env, target.wslDistro),
|
||||
async () => {
|
||||
if (canceledWhileQueued) {
|
||||
return { success: false, error: 'Generation canceled.', canceled: true }
|
||||
}
|
||||
return run()
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
if (cancelTokensByLane.get(laneKey) === queuedCancelToken) {
|
||||
cancelTokensByLane.delete(laneKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeFromAgentOutput(args: {
|
||||
code: number | null
|
||||
stdout: string
|
||||
@@ -890,13 +955,12 @@ export async function generateCommitMessageFromContext(
|
||||
const internalResult =
|
||||
target.kind === 'remote'
|
||||
? await runRemotePlan(planned.plan, target)
|
||||
: await runLocalPlan(
|
||||
: await runLocalPlanForAgent(
|
||||
params.agentId,
|
||||
planned.plan,
|
||||
target.cwd,
|
||||
target.env,
|
||||
target,
|
||||
'message',
|
||||
'commit-message',
|
||||
target.wslDistro
|
||||
'commit-message'
|
||||
)
|
||||
return formatCommitMessageGenerationResult(internalResult)
|
||||
}
|
||||
@@ -967,13 +1031,12 @@ export async function generatePullRequestFieldsFromContext(
|
||||
const internalResult =
|
||||
target.kind === 'remote'
|
||||
? await runRemotePlan(planned.plan, target, 'details', 'pull-request-fields')
|
||||
: await runLocalPlan(
|
||||
: await runLocalPlanForAgent(
|
||||
params.agentId,
|
||||
planned.plan,
|
||||
target.cwd,
|
||||
target.env,
|
||||
target,
|
||||
'details',
|
||||
'pull-request-fields',
|
||||
target.wslDistro
|
||||
'pull-request-fields'
|
||||
)
|
||||
return formatPullRequestFieldsGenerationResult(internalResult, context)
|
||||
}
|
||||
@@ -1014,13 +1077,12 @@ export async function generateBranchNameFromContext(
|
||||
const internalResult =
|
||||
target.kind === 'remote'
|
||||
? await runRemotePlan(planned.plan, target, 'branch name', 'branch-name')
|
||||
: await runLocalPlan(
|
||||
: await runLocalPlanForAgent(
|
||||
params.agentId,
|
||||
planned.plan,
|
||||
target.cwd,
|
||||
target.env,
|
||||
target,
|
||||
'branch name',
|
||||
'branch-name',
|
||||
target.wslDistro
|
||||
'branch-name'
|
||||
)
|
||||
if (!internalResult.success) {
|
||||
return internalResult
|
||||
|
||||
Reference in New Issue
Block a user