mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
* fix(codex): stop blocking the main thread on trust grants (#16441) Codex hook trust was granted by blocking the Electron main thread on `spawnSync` of a bundled ELECTRON_RUN_AS_NODE entry for the whole app-server deadline: 15s native, 35s WSL, ~45s on the real-home path (rebase inspect + repair + grant). Cold start and every Codex pane launch showed "Not Responding"; the reported event-loop gap was 15,049 ms. The subprocess only ever existed to donate an event loop to a deliberately blocked parent — `runCodexHookTrustGrantSession` was already the real async implementation. Make the callers async and the fork is unnecessary, so the bridge, the forked entry and its envelope are deleted along with their build/knip/tsconfig registrations. The CLI `agent hooks prepare-codex` handler is already async, so it awaits the in-process session and saves a process spawn per managed-home shell. `resolveCodexTrustGrantHost` is async too; the WSL identity probe moves from `execFileSync` to `runProcess`, dropping that file from the child-process import allowlist. Status reads keep a synchronous native-only stamp path. Two invariants that held only because the lane blocked: - Overlapping capability probes were impossible by construction. `GitCapabilityCache`'s dedupe engine is extracted to a shared `CapabilityProbeCache` and `CodexAppServerCapabilityCache` now inherits it, so concurrent launches against a cold host share one app-server session instead of one each. - Two grants on one `config.toml` could not interleave capture and restore. A reentrant per-file lane now serializes the whole install sequence (managed, WSL runtime, real-home ensure, legacy sweep) and the grant and rebase inside it. Cold-start work moves off the critical path: retained-home reconciliation (N sequential sessions) is fire-and-forget behind the daemon provider, and the startup real-home ensure chains into managed hook reconciliation instead of blocking app init. Every preserved semantic is unchanged: never throws, the ORCA_DISABLE_CODEX_TRUST_RPC kill switch, ledger hits, backfill-pending and cooldown fallbacks, config rollback on every failure path, pre-grant self-computed trust removal, the verify-failure taxonomy, diagnostics and telemetry. * fix(codex): widen the trust-config lane to every config.toml writer Review follow-ups on #16441's async trust grant: - `markCodexProjectTrusted` now runs inside the runtime+system config.toml lanes, so a project-trust write can no longer land inside a hook grant's capture->restore window and be silently reverted. Its callers await it. - `install`/`refreshRuntimeUserHooks`/`remove` hold the system config.toml lane as well as the runtime one — they promote approvals into ~/.codex/config.toml and mirror it back. Lock order is runtime-before-system everywhere. - The real-home ensure chain resumes after a rejection instead of returning the same rejected promise to every later pane launch, and resolving the real home is now inside the module's never-throws boundary. - `buildSpawnEnv` awaits inside a cancelable pending-spawn registration, so shutdown during the (now long) env build stops the PTY from launching. `prepareLocalPtySpawn` generalizes into `awaitCancelableLocalPtySpawn`. - CapabilityProbeCache drops the test-only `nowMs` passthrough; its probe backstop comment now describes what it actually guards. - Preflight is a plain async function; the trust dispatch in orca-runtime collapses into one `markWorkspaceTrustedForAgent`. * test(codex): exercise the trust-config lane under real concurrency The async grant makes two pane launches overlap for the first time. These drive the real modules end to end on real files: a rollback swallowing a sibling's grant, a markCodexProjectTrusted write landing inside a capture -> restore window, shared capability-probe dedupe on a cold host, the host-scoped transient cooldown, and reentrancy from inside an installer. Each was verified to fail against a deliberately broken implementation (lane removed, dedupe disabled, cooldown made global, reentrancy pass- through disabled). * test(codex): stop hook-service suites spawning the developer's real codex The forked grant bundle never existed under vitest, so the RPC lane was unreachable in tests on main. Running it in-process makes these suites spawn a real `codex app-server` when one is installed: 38 spawns and two failures in hook-service-runtime-trust-repair on a machine with codex, green in CI where there is none. Stand in for the missing binary so both environments exercise the same fallback lane. * docs(codex): scope the trust-RPC kill switch comment to what it actually gates The comment read as though the flag forces the fallback lane everywhere. It gates the managed grant only: the real-home rebase still runs its own inspect/repair app-server sessions when Orca's insertion shifts a user's hook positions, and never reads the flag. Verified by exercise, not by reading — with the flag set, both inspect-user-hook-trust and repair-user-hook-trust still ran. Pre-existing: main has no check there either, it just blocked the main thread while doing it. Widening the flag to cover the rebase is a follow-up; this only stops the comment promising something the constant does not do.
129 lines
4.4 KiB
TypeScript
129 lines
4.4 KiB
TypeScript
/**
|
|
* Optimistic capability probing with a bounded retry window and in-flight
|
|
* probe dedupe.
|
|
*
|
|
* Extracted from GitCapabilityCache so every host-capability cache in the tree
|
|
* gets the same three behaviors: probe once, remember only a positive absence
|
|
* signal, and let a concurrent caller wait on the probe already running rather
|
|
* than starting a duplicate one.
|
|
*/
|
|
export type CapabilityProbeOutcome = 'supported' | 'unsupported' | 'unknown'
|
|
|
|
export class CapabilityProbeCache<TCapability> {
|
|
private readonly retryAfterByCapability = new Map<TCapability, number>()
|
|
private readonly probesByCapability = new Map<TCapability, Promise<CapabilityProbeOutcome>>()
|
|
private readonly supportedCapabilities = new Set<TCapability>()
|
|
|
|
constructor(private readonly retryIntervalMs: number) {}
|
|
|
|
shouldTry(capability: TCapability, nowMs = Date.now()): boolean {
|
|
const retryAfterMs = this.retryAfterByCapability.get(capability)
|
|
if (retryAfterMs === undefined) {
|
|
return true
|
|
}
|
|
if (nowMs < retryAfterMs) {
|
|
return false
|
|
}
|
|
this.retryAfterByCapability.delete(capability)
|
|
return true
|
|
}
|
|
|
|
isKnownSupported(capability: TCapability): boolean {
|
|
return this.supportedCapabilities.has(capability)
|
|
}
|
|
|
|
rememberSupported(capability: TCapability): void {
|
|
this.retryAfterByCapability.delete(capability)
|
|
this.supportedCapabilities.add(capability)
|
|
}
|
|
|
|
rememberUnsupported(capability: TCapability, nowMs = Date.now()): void {
|
|
// Why: optimistic probes preserve newer behavior, but repeating a known
|
|
// failure on every poll/search wastes subprocesses and trace space.
|
|
this.supportedCapabilities.delete(capability)
|
|
this.retryAfterByCapability.set(capability, nowMs + this.retryIntervalMs)
|
|
}
|
|
|
|
async runWithFallback<T>(
|
|
capability: TCapability,
|
|
runPreferred: () => Promise<T>,
|
|
runFallback: () => Promise<T>,
|
|
isUnsupportedError: (error: unknown) => boolean
|
|
): Promise<T> {
|
|
if (this.supportedCapabilities.has(capability)) {
|
|
// Why: supported commands are real work, not disposable probes. Let
|
|
// sibling repo/SSH calls retain their intended concurrency.
|
|
return this.runPreferredOrFallback(capability, runPreferred, runFallback, isUnsupportedError)
|
|
}
|
|
if (!this.shouldTry(capability)) {
|
|
return runFallback()
|
|
}
|
|
|
|
const inFlightProbe = this.probesByCapability.get(capability)
|
|
if (inFlightProbe) {
|
|
const outcome = await inFlightProbe
|
|
if (outcome === 'unsupported' || !this.shouldTry(capability)) {
|
|
return runFallback()
|
|
}
|
|
return this.runPreferredOrFallback(capability, runPreferred, runFallback, isUnsupportedError)
|
|
}
|
|
|
|
let settleProbe!: (outcome: CapabilityProbeOutcome) => void
|
|
const probe = new Promise<CapabilityProbeOutcome>((resolve) => {
|
|
settleProbe = resolve
|
|
})
|
|
this.probesByCapability.set(capability, probe)
|
|
try {
|
|
return await this.runPreferredOrFallback(
|
|
capability,
|
|
runPreferred,
|
|
runFallback,
|
|
isUnsupportedError,
|
|
settleProbe
|
|
)
|
|
} finally {
|
|
if (this.probesByCapability.get(capability) === probe) {
|
|
this.probesByCapability.delete(capability)
|
|
}
|
|
// Backstop: `isUnsupportedError` or `rememberUnsupported` can throw
|
|
// before the settle below them runs; waiters must not hang behind it.
|
|
settleProbe('unknown')
|
|
}
|
|
}
|
|
|
|
clear(): void {
|
|
this.retryAfterByCapability.clear()
|
|
this.probesByCapability.clear()
|
|
this.supportedCapabilities.clear()
|
|
}
|
|
|
|
private async runPreferredOrFallback<T>(
|
|
capability: TCapability,
|
|
runPreferred: () => Promise<T>,
|
|
runFallback: () => Promise<T>,
|
|
isUnsupportedError: (error: unknown) => boolean,
|
|
settleProbe?: (outcome: CapabilityProbeOutcome) => void
|
|
): Promise<T> {
|
|
try {
|
|
const result = await runPreferred()
|
|
// A preferred callback can detect a weaker positive signal (old Git's
|
|
// exit-zero option echo) and remember it as unsupported, so do not
|
|
// overwrite that stronger signal.
|
|
const outcome = this.retryAfterByCapability.has(capability) ? 'unsupported' : 'supported'
|
|
if (outcome === 'supported') {
|
|
this.supportedCapabilities.add(capability)
|
|
}
|
|
settleProbe?.(outcome)
|
|
return result
|
|
} catch (error) {
|
|
if (!isUnsupportedError(error)) {
|
|
settleProbe?.('unknown')
|
|
throw error
|
|
}
|
|
this.rememberUnsupported(capability)
|
|
settleProbe?.('unsupported')
|
|
return runFallback()
|
|
}
|
|
}
|
|
}
|