Merge branch 'ra/max-lines-bottom-13-gitlab-item-dialog' into ra/max-lines-bottom-14-relay-dispatcher

This commit is contained in:
Neil
2026-08-29 15:51:29 -07:00
236 changed files with 6795 additions and 2641 deletions
@@ -44,9 +44,20 @@ __orca_omp_should_skip_extension() {
esac
return 1
}
__orca_omp() {
local __orca_use_extension=1
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
__orca_omp_cwd_is_usable() {
local __orca_physical_cwd
[[ -x . ]] || return 1
if [[ -n "${PWD:-}" && -d "${PWD:-}" ]]; then
[[ "${PWD}" -ef . ]]
else
# Why compare the path: shell builtins can print a cached path for a deleted cwd.
__orca_physical_cwd="$(builtin pwd -P 2>/dev/null)" || return 1
[[ -d "$__orca_physical_cwd" && "$__orca_physical_cwd" -ef . ]]
fi
}
__orca_omp_invoke() {
local __orca_use_extension="$1"
shift
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
if [[ "${1:-}" == "launch" ]]; then
shift
@@ -58,6 +69,27 @@ __orca_omp() {
command omp "$@"
fi
}
__orca_omp() {
local __orca_use_extension=1
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
if ! __orca_omp_cwd_is_usable; then
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
(
if [[ -z "$__orca_logical_cwd" ]]; then
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
return 1
fi
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
return 1
fi
__orca_omp_invoke "$__orca_use_extension" "$@"
)
else
__orca_omp_invoke "$__orca_use_extension" "$@"
fi
}
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
# Why the function reserved word: it suppresses alias expansion of the name, which
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
@@ -82,9 +82,20 @@ __orca_deferred_init() {
esac
return 1
}
__orca_omp() {
local __orca_use_extension=1
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
__orca_omp_cwd_is_usable() {
local __orca_physical_cwd
[[ -x . ]] || return 1
if [[ -n "${PWD:-}" && -d "${PWD:-}" ]]; then
[[ "${PWD}" -ef . ]]
else
# Why compare the path: shell builtins can print a cached path for a deleted cwd.
__orca_physical_cwd="$(builtin pwd -P 2>/dev/null)" || return 1
[[ -d "$__orca_physical_cwd" && "$__orca_physical_cwd" -ef . ]]
fi
}
__orca_omp_invoke() {
local __orca_use_extension="$1"
shift
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
if [[ "${1:-}" == "launch" ]]; then
shift
@@ -96,6 +107,27 @@ __orca_deferred_init() {
command omp "$@"
fi
}
__orca_omp() {
local __orca_use_extension=1
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
if ! __orca_omp_cwd_is_usable; then
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
(
if [[ -z "$__orca_logical_cwd" ]]; then
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
return 1
fi
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
return 1
fi
__orca_omp_invoke "$__orca_use_extension" "$@"
)
else
__orca_omp_invoke "$__orca_use_extension" "$@"
fi
}
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
# Why the function reserved word: it suppresses alias expansion of the name, which
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
@@ -47,9 +47,20 @@ __orca_omp_should_skip_extension() {
esac
return 1
}
__orca_omp() {
local __orca_use_extension=1
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
__orca_omp_cwd_is_usable() {
local __orca_physical_cwd
[[ -x . ]] || return 1
if [[ -n "${PWD:-}" && -d "${PWD:-}" ]]; then
[[ "${PWD}" -ef . ]]
else
# Why compare the path: shell builtins can print a cached path for a deleted cwd.
__orca_physical_cwd="$(builtin pwd -P 2>/dev/null)" || return 1
[[ -d "$__orca_physical_cwd" && "$__orca_physical_cwd" -ef . ]]
fi
}
__orca_omp_invoke() {
local __orca_use_extension="$1"
shift
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
if [[ "${1:-}" == "launch" ]]; then
shift
@@ -61,6 +72,27 @@ __orca_omp() {
command omp "$@"
fi
}
__orca_omp() {
local __orca_use_extension=1
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
if ! __orca_omp_cwd_is_usable; then
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
(
if [[ -z "$__orca_logical_cwd" ]]; then
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
return 1
fi
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
return 1
fi
__orca_omp_invoke "$__orca_use_extension" "$@"
)
else
__orca_omp_invoke "$__orca_use_extension" "$@"
fi
}
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
# Why the function reserved word: it suppresses alias expansion of the name, which
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
@@ -82,9 +82,20 @@ __orca_deferred_init() {
esac
return 1
}
__orca_omp() {
local __orca_use_extension=1
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
__orca_omp_cwd_is_usable() {
local __orca_physical_cwd
[[ -x . ]] || return 1
if [[ -n "${PWD:-}" && -d "${PWD:-}" ]]; then
[[ "${PWD}" -ef . ]]
else
# Why compare the path: shell builtins can print a cached path for a deleted cwd.
__orca_physical_cwd="$(builtin pwd -P 2>/dev/null)" || return 1
[[ -d "$__orca_physical_cwd" && "$__orca_physical_cwd" -ef . ]]
fi
}
__orca_omp_invoke() {
local __orca_use_extension="$1"
shift
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
if [[ "${1:-}" == "launch" ]]; then
shift
@@ -96,6 +107,27 @@ __orca_deferred_init() {
command omp "$@"
fi
}
__orca_omp() {
local __orca_use_extension=1
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
if ! __orca_omp_cwd_is_usable; then
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
(
if [[ -z "$__orca_logical_cwd" ]]; then
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
return 1
fi
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
return 1
fi
__orca_omp_invoke "$__orca_use_extension" "$@"
)
else
__orca_omp_invoke "$__orca_use_extension" "$@"
fi
}
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
# Why the function reserved word: it suppresses alias expansion of the name, which
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
@@ -36,9 +36,20 @@ __orca_omp_should_skip_extension() {
esac
return 1
}
__orca_omp() {
local __orca_use_extension=1
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
__orca_omp_cwd_is_usable() {
local __orca_physical_cwd
[[ -x . ]] || return 1
if [[ -n "${PWD:-}" && -d "${PWD:-}" ]]; then
[[ "${PWD}" -ef . ]]
else
# Why compare the path: shell builtins can print a cached path for a deleted cwd.
__orca_physical_cwd="$(builtin pwd -P 2>/dev/null)" || return 1
[[ -d "$__orca_physical_cwd" && "$__orca_physical_cwd" -ef . ]]
fi
}
__orca_omp_invoke() {
local __orca_use_extension="$1"
shift
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
if [[ "${1:-}" == "launch" ]]; then
shift
@@ -50,6 +61,27 @@ __orca_omp() {
command omp "$@"
fi
}
__orca_omp() {
local __orca_use_extension=1
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
if ! __orca_omp_cwd_is_usable; then
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
(
if [[ -z "$__orca_logical_cwd" ]]; then
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
return 1
fi
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
return 1
fi
__orca_omp_invoke "$__orca_use_extension" "$@"
)
else
__orca_omp_invoke "$__orca_use_extension" "$@"
fi
}
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
# Why the function reserved word: it suppresses alias expansion of the name, which
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
@@ -56,9 +56,20 @@ __orca_deferred_init() {
esac
return 1
}
__orca_omp() {
local __orca_use_extension=1
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
__orca_omp_cwd_is_usable() {
local __orca_physical_cwd
[[ -x . ]] || return 1
if [[ -n "${PWD:-}" && -d "${PWD:-}" ]]; then
[[ "${PWD}" -ef . ]]
else
# Why compare the path: shell builtins can print a cached path for a deleted cwd.
__orca_physical_cwd="$(builtin pwd -P 2>/dev/null)" || return 1
[[ -d "$__orca_physical_cwd" && "$__orca_physical_cwd" -ef . ]]
fi
}
__orca_omp_invoke() {
local __orca_use_extension="$1"
shift
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
if [[ "${1:-}" == "launch" ]]; then
shift
@@ -70,6 +81,27 @@ __orca_deferred_init() {
command omp "$@"
fi
}
__orca_omp() {
local __orca_use_extension=1
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
if ! __orca_omp_cwd_is_usable; then
local __orca_logical_cwd="${PWD:-${ORCA_WORKTREE_PATH:-${ORCA_ROOT_PATH:-}}}"
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
(
if [[ -z "$__orca_logical_cwd" ]]; then
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\n' >&2
return 1
fi
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\n' "$__orca_logical_cwd" >&2
return 1
fi
__orca_omp_invoke "$__orca_use_extension" "$@"
)
else
__orca_omp_invoke "$__orca_use_extension" "$@"
fi
}
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
# Why the function reserved word: it suppresses alias expansion of the name, which
# an `alias omp` otherwise rewrites at parse time, aborting the rest of the file.
+20 -20
View File
@@ -85,23 +85,21 @@ export async function scanAiVaultSessions(
const candidates = dedupeCodexRolloutFileAliases(
discoveries
.flatMap((discovery) =>
discovery.files.map(
(file): SessionFileCandidate => ({
agent: discovery.agent,
file,
codexHome:
discovery.agent === 'codex'
? codexHomeForSessionsDir(
discovery.rootDir,
options.defaultCodexHomeDir ?? DEFAULT_CODEX_HOME_DIR
)
: null,
antigravityHistoryPath:
discovery.agent === 'antigravity'
? antigravityHistoryPathForBrainDir(discovery.rootDir)
: undefined
})
)
discovery.files.map((file): SessionFileCandidate => ({
agent: discovery.agent,
file,
codexHome:
discovery.agent === 'codex'
? codexHomeForSessionsDir(
discovery.rootDir,
options.defaultCodexHomeDir ?? DEFAULT_CODEX_HOME_DIR
)
: null,
antigravityHistoryPath:
discovery.agent === 'antigravity'
? antigravityHistoryPathForBrainDir(discovery.rootDir)
: undefined
}))
)
.sort((left, right) => right.file.mtimeMs - left.file.mtimeMs),
{
@@ -203,9 +201,11 @@ async function scanInScopeSessions(args: {
excludedFilePaths: args.alreadyParsedFilePaths,
issues: args.issues
})
const candidates = files.map(
(file): SessionFileCandidate => ({ agent: 'claude', file, codexHome: null })
)
const candidates = files.map((file): SessionFileCandidate => ({
agent: 'claude',
file,
codexHome: null
}))
if (candidates.length === 0) {
return []
}
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { build as buildVite } from 'vite'
import { resolveElectronProbeLaunch } from './electron-probe-display-launch'
const electronBinary = createRequire(import.meta.url)('electron') as string
const fixtureRoots: string[] = []
@@ -276,11 +277,12 @@ async function runFixture(): Promise<FixtureResult> {
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
const electronArgs = [mainPath, `--user-data-dir=${join(root, 'profile')}`]
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
const args =
process.platform === 'linux'
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
: electronArgs
const { executable, args } = resolveElectronProbeLaunch({
electronBinary,
electronArgs,
platform: process.platform,
display: env.DISPLAY
})
const run = spawnSync(executable, args, { encoding: 'utf8', env, timeout: 60_000 })
const rawResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
expect(run.error).toBeUndefined()
@@ -160,7 +160,9 @@ export function forwardGuestShortcutInput(
if (isFloatingGuest) {
renderer.send('ui:closeFloatingItem', { sourceId: browserTabId })
} else {
renderer.send('ui:closeActiveTab')
// Why: carry the guest's page id — activeTabType/activeBrowserTabId can be stale in split
// layouts (guest focus doesn't reach the group's focus-capture), silently dropping the close.
renderer.send('ui:closeActiveTab', { sourceId: browserTabId })
}
} else if (keybindingMatchesAction('tab.nextSameType', input, process.platform, keybindings)) {
renderer.send('ui:switchTab', 1)
@@ -872,7 +872,9 @@ describe('setupGuestShortcutForwarding', () => {
expect(rendererSendMock).toHaveBeenCalledWith('ui:closeFloatingItem', {
sourceId: browserTabId
})
expect(rendererSendMock).not.toHaveBeenCalledWith('ui:closeActiveTab')
expect(rendererSendMock.mock.calls.some(([channel]) => channel === 'ui:closeActiveTab')).toBe(
false
)
})
it('routes workspace/tab index chords to ui:selectFloatingIndex for a floating guest', () => {
@@ -903,7 +905,7 @@ describe('setupGuestShortcutForwarding', () => {
triggerBeforeInput(closeInput)
triggerBeforeInput(workspaceIndexInput)
expect(rendererSendMock).toHaveBeenCalledWith('ui:closeActiveTab')
expect(rendererSendMock).toHaveBeenCalledWith('ui:closeActiveTab', { sourceId: browserTabId })
expect(rendererSendMock).toHaveBeenCalledWith('ui:jumpToWorktreeIndex', 0)
expect(rendererSendMock).not.toHaveBeenCalledWith('ui:closeFloatingItem', expect.anything())
expect(rendererSendMock).not.toHaveBeenCalledWith('ui:selectFloatingIndex', expect.anything())
@@ -918,7 +920,7 @@ describe('setupGuestShortcutForwarding', () => {
triggerBeforeInput(closeInput)
expect(rendererSendMock).toHaveBeenCalledWith('ui:closeActiveTab')
expect(rendererSendMock).toHaveBeenCalledWith('ui:closeActiveTab', { sourceId: browserTabId })
expect(rendererSendMock).not.toHaveBeenCalledWith('ui:closeFloatingItem', expect.anything())
})
@@ -297,7 +297,9 @@ describe('browserManager', () => {
expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:newBrowserTab')
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:newTerminalTab')
expect(rendererSendMock).toHaveBeenNthCalledWith(3, 'ui:closeActiveTab')
expect(rendererSendMock).toHaveBeenNthCalledWith(3, 'ui:closeActiveTab', {
sourceId: 'browser-1'
})
expect(rendererSendMock).toHaveBeenNthCalledWith(4, 'ui:switchTabAcrossAllTypes', 1)
expect(rendererSendMock).toHaveBeenNthCalledWith(5, 'ui:switchTab', 1)
expect(rendererSendMock).toHaveBeenNthCalledWith(6, 'ui:switchTerminalTab', 1)
@@ -2,6 +2,7 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { resolveElectronProbeLaunch } from './electron-probe-display-launch'
const electronBinary = createRequire(import.meta.url)('electron') as string
@@ -20,12 +21,13 @@ export async function runBrowserRouteEgressElectron(
`--user-data-dir=${join(root, 'profile')}`,
...extraElectronArgs
]
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
const args =
process.platform === 'linux'
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
: electronArgs
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
const { executable, args } = resolveElectronProbeLaunch({
electronBinary,
electronArgs,
platform: process.platform,
display: env.DISPLAY
})
const child = spawn(executable, args, {
detached: true,
env,
@@ -2,6 +2,7 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
import { existsSync, readFileSync, rmSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { resolveElectronProbeLaunch } from './electron-probe-display-launch'
const electronBinary = createRequire(import.meta.url)('electron') as string
@@ -20,12 +21,13 @@ export async function runPersistedWorkerElectron(
const resultPath = join(root, 'result.json')
rmSync(resultPath, { force: true })
const electronArgs = [mainPath, configPath, mode, `--user-data-dir=${join(root, 'profile')}`]
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
const args =
process.platform === 'linux'
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
: electronArgs
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
const { executable, args } = resolveElectronProbeLaunch({
electronBinary,
electronArgs,
platform: process.platform,
display: env.DISPLAY
})
const child = spawn(executable, args, {
detached: true,
env,
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { resolveElectronProbeLaunch } from './electron-probe-display-launch'
const electronBinary = createRequire(import.meta.url)('electron') as string
const fixtureRoots: string[] = []
@@ -138,11 +139,12 @@ function runProbe(protectedGuest: boolean): ProbeResult {
writeFileSync(mainPath, probeMain(resultPath, protectedGuest))
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
const electronArgs = [mainPath, `--user-data-dir=${join(root, 'profile')}`]
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
const args =
process.platform === 'linux'
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
: electronArgs
const { executable, args } = resolveElectronProbeLaunch({
electronBinary,
electronArgs,
platform: process.platform,
display: env.DISPLAY
})
const run = spawnSync(executable, args, { encoding: 'utf8', env, timeout: 30_000 })
const rawResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
expect(run.error).toBeUndefined()
+1 -1
View File
@@ -822,7 +822,7 @@ export class CdpBridge {
async tabSwitch(index: number): Promise<BrowserTabSwitchResult> {
// Why: filter to live tabs so indices match tabList(), skipping destroyed-but-uncleaned entries.
const liveEntries = [...this.getRegisteredTabs()].filter(([_, wcId]) => {
const liveEntries = [...this.getRegisteredTabs()].filter(([_tabId, wcId]) => {
const guest = webContents.fromId(wcId)
return guest && !guest.isDestroyed()
})
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { resolveElectronProbeLaunch } from './electron-probe-display-launch'
const electronBinary = '/tmp/electron'
const electronArgs = ['/tmp/main.cjs', '--user-data-dir=/tmp/profile']
describe('electron probe launch resolution', () => {
it('reuses an inherited X display instead of nesting another xvfb-run server', () => {
expect(
resolveElectronProbeLaunch({
electronBinary,
electronArgs,
platform: 'linux',
display: ':99'
})
).toEqual({
executable: electronBinary,
args: ['/tmp/main.cjs', '--user-data-dir=/tmp/profile', '--no-sandbox']
})
})
it('owns a display when Linux hands the probe none', () => {
expect(
resolveElectronProbeLaunch({
electronBinary,
electronArgs,
platform: 'linux',
display: undefined
})
).toEqual({
executable: 'xvfb-run',
args: [
'--auto-servernum',
electronBinary,
'/tmp/main.cjs',
'--user-data-dir=/tmp/profile',
'--no-sandbox'
]
})
})
it('treats an empty DISPLAY as no display', () => {
expect(
resolveElectronProbeLaunch({ electronBinary, electronArgs, platform: 'linux', display: '' })
.executable
).toBe('xvfb-run')
})
it('leaves non-Linux launches on the plain Electron binary', () => {
expect(
resolveElectronProbeLaunch({
electronBinary,
electronArgs,
platform: 'darwin',
display: undefined
})
).toEqual({ executable: electronBinary, args: electronArgs })
})
})
@@ -0,0 +1,31 @@
export type ElectronProbeLaunch = {
executable: string
args: string[]
}
export type ElectronProbeLaunchInput = {
electronBinary: string
electronArgs: readonly string[]
platform: NodeJS.Platform
display: string | undefined
}
// Why: these probes each start a full Electron stack, and CI already runs the whole vitest
// invocation under one `xvfb-run`. Nesting a private `--auto-servernum` server per probe adds an
// X server per concurrent file and races the sibling probes for a free display number, so reuse
// the display we were handed and only own one when there is none.
export function resolveElectronProbeLaunch({
electronBinary,
electronArgs,
platform,
display
}: ElectronProbeLaunchInput): ElectronProbeLaunch {
if (platform !== 'linux') {
return { executable: electronBinary, args: [...electronArgs] }
}
const linuxArgs = [...electronArgs, '--no-sandbox']
if (display) {
return { executable: electronBinary, args: linuxArgs }
}
return { executable: 'xvfb-run', args: ['--auto-servernum', electronBinary, ...linuxArgs] }
}
@@ -33,8 +33,8 @@ describe('estimateCostUsd cache-write TTL rates', () => {
})
it('never lowers a long-context estimate as writes shift from 5-minute to 1-hour', () => {
const costs = [0, 50_000, 100_000, 200_000, 300_000, 400_000].map(
(write1h) => estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, write1h)!
const costs = [0, 50_000, 100_000, 200_000, 300_000, 400_000].map((write1h) =>
estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, write1h)!
)
for (let index = 1; index < costs.length; index++) {
expect(costs[index]).toBeGreaterThan(costs[index - 1])
@@ -205,9 +205,10 @@ export function createRuntimeHome(): RuntimeHomeStub {
return {
syncForCurrentSelection: vi.fn(),
clearLastWrittenAuthJson: vi.fn(),
prepareForRateLimitFetch: vi.fn(
(): CodexRateLimitHomeResolution => ({ kind: 'ready', codexHomePath: null })
)
prepareForRateLimitFetch: vi.fn((): CodexRateLimitHomeResolution => ({
kind: 'ready',
codexHomePath: null
}))
}
}
@@ -191,13 +191,11 @@ describe('Codex state DB backfill recovery', () => {
const summary = await runCodexStateDbBackfillRecovery('/managed-home', controller.signal, {
spawnProcess: spawnProcess as never,
readStatus: vi.fn(
(): CodexStateDbBackfillStatus => ({
kind: 'incomplete',
stateDbPath: '/state.sqlite',
status: 'running'
})
),
readStatus: vi.fn((): CodexStateDbBackfillStatus => ({
kind: 'incomplete',
stateDbPath: '/state.sqlite',
status: 'running'
})),
terminate,
sleep,
now: () => now
@@ -270,15 +268,14 @@ describe('Codex state DB backfill recovery', () => {
new AbortController().signal,
{
spawnProcess: spawnProcess as never,
readStatus: vi.fn(
(): CodexStateDbBackfillStatus =>
spawnProcess.mock.calls.length >= 3
? { kind: 'complete', stateDbPath: '/state.sqlite' }
: {
kind: 'incomplete',
stateDbPath: '/state.sqlite',
status: 'running'
}
readStatus: vi.fn((): CodexStateDbBackfillStatus =>
spawnProcess.mock.calls.length >= 3
? { kind: 'complete', stateDbPath: '/state.sqlite' }
: {
kind: 'incomplete',
stateDbPath: '/state.sqlite',
status: 'running'
}
),
terminate: terminate as never,
sleep,
@@ -328,11 +325,10 @@ describe('Codex state DB backfill recovery', () => {
await expect(
runCodexStateDbBackfillRecovery('/managed-home', new AbortController().signal, {
spawnProcess: spawnProcess as never,
readStatus: vi.fn(
(): CodexStateDbBackfillStatus =>
spawnCount >= 2
? { kind: 'complete', stateDbPath: '/state.sqlite' }
: { kind: 'incomplete', stateDbPath: '/state.sqlite', status: 'running' }
readStatus: vi.fn((): CodexStateDbBackfillStatus =>
spawnCount >= 2
? { kind: 'complete', stateDbPath: '/state.sqlite' }
: { kind: 'incomplete', stateDbPath: '/state.sqlite', status: 'running' }
),
terminate,
sleep: vi.fn(async (ms: number) => {
@@ -427,11 +423,10 @@ describe('Codex state DB backfill recovery', () => {
}
return child
})
const readStatus = vi.fn(
(): CodexStateDbBackfillStatus =>
spawnCount >= 2
? { kind: 'complete', stateDbPath: '/state.sqlite' }
: { kind: 'incomplete', stateDbPath: '/state.sqlite', status: 'running' }
const readStatus = vi.fn((): CodexStateDbBackfillStatus =>
spawnCount >= 2
? { kind: 'complete', stateDbPath: '/state.sqlite' }
: { kind: 'incomplete', stateDbPath: '/state.sqlite', status: 'running' }
)
await expect(
@@ -0,0 +1,241 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { buildGpuCrashDiagnostics, GpuCrashDiagnosticsRecorder } from './gpu-crash-diagnostics'
const FEATURE_STATUS = {
gpu_compositing: 'enabled',
rasterization: 'enabled',
webgl: 'enabled',
webgl2: 'enabled',
video_decode: 'enabled'
}
const BASIC_INFO = {
gpuDevice: [
{
active: false,
vendorId: 0x8086,
deviceId: 0x9a49,
vendorString: 'Intel',
deviceString: 'Integrated GPU'
},
{
active: true,
vendorId: 0x10de,
deviceId: 0x2684,
vendorString: 'NVIDIA',
deviceString: 'Discrete GPU',
driverVendor: 'NVIDIA',
driverVersion: '32.0.15.6094'
}
],
auxAttributes: {
glVendor: 'Google Inc.',
glRenderer: 'ANGLE (NVIDIA, D3D11)',
glVersion: 'OpenGL ES 3.0'
}
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
} {
let resolvePromise: ((value: T) => void) | undefined
const promise = new Promise<T>((resolve) => {
resolvePromise = resolve
})
return {
promise,
resolve: (value) => resolvePromise?.(value)
}
}
describe('buildGpuCrashDiagnostics', () => {
it('keeps only the active GPU identity, driver, rendering backend, and feature status', () => {
expect(
buildGpuCrashDiagnostics({ info: BASIC_INFO, level: 'complete' }, FEATURE_STATUS)
).toEqual({
gpuInfoLevel: 'complete',
gpuDeviceCount: 2,
gpuVendorId: 0x10de,
gpuDeviceId: 0x2684,
gpuVendor: 'NVIDIA',
gpuDevice: 'Discrete GPU',
gpuDriverVendor: 'NVIDIA',
gpuDriverVersion: '32.0.15.6094',
gpuGlVendor: 'Google Inc.',
gpuGlRenderer: 'ANGLE (NVIDIA, D3D11)',
gpuGlVersion: 'OpenGL ES 3.0',
gpuCompositingStatus: 'enabled',
gpuRasterizationStatus: 'enabled',
gpuWebglStatus: 'enabled',
gpuWebgl2Status: 'enabled',
gpuVideoDecodeStatus: 'enabled'
})
})
it('degrades malformed or unavailable GPU info without copying arbitrary fields', () => {
expect(
buildGpuCrashDiagnostics(
{
level: 'basic',
info: {
gpuDevice: [{ active: true, vendorId: Number.NaN, secret: 'do not copy' }],
machineModelName: 'do not copy'
}
},
{ webgl: 'unavailable', unexpected: 'do not copy' }
)
).toEqual({
gpuInfoLevel: 'basic',
gpuDeviceCount: 1,
gpuWebglStatus: 'unavailable'
})
expect(buildGpuCrashDiagnostics(null, null)).toEqual({ gpuInfoLevel: 'unavailable' })
})
})
describe('GpuCrashDiagnosticsRecorder', () => {
it('warms only complete info and records one breadcrumb across a crash burst', async () => {
const recordBreadcrumb = vi.fn()
const provider = {
getGPUInfo: vi.fn(async (level: 'basic' | 'complete') => ({
...BASIC_INFO,
gpuDevice: BASIC_INFO.gpuDevice.map((device) => ({
...device,
...(level === 'complete' ? { driverVersion: 'complete-driver' } : {})
}))
})),
getGPUFeatureStatus: vi.fn(() => FEATURE_STATUS)
}
const recorder = new GpuCrashDiagnosticsRecorder({ provider, recordBreadcrumb })
recorder.warm()
await vi.waitFor(() => {
expect(provider.getGPUInfo).toHaveBeenCalledTimes(1)
})
await Promise.resolve()
await recorder.record()
await recorder.record()
expect(provider.getGPUInfo).toHaveBeenCalledOnce()
expect(provider.getGPUInfo).toHaveBeenCalledWith('complete')
expect(recordBreadcrumb).toHaveBeenCalledTimes(1)
expect(recordBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({
gpuInfoLevel: 'complete',
gpuVendorId: 0x10de,
gpuDeviceId: 0x2684,
gpuDriverVersion: 'complete-driver'
})
)
})
it('uses promptly available basic info when complete collection is still pending', async () => {
const complete = deferred<unknown>()
const recordBreadcrumb = vi.fn()
const provider = {
getGPUInfo: vi.fn((level: 'basic' | 'complete') =>
level === 'basic' ? Promise.resolve(BASIC_INFO) : complete.promise
),
getGPUFeatureStatus: vi.fn(() => FEATURE_STATUS)
}
const recorder = new GpuCrashDiagnosticsRecorder({ provider, recordBreadcrumb })
recorder.warm()
expect(provider.getGPUInfo).toHaveBeenCalledOnce()
expect(provider.getGPUInfo).toHaveBeenCalledWith('complete')
await recorder.record()
expect(recordBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({
gpuInfoLevel: 'basic',
gpuVendorId: 0x10de,
gpuDriverVersion: '32.0.15.6094'
})
)
complete.resolve(BASIC_INFO)
})
it('shares pending capture work and releases it when complete info arrives first', async () => {
const complete = deferred<unknown>()
const basic = deferred<unknown>()
const recordBreadcrumb = vi.fn()
const provider = {
getGPUInfo: vi.fn((level: 'basic' | 'complete') =>
level === 'basic' ? basic.promise : complete.promise
),
getGPUFeatureStatus: vi.fn(() => FEATURE_STATUS)
}
const recorder = new GpuCrashDiagnosticsRecorder({ provider, recordBreadcrumb })
recorder.warm()
const first = recorder.record()
const second = recorder.record()
expect(second).toBe(first)
expect(recordBreadcrumb).not.toHaveBeenCalled()
complete.resolve(BASIC_INFO)
await first
expect(recordBreadcrumb).toHaveBeenCalledOnce()
expect(recordBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({ gpuInfoLevel: 'complete' })
)
})
it('does not let stalled GPU info block crash recovery', async () => {
const never = Promise.withResolvers<unknown>().promise
const recordBreadcrumb = vi.fn()
const provider = {
getGPUInfo: vi.fn(() => never),
getGPUFeatureStatus: vi.fn(() => FEATURE_STATUS)
}
const recorder = new GpuCrashDiagnosticsRecorder({
provider,
recordBreadcrumb,
recordTimeoutMs: 0
})
await recorder.record()
expect(recordBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({ gpuInfoLevel: 'unavailable' })
)
})
it('still records collection status when Electron throws during both GPU info calls', async () => {
const recordBreadcrumb = vi.fn()
const provider = {
getGPUInfo: vi.fn(() => {
throw new Error('GPU access disabled')
}),
getGPUFeatureStatus: vi.fn(() => {
throw new Error('GPU teardown')
})
}
const recorder = new GpuCrashDiagnosticsRecorder({ provider, recordBreadcrumb })
recorder.warm()
await recorder.record()
expect(recordBreadcrumb).toHaveBeenCalledWith({ gpuInfoLevel: 'unavailable' })
})
})
describe('GPU crash diagnostics production wiring', () => {
it('starts diagnostics without delaying safe-graphics fallback', () => {
const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8')
const listenerStart = source.indexOf("app.on('child-process-gone'")
expect(listenerStart).toBeGreaterThan(0)
const listener = source.slice(listenerStart, source.indexOf('\n })', listenerStart))
expect(source).toMatch(
/recordBreadcrumb: \(data\) =>\s*recordDurableCrashBreadcrumb\('gpu_crash_hardware', data\)/
)
expect(listener).toMatch(
/const crashedAt = performance\.now\(\)[\s\S]*?void gpuCrashDiagnostics\?\.record\(\)[\s\S]*?void handleGpuChildCrash\(details\.reason, details\.exitCode \?\? null, crashedAt\)/
)
expect(listener).not.toMatch(/gpuCrashDiagnostics\?\.record\(\)[\s\S]*?\.then\(/)
})
})
@@ -0,0 +1,222 @@
import type { CrashReportBreadcrumbData } from '../../shared/crash-reporting'
type GpuInfoLevel = 'basic' | 'complete'
const DEFAULT_GPU_CRASH_DIAGNOSTICS_WAIT_MS = 1_000
type GpuInfoProvider = {
getGPUInfo(infoType: GpuInfoLevel): Promise<unknown>
getGPUFeatureStatus(): unknown
}
type GpuCrashDiagnosticsRecorderOptions = {
provider: GpuInfoProvider
recordBreadcrumb: (data: CrashReportBreadcrumbData) => void
recordTimeoutMs?: number
}
type GpuInfoSnapshot = {
info: unknown
level: GpuInfoLevel
}
async function waitAtMost(promise: Promise<void>, timeoutMs: number): Promise<void> {
const timeoutGate = Promise.withResolvers<void>()
const timeout = setTimeout(timeoutGate.resolve, timeoutMs)
try {
await Promise.race([promise, timeoutGate.promise])
} finally {
clearTimeout(timeout)
}
}
function waitForFirstAvailable(promises: Promise<boolean>[]): Promise<void> {
const availableGate = Promise.withResolvers<void>()
let remaining = promises.length
for (const promise of promises) {
void promise.then((available) => {
remaining -= 1
if (available || remaining === 0) {
availableGate.resolve()
}
})
}
return availableGate.promise
}
function recordValue(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
function finiteNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}
function addString(target: CrashReportBreadcrumbData, key: string, value: unknown): void {
const safe = nonEmptyString(value)
if (safe !== undefined) {
target[key] = safe
}
}
function addNumber(target: CrashReportBreadcrumbData, key: string, value: unknown): void {
const safe = finiteNumber(value)
if (safe !== undefined) {
target[key] = safe
}
}
function activeGpuDevice(info: Record<string, unknown>): {
device: Record<string, unknown> | null
count: number
} {
const devices = Array.isArray(info.gpuDevice)
? info.gpuDevice.map(recordValue).filter((device) => device !== null)
: []
return {
device: devices.find((device) => device.active === true) ?? devices[0] ?? null,
count: devices.length
}
}
function addFeatureStatuses(details: CrashReportBreadcrumbData, featureStatus: unknown): void {
const status = recordValue(featureStatus)
if (!status) {
return
}
addString(details, 'gpuCompositingStatus', status.gpu_compositing)
addString(details, 'gpuRasterizationStatus', status.rasterization)
addString(details, 'gpuWebglStatus', status.webgl)
addString(details, 'gpuWebgl2Status', status.webgl2)
addString(details, 'gpuVideoDecodeStatus', status.video_decode)
}
export function buildGpuCrashDiagnostics(
snapshot: GpuInfoSnapshot | null,
featureStatus: unknown
): CrashReportBreadcrumbData {
const details: CrashReportBreadcrumbData = {
gpuInfoLevel: snapshot?.level ?? 'unavailable'
}
addFeatureStatuses(details, featureStatus)
const info = recordValue(snapshot?.info)
if (!info) {
return details
}
const { device, count } = activeGpuDevice(info)
details.gpuDeviceCount = count
if (device) {
addNumber(details, 'gpuVendorId', device.vendorId)
addNumber(details, 'gpuDeviceId', device.deviceId)
addString(details, 'gpuVendor', device.vendorString)
addString(details, 'gpuDevice', device.deviceString)
addString(details, 'gpuDriverVendor', device.driverVendor)
addString(details, 'gpuDriverVersion', device.driverVersion)
}
const aux = recordValue(info.auxAttributes)
if (aux) {
addString(details, 'gpuGlVendor', aux.glVendor)
addString(details, 'gpuGlRenderer', aux.glRenderer)
addString(details, 'gpuGlVersion', aux.glVersion)
}
return details
}
/** Captures GPU identity before a crash and emits it once when a Windows GPU burst starts. */
export class GpuCrashDiagnosticsRecorder {
private readonly provider: GpuInfoProvider
private readonly recordBreadcrumb: (data: CrashReportBreadcrumbData) => void
private readonly recordTimeoutMs: number
private basicInfoPromise: Promise<boolean> | null = null
private completeInfoPromise: Promise<boolean> | null = null
private recordingPromise: Promise<void> | null = null
private basicInfo: unknown = null
private completeInfo: unknown = null
constructor(options: GpuCrashDiagnosticsRecorderOptions) {
this.provider = options.provider
this.recordBreadcrumb = options.recordBreadcrumb
this.recordTimeoutMs = options.recordTimeoutMs ?? DEFAULT_GPU_CRASH_DIAGNOSTICS_WAIT_MS
}
warm(): void {
void this.ensureCompleteInfo()
}
record(): Promise<void> {
this.recordingPromise ??= this.recordOnce()
return this.recordingPromise
}
private async recordOnce(): Promise<void> {
let featureStatus: unknown = null
try {
featureStatus = this.provider.getGPUFeatureStatus()
} catch {
// GPU teardown can race this read; device identity is still useful.
}
if (this.completeInfo === null) {
await waitAtMost(
waitForFirstAvailable([this.ensureBasicInfo(), this.ensureCompleteInfo()]),
this.recordTimeoutMs
)
}
const snapshot = this.preferredSnapshot()
try {
this.recordBreadcrumb(buildGpuCrashDiagnostics(snapshot, featureStatus))
} catch {
// Diagnostics must never block safe-graphics recovery.
}
}
private ensureBasicInfo(): Promise<boolean> {
if (this.basicInfoPromise === null) {
try {
this.basicInfoPromise = this.provider.getGPUInfo('basic').then(
(info) => {
this.basicInfo = info
return true
},
() => false
)
} catch {
this.basicInfoPromise = Promise.resolve(false)
}
}
return this.basicInfoPromise
}
private ensureCompleteInfo(): Promise<boolean> {
if (this.completeInfoPromise === null) {
try {
this.completeInfoPromise = this.provider.getGPUInfo('complete').then(
(info) => {
this.completeInfo = info
return true
},
() => false
)
} catch {
this.completeInfoPromise = Promise.resolve(false)
}
}
return this.completeInfoPromise
}
private preferredSnapshot(): GpuInfoSnapshot | null {
if (this.completeInfo !== null) {
return { info: this.completeInfo, level: 'complete' }
}
if (this.basicInfo !== null) {
return { info: this.basicInfo, level: 'basic' }
}
return null
}
}
@@ -0,0 +1,130 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD,
DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
GpuCrashFallbackTracker,
isGpuFallbackCrashCandidate
} from './gpu-crash-fallback-decision'
import { shouldRecordProcessGoneCrash } from './process-gone-classification'
/**
* Replays the win32 GPU-child deaths from the 1.4.190 'crashed' renderer cluster
* through the production decision path, so the reason no host was ever offered
* Safe Graphics Mode is pinned rather than argued about.
*
* Each session below is one crash report's `Recent activity`; a breadcrumb's
* `suppressedSinceLast=N` means N further identical GPU deaths were coalesced
* into it, so the session saw N+1 GPU crashes.
*/
type FieldSession = {
/** Crash-report id from the field bundle. */
report: string
/** GPU child crash times, ms since main-process start. */
gpuCrashesMsSinceLaunch: number[]
}
// crashed.txt: every win32 report in the cluster. Times are (breadcrumb ts -
// mainProcessStartedAt); coalesced repeats are placed inside the same second,
// which is the only interval the emitted breadcrumb pins them to.
const CRASHED_CLUSTER_SESSIONS: FieldSession[] = [
// 23:36:54.200 - 23:36:49.931, suppressedSinceLast=1 -> 2 crashes
{ report: 'db1f1ee2', gpuCrashesMsSinceLaunch: [4_269, 4_800] },
// 02:58:25.287 - 02:58:20.749, no suppression -> 1 crash
{ report: '66cc54d8', gpuCrashesMsSinceLaunch: [4_538] },
// 21:47:05.776 - 21:46:59.584, suppressedSinceLast=1 -> 2 crashes
{ report: '1f5564de', gpuCrashesMsSinceLaunch: [6_192, 6_240] },
// 00:17:17.840 - 00:17:15.732, no suppression -> 1 crash
{ report: '96d8c63b', gpuCrashesMsSinceLaunch: [2_108] }
]
/** index.ts's `child-process-gone` listener body — the wiring these claims rest on. */
function readChildProcessGoneListener(): string {
const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8')
const start = source.indexOf("app.on('child-process-gone'")
expect(start).toBeGreaterThan(0)
return source.slice(start, source.indexOf('\n })', start))
}
function newTracker(): GpuCrashFallbackTracker {
return new GpuCrashFallbackTracker({
windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD
})
}
const FIELD_GPU_EVENT = {
source: 'child',
processType: 'GPU',
serviceName: 'GPU',
reason: 'crashed',
expectedTeardown: 'none'
} as const
describe('1.4.190 win32 GPU-child crash cluster', () => {
it('does not let process_gone_suppressed gate the fallback candidate check', () => {
// The GPU death is suppressed as recoverable churn (no user-facing report)...
expect(shouldRecordProcessGoneCrash({ ...FIELD_GPU_EVENT, exitCode: -2147483645 })).toBe(false)
// ...but the fallback path reads the raw child-process-gone event, so the
// suppression cannot hide a broken driver from recovery.
expect(
isGpuFallbackCrashCandidate({
platform: 'win32',
processType: 'GPU',
reason: 'crashed'
})
).toBe(true)
// Both assertions above still pass if the candidate check is moved behind the
// suppressed-report path, so pin that nothing branches ahead of it in index.ts.
const listener = readChildProcessGoneListener()
const guardStart = listener.indexOf('isGpuFallbackCrashCandidate(')
expect(guardStart).toBeGreaterThan(0)
expect(listener.slice(0, guardStart).match(/\bif\s*\(/g) ?? []).toHaveLength(1)
expect(listener).toMatch(
/isGpuFallbackCrashCandidate\([\s\S]*?gpuCrashDiagnostics\?\.record\(\)[\s\S]*?handleGpuChildCrash\(/
)
// The `if (` count alone still allows `recorded && isGpuFallbackCrashCandidate(...)`, which
// re-couples recovery to the suppression decision, so pin the guard to that check alone.
const recoveryGuard = listener.slice(
listener.lastIndexOf('if (', guardStart),
listener.indexOf('handleGpuChildCrash(')
)
expect(recoveryGuard).not.toMatch(/&&|\|\|/)
})
it('never reaches the burst threshold in any observed cluster session', () => {
const outcomes = CRASHED_CLUSTER_SESSIONS.map((session) => {
// Each launch constructs a fresh tracker (src/main/index.ts), so evidence
// does not survive the relaunch these users performed after every crash.
const tracker = newTracker()
const engaged = session.gpuCrashesMsSinceLaunch.some(
(at) => tracker.recordGpuCrash(at).shouldEngageFallback
)
return {
report: session.report,
gpuCrashes: session.gpuCrashesMsSinceLaunch.length,
engagedSafeGraphicsPrompt: engaged
}
})
expect(outcomes).toEqual([
{ report: 'db1f1ee2', gpuCrashes: 2, engagedSafeGraphicsPrompt: false },
{ report: '66cc54d8', gpuCrashes: 1, engagedSafeGraphicsPrompt: false },
{ report: '1f5564de', gpuCrashes: 2, engagedSafeGraphicsPrompt: false },
{ report: '96d8c63b', gpuCrashes: 1, engagedSafeGraphicsPrompt: false }
])
})
it('engages on the session that did reach three crashes (field launch 51b9e93c)', () => {
// oom.txt, win32: GPU crashed/exitCode=34 with suppressedSinceLast=2, then
// `gpu_fallback_engaged (crashesInWindow=3)` and `gpu_fallback_restart_deferred`.
const tracker = newTracker()
expect(tracker.recordGpuCrash(3_600_000).shouldEngageFallback).toBe(false)
expect(tracker.recordGpuCrash(3_601_000).shouldEngageFallback).toBe(false)
expect(tracker.recordGpuCrash(3_601_890)).toEqual({
shouldEngageFallback: true,
crashesInWindow: 3
})
})
})
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from 'vitest'
import {
engageGpuFallbackAfterCrashBurst,
type GpuFallbackEngagement,
type GpuFallbackEngagementHandlers
} from './gpu-fallback-engagement'
import type { GpuFallbackRestartDecision } from './gpu-fallback-restart-prompt'
const ENGAGEMENT: GpuFallbackEngagement = {
reason: 'crashed',
// 0x80000003 STATUS_BREAKPOINT — the production Windows signature.
exitCode: -2147483645,
crashesInWindow: 3,
engagedAt: 1_700_000_000_000
}
function createHandlers(overrides: Partial<GpuFallbackEngagementHandlers> = {}): {
handlers: GpuFallbackEngagementHandlers
order: string[]
} {
const order: string[] = []
const handlers: GpuFallbackEngagementHandlers = {
isQuitting: () => false,
persistMarker: vi.fn(() => {
order.push('persistMarker')
return true
}),
confirmMarker: vi.fn(() => order.push('confirmMarker')),
clearMarker: vi.fn(() => order.push('clearMarker')),
promptForRestart: vi.fn(async () => {
order.push('prompt')
return 'restart' as GpuFallbackRestartDecision
}),
onPromptFailed: vi.fn(),
onEngaged: vi.fn(),
onRestartDeferred: vi.fn(() => order.push('restartDeferred')),
restartIntoSafeGraphics: vi.fn(() => order.push('restart')),
...overrides
}
return { handlers, order }
}
describe('engageGpuFallbackAfterCrashBurst', () => {
// Repro for the Windows startup GPU cluster (v1.4.190, exit -2147483645).
// Measured on Windows 11 26200 / Electron 43.1.0: Chromium's own ladder aborts
// the whole browser ("GPU process isn't usable. Goodbye.") on the 6th GPU
// crash, 1.285s after the 3rd — the crash that trips this threshold. If the
// marker is only written after the user answers the modal, the app dies first
// and the next launch retries hardware acceleration, looping forever.
it('persists the safe-graphics marker before the restart prompt is answered', async () => {
let resolvePrompt: ((decision: GpuFallbackRestartDecision) => void) | undefined
const { handlers } = createHandlers({
promptForRestart: vi.fn(
() =>
new Promise<GpuFallbackRestartDecision>((resolve) => {
resolvePrompt = resolve
})
)
})
const engaging = engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
await Promise.resolve()
// Chromium kills the process here; whatever is on disk now is all the next launch gets.
expect(handlers.persistMarker).toHaveBeenCalledWith(ENGAGEMENT)
resolvePrompt?.('restart')
await engaging
})
it('relaunches into safe graphics when the user accepts', async () => {
const { handlers, order } = createHandlers()
await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
expect(order).toEqual(['persistMarker', 'prompt', 'confirmMarker', 'restart'])
expect(handlers.clearMarker).not.toHaveBeenCalled()
})
it('undoes the marker when the user chooses Keep Running', async () => {
const { handlers, order } = createHandlers({
promptForRestart: vi.fn(async () => 'continue' as GpuFallbackRestartDecision)
})
await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
expect(order).toEqual(['persistMarker', 'clearMarker', 'restartDeferred'])
expect(handlers.restartIntoSafeGraphics).not.toHaveBeenCalled()
})
it('keeps the marker when the prompt itself fails, so the next launch is still safe', async () => {
const error = new Error('no display')
const { handlers } = createHandlers({
promptForRestart: vi.fn(async () => {
throw error
})
})
await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
expect(handlers.persistMarker).toHaveBeenCalledTimes(1)
expect(handlers.clearMarker).not.toHaveBeenCalled()
expect(handlers.onPromptFailed).toHaveBeenCalledWith(error)
})
it('does not relaunch when a quit began while the prompt was open', async () => {
const { handlers } = createHandlers({ isQuitting: () => true })
await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
expect(handlers.restartIntoSafeGraphics).not.toHaveBeenCalled()
// The marker stays: a quit is not the user declining safe graphics.
expect(handlers.clearMarker).not.toHaveBeenCalled()
})
it('does not claim a restart when the marker could not be written', async () => {
const { handlers } = createHandlers({ persistMarker: vi.fn(() => false) })
await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers)
expect(handlers.restartIntoSafeGraphics).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,66 @@
import type { GpuFallbackRestartDecision } from './gpu-fallback-restart-prompt'
/** The GPU-crash burst that tripped the software-rendering fallback. */
export type GpuFallbackEngagement = {
reason: string
exitCode: number | null
crashesInWindow: number
/** Wall-clock ms stamped into the persisted marker. */
engagedAt: number
}
export type GpuFallbackEngagementHandlers = {
isQuitting: () => boolean
/** Writes the build-scoped safe-graphics marker; false when it could not be persisted. */
persistMarker: (engagement: GpuFallbackEngagement) => boolean
/** Marks that the user explicitly accepted safe graphics before relaunching. */
confirmMarker: (engagement: GpuFallbackEngagement) => void
clearMarker: () => void
promptForRestart: () => Promise<GpuFallbackRestartDecision>
onPromptFailed: (error: unknown) => void
onEngaged: (engagement: GpuFallbackEngagement) => void
onRestartDeferred: (engagement: GpuFallbackEngagement) => void
restartIntoSafeGraphics: (engagement: GpuFallbackEngagement) => void
}
/**
* Drives the safe-graphics handover once a GPU crash burst trips the threshold.
*
* Why the marker is written before the prompt: measured on Windows 11 26200 /
* Electron 43.1.0, Chromium's own GPU ladder aborts the whole browser process
* (`FATAL gpu_data_manager_impl_private.cc: GPU process isn't usable. Goodbye.`)
* on the 6th GPU crash — 1.285s after the 3rd, which is the crash that trips
* this threshold. Persisting only after the user answers a modal means the app
* dies first, no marker survives, and the next launch retries hardware
* acceleration: the crash loop never ends. An explicit "Keep Running" undoes it.
*/
export async function engageGpuFallbackAfterCrashBurst(
engagement: GpuFallbackEngagement,
handlers: GpuFallbackEngagementHandlers
): Promise<void> {
handlers.onEngaged(engagement)
const persisted = handlers.persistMarker(engagement)
let decision: GpuFallbackRestartDecision
try {
decision = await handlers.promptForRestart()
} catch (error) {
// Marker stays: the next launch is safe even though the user was never asked.
handlers.onPromptFailed(error)
return
}
if (handlers.isQuitting()) {
return
}
if (decision !== 'restart') {
if (persisted) {
handlers.clearMarker()
}
handlers.onRestartDeferred(engagement)
return
}
if (!persisted) {
return
}
handlers.confirmMarker(engagement)
handlers.restartIntoSafeGraphics(engagement)
}
@@ -0,0 +1,119 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { showMessageBoxMock } = vi.hoisted(() => ({
showMessageBoxMock: vi.fn()
}))
vi.mock('electron', () => ({
dialog: { showMessageBox: showMessageBoxMock }
}))
import {
handleGpuFallbackRecoveredLaunch,
promptForGpuFallbackRecoveredLaunch,
type GpuFallbackRecoveredLaunchDecision,
type GpuFallbackRecoveredLaunchHandlers
} from './gpu-fallback-recovered-launch'
beforeEach(() => {
showMessageBoxMock.mockReset()
})
function createHandlers(
decision: GpuFallbackRecoveredLaunchDecision = 'keep-safe',
overrides: Partial<GpuFallbackRecoveredLaunchHandlers> = {}
): { handlers: GpuFallbackRecoveredLaunchHandlers; order: string[] } {
const order: string[] = []
const handlers: GpuFallbackRecoveredLaunchHandlers = {
isQuitting: () => false,
prompt: vi.fn(async () => {
order.push('prompt')
return decision
}),
confirmSafeGraphics: vi.fn(() => order.push('confirm')),
clearSafeGraphics: vi.fn(() => order.push('clear')),
onPromptFailed: vi.fn(),
onSafeGraphicsKept: vi.fn(() => order.push('kept')),
restartWithHardware: vi.fn(() => order.push('restart')),
...overrides
}
return { handlers, order }
}
describe('promptForGpuFallbackRecoveredLaunch', () => {
it('defaults dismissal to the stable safe-graphics choice', async () => {
const parentWindow = { id: 1 }
showMessageBoxMock.mockResolvedValue({ response: 0 })
await expect(promptForGpuFallbackRecoveredLaunch(parentWindow as never)).resolves.toBe(
'keep-safe'
)
expect(showMessageBoxMock).toHaveBeenCalledWith(parentWindow, {
type: 'info',
buttons: ['Keep Safe Graphics Mode', 'Try Hardware Acceleration'],
defaultId: 0,
cancelId: 0,
title: 'Safe Graphics Mode is Active',
message: 'Orca recovered in Safe Graphics Mode.',
detail:
'Safe Graphics Mode was enabled after repeated graphics crashes. Keep it for stability, or restart and try hardware acceleration again.'
})
})
it('returns the explicit hardware retry choice', async () => {
showMessageBoxMock.mockResolvedValue({ response: 1 })
await expect(promptForGpuFallbackRecoveredLaunch()).resolves.toBe('retry-hardware')
})
})
describe('handleGpuFallbackRecoveredLaunch', () => {
it('confirms safe graphics so later launches do not prompt again', async () => {
const { handlers, order } = createHandlers()
await handleGpuFallbackRecoveredLaunch(handlers)
expect(order).toEqual(['prompt', 'confirm', 'kept'])
expect(handlers.clearSafeGraphics).not.toHaveBeenCalled()
expect(handlers.restartWithHardware).not.toHaveBeenCalled()
})
it('clears the marker before restarting with hardware acceleration', async () => {
const { handlers, order } = createHandlers('retry-hardware')
await handleGpuFallbackRecoveredLaunch(handlers)
expect(order).toEqual(['prompt', 'clear', 'restart'])
expect(handlers.confirmSafeGraphics).not.toHaveBeenCalled()
})
it('leaves the unconfirmed marker intact when the prompt fails', async () => {
const error = new Error('dialog failed')
const { handlers } = createHandlers('keep-safe', {
prompt: vi.fn(async () => {
throw error
})
})
await handleGpuFallbackRecoveredLaunch(handlers)
expect(handlers.onPromptFailed).toHaveBeenCalledWith(error)
expect(handlers.confirmSafeGraphics).not.toHaveBeenCalled()
expect(handlers.clearSafeGraphics).not.toHaveBeenCalled()
})
it('does not mutate the marker when shutdown starts while the prompt is open', async () => {
const { handlers } = createHandlers('retry-hardware', { isQuitting: () => true })
await handleGpuFallbackRecoveredLaunch(handlers)
expect(handlers.confirmSafeGraphics).not.toHaveBeenCalled()
expect(handlers.clearSafeGraphics).not.toHaveBeenCalled()
expect(handlers.restartWithHardware).not.toHaveBeenCalled()
})
})
describe('recovered safe-graphics production wiring', () => {
it('prompts only after the recovered window is shown and persists both consent states', () => {
const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8')
expect(source).toMatch(
/window\.once\('show',[\s\S]*?presentGpuFallbackRecoveredLaunchPrompt\(window\)/
)
expect(source).toMatch(
/persistMarker:[\s\S]*?userConfirmed: false[\s\S]*?confirmMarker:[\s\S]*?userConfirmed: true/
)
})
})
@@ -0,0 +1,56 @@
import { dialog, type BrowserWindow, type MessageBoxOptions } from 'electron'
export type GpuFallbackRecoveredLaunchDecision = 'keep-safe' | 'retry-hardware'
const GPU_FALLBACK_RECOVERED_LAUNCH_OPTIONS: MessageBoxOptions = {
type: 'info',
buttons: ['Keep Safe Graphics Mode', 'Try Hardware Acceleration'],
defaultId: 0,
cancelId: 0,
title: 'Safe Graphics Mode is Active',
message: 'Orca recovered in Safe Graphics Mode.',
detail:
'Safe Graphics Mode was enabled after repeated graphics crashes. Keep it for stability, or restart and try hardware acceleration again.'
}
export async function promptForGpuFallbackRecoveredLaunch(
parentWindow?: BrowserWindow
): Promise<GpuFallbackRecoveredLaunchDecision> {
const { response } = parentWindow
? await dialog.showMessageBox(parentWindow, GPU_FALLBACK_RECOVERED_LAUNCH_OPTIONS)
: await dialog.showMessageBox(GPU_FALLBACK_RECOVERED_LAUNCH_OPTIONS)
return response === 1 ? 'retry-hardware' : 'keep-safe'
}
export type GpuFallbackRecoveredLaunchHandlers = {
isQuitting: () => boolean
prompt: () => Promise<GpuFallbackRecoveredLaunchDecision>
confirmSafeGraphics: () => void
clearSafeGraphics: () => void
onPromptFailed: (error: unknown) => void
onSafeGraphicsKept: () => void
restartWithHardware: () => void
}
/** Resolves consent after an unanswered crash-time prompt recovered into safe graphics. */
export async function handleGpuFallbackRecoveredLaunch(
handlers: GpuFallbackRecoveredLaunchHandlers
): Promise<void> {
let decision: GpuFallbackRecoveredLaunchDecision
try {
decision = await handlers.prompt()
} catch (error) {
handlers.onPromptFailed(error)
return
}
if (handlers.isQuitting()) {
return
}
if (decision === 'retry-hardware') {
handlers.clearSafeGraphics()
handlers.restartWithHardware()
return
}
handlers.confirmSafeGraphics()
handlers.onSafeGraphicsKept()
}
@@ -13,15 +13,13 @@ function makeBackend(): {
const resolveDeviceId = vi.fn(async () => {
throw new Error('shutdown AVD cannot resolve before boot')
})
const startSession = vi.fn(
async (): Promise<EmulatorSessionInfo> => ({
deviceUdid: 'emulator-5554',
streamUrl: 'scrcpy://emulator-5554',
wsUrl: '',
streamCodec: 'h264',
backend: 'android'
})
)
const startSession = vi.fn(async (): Promise<EmulatorSessionInfo> => ({
deviceUdid: 'emulator-5554',
streamUrl: 'scrcpy://emulator-5554',
wsUrl: '',
streamCodec: 'h264',
backend: 'android'
}))
const stopHelperForDevice = vi.fn(async () => {})
const shutdownDevice = vi.fn(async () => {})
const backend: EmulatorBackend = {
+19 -4
View File
@@ -218,6 +218,21 @@ export function parseAndFilterSearchRefDetails(
.slice(0, Math.max(0, limit))
}
export function resolveConfiguredRemoteBranchName(
fullRef: string,
longestFirstRemoteNames: readonly string[]
): string | null {
const remoteRefPrefix = 'refs/remotes/'
if (!fullRef.startsWith(remoteRefPrefix)) {
return null
}
const remoteAndBranch = fullRef.slice(remoteRefPrefix.length)
const remote = longestFirstRemoteNames.find((candidate) =>
remoteAndBranch.startsWith(`${candidate}/`)
)
return remote ? remoteAndBranch.slice(remote.length + 1) || null : null
}
export function resolveLocalBranchName(
fullRef: string,
shortRef: string,
@@ -227,11 +242,11 @@ export function resolveLocalBranchName(
if (!fullRef.startsWith(remoteRefPrefix)) {
return shortRef
}
const remoteAndBranch = fullRef.slice(remoteRefPrefix.length)
const remote = remotes.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`))
if (remote) {
return remoteAndBranch.slice(remote.length + 1)
const configuredBranch = resolveConfiguredRemoteBranchName(fullRef, remotes)
if (configuredBranch) {
return configuredBranch
}
const remoteAndBranch = fullRef.slice(remoteRefPrefix.length)
return remoteAndBranch.split('/').slice(1).join('/') || shortRef
}
+41 -22
View File
@@ -1,45 +1,50 @@
import { listRemoteNames, resolveLocalBranchName } from './repo-base-ref-search'
import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-ref'
import { resolveConfiguredRemoteBranchName } from './repo-base-ref-search'
import { gitExecOptions, type GitExec, type LocalGitExecOptions } from './repo-default-base-ref'
import { gitExecFileAsync } from './runner'
export type BranchConflictKind = 'local' | 'remote'
async function hasGitRefAsync(
path: string,
ref: string,
options: LocalGitExecOptions = {}
): Promise<boolean> {
async function hasGitRefAsync(exec: GitExec, ref: string): Promise<boolean> {
try {
await gitExecFileAsync(['rev-parse', '--verify', ref], gitExecOptions(path, options))
return true
const { stdout } = await exec(['rev-parse', '--verify', ref])
return stdout.trim().length > 0
} catch {
return false
}
}
export async function getBranchConflictKind(
path: string,
async function listRemoteNamesViaExec(exec: GitExec): Promise<string[]> {
try {
const { stdout } = await exec(['remote'])
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.sort((a, b) => b.length - a.length)
} catch {
return []
}
}
/** Run branch-conflict policy through the host that owns Git execution. */
export async function getBranchConflictKindViaExec(
exec: GitExec,
branchName: string,
allowedBaseRef?: string,
options: LocalGitExecOptions = {}
allowedBaseRef?: string
): Promise<BranchConflictKind | null> {
if (await hasGitRefAsync(path, `refs/heads/${branchName}`, options)) {
if (await hasGitRefAsync(exec, `refs/heads/${branchName}`)) {
return 'local'
}
try {
const remoteNames = (await listRemoteNames(path, options)).sort((a, b) => b.length - a.length)
const { stdout } = await gitExecFileAsync(
['for-each-ref', '--format=%(refname)', 'refs/remotes'],
gitExecOptions(path, options)
)
const hasRemoteConflict = stdout.split('\n').some((ref) => {
const remoteNames = await listRemoteNamesViaExec(exec)
const { stdout } = await exec(['for-each-ref', '--format=%(refname)', 'refs/remotes'])
const hasRemoteConflict = stdout.split(/\r?\n/).some((ref) => {
const trimmed = ref.trim()
if (isAllowedRemoteBaseRef(trimmed, allowedBaseRef)) {
return false
}
const shortRef = trimmed.replace(/^refs\/remotes\//, '')
return resolveLocalBranchName(trimmed, shortRef, remoteNames) === branchName
return resolveConfiguredRemoteBranchName(trimmed, remoteNames) === branchName
})
return hasRemoteConflict ? 'remote' : null
@@ -48,6 +53,20 @@ export async function getBranchConflictKind(
}
}
export function getBranchConflictKind(
path: string,
branchName: string,
allowedBaseRef?: string,
options: LocalGitExecOptions = {}
): Promise<BranchConflictKind | null> {
const execOptions = gitExecOptions(path, options)
return getBranchConflictKindViaExec(
(argv) => gitExecFileAsync(argv, execOptions),
branchName,
allowedBaseRef
)
}
function isAllowedRemoteBaseRef(refName: string, allowedBaseRef: string | undefined): boolean {
if (!allowedBaseRef) {
return false
+37
View File
@@ -215,6 +215,7 @@ describe('searchBaseRefs (widened glob)', () => {
it('allows creating a local branch from the selected matching remote base ref', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
createRemoteRef(tmpDir, 'origin/feature/something', sha)
const result = await getBranchConflictKind(
@@ -228,6 +229,10 @@ describe('searchBaseRefs (widened glob)', () => {
it('still reports a remote conflict for a different tracking ref with the same branch name', async () => {
const sha = getHeadSha(tmpDir)
// Why register both: a ref only tracks a branch when a remote actually owns its
// prefix, and this case is about a second *tracking* ref, not an orphan.
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
git(tmpDir, ['remote', 'add', 'upstream', 'https://example.invalid/upstream.git'])
createRemoteRef(tmpDir, 'origin/feature/something', sha)
createRemoteRef(tmpDir, 'upstream/feature/something', sha)
@@ -236,6 +241,38 @@ describe('searchBaseRefs (widened glob)', () => {
).toBe('remote')
})
it('ignores a ref whose prefix matches no configured remote', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
createRemoteRef(tmpDir, 'mimic-fork/my-task', sha)
expect(await getBranchConflictKind(tmpDir, 'my-task', 'origin/main')).toBeNull()
})
it('stops treating leftover refs from a removed remote as conflicts', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
git(tmpDir, ['remote', 'add', 'fork', 'https://example.invalid/fork.git'])
createRemoteRef(tmpDir, 'fork/my-task', sha)
expect(await getBranchConflictKind(tmpDir, 'my-task', 'origin/main')).toBe('remote')
// Why not `remote remove`: that prunes the tracking refs too, which would pass
// with or without the ownership guard. Dropping only the config leaves the
// orphan ref behind, which is the state this guard exists for.
git(tmpDir, ['config', '--remove-section', 'remote.fork'])
expect(await getBranchConflictKind(tmpDir, 'my-task', 'origin/main')).toBeNull()
})
it('still reports a local conflict when an unrelated ref shares the name', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
createRemoteRef(tmpDir, 'mimic-fork/my-task', sha)
git(tmpDir, ['branch', 'my-task'])
expect(await getBranchConflictKind(tmpDir, 'my-task', 'origin/main')).toBe('local')
})
it('reports remote conflicts when the remote name contains a slash', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'foo/bar', 'https://example.invalid/repo.git'])
+31 -37
View File
@@ -149,19 +149,17 @@ export async function getPRComments(
if (!pullRequest) {
console.warn('Review threads response missing pullRequest; keeping REST results')
}
const graphQLIssueComments = (pullRequest?.comments?.nodes ?? []).map(
(c): PRComment => ({
id: c.databaseId,
author: c.author?.login ?? 'ghost',
authorAvatarUrl: c.author?.avatarUrl ?? '',
body: c.body ?? '',
createdAt: c.createdAt,
url: c.url,
isBot: c.author?.__typename === 'Bot',
reactionSubjectId: c.id,
reactions: mapGraphQLReactionGroups(c.reactionGroups)
})
)
const graphQLIssueComments = (pullRequest?.comments?.nodes ?? []).map((c): PRComment => ({
id: c.databaseId,
author: c.author?.login ?? 'ghost',
authorAvatarUrl: c.author?.avatarUrl ?? '',
body: c.body ?? '',
createdAt: c.createdAt,
url: c.url,
isBot: c.author?.__typename === 'Bot',
reactionSubjectId: c.id,
reactions: mapGraphQLReactionGroups(c.reactionGroups)
}))
if (graphQLIssueComments.length > 0) {
issueComments = graphQLIssueComments
}
@@ -169,19 +167,17 @@ export async function getPRComments(
graphQLReviewSummaries = pullRequest
? (pullRequest.reviews?.nodes ?? [])
.filter((review) => review.body?.trim())
.map(
(review): PRComment => ({
id: review.databaseId,
author: review.author?.login ?? 'ghost',
authorAvatarUrl: review.author?.avatarUrl ?? '',
body: review.body,
createdAt: review.createdAt,
url: review.url,
isBot: review.author?.__typename === 'Bot',
reactionSubjectId: review.id,
reactions: mapGraphQLReactionGroups(review.reactionGroups)
})
)
.map((review): PRComment => ({
id: review.databaseId,
author: review.author?.login ?? 'ghost',
authorAvatarUrl: review.author?.avatarUrl ?? '',
body: review.body,
createdAt: review.createdAt,
url: review.url,
isBot: review.author?.__typename === 'Bot',
reactionSubjectId: review.id,
reactions: mapGraphQLReactionGroups(review.reactionGroups)
}))
: undefined
const threads = pullRequest?.reviewThreads?.nodes ?? []
@@ -228,17 +224,15 @@ export async function getPRComments(
} else if (reviewsResult.status === 'fulfilled') {
reviewSummaries = (JSON.parse(reviewsResult.value.stdout) as RESTReview[])
.filter((r) => r.body?.trim())
.map(
(r): PRComment => ({
id: r.id,
author: r.user?.login ?? 'ghost',
authorAvatarUrl: r.user?.avatar_url ?? '',
body: r.body,
createdAt: r.submitted_at,
url: r.html_url,
isBot: r.user?.type === 'Bot'
})
)
.map((r): PRComment => ({
id: r.id,
author: r.user?.login ?? 'ghost',
authorAvatarUrl: r.user?.avatar_url ?? '',
body: r.body,
createdAt: r.submitted_at,
url: r.html_url,
isBot: r.user?.type === 'Bot'
}))
} else {
console.warn('Failed to fetch review summaries:', reviewsResult.reason)
}
+8 -10
View File
@@ -238,16 +238,14 @@ export async function getIssueBodyAndComments(
}[]
assignees?: { login: string }[]
}
const comments = (data.comments ?? []).map(
(comment, index): PRComment => ({
id: index,
author: comment.author?.login ?? 'ghost',
authorAvatarUrl: '',
body: comment.body ?? '',
createdAt: comment.createdAt,
url: comment.url ?? ''
})
)
const comments = (data.comments ?? []).map((comment, index): PRComment => ({
id: index,
author: comment.author?.login ?? 'ghost',
authorAvatarUrl: '',
body: comment.body ?? '',
createdAt: comment.createdAt,
url: comment.url ?? ''
}))
return {
body: data.body ?? '',
comments,
+13
View File
@@ -22,6 +22,19 @@ describe('parseOrcaYaml', () => {
})
})
it('parses a project requirement to finish setup before agent startup', () => {
const yaml = [
'setupAgentStartupPolicy: wait-for-setup',
'scripts:',
' setup: node install-project-skills.mjs'
].join('\n')
expect(parseOrcaYaml(yaml)).toEqual({
scripts: { setup: 'node install-project-skills.mjs' },
setupAgentStartupPolicy: 'wait-for-setup'
})
})
it('parses YAML with archive script only', () => {
const yaml = `scripts:\n archive: |\n echo "archiving"\n`
const result = parseOrcaYaml(yaml)
+21 -1
View File
@@ -302,7 +302,7 @@ describe('createSetupRunnerScript', () => {
}
})
it('omits waitForAgentStartup unless the repo explicitly waits for setup', async () => {
it('waits when either local or project policy requires completed setup', async () => {
gitExecFileSyncMock.mockReset()
gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n')
const { createSetupRunnerScript } = await import('./worktree-runner-script')
@@ -318,6 +318,26 @@ describe('createSetupRunnerScript', () => {
createSetupRunnerScript(makeRepo('wait-for-setup'), '/test/worktree', 'echo setup')
.waitForAgentStartup
).toBe(true)
expect(
createSetupRunnerScript(
makeRepo('start-immediately'),
'/test/worktree',
'echo setup',
undefined,
undefined,
'wait-for-setup'
).waitForAgentStartup
).toBe(true)
expect(
createSetupRunnerScript(
makeRepo('wait-for-setup'),
'/test/worktree',
'echo setup',
undefined,
undefined,
'start-immediately'
).waitForAgentStartup
).toBe(true)
})
it('marks setup-runner terminals for the always-on credential guard', async () => {
+1
View File
@@ -55,6 +55,7 @@ export function hasHooksFile(repoPath: string): boolean {
// Why: detect unrecognised keys so the UI can suggest an update instead of showing a "could not be parsed" error.
const RECOGNIZED_ORCA_YAML_KEYS = new Set([
'scripts',
'setupAgentStartupPolicy',
'issueCommand',
'defaultTabs',
'environmentRecipes',
+131 -49
View File
@@ -154,6 +154,7 @@ import {
configureDevUserDataPath,
configureOrcaUserDataPathEnv,
disableUnsupportedChromiumFeatures,
optOutOfHiddenPageWakeUpThrottling,
enableMainProcessGpuFeatures,
installDevParentDisconnectQuit,
installDevParentSignalQuit,
@@ -170,8 +171,10 @@ import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom'
import { argvRequestsServeMode, normalizeServeModeArgv } from './startup/serve-mode-argv'
import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display'
import {
clearGpuFallbackMarker,
readActiveGpuFallbackMarker,
writeGpuFallbackMarker,
type GpuFallbackMarker,
type GpuFallbackEnvironment,
type WindowsGpuFallbackEnvironment
} from './startup/gpu-fallback-marker'
@@ -182,10 +185,13 @@ import {
GpuCrashFallbackTracker,
isGpuFallbackCrashCandidate
} from './crash-reporting/gpu-crash-fallback-decision'
import { promptForGpuFallbackRestart } from './crash-reporting/gpu-fallback-restart-prompt'
import { engageGpuFallbackAfterCrashBurst } from './crash-reporting/gpu-fallback-engagement'
import { GpuCrashDiagnosticsRecorder } from './crash-reporting/gpu-crash-diagnostics'
import {
promptForGpuFallbackRestart,
type GpuFallbackRestartDecision
} from './crash-reporting/gpu-fallback-restart-prompt'
handleGpuFallbackRecoveredLaunch,
promptForGpuFallbackRecoveredLaunch
} from './crash-reporting/gpu-fallback-recovered-launch'
import {
shouldSuppressDevEducation,
suppressDevEducationForStore
@@ -462,8 +468,19 @@ const gpuCrashFallbackTracker = new GpuCrashFallbackTracker({
windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD
})
let activeGpuFallbackMarker: GpuFallbackMarker | null = null
let gpuFallbackActiveThisLaunch = false
let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null
const gpuCrashDiagnostics =
process.platform === 'win32'
? new GpuCrashDiagnosticsRecorder({
provider: {
getGPUInfo: (infoType) => app.getGPUInfo(infoType),
getGPUFeatureStatus: () => app.getGPUFeatureStatus()
},
recordBreadcrumb: (data) => recordDurableCrashBreadcrumb('gpu_crash_hardware', data)
})
: null
let localPtyStartupReady: Promise<void> = Promise.resolve()
let localPtyProviderStartupReady: Promise<void> = Promise.resolve()
const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000
@@ -550,6 +567,7 @@ function updateGpuAccelerationAboutPanel(): void {
app.on('gpu-info-update', () => {
gpuFeatureStatus = app.getGPUFeatureStatus()
gpuCrashDiagnostics?.warm()
if (app.isReady()) {
updateGpuAccelerationAboutPanel()
}
@@ -982,6 +1000,8 @@ if (hasSingleInstanceLock) {
...getMainProcessLifecycleIdentity()
})
disableUnsupportedChromiumFeatures()
// Why: unconditional — a GPU-fallback launch skips enableMainProcessGpuFeatures() below.
optOutOfHiddenPageWakeUpThrottling()
configureElectronNetworkCompatibility()
enableRendererHeapHeadroom()
maybeApplyGpuFallbackForThisLaunch()
@@ -1573,6 +1593,7 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow
})
window.once('show', () => {
logStartupMilestone('window-shown')
void presentGpuFallbackRecoveredLaunchPrompt(window)
})
const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS)
trayCreateFallback.unref?.()
@@ -1859,7 +1880,25 @@ function getWindowsGpuFallbackEnvironment(): WindowsGpuFallbackEnvironment | nul
return { ...environment, platform: 'win32' }
}
// Why: read the GPU-fallback marker before app.whenReady() so app.disableHardwareAcceleration() takes effect. Windows desktop only.
// Writes both crash-time and post-recovery consent states through one build-scoped path.
function persistGpuFallbackMarker(
userDataPath: string,
info: { engagedAt: number; crashesInWindow: number; userConfirmed: boolean }
): boolean {
const environment = getWindowsGpuFallbackEnvironment()
if (!environment) {
return false
}
try {
writeGpuFallbackMarker(userDataPath, info, environment)
return true
} catch (error) {
console.warn('[gpu-fallback] failed to persist marker:', error)
return false
}
}
// Read before app.whenReady() so app.disableHardwareAcceleration() takes effect. Windows desktop only.
function maybeApplyGpuFallbackForThisLaunch(): void {
if (isServeMode || process.platform !== 'win32') {
return
@@ -1868,6 +1907,7 @@ function maybeApplyGpuFallbackForThisLaunch(): void {
if (!marker) {
return
}
activeGpuFallbackMarker = marker
app.disableHardwareAcceleration()
const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform)
gpuFallbackActiveThisLaunch = true
@@ -1879,64 +1919,104 @@ function maybeApplyGpuFallbackForThisLaunch(): void {
})
}
async function presentGpuFallbackRecoveredLaunchPrompt(window: BrowserWindow): Promise<void> {
const marker = activeGpuFallbackMarker
if (!marker || marker.userConfirmed || window.isDestroyed() || isQuitting) {
return
}
// One prompt per process. A failure leaves the on-disk marker unconfirmed so the next launch retries.
activeGpuFallbackMarker = null
const userDataPath = app.getPath('userData')
await handleGpuFallbackRecoveredLaunch({
isQuitting: () => isQuitting,
prompt: () => promptForGpuFallbackRecoveredLaunch(window),
confirmSafeGraphics: () => {
persistGpuFallbackMarker(userDataPath, {
engagedAt: marker.engagedAt,
crashesInWindow: marker.crashesInWindow,
userConfirmed: true
})
},
clearSafeGraphics: () => clearGpuFallbackMarker(userDataPath),
onPromptFailed: (error) =>
console.warn('[gpu-fallback] failed to show recovered-launch prompt:', error),
onSafeGraphicsKept: () =>
recordDurableCrashBreadcrumb('gpu_fallback_safe_graphics_kept', {
crashesInWindow: marker.crashesInWindow
}),
restartWithHardware: () => {
isQuitting = true
relaunchApp('gpu-fallback', {
mode: 'hardware-retry',
crashesInWindow: marker.crashesInWindow
})
destroySystemTray()
app.exit(0)
}
})
}
// Why: a burst of GPU child crashes means HW acceleration is unusable — persist a build-scoped marker and offer software rendering.
async function handleGpuChildCrash(reason: string, exitCode: number | null): Promise<void> {
async function handleGpuChildCrash(
reason: string,
exitCode: number | null,
crashedAt: number
): Promise<void> {
// Software rendering already active or shutting down: nothing more to do.
if (gpuFallbackActiveThisLaunch || isQuitting || isServeMode) {
return
}
const result = gpuCrashFallbackTracker.recordGpuCrash(performance.now())
const result = gpuCrashFallbackTracker.recordGpuCrash(crashedAt)
if (!result.shouldEngageFallback) {
return
}
recordCrashBreadcrumb('gpu_fallback_engaged', {
reason,
exitCode,
crashesInWindow: result.crashesInWindow
})
const engagedAt = Date.now()
const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined
let restartDecision: GpuFallbackRestartDecision
try {
restartDecision = await promptForGpuFallbackRestart(window)
} catch (error) {
console.warn('[gpu-fallback] failed to show restart prompt:', error)
return
}
const fallbackData = {
processReason: reason,
exitCode,
crashesInWindow: result.crashesInWindow
}
if (isQuitting) {
return
}
if (restartDecision !== 'restart') {
recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData)
return
}
const environment = getWindowsGpuFallbackEnvironment()
if (!environment) {
return
}
try {
writeGpuFallbackMarker(
app.getPath('userData'),
{
engagedAt,
crashesInWindow: result.crashesInWindow
const userDataPath = app.getPath('userData')
await engageGpuFallbackAfterCrashBurst(
{ reason, exitCode, crashesInWindow: result.crashesInWindow, engagedAt: Date.now() },
{
isQuitting: () => isQuitting,
onEngaged: (engagement) =>
recordCrashBreadcrumb('gpu_fallback_engaged', {
reason: engagement.reason,
exitCode: engagement.exitCode,
crashesInWindow: engagement.crashesInWindow
}),
persistMarker: (engagement) =>
persistGpuFallbackMarker(userDataPath, {
engagedAt: engagement.engagedAt,
crashesInWindow: engagement.crashesInWindow,
userConfirmed: false
}),
confirmMarker: (engagement) => {
persistGpuFallbackMarker(userDataPath, {
engagedAt: engagement.engagedAt,
crashesInWindow: engagement.crashesInWindow,
userConfirmed: true
})
},
environment
)
} catch (error) {
console.warn('[gpu-fallback] failed to persist marker:', error)
return
}
isQuitting = true
relaunchApp('gpu-fallback', fallbackData)
// Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon.
destroySystemTray()
app.exit(0)
clearMarker: () => clearGpuFallbackMarker(userDataPath),
promptForRestart: () =>
promptForGpuFallbackRestart(
mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined
),
onPromptFailed: (error) =>
console.warn('[gpu-fallback] failed to show restart prompt:', error),
onRestartDeferred: () =>
recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData),
restartIntoSafeGraphics: () => {
isQuitting = true
relaunchApp('gpu-fallback', fallbackData)
// Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon.
destroySystemTray()
app.exit(0)
}
}
)
}
function recordProcessGoneCrash(
@@ -3155,7 +3235,9 @@ void app.whenReady().then(async () => {
reason: details.reason
})
) {
void handleGpuChildCrash(details.reason, details.exitCode ?? null)
const crashedAt = performance.now()
void gpuCrashDiagnostics?.record()
void handleGpuChildCrash(details.reason, details.exitCode ?? null, crashedAt)
}
})
@@ -8,10 +8,10 @@ import {
describe('queueWatcherEvents', () => {
function events(count: number): WatcherEvent[] {
return Array.from(
{ length: count },
(_, index): WatcherEvent => ({ type: 'update', path: `/repo/file-${index}.ts` })
)
return Array.from({ length: count }, (_, index): WatcherEvent => ({
type: 'update',
path: `/repo/file-${index}.ts`
}))
}
it('keeps precise events while the batch remains under the overflow limit', () => {
@@ -73,10 +73,10 @@ describe('local filesystem watcher large batches', () => {
{ worktreePath }
)
const events = Array.from(
{ length: 200_000 },
(_, index): WatcherEvent => ({ type: 'delete', path: join(worktreePath, `file-${index}`) })
)
const events = Array.from({ length: 200_000 }, (_, index): WatcherEvent => ({
type: 'delete',
path: join(worktreePath, `file-${index}`)
}))
expect(() => watcherCallback?.(null, events)).not.toThrow()
await closeAllWatchers()
@@ -97,13 +97,10 @@ describe('local filesystem watcher large batches', () => {
await handlers['fs:watchWorktree']({ sender }, { worktreePath })
watcherCallback?.(
null,
Array.from(
{ length: 5_001 },
(_, index): WatcherEvent => ({
type: 'update',
path: join(worktreePath, `file-${index}.txt`)
})
)
Array.from({ length: 5_001 }, (_, index): WatcherEvent => ({
type: 'update',
path: join(worktreePath, `file-${index}.txt`)
}))
)
await vi.advanceTimersByTimeAsync(150)
@@ -107,13 +107,10 @@ describe('remote filesystem watcher batching', () => {
await handlers['fs:watchWorktree']({ sender }, WATCH_ARGS)
watchCallbacks[0](
Array.from(
{ length: 6_000 },
(_unused, index): FsChangeEvent => ({
kind: 'update',
absolutePath: `${WORKTREE_PATH}/file-${index}.ts`
})
)
Array.from({ length: 6_000 }, (_unused, index): FsChangeEvent => ({
kind: 'update',
absolutePath: `${WORKTREE_PATH}/file-${index}.ts`
}))
)
await vi.advanceTimersByTimeAsync(150)
+6 -10
View File
@@ -169,17 +169,13 @@ export function registerOrcaProfileHandlers(
store: Store,
options: RegisterOrcaProfileHandlersOptions = {}
): void {
ipcMain.handle(
'orcaProfiles:list',
(): OrcaProfileListResult => ({
...getOrcaProfileListState(),
multiProfileUi: isMultiProfileUiEnabled()
})
)
ipcMain.handle('orcaProfiles:list', (): OrcaProfileListResult => ({
...getOrcaProfileListState(),
multiProfileUi: isMultiProfileUiEnabled()
}))
ipcMain.handle(
'orcaProfiles:authStatus',
(): OrcaProfileAuthStatus => getCurrentOrcaProfileAuthStatus(getProfileUserDataPath())
ipcMain.handle('orcaProfiles:authStatus', (): OrcaProfileAuthStatus =>
getCurrentOrcaProfileAuthStatus(getProfileUserDataPath())
)
ipcMain.handle(
+2 -3
View File
@@ -81,9 +81,8 @@ export function registerPetHandlers(): void {
}
})
ipcMain.handle(
'pet:importPetBundle',
async (event): Promise<CustomPet | null> => importPetBundle(event)
ipcMain.handle('pet:importPetBundle', async (event): Promise<CustomPet | null> =>
importPetBundle(event)
)
ipcMain.handle(
@@ -182,13 +182,10 @@ describe('createRemoteWatcherEventBatch', () => {
for (let i = 0; i < 3; i++) {
batch.push(
Array.from(
{ length: 50 },
(_unused, index): FsChangeEvent => ({
kind: 'update',
absolutePath: `${ROOT}/wave-${i}-${index}.ts`
})
)
Array.from({ length: 50 }, (_unused, index): FsChangeEvent => ({
kind: 'update',
absolutePath: `${ROOT}/wave-${i}-${index}.ts`
}))
)
}
vi.advanceTimersByTime(150)
@@ -107,7 +107,7 @@ export class SshPtyModelAdmissionPressure {
if (options.disposed) {
return
}
for (let index = 0; index < this.entries.length; ) {
for (let index = 0; index < this.entries.length;) {
const entry = this.entries[index]!
const hasEarlierEntryForPty = this.entries
.slice(0, index)
@@ -447,16 +447,13 @@ describe('workspace cleanup broad scan opt-in', () => {
it('batches fleet progress instead of emitting one IPC payload per row', async () => {
const worktrees = [
GIT_WORKTREES[0],
...Array.from(
{ length: 200 },
(_, index): GitWorktreeInfo => ({
path: `/repo-batch-${index}`,
head: `head-${index}`,
branch: `refs/heads/batch-${index}`,
isBare: false,
isMainWorktree: false
})
)
...Array.from({ length: 200 }, (_, index): GitWorktreeInfo => ({
path: `/repo-batch-${index}`,
head: `head-${index}`,
branch: `refs/heads/batch-${index}`,
isBare: false,
isMainWorktree: false
}))
]
listRepoWorktreesMock.mockResolvedValue(worktrees)
const onProgress = vi.fn()
+9 -12
View File
@@ -767,18 +767,15 @@ describe('workspace cleanup scan', () => {
})
it('summarizes large diff-note lists without hitting argument limits', async () => {
const diffComments = Array.from(
{ length: 150_000 },
(_, index): DiffComment => ({
id: `comment-${index}`,
worktreeId: 'repo-1::/repo-feature',
filePath: 'src/file.ts',
lineNumber: 12,
body: 'Follow up before deleting',
createdAt: NOW - index,
side: 'modified'
})
)
const diffComments = Array.from({ length: 150_000 }, (_, index): DiffComment => ({
id: `comment-${index}`,
worktreeId: 'repo-1::/repo-feature',
filePath: 'src/file.ts',
lineNumber: 12,
body: 'Follow up before deleting',
createdAt: NOW - index,
side: 'modified'
}))
const result = await scanWorkspaceCleanup(
makeStore({
+2 -4
View File
@@ -127,10 +127,8 @@ export function registerWorkspaceCleanupHandlers(
return true
})
ipcMain.handle(
'workspaceCleanup:getCachedScan',
(): Promise<WorkspaceCleanupScanResult | null> =>
readWorkspaceCleanupScanSnapshot(snapshotDirectory)
ipcMain.handle('workspaceCleanup:getCachedScan', (): Promise<WorkspaceCleanupScanResult | null> =>
readWorkspaceCleanupScanSnapshot(snapshotDirectory)
)
ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => {
+2 -4
View File
@@ -114,10 +114,8 @@ export function registerWorkspaceSpaceHandlers(store: Store): void {
return inFlightScan.promise
})
ipcMain.handle(
'workspaceSpace:getCachedAnalysis',
(): Promise<WorkspaceSpaceAnalysis | null> =>
readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory)
ipcMain.handle('workspaceSpace:getCachedAnalysis', (): Promise<WorkspaceSpaceAnalysis | null> =>
readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory)
)
ipcMain.handle('workspaceSpace:cancel', async (): Promise<boolean> => {
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest'
import { getSshBranchConflictKind } from './worktree-remote'
type ConflictProvider = Parameters<typeof getSshBranchConflictKind>[0]
function providerAnswering(answers: {
remotes: string[]
localBranchHead?: string
remoteRefs: string[]
}): ConflictProvider {
const exec: ConflictProvider['exec'] = vi.fn(async (args: string[]) => {
if (args[0] === 'remote') {
return { stdout: `${answers.remotes.join('\n')}\n`, stderr: '' }
}
if (args[0] === 'rev-parse') {
if (!answers.localBranchHead) {
throw new Error('missing ref')
}
return { stdout: `${answers.localBranchHead}\n`, stderr: '' }
}
if (args[0] === 'for-each-ref') {
return { stdout: `${answers.remoteRefs.join('\n')}\n`, stderr: '' }
}
throw new Error(`unexpected git ${args.join(' ')}`)
})
return { exec }
}
describe('getSshBranchConflictKind remote ownership', () => {
it('reports a local branch before scanning remote refs', async () => {
const provider = providerAnswering({
localBranchHead: 'abc123',
remotes: ['origin'],
remoteRefs: []
})
await expect(
getSshBranchConflictKind(provider, '/srv/repo', 'my-task', 'origin/main')
).resolves.toBe('local')
})
it('ignores a ref whose prefix matches no configured remote', async () => {
const provider = providerAnswering({
remotes: ['origin'],
remoteRefs: ['refs/remotes/origin/main', 'refs/remotes/deleted-fork/my-task']
})
await expect(
getSshBranchConflictKind(provider, '/srv/repo', 'my-task', 'origin/main')
).resolves.toBeNull()
})
it('still reports a ref owned by a configured remote', async () => {
const provider = providerAnswering({
remotes: ['origin'],
remoteRefs: ['refs/remotes/origin/main', 'refs/remotes/origin/my-task']
})
await expect(
getSshBranchConflictKind(provider, '/srv/repo', 'my-task', 'origin/main')
).resolves.toBe('remote')
})
it('prefers the longest configured remote name', async () => {
const provider = providerAnswering({
remotes: ['foo', 'foo/bar'],
remoteRefs: ['refs/remotes/foo/bar/my-task']
})
await expect(
getSshBranchConflictKind(provider, '/srv/repo', 'my-task', 'origin/main')
).resolves.toBe('remote')
})
})
+20 -92
View File
@@ -8,6 +8,7 @@ import { randomUUID } from 'node:crypto'
import type { Store } from '../persistence'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { Repo } from '../../shared/repo-types'
import type { SetupAgentStartupPolicy } from '../../shared/orca-yaml-hook-types'
import type {
LocalBaseRefRefreshResult,
LocalBaseRefUpdateSuggestion
@@ -33,6 +34,7 @@ import {
resolveDefaultBaseRefViaExec,
resolveDefaultBaseRefWithLocalGit
} from '../git/repo'
import { getBranchConflictKindViaExec } from '../git/repo-branch-conflict'
import { resolveLocalGitUsername, getSshGitUsername } from '../git/git-username'
import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
import { probeWorktreeBaseRefPresence } from '../git/worktree-base-ref-probe'
@@ -838,98 +840,19 @@ async function canCheckoutExistingLocalBranchSsh(
return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName)
}
async function listSshRemoteNames(provider: SshGitProvider, repoPath: string): Promise<string[]> {
try {
const { stdout } = await provider.exec(['remote'], repoPath)
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.sort((a, b) => b.length - a.length)
} catch {
return []
}
}
type SshGitExecutor = Pick<SshGitProvider, 'exec'>
function isAllowedSshRemoteBaseRef(refName: string, allowedBaseRef: string): boolean {
if (!allowedBaseRef) {
return false
}
const normalizedAllowedRef = allowedBaseRef.startsWith('refs/remotes/')
? allowedBaseRef
: `refs/remotes/${allowedBaseRef}`
return refName === normalizedAllowedRef
}
function resolveSshRemoteBranchName(refName: string, remoteNames: string[]): string {
const remotePrefix = 'refs/remotes/'
if (!refName.startsWith(remotePrefix)) {
return refName
}
const remoteAndBranch = refName.slice(remotePrefix.length)
const remote = remoteNames.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`))
if (remote) {
return remoteAndBranch.slice(remote.length + 1)
}
return remoteAndBranch.split('/').slice(1).join('/') || remoteAndBranch
}
async function hasSshRemoteBranchConflict(
provider: SshGitProvider,
repoPath: string,
branchName: string,
allowedBaseRef: string
): Promise<boolean> {
const remoteNames = await listSshRemoteNames(provider, repoPath)
try {
const { stdout } = await provider.exec(
['for-each-ref', '--format=%(refname)', 'refs/remotes'],
repoPath
)
return stdout.split(/\r?\n/).some((line) => {
const refName = line.trim()
if (!refName || /^refs\/remotes\/.+\/HEAD$/.test(refName)) {
return false
}
if (isAllowedSshRemoteBaseRef(refName, allowedBaseRef)) {
return false
}
// Why: `git branch --all --list feature/x` doesn't match `remotes/origin/feature/x`; parse remote refs directly.
return resolveSshRemoteBranchName(refName, remoteNames) === branchName
})
} catch {
return false
}
}
async function hasSshLocalBranchConflict(
provider: SshGitProvider,
repoPath: string,
branchName: string
): Promise<boolean> {
try {
const { stdout } = await provider.exec(
['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}^{commit}`],
repoPath
)
return stdout.trim().length > 0
} catch {
return false
}
}
async function getSshBranchConflictKind(
provider: SshGitProvider,
export function getSshBranchConflictKind(
provider: SshGitExecutor,
repoPath: string,
branchName: string,
allowedBaseRef: string
): Promise<'local' | 'remote' | null> {
if (await hasSshLocalBranchConflict(provider, repoPath, branchName)) {
return 'local'
}
return (await hasSshRemoteBranchConflict(provider, repoPath, branchName, allowedBaseRef))
? 'remote'
: null
return getBranchConflictKindViaExec(
(argv) => provider.exec(argv, repoPath),
branchName,
allowedBaseRef
)
}
type SelectedReviewBranchInput = Pick<
@@ -1259,7 +1182,8 @@ async function createRemoteSetupRunnerScript(
worktreePath: string,
script: string,
gitProvider: SshGitProvider,
fsProvider: IFilesystemProvider
fsProvider: IFilesystemProvider,
projectStartupPolicy?: SetupAgentStartupPolicy
): Promise<CreateWorktreeResult['setup']> {
const useWindowsFormat = isWindowsAbsolutePathLike(worktreePath)
// Why: SSH terminals choose their shell on the remote host; local Windows
@@ -1281,7 +1205,10 @@ async function createRemoteSetupRunnerScript(
return {
runnerScriptPath,
envVars: getSetupRunnerEnvVars(repo, worktreePath),
...(shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy)
...(shouldWaitForSetupBeforeAgentStartup(
repo.hookSettings?.setupAgentStartupPolicy,
projectStartupPolicy
)
? { waitForAgentStartup: true }
: {})
}
@@ -2030,7 +1957,8 @@ export async function createRemoteWorktree(
created.path,
setupScript,
provider,
fsProvider
fsProvider,
yamlHooks?.setupAgentStartupPolicy
)
} catch (error) {
console.error(`[hooks] Failed to prepare setup runner for ${created.path}:`, error)
@@ -2721,13 +2649,13 @@ export async function createLocalWorktree(
try {
// Why: main only writes the runner script and must not execute setup itself, or we reintroduce the old hidden background-hook behavior.
// Why: worktree already exists, so a runner-gen failure degrades to "created without setup launch" rather than failing creation.
// Why: both trailing args are optional — the shell is undefined off Windows.
setup = createSetupRunnerScript(
repo,
worktreePath,
setupScript,
localWorktreeGitOptionArgs[0],
resolveSetupRunnerShell(settings)
resolveSetupRunnerShell(settings),
createdYamlHooks?.setupAgentStartupPolicy
)
} catch (error) {
console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error)
@@ -514,10 +514,14 @@ describe('registerWorktreeHandlers', () => {
isMainWorktree: false
}
])
loadHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
loadHooksMock.mockReturnValue({
scripts: { setup: 'pnpm install' },
setupAgentStartupPolicy: 'wait-for-setup'
})
getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
shouldRunSetupForCreateMock.mockReturnValue(true)
expect(createSetupRunnerScriptMock).not.toHaveBeenCalled()
const result = (await handlers['worktrees:create'](null, {
repoId: 'repo-1',
@@ -538,6 +542,14 @@ describe('registerWorktreeHandlers', () => {
startupTerminal?: { spawned: boolean; surface?: string }
timing?: { phases: { phase: string }[] }
}
expect(createSetupRunnerScriptMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'repo-1' }),
'/workspace/improve-dashboard',
'pnpm install',
undefined,
undefined,
'wait-for-setup'
)
expect(runtimeStub.createTerminal).toHaveBeenNthCalledWith(
1,
@@ -118,6 +118,7 @@ describe('registerWorktreeHandlers', () => {
'/workspace/improve-dashboard',
'pnpm worktree:setup',
undefined,
undefined,
undefined
)
expect(result).toMatchObject({
@@ -169,6 +170,7 @@ describe('registerWorktreeHandlers', () => {
'/workspace/improve-dashboard',
'pnpm worktree:setup # worktree',
undefined,
undefined,
undefined
)
expect(result).toEqual(
@@ -137,7 +137,7 @@ describe('registerWorktreeHandlers', () => {
}
const fsProvider = {
readFile: vi.fn().mockResolvedValue({
content: 'scripts:\n setup: pnpm install\n',
content: 'setupAgentStartupPolicy: wait-for-setup\nscripts:\n setup: pnpm install\n',
isBinary: false
}),
createDir: vi.fn().mockResolvedValue(undefined),
@@ -153,7 +153,10 @@ describe('registerWorktreeHandlers', () => {
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
getActiveMultiplexerMock.mockReturnValue(mux)
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
parseOrcaYamlMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
parseOrcaYamlMock.mockReturnValue({
scripts: { setup: 'pnpm install' },
setupAgentStartupPolicy: 'wait-for-setup'
})
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
shouldRunSetupForCreateMock.mockReturnValue(true)
@@ -184,7 +187,8 @@ describe('registerWorktreeHandlers', () => {
envVars: expect.objectContaining({
ORCA_ROOT_PATH: '/remote/repo',
ORCA_WORKTREE_PATH: '/remote/repo-improve-dashboard'
})
}),
waitForAgentStartup: true
}
})
)
+2 -1
View File
@@ -467,7 +467,8 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
'C:\\workspaces\\improve-dashboard',
'pnpm install',
undefined,
setupShell
setupShell,
undefined
)
expect(result).toMatchObject({
setup: {
@@ -369,6 +369,7 @@ describe('registerWorktreeHandlers', () => {
'/workspace/improve-dashboard',
'pnpm worktree:setup',
{ wslDistro: 'Ubuntu' },
undefined,
undefined
)
expect(addWorktreeMock).toHaveBeenCalledWith(
+1 -1
View File
@@ -25,7 +25,7 @@ export function escapeMarkdownLinkDestination(url: string): string | null {
}
let encoded = ''
for (let i = 0; i < url.length; ) {
for (let i = 0; i < url.length;) {
const char = url[i] ?? ''
if (char === '%') {
const hex = url.slice(i + 1, i + 3)
@@ -161,7 +161,9 @@ describe('readNativeChatTranscriptCached', () => {
await utimes(fileA, when, when)
await utimes(fileC, when, when)
const readText = (result: Awaited<ReturnType<typeof readNativeChatTranscriptCached>>): string =>
const readText = (
result: Awaited<ReturnType<typeof readNativeChatTranscriptCached>>
): string =>
'messages' in result
? result.messages
.flatMap((message) => message.blocks)
@@ -60,7 +60,8 @@ function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): str
})}\n`
}
async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
// fs.watch and filesystem mutations use the platform clock; poll observable callbacks to a deadline.
async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise<void> {
const start = Date.now()
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
@@ -166,13 +167,14 @@ describe('native chat transcript watcher liveness', () => {
onReplace: replacements,
onAppend: () => {},
debounceMs: 0,
reconciliationIntervalMs: 20
reconciliationIntervalMs: 10_000
})
await waitFor(() => snapshots.mock.calls.length === 1)
await writeFile(filePath, prefixAfter + stableTail)
const future = new Date(Date.now() + 10_000)
await utimes(filePath, future, future)
watchCallbacks[0]!('change', 'transcript.jsonl')
await waitFor(() =>
replacements.mock.calls.flat(2).some((message) => message.id === 'prefix-new')
)
+19 -21
View File
@@ -150,25 +150,23 @@ export function buildOpenCodeUsageRecentSessions(
filteredSessions: OpenCodeUsageSession[],
limit = 10
): OpenCodeUsageSessionRow[] {
return filteredSessions.slice(0, limit).map(
(session): OpenCodeUsageSessionRow => ({
sessionId: session.sessionId,
lastActiveAt: session.lastTimestamp,
durationMinutes: Math.max(
0,
Math.round(
(new Date(session.lastTimestamp).getTime() - new Date(session.firstTimestamp).getTime()) /
60_000
)
),
projectLabel: session.primaryProjectLabel,
model: session.primaryModel,
events: session.eventCount,
inputTokens: session.totalInputTokens,
cachedInputTokens: session.totalCachedInputTokens,
outputTokens: session.totalOutputTokens,
reasoningOutputTokens: session.totalReasoningOutputTokens,
totalTokens: session.totalTokens
})
)
return filteredSessions.slice(0, limit).map((session): OpenCodeUsageSessionRow => ({
sessionId: session.sessionId,
lastActiveAt: session.lastTimestamp,
durationMinutes: Math.max(
0,
Math.round(
(new Date(session.lastTimestamp).getTime() - new Date(session.firstTimestamp).getTime()) /
60_000
)
),
projectLabel: session.primaryProjectLabel,
model: session.primaryModel,
events: session.eventCount,
inputTokens: session.totalInputTokens,
cachedInputTokens: session.totalCachedInputTokens,
outputTokens: session.totalOutputTokens,
reasoningOutputTokens: session.totalReasoningOutputTokens,
totalTokens: session.totalTokens
}))
}
@@ -338,7 +338,7 @@ describe('getPiAgentStatusExtensionSource', () => {
await vi.waitFor(() => expect(harness.fetchMock).toHaveBeenCalledTimes(2))
const payloads = harness.fetchMock.mock.calls.map(
([_, init]) => JSON.parse(String(init?.body)).payload
([_event, init]) => JSON.parse(String(init?.body)).payload
)
expect(payloads).toEqual([
{ hook_event_name: 'session_start' },
@@ -391,7 +391,7 @@ describe('getPiAgentStatusExtensionSource', () => {
await vi.waitFor(() => expect(harness.fetchMock).toHaveBeenCalledTimes(3))
expect(
harness.fetchMock.mock.calls.map(([_, init]) => JSON.parse(String(init?.body)).payload)
harness.fetchMock.mock.calls.map(([_event, init]) => JSON.parse(String(init?.body)).payload)
).toEqual([
{ hook_event_name: 'agent_start', session_id: 'omp-session-8' },
{
+176 -11
View File
@@ -1,5 +1,15 @@
import { spawnSync } from 'node:child_process'
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import {
chmodSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import * as pty from 'node-pty'
@@ -8,7 +18,11 @@ import { getPosixOmpShellWrapper } from './omp-shell-wrapper'
const describePosix = process.platform === 'win32' ? describe.skip : describe
const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0
const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0
const itWithBash = hasBash ? it : it.skip
const itWithZsh = hasZsh ? it : it.skip
type PosixShell = 'bash' | 'zsh'
const tempDirs: string[] = []
@@ -31,33 +45,38 @@ fi
{
printf 'PI=%s\\n' "$PI_CODING_AGENT_DIR"
printf 'EFFECTIVE=%s\\n' "$agent_dir"
printf 'CWD=%s\\n' "$(pwd -P)"
i=0
for arg in "$@"; do
i=$((i + 1))
printf 'ARG%s=%s\\n' "$i" "$arg"
done
} > "$ORCA_CAPTURE_FILE"
exit "\${ORCA_TEST_FAKE_OMP_EXIT_CODE:-0}"
`,
{ mode: 0o755 }
)
chmodSync(ompPath, 0o755)
}
async function runInteractiveBashPty(args: {
async function runInteractivePosixPty(args: {
rcfileContent: string
env: Record<string, string>
input: string
cwd: string
shell?: PosixShell
}): Promise<string> {
const rcfile = join(args.cwd, 'rcfile')
writeFileSync(rcfile, args.rcfileContent)
const shell = args.shell ?? 'bash'
const shellArgs = shell === 'bash' ? ['--noprofile', '--rcfile', rcfile, '-i'] : ['-f', '-i']
const proc = pty.spawn('bash', ['--noprofile', '--rcfile', rcfile, '-i'], {
const proc = pty.spawn(shell, shellArgs, {
name: 'xterm-256color',
cols: 100,
rows: 30,
cwd: args.cwd,
env: args.env
env: { ...args.env, ORCA_TEST_RCFILE: rcfile }
})
let output = ''
@@ -72,13 +91,14 @@ async function runInteractiveBashPty(args: {
let timeout: ReturnType<typeof setTimeout> | null = null
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeout = setTimeout(
() => reject(new Error(`timed out waiting for bash PTY output:\n${output}`)),
() => reject(new Error(`timed out waiting for ${shell} PTY output:\n${output}`)),
5000
)
})
try {
proc.write(args.input.replace(/\n/g, '\r'))
const input = shell === 'zsh' ? `source "$ORCA_TEST_RCFILE"\n${args.input}` : args.input
proc.write(input.replace(/\n/g, '\r'))
const { exitCode } = await Promise.race([exitPromise, timeoutPromise])
expect(exitCode).toBe(0)
return output
@@ -130,7 +150,7 @@ describePosix('OMP shell wrapper node-pty reproduction', () => {
const unwrappedCapture = join(tempDir, 'unwrapped-capture')
const unwrappedAfterPi = join(tempDir, 'unwrapped-after-pi')
await runInteractiveBashPty({
await runInteractivePosixPty({
cwd: tempDir,
rcfileContent: '',
env: makeEnv(unwrappedCapture, unwrappedAfterPi),
@@ -148,7 +168,7 @@ exit 0
const wrappedCapture = join(tempDir, 'wrapped-capture')
const wrappedAfterPi = join(tempDir, 'wrapped-after-pi')
const wrappedOutput = await runInteractiveBashPty({
const wrappedOutput = await runInteractivePosixPty({
cwd: tempDir,
rcfileContent: getPosixOmpShellWrapper(),
env: makeEnv(wrappedCapture, wrappedAfterPi),
@@ -182,7 +202,7 @@ exit 0
writeFakeOmp(binDir)
const captureFile = join(tempDir, 'config-capture')
await runInteractiveBashPty({
await runInteractivePosixPty({
cwd: tempDir,
rcfileContent: getPosixOmpShellWrapper(),
env: {
@@ -237,7 +257,7 @@ exit 0
writeFakeOmp(binDir)
const captureFile = join(tempDir, `${subcommand}-capture`)
await runInteractiveBashPty({
await runInteractivePosixPty({
cwd: tempDir,
rcfileContent: getPosixOmpShellWrapper(),
env: {
@@ -280,7 +300,7 @@ exit 0
writeFakeOmp(binDir)
const captureFile = join(tempDir, 'default-config-capture')
await runInteractiveBashPty({
await runInteractivePosixPty({
cwd: tempDir,
rcfileContent: getPosixOmpShellWrapper(),
env: {
@@ -308,4 +328,149 @@ exit 0
)
}
)
async function expectStaleCwdRecovery(shell: PosixShell): Promise<void> {
const tempDir = makeTempDir()
const workspaceDir = join(tempDir, 'workspace')
const projectDir = join(workspaceDir, 'project')
const homeDir = join(tempDir, 'home')
const binDir = join(tempDir, 'bin')
const extensionDir = join(tempDir, 'extensions')
const logicalProjectLink = join(tempDir, 'logical-project')
mkdirSync(projectDir, { recursive: true })
mkdirSync(homeDir)
mkdirSync(binDir)
mkdirSync(extensionDir)
symlinkSync(projectDir, logicalProjectLink)
const expectedProjectDir = realpathSync(projectDir)
const expectedWorkspaceDir = realpathSync(workspaceDir)
const statusExtension = join(extensionDir, 'orca-agent-status.ts')
writeFileSync(statusExtension, 'export default {}')
writeFakeOmp(binDir)
const unsetPwdCaptureFile = join(tempDir, 'unset-pwd-capture')
const deletedLinkCaptureFile = join(tempDir, 'deleted-link-capture')
const staleCaptureFile = join(tempDir, 'stale-cwd-capture')
const skipCaptureFile = join(tempDir, 'skip-capture')
const staleUnsetCaptureFile = join(tempDir, 'stale-unset-capture')
const noLogicalCaptureFile = join(tempDir, 'no-logical-capture')
const resultFile = join(tempDir, 'stale-cwd-result')
const scenarioFile = join(tempDir, 'stale-cwd-scenario')
writeFileSync(
scenarioFile,
`ORCA_CAPTURE_FILE="$ORCA_UNSET_PWD_CAPTURE_FILE"
unset PWD
omp
__orca_test_unset_status=$?
if [[ -z "\${PWD+x}" ]]; then
__orca_test_pwd_state=unset
else
__orca_test_pwd_state=set
fi
builtin cd -- "$ORCA_LOGICAL_PROJECT_LINK"
/bin/rm -- "$ORCA_LOGICAL_PROJECT_LINK"
ORCA_CAPTURE_FILE="$ORCA_DELETED_LINK_CAPTURE_FILE"
omp
__orca_test_deleted_link_status=$?
builtin cd -P -- "$ORCA_STALE_PROJECT_DIR"
ORCA_CAPTURE_FILE="$ORCA_STALE_CAPTURE_FILE"
/bin/rm -rf -- "$ORCA_STALE_PROJECT_DIR"
/bin/mkdir -p -- "$ORCA_STALE_PROJECT_DIR"
omp
__orca_test_first_status=$?
ORCA_CAPTURE_FILE="$ORCA_SKIP_CAPTURE_FILE"
omp --version
__orca_test_skip_status=$?
if [[ "$PWD" -ef . ]]; then
__orca_test_parent_state=live
else
__orca_test_parent_state=stale
fi
unset PWD
ORCA_CAPTURE_FILE="$ORCA_STALE_UNSET_CAPTURE_FILE"
omp
__orca_test_stale_unset_status=$?
unset ORCA_WORKTREE_PATH ORCA_ROOT_PATH
ORCA_CAPTURE_FILE="$ORCA_NO_LOGICAL_CAPTURE_FILE"
omp
__orca_test_no_logical_status=$?
PWD="$ORCA_STALE_PROJECT_DIR"
/bin/rm -rf -- "$ORCA_STALE_PROJECT_DIR"
omp
__orca_test_missing_status=$?
{
echo "UNSET=$__orca_test_unset_status"
echo "PWD=$__orca_test_pwd_state"
echo "LINK=$__orca_test_deleted_link_status"
echo "FIRST=$__orca_test_first_status"
echo "SKIP=$__orca_test_skip_status"
echo "PARENT=$__orca_test_parent_state"
echo "STALE_UNSET=$__orca_test_stale_unset_status"
echo "NO_LOGICAL=$__orca_test_no_logical_status"
echo "MISSING=$__orca_test_missing_status"
} > "$ORCA_RESULT_FILE"
exit 0
`
)
const output = await runInteractivePosixPty({
shell,
cwd: projectDir,
rcfileContent: `cd() { return 97; }
${getPosixOmpShellWrapper()}`,
env: {
INPUTRC: '/dev/null',
PROMPT_COMMAND: '',
ORCA_STALE_PROJECT_DIR: projectDir,
ORCA_LOGICAL_PROJECT_LINK: logicalProjectLink,
ORCA_UNSET_PWD_CAPTURE_FILE: unsetPwdCaptureFile,
ORCA_DELETED_LINK_CAPTURE_FILE: deletedLinkCaptureFile,
ORCA_STALE_CAPTURE_FILE: staleCaptureFile,
ORCA_SKIP_CAPTURE_FILE: skipCaptureFile,
ORCA_STALE_UNSET_CAPTURE_FILE: staleUnsetCaptureFile,
ORCA_NO_LOGICAL_CAPTURE_FILE: noLogicalCaptureFile,
ORCA_WORKTREE_PATH: workspaceDir,
HOME: homeDir,
PATH: `${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`,
ORCA_OMP_STATUS_EXTENSION: statusExtension,
ORCA_CAPTURE_FILE: staleCaptureFile,
ORCA_RESULT_FILE: resultFile,
ORCA_SCENARIO_FILE: scenarioFile,
ORCA_TEST_FAKE_OMP_EXIT_CODE: '23',
TERM: 'xterm-256color'
},
input: 'source "$ORCA_SCENARIO_FILE"\n'
})
const unsetPwdCapture = readFileSync(unsetPwdCaptureFile, 'utf8')
expect(unsetPwdCapture).toContain(`CWD=${expectedProjectDir}`)
const deletedLinkCapture = readFileSync(deletedLinkCaptureFile, 'utf8')
expect(deletedLinkCapture).toContain(`CWD=${expectedProjectDir}`)
const staleCapture = readFileSync(staleCaptureFile, 'utf8')
expect(staleCapture).toContain(`CWD=${expectedProjectDir}`)
expect(staleCapture.split('\n').filter((line) => line.startsWith('ARG'))).toEqual([
'ARG1=--extension',
`ARG2=${statusExtension}`
])
expect(staleCapture).not.toContain('--cwd')
const skipCapture = readFileSync(skipCaptureFile, 'utf8')
expect(skipCapture).toContain(`CWD=${expectedProjectDir}`)
expect(skipCapture).toContain('ARG1=--version')
expect(skipCapture).not.toContain('--extension')
const staleUnsetCapture = readFileSync(staleUnsetCaptureFile, 'utf8')
expect(staleUnsetCapture).toContain(`CWD=${expectedWorkspaceDir}`)
expect(existsSync(noLogicalCaptureFile)).toBe(false)
expect(readFileSync(resultFile, 'utf8')).toBe(
'UNSET=23\nPWD=unset\nLINK=23\nFIRST=23\nSKIP=23\nPARENT=stale\nSTALE_UNSET=23\nNO_LOGICAL=1\nMISSING=1\n'
)
expect(output).toContain('no terminal working directory is available')
expect(output).toContain('Orca: OMP cannot access the terminal working directory')
}
itWithBash('rebinds a stale Bash cwd before launching OMP', async () => {
await expectStaleCwdRecovery('bash')
})
itWithZsh('rebinds a stale Zsh cwd before launching OMP', async () => {
await expectStaleCwdRecovery('zsh')
})
})
+35 -3
View File
@@ -51,9 +51,20 @@ __orca_omp_should_skip_extension() {
esac
return 1
}
__orca_omp() {
local __orca_use_extension=1
__orca_omp_should_skip_extension "\${1:-}" && __orca_use_extension=0
__orca_omp_cwd_is_usable() {
local __orca_physical_cwd
[[ -x . ]] || return 1
if [[ -n "\${PWD:-}" && -d "\${PWD:-}" ]]; then
[[ "\${PWD}" -ef . ]]
else
# Why compare the path: shell builtins can print a cached path for a deleted cwd.
__orca_physical_cwd="$(builtin pwd -P 2>/dev/null)" || return 1
[[ -d "$__orca_physical_cwd" && "$__orca_physical_cwd" -ef . ]]
fi
}
__orca_omp_invoke() {
local __orca_use_extension="$1"
shift
if [[ $__orca_use_extension -eq 1 && -n "\${ORCA_OMP_STATUS_EXTENSION:-}" && -f "\${ORCA_OMP_STATUS_EXTENSION}" ]]; then
if [[ "\${1:-}" == "launch" ]]; then
shift
@@ -65,6 +76,27 @@ __orca_omp() {
command omp "$@"
fi
}
__orca_omp() {
local __orca_use_extension=1
__orca_omp_should_skip_extension "\${1:-}" && __orca_use_extension=0
if ! __orca_omp_cwd_is_usable; then
local __orca_logical_cwd="\${PWD:-\${ORCA_WORKTREE_PATH:-\${ORCA_ROOT_PATH:-}}}"
# Why: a restored shell can retain the deleted directory inode after its path is recreated.
(
if [[ -z "$__orca_logical_cwd" ]]; then
printf 'Orca: OMP cannot start because no terminal working directory is available. Open a new terminal in an existing directory.\\n' >&2
return 1
fi
if ! builtin cd -P -- "$__orca_logical_cwd" 2>/dev/null; then
printf 'Orca: OMP cannot access the terminal working directory "%s". Open a new terminal in an existing directory.\\n' "$__orca_logical_cwd" >&2
return 1
fi
__orca_omp_invoke "$__orca_use_extension" "$@"
)
else
__orca_omp_invoke "$__orca_use_extension" "$@"
fi
}
if [[ -n "\${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
# Why the function reserved word: it suppresses alias expansion of the name, which
# an \`alias omp\` otherwise rewrites at parse time, aborting the rest of the file.
+24 -30
View File
@@ -1938,16 +1938,14 @@ export class RateLimitService {
allowPtyFallback: this.shouldAllowCodexPtyFallback(),
signal
})
).catch(
(err): ProviderRateLimits => ({
provider: 'codex',
session: null,
weekly: null,
updatedAt: Date.now(),
error: err instanceof Error ? err.message : 'Unknown error',
status: 'error'
})
)
).catch((err): ProviderRateLimits => ({
provider: 'codex',
session: null,
weekly: null,
updatedAt: Date.now(),
error: err instanceof Error ? err.message : 'Unknown error',
status: 'error'
}))
if (signal.aborted) {
return
@@ -2008,16 +2006,14 @@ export class RateLimitService {
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
networkProxySettings: this.networkProxySettingsResolver?.(),
signal
}).catch(
(err): ProviderRateLimits => ({
provider: 'claude',
session: null,
weekly: null,
updatedAt: Date.now(),
error: err instanceof Error ? err.message : 'Unknown error',
status: 'error'
})
)
}).catch((err): ProviderRateLimits => ({
provider: 'claude',
session: null,
weekly: null,
updatedAt: Date.now(),
error: err instanceof Error ? err.message : 'Unknown error',
status: 'error'
}))
if (signal.aborted) {
return
@@ -2060,16 +2056,14 @@ export class RateLimitService {
const grok = await fetchGrokRateLimits({
signal,
authReadResult: grokAuthReadResult
}).catch(
(err): ProviderRateLimits => ({
provider: 'grok',
session: null,
weekly: null,
updatedAt: Date.now(),
error: err instanceof Error ? err.message : 'Unknown error',
status: 'error'
})
)
}).catch((err): ProviderRateLimits => ({
provider: 'grok',
session: null,
weekly: null,
updatedAt: Date.now(),
error: err instanceof Error ? err.message : 'Unknown error',
status: 'error'
}))
if (signal.aborted) {
return
+6 -2
View File
@@ -45042,6 +45042,7 @@ describe('OrcaRuntimeService', () => {
'/tmp/workspaces/runtime-hook-test',
'pnpm worktree:setup',
undefined,
undefined,
undefined
)
expect(runHook).not.toHaveBeenCalled()
@@ -45197,7 +45198,8 @@ describe('OrcaRuntimeService', () => {
'C:\\workspaces\\runtime-hook-activate',
'pnpm worktree:setup',
undefined,
{ family: 'posix' }
{ family: 'posix' },
undefined
)
expect(result.setup).toMatchObject({
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh',
@@ -45277,6 +45279,7 @@ describe('OrcaRuntimeService', () => {
'/tmp/workspaces/runtime-hook-skip',
'pnpm worktree:setup',
undefined,
undefined,
undefined
)
expect(runHook).not.toHaveBeenCalled()
@@ -45480,7 +45483,8 @@ describe('OrcaRuntimeService', () => {
'C:\\workspaces\\runtime-hook-windowless',
'pnpm worktree:setup',
undefined,
{ family: 'posix' }
{ family: 'posix' },
undefined
)
expect(runHook).not.toHaveBeenCalled()
expect(result.setupReceipt).toMatchObject({ state: 'running' })
+12 -16
View File
@@ -6067,22 +6067,18 @@ export class OrcaRuntimeService {
)
const worktrees = (await this.listResolvedWorktrees())
.filter((worktree) => repos.has(getRepoIdFromWorktreeId(worktree.id)))
.map(
(worktree): SkillSshWorkspaceAuthority => ({
kind: 'worktree',
id: worktree.id,
path: worktree.path
})
)
.map((worktree): SkillSshWorkspaceAuthority => ({
kind: 'worktree',
id: worktree.id,
path: worktree.path
}))
const folders = this.listFolderWorkspaces()
.filter((folder) => folder.connectionId === connectionId)
.map(
(folder): SkillSshWorkspaceAuthority => ({
kind: 'folder',
id: folder.id,
path: folder.folderPath
})
)
.map((folder): SkillSshWorkspaceAuthority => ({
kind: 'folder',
id: folder.id,
path: folder.folderPath
}))
return [...worktrees, ...folders]
}
@@ -27367,13 +27363,13 @@ export class OrcaRuntimeService {
// a renderer window, so the startup shell can wait on setup completion
// and windowless creates resolve the same Windows setup shell.
const runtimeTarget = this.getLocalGitExecutionOptionArgs(repo)[0]
// Why: both trailing args are optional — the shell is undefined off Windows.
setup = createSetupRunnerScript(
repo,
worktreePath,
hooks.scripts.setup,
runtimeTarget,
resolveSetupRunnerShell(settings)
resolveSetupRunnerShell(settings),
yamlHooks?.setupAgentStartupPolicy
)
} catch (error) {
// Why: the git worktree is already real at this point. If runner
@@ -34,7 +34,7 @@ export function exceedsTerminalStreamChunkBytes(data: string): boolean {
return false
}
let byteLength = 0
for (let start = 0; start < data.length; ) {
for (let start = 0; start < data.length;) {
let end = Math.min(start + TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS, data.length)
const high = data.charCodeAt(end - 1)
const low = data.charCodeAt(end)
+8 -10
View File
@@ -92,16 +92,14 @@ export const nativeSkillInstallFilesystem: SkillInstallFilesystem = {
inspectPaths: async (paths) =>
new Map(
await runSkillCandidateTasks(
paths.map(
(path) => async (): Promise<[string, SkillPathInspection]> => [
path,
{
kind: statKind(await lstat(path).catch(() => null)),
realpath: await realpath(path).catch(() => null),
mtimeMs: (await stat(path).catch(() => null))?.mtimeMs ?? null
}
]
)
paths.map((path) => async (): Promise<[string, SkillPathInspection]> => [
path,
{
kind: statKind(await lstat(path).catch(() => null)),
realpath: await realpath(path).catch(() => null),
mtimeMs: (await stat(path).catch(() => null))?.mtimeMs ?? null
}
])
)
)
}
+1 -2
View File
@@ -3,8 +3,7 @@ import { startSttDictation } from './stt-session-start'
import { createSttSessionState, type SttSessionState } from './stt-session-state'
import { prepareSttModelForDeletion, stopSttDictation } from './stt-session-stop'
export const START_DICTATION_TIMEOUT_MS = 60_000
export const IDLE_WORKER_TEARDOWN_MS = 60 * 60 * 1000
export { IDLE_WORKER_TEARDOWN_MS, START_DICTATION_TIMEOUT_MS } from './stt-session-timeouts'
export type SttEvent =
| { type: 'ready' }
+1 -2
View File
@@ -17,8 +17,7 @@ import {
initializeSttWorker,
waitForSttWorkerReady
} from './stt-worker-startup'
export const START_DICTATION_TIMEOUT_MS = 60_000
import { START_DICTATION_TIMEOUT_MS } from './stt-session-timeouts'
export async function startSttDictation(
state: SttSessionState,
+1 -1
View File
@@ -1,9 +1,9 @@
import type { Worker } from 'node:worker_threads'
import type { SttSessionState } from './stt-session-state'
import { waitForSttWorkerStop, type SttWorkerStopOutcome } from './stt-worker-stop'
import { IDLE_WORKER_TEARDOWN_MS } from './stt-session-timeouts'
const STOP_DICTATION_TIMEOUT_MS = 60_000
export const IDLE_WORKER_TEARDOWN_MS = 60 * 60 * 1000
export async function stopSttDictation(
state: SttSessionState,
+3
View File
@@ -0,0 +1,3 @@
/** Single source for the dictation timeouts the start and stop paths share. */
export const START_DICTATION_TIMEOUT_MS = 60_000
export const IDLE_WORKER_TEARDOWN_MS = 60 * 60 * 1000
+75 -15
View File
@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -508,20 +508,6 @@ describe('enableMainProcessGpuFeatures', () => {
expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('enable-unsafe-webgpu')
})
it('opts hidden pages out of intensive wake-up throttling', async () => {
const { app } = await import('electron')
const { enableMainProcessGpuFeatures } = await import('./configure-process')
delete process.env.ORCA_E2E_USER_DATA_DIR
vi.mocked(app.commandLine.appendSwitch).mockClear()
enableMainProcessGpuFeatures()
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith(
'disable-features',
'IntensiveWakeUpThrottling'
)
})
it('raises the WebGL context budget above the 16-context Blink default', async () => {
const { app } = await import('electron')
const { enableMainProcessGpuFeatures } = await import('./configure-process')
@@ -754,3 +740,77 @@ describe('enableMainProcessGpuFeatures', () => {
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('enable-features', 'ExistingFeature')
})
})
describe('safe graphics mode startup switches', () => {
const originalE2EUserDataDir = process.env.ORCA_E2E_USER_DATA_DIR
afterEach(() => {
if (originalE2EUserDataDir === undefined) {
delete process.env.ORCA_E2E_USER_DATA_DIR
} else {
process.env.ORCA_E2E_USER_DATA_DIR = originalE2EUserDataDir
}
})
function disabledFeaturesFrom(appendSwitch: ReturnType<typeof vi.fn>): string[] {
return appendSwitch.mock.calls
.filter(([name]) => name === 'disable-features')
.flatMap(([, value]) => String(value ?? '').split(','))
.filter(Boolean)
}
it('opts hidden pages out of intensive wake-up throttling', async () => {
const { app } = await import('electron')
const { optOutOfHiddenPageWakeUpThrottling } = await import('./configure-process')
vi.mocked(app.commandLine.appendSwitch).mockClear()
optOutOfHiddenPageWakeUpThrottling()
expect(app.commandLine.appendSwitch).toHaveBeenCalledWith(
'disable-features',
'IntensiveWakeUpThrottling'
)
})
// Why: the defect was the call site, not the switch — a win32 safe-graphics launch runs
// `if (!gpuFallbackActiveThisLaunch) enableMainProcessGpuFeatures()` and skips everything
// parked inside it, so only an unconditional call site reaches the users a GPU crash already hit.
it('calls the throttling opt-out outside the GPU-fallback gate in index.ts', () => {
const mainSource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8')
const gateStart = mainSource.indexOf('if (!gpuFallbackActiveThisLaunch) {')
expect(gateStart).toBeGreaterThanOrEqual(0)
const gateEnd = mainSource.indexOf('\n }', gateStart)
expect(gateEnd).toBeGreaterThan(gateStart)
expect(mainSource.match(/\boptOutOfHiddenPageWakeUpThrottling\(\)/g)).toHaveLength(1)
expect(mainSource.slice(gateStart, gateEnd)).not.toContain('optOutOfHiddenPageWakeUpThrottling')
})
// Why: Chromium consumes the command line at ready, so this must stay in the pre-ready
// top-level block and never move into the whenReady callback, where appendSwitch is a silent
// no-op — the same invisible failure as parking it behind the GPU gate.
it('appends the throttling opt-out before app ready in index.ts', () => {
const mainSource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8')
const readyStart = mainSource.indexOf('void app.whenReady()')
expect(readyStart).toBeGreaterThan(0)
const callIndex = mainSource.indexOf('optOutOfHiddenPageWakeUpThrottling()')
expect(callIndex).toBeGreaterThan(0)
expect(callIndex).toBeLessThan(readyStart)
})
// Why: Chromium enables IntensiveWakeUpThrottling on every desktop platform, so the opt-out
// must never become reachable only through the GPU-feature path again.
it('does not couple the throttling opt-out to GPU feature setup', async () => {
const { app } = await import('electron')
const { enableMainProcessGpuFeatures } = await import('./configure-process')
delete process.env.ORCA_E2E_USER_DATA_DIR
vi.mocked(app.commandLine.appendSwitch).mockClear()
enableMainProcessGpuFeatures()
expect(disabledFeaturesFrom(vi.mocked(app.commandLine.appendSwitch))).not.toContain(
'IntensiveWakeUpThrottling'
)
})
})
+6 -4
View File
@@ -71,6 +71,12 @@ export function disableUnsupportedChromiumFeatures(): void {
appendDisabledChromiumFeatures([...DISABLED_CHROMIUM_FEATURES])
}
// Why: Chromium clamps hidden-page timers to 1/min after 5min on every desktop platform,
// delaying agent-done/bell notifications ~60s. Call site is unconditional (see index.ts).
export function optOutOfHiddenPageWakeUpThrottling(): void {
appendDisabledChromiumFeatures(['IntensiveWakeUpThrottling'])
}
function appendDisabledChromiumFeatures(features: string[]): void {
const existingFeatures = app.commandLine
.getSwitchValue('disable-features')
@@ -314,8 +320,4 @@ export function enableMainProcessGpuFeatures(): void {
if (features) {
app.commandLine.appendSwitch('enable-features', features)
}
// Why: IntensiveWakeUpThrottling clamps hidden-page timers to 1/min after 5min, delaying agent-done/bell notifications ~60s.
// This opt-out is skipped under GPU fallback (win32-only today); if throttling ever reaches Windows it must move out of this path.
appendDisabledChromiumFeatures(['IntensiveWakeUpThrottling'])
}
+41 -7
View File
@@ -27,24 +27,42 @@ describe('gpu-fallback-marker', () => {
})
it('round-trips a written marker', () => {
writeGpuFallbackMarker(userDataPath, { engagedAt: 123, crashesInWindow: 3 }, environment)
writeGpuFallbackMarker(
userDataPath,
{ engagedAt: 123, crashesInWindow: 3, userConfirmed: false },
environment
)
expect(readGpuFallbackMarker(userDataPath)).toEqual({
schemeVersion: 2,
schemeVersion: 3,
engagedAt: 123,
crashesInWindow: 3,
userConfirmed: false,
appVersion: '1.2.3',
electronVersion: '42.3.3',
platform: 'win32'
})
})
it('persists explicit safe-graphics consent across launches', () => {
writeGpuFallbackMarker(
userDataPath,
{ engagedAt: 123, crashesInWindow: 3, userConfirmed: true },
environment
)
expect(readActiveGpuFallbackMarker(userDataPath, environment)?.userConfirmed).toBe(true)
})
it('returns null when no marker exists', () => {
expect(readGpuFallbackMarker(userDataPath)).toBeNull()
expect(readActiveGpuFallbackMarker(userDataPath, environment)).toBeNull()
})
it('keeps an active marker for repeated launches on the same build', () => {
writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment)
writeGpuFallbackMarker(
userDataPath,
{ engagedAt: 1, crashesInWindow: 4, userConfirmed: false },
environment
)
expect(existsSync(join(userDataPath, GPU_FALLBACK_MARKER_FILE))).toBe(true)
const firstRead = readActiveGpuFallbackMarker(userDataPath, environment)
@@ -56,7 +74,11 @@ describe('gpu-fallback-marker', () => {
})
it('clears an active marker when the app build changes', () => {
writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment)
writeGpuFallbackMarker(
userDataPath,
{ engagedAt: 1, crashesInWindow: 4, userConfirmed: false },
environment
)
expect(
readActiveGpuFallbackMarker(userDataPath, {
@@ -68,7 +90,11 @@ describe('gpu-fallback-marker', () => {
})
it('clears an active marker outside Windows', () => {
writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment)
writeGpuFallbackMarker(
userDataPath,
{ engagedAt: 1, crashesInWindow: 4, userConfirmed: false },
environment
)
expect(
readActiveGpuFallbackMarker(userDataPath, {
@@ -83,7 +109,11 @@ describe('gpu-fallback-marker', () => {
// carries the macOS disable-skia-graphite fix. A marker that survived on darwin would silently
// strip the fix from the Macs it targets, so pin the platform gate for darwin specifically.
it('clears an active marker on macOS so the Graphite fix is never skipped', () => {
writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment)
writeGpuFallbackMarker(
userDataPath,
{ engagedAt: 1, crashesInWindow: 4, userConfirmed: false },
environment
)
expect(
readActiveGpuFallbackMarker(userDataPath, {
@@ -110,7 +140,11 @@ describe('gpu-fallback-marker', () => {
})
it('can explicitly clear the marker', () => {
writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment)
writeGpuFallbackMarker(
userDataPath,
{ engagedAt: 1, crashesInWindow: 4, userConfirmed: false },
environment
)
clearGpuFallbackMarker(userDataPath)
expect(readGpuFallbackMarker(userDataPath)).toBeNull()
})
+6 -2
View File
@@ -11,7 +11,7 @@ import { join } from 'node:path'
*/
export const GPU_FALLBACK_MARKER_FILE = 'gpu-fallback.json'
export const GPU_FALLBACK_SCHEME_VERSION = 2
export const GPU_FALLBACK_SCHEME_VERSION = 3
export type GpuFallbackEnvironment = {
appVersion: string
@@ -25,6 +25,7 @@ export type GpuFallbackMarker = {
schemeVersion: number
engagedAt: number
crashesInWindow: number
userConfirmed: boolean
appVersion: string
electronVersion: string
platform: 'win32'
@@ -47,6 +48,7 @@ export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker |
!Number.isFinite(parsed.engagedAt) ||
typeof parsed.crashesInWindow !== 'number' ||
!Number.isFinite(parsed.crashesInWindow) ||
typeof parsed.userConfirmed !== 'boolean' ||
typeof parsed.appVersion !== 'string' ||
typeof parsed.electronVersion !== 'string' ||
parsed.platform !== 'win32'
@@ -57,6 +59,7 @@ export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker |
schemeVersion: GPU_FALLBACK_SCHEME_VERSION,
engagedAt: parsed.engagedAt,
crashesInWindow: parsed.crashesInWindow,
userConfirmed: parsed.userConfirmed,
appVersion: parsed.appVersion,
electronVersion: parsed.electronVersion,
platform: parsed.platform
@@ -69,13 +72,14 @@ export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker |
export function writeGpuFallbackMarker(
userDataPath: string,
info: { engagedAt: number; crashesInWindow: number },
info: { engagedAt: number; crashesInWindow: number; userConfirmed: boolean },
environment: WindowsGpuFallbackEnvironment
): void {
const marker: GpuFallbackMarker = {
schemeVersion: GPU_FALLBACK_SCHEME_VERSION,
engagedAt: info.engagedAt,
crashesInWindow: info.crashesInWindow,
userConfirmed: info.userConfirmed,
appVersion: environment.appVersion,
electronVersion: environment.electronVersion,
platform: 'win32'
+2 -2
View File
@@ -239,10 +239,10 @@ describe('updater', () => {
changelog: null
})
await vi.advanceTimersByTimeAsync(23 * 60 * 60 * 1000 + 59 * 60 * 1000)
await vi.advanceTimersByTimeAsync(23 * 60 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(60 * 1000)
await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
// Why: the boundary tick sweeps the updater's other timers (30-minute nudge poll, 45-second
// stall guard) too, so pin the reschedule itself — nothing before 24h, a check once it elapses —
// rather than an exact process-wide call total.
@@ -30,6 +30,18 @@ function isAlive(pid: number): boolean {
}
}
// Job-object teardown is external to fake timers; poll the real process table to a deadline.
async function waitForProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (!isAlive(pid)) {
return true
}
await sleep(50)
}
return !isAlive(pid)
}
describeOnWindows('host job reaps the tree when the host dies', () => {
let dir: string
@@ -83,11 +95,13 @@ describeOnWindows('host job reaps the tree when the host dies', () => {
// Force-kill only the host: no tree kill, nothing given a chance to unwind.
// This is the daemon-crash shape.
process.kill(host.pid!, 'SIGKILL')
await sleep(3_000)
try {
expect(isAlive(shellPid)).toBe(false)
expect(isAlive(grandchildPid)).toBe(false)
const [shellExited, grandchildExited] = await Promise.all([
waitForProcessExit(shellPid, 15_000),
waitForProcessExit(grandchildPid, 15_000)
])
expect(shellExited).toBe(true)
expect(grandchildExited).toBe(true)
} finally {
for (const pid of [shellPid, grandchildPid]) {
try {
+5 -2
View File
@@ -13,6 +13,7 @@ import { buildPosixRunnerScript, buildWindowsRunnerScript } from './setup-runner
import type { HookRuntimeTarget } from './hook-runtime-target'
import type { Repo } from '../shared/repo-types'
import type { WorktreeSetupLaunch } from '../shared/worktree/launch-types'
import type { SetupAgentStartupPolicy } from '../shared/orca-yaml-hook-types'
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
import type { SetupRunnerShell } from '../shared/setup-runner-command'
@@ -30,7 +31,8 @@ export function createSetupRunnerScript(
worktreePath: string,
script: string,
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget,
setupShell?: SetupRunnerShell
setupShell?: SetupRunnerShell,
projectStartupPolicy?: SetupAgentStartupPolicy
): WorktreeSetupLaunch {
return createWorktreeRunnerScript({
repo,
@@ -39,7 +41,8 @@ export function createSetupRunnerScript(
runnerBaseName: 'setup-runner',
runtimeTarget: getHookRuntimeTarget(projectRuntime),
waitForAgentStartup: shouldWaitForSetupBeforeAgentStartup(
repo.hookSettings?.setupAgentStartupPolicy
repo.hookSettings?.setupAgentStartupPolicy,
projectStartupPolicy
),
setupShell
})
+3 -1
View File
@@ -32,6 +32,8 @@ import type {
SessionTabCloseResponse
} from '../../shared/session-tab-close'
export type CloseActiveTabPayload = { sourceId: string }
export type UiCommandEventApi = {
get: () => Promise<PersistedUIState>
set: (args: Partial<PersistedUIState>) => Promise<void>
@@ -105,7 +107,7 @@ export type UiCommandEventApi = {
onBrowserHistoryNavigate: (callback: (direction: 'back' | 'forward') => void) => () => void
onZoomBrowserPage: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
onHardReloadBrowserPage: (callback: () => void) => () => void
onCloseActiveTab: (callback: () => void) => () => void
onCloseActiveTab: (callback: (payload?: CloseActiveTabPayload) => void) => () => void
onCloseFloatingItem: (callback: (payload: { sourceId: string }) => void) => () => void
onSelectFloatingIndex: (callback: (payload: { index: number }) => void) => () => void
onSwitchTab: (callback: (direction: 1 | -1) => void) => () => void
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { admitCloseActiveTabPayload } from './close-active-tab-payload-admission'
describe('admitCloseActiveTabPayload', () => {
it('preserves the legacy omitted payload', () => {
expect(admitCloseActiveTabPayload(undefined)).toEqual({ kind: 'legacy' })
})
it('admits a nonempty source id', () => {
expect(admitCloseActiveTabPayload({ sourceId: 'page-1' })).toEqual({
kind: 'source',
payload: { sourceId: 'page-1' }
})
})
it.each([null, {}, [], { sourceId: '' }, { sourceId: 1 }, 'page-1'])(
'rejects malformed payload %#',
(payload) => {
expect(admitCloseActiveTabPayload(payload)).toEqual({ kind: 'invalid' })
}
)
})
@@ -0,0 +1,25 @@
import type { CloseActiveTabPayload } from './api/ui-command-event-api'
export type AdmittedCloseActiveTabPayload =
| { kind: 'legacy' }
| { kind: 'source'; payload: CloseActiveTabPayload }
| { kind: 'invalid' }
export function admitCloseActiveTabPayload(value: unknown): AdmittedCloseActiveTabPayload {
if (value === undefined) {
return { kind: 'legacy' }
}
if (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
typeof (value as Record<string, unknown>).sourceId === 'string' &&
(value as Record<string, unknown>).sourceId !== ''
) {
return {
kind: 'source',
payload: { sourceId: (value as Record<string, unknown>).sourceId as string }
}
}
return { kind: 'invalid' }
}
+11 -2
View File
@@ -3,6 +3,8 @@ import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import { preloadE2EConfig } from './e2e-config'
import { glApi } from './gitlab'
import { admitCloseActiveTabPayload } from './close-active-tab-payload-admission'
import type { CloseActiveTabPayload } from './api/ui-command-event-api'
import type {
SkillDeletePlan,
SkillDeleteRequest,
@@ -4050,8 +4052,15 @@ const api = {
ipcRenderer.on('ui:hardReloadBrowserPage', listener)
return () => ipcRenderer.removeListener('ui:hardReloadBrowserPage', listener)
},
onCloseActiveTab: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
onCloseActiveTab: (callback: (payload?: CloseActiveTabPayload) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload?: unknown): void => {
const admitted = admitCloseActiveTabPayload(payload)
if (admitted.kind === 'legacy') {
callback()
} else if (admitted.kind === 'source') {
callback(admitted.payload)
}
}
ipcRenderer.on('ui:closeActiveTab', listener)
return () => ipcRenderer.removeListener('ui:closeActiveTab', listener)
},
@@ -654,7 +654,10 @@ describe('PtyHandler negotiated source publication', () => {
expect(mockPtySpawn).toHaveBeenCalledOnce()
expect(
responseResult(replacementWrites.find((buffer) => responseResult(buffer, 4))!, 4)
responseResult(
replacementWrites.find((buffer) => responseResult(buffer, 4))!,
4
)
).toMatchObject({
id: 'pty-1',
incarnationId: expect.any(String),
@@ -96,34 +96,32 @@ export function usePersistedUIWriter(): void {
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
const activeView = useAppStore((s) => s.activeView)
const ui = useAppStore(
useShallow(
(s): PersistedUIWriteBaseline => ({
sidebarWidth: s.sidebarWidth,
rightSidebarOpen: s.rightSidebarOpen,
rightSidebarTab: s.rightSidebarTab,
rightSidebarExplorerView: s.rightSidebarExplorerView,
rightSidebarWidth: s.rightSidebarWidth,
markdownTocPanelWidth: s.markdownTocPanelWidth,
combinedDiffFileTreeWidth: s.combinedDiffFileTreeWidth,
groupBy: s.groupBy,
sortBy: s.sortBy,
projectOrderBy: s.projectOrderBy,
showSleepingWorkspaces: s.showSleepingWorkspaces,
hideDefaultBranchWorkspace: s.hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces: s.hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces: s.hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces: s.hideDetachedHeadWorkspaces,
hideWorkspacesFromOtherDevices: s.hideWorkspacesFromOtherDevices,
alwaysShowDefaultBranchWorkspace: s.alwaysShowDefaultBranchWorkspace,
showDotfilesByWorktree: s.showDotfilesByWorktree,
filterRepoIds: s.filterRepoIds,
// Why: dashboard auto-acks (fire on focus/visibility) and the in-memory ack cleanup
// paths in agent-status.ts (close/dismiss) flow to disk through map identity changes.
// Without persisting, agent rows that survive restart come back bold even when the
// user had already visited them.
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey
})
)
useShallow((s): PersistedUIWriteBaseline => ({
sidebarWidth: s.sidebarWidth,
rightSidebarOpen: s.rightSidebarOpen,
rightSidebarTab: s.rightSidebarTab,
rightSidebarExplorerView: s.rightSidebarExplorerView,
rightSidebarWidth: s.rightSidebarWidth,
markdownTocPanelWidth: s.markdownTocPanelWidth,
combinedDiffFileTreeWidth: s.combinedDiffFileTreeWidth,
groupBy: s.groupBy,
sortBy: s.sortBy,
projectOrderBy: s.projectOrderBy,
showSleepingWorkspaces: s.showSleepingWorkspaces,
hideDefaultBranchWorkspace: s.hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces: s.hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces: s.hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces: s.hideDetachedHeadWorkspaces,
hideWorkspacesFromOtherDevices: s.hideWorkspacesFromOtherDevices,
alwaysShowDefaultBranchWorkspace: s.alwaysShowDefaultBranchWorkspace,
showDotfilesByWorktree: s.showDotfilesByWorktree,
filterRepoIds: s.filterRepoIds,
// Why: dashboard auto-acks (fire on focus/visibility) and the in-memory ack cleanup
// paths in agent-status.ts (close/dismiss) flow to disk through map identity changes.
// Without persisting, agent rows that survive restart come back bold even when the
// user had already visited them.
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey
}))
)
useEffect(() => {
// The baseline holds the values this client last saw persisted (newest
+2 -2
View File
@@ -758,8 +758,8 @@ export default function TaskPage(): React.JSX.Element {
// Hard guarantee (K4): always overlay pending after reconcile so list
// fetch clobbers never paint unprotected coordinator fields.
setPages((current) =>
reconcileTaskPagePagesWithWorkItemsCache(current, selectedWorkItemsCacheEntries).map((page) =>
page ? (overlayPendingOnTaskPagePages([page])[0] ?? []) : null
reconcileTaskPagePagesWithWorkItemsCache(current, selectedWorkItemsCacheEntries).map(
(page) => (page ? (overlayPendingOnTaskPagePages([page])[0] ?? []) : null)
)
)
}, [githubMode, selectedWorkItemsCacheEntries, taskSource, setPages])
@@ -8,6 +8,7 @@ import React, {
useRef,
useState
} from 'react'
import { flushSync } from 'react-dom'
import { useShallow } from 'zustand/react/shallow'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -42,7 +43,6 @@ import {
CommandEmpty,
CommandItem
} from '@/components/ui/command'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { parseGitHubIssueOrPRNumber, parseGitHubIssueOrPRLink } from '@/lib/github-links'
import { getLinkedWorkItemSuggestedName, getLinkedWorkItemWorkspaceName } from '@/lib/new-workspace'
@@ -568,7 +568,7 @@ function PaletteOpenTabPrimaryLine({
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
<span
data-slot="palette-open-tab-title"
className="min-w-0 truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground"
className="min-w-0 flex-1 truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground"
>
<HighlightedText text={title} matchRanges={titleRanges} />
</span>
@@ -2022,13 +2022,11 @@ function WorktreeJumpPaletteContent({
fetchLinearIssue: state.fetchLinearIssue
})
.catch(() => null)
.then(
(issue): CmdJLinearIssuePreview => ({
...pendingPreview,
issue,
loading: false
})
)
.then((issue): CmdJLinearIssuePreview => ({
...pendingPreview,
issue,
loading: false
}))
linearIssueLookupRef.current = { query: createWorktreeName, promise }
void promise.then((preview) => {
if (linearIssueLookupGenerationRef.current === generation) {
@@ -2077,13 +2075,11 @@ function WorktreeJumpPaletteContent({
sourceContext
})
.catch(() => null)
.then(
(item): CmdJGitHubWorkItemPreview => ({
...pendingPreview,
item: item ?? null,
loading: false
})
)
.then((item): CmdJGitHubWorkItemPreview => ({
...pendingPreview,
item: item ?? null,
loading: false
}))
githubLookupRef.current = { query: createWorktreeName, promise }
void promise.then((preview) => {
if (githubLookupGenerationRef.current === generation) {
@@ -3262,30 +3258,33 @@ function WorktreeJumpPaletteContent({
}
if (entry.type === 'hint') {
// Why: plain div (not CommandItem) so cmdk can't select it; arrow keys skip it via selectableItems.
return (
<div
<CommandItem
key={renderKey}
className="mx-0.5 mt-1 flex items-center gap-2 px-3 py-1.5 text-[12px] text-muted-foreground"
value={renderKey}
onSelect={() => {
const previousIndex = selectionItemIds.indexOf(renderKey)
flushSync(() => entry.onSeeMore?.())
const expandedItemId = Array.from(
listRef.current?.querySelectorAll<HTMLElement>('[cmdk-item]') ?? []
)[previousIndex]?.getAttribute('data-value')
if (expandedItemId) {
setSelectedItemId(expandedItemId)
}
inputRef.current?.focus()
}}
className={cn(
JUMP_PALETTE_ITEM_CLASSNAME,
'mt-1 min-h-0 gap-2 py-1.5 text-[12px] text-muted-foreground'
)}
>
<span className="truncate">{entry.label}</span>
{entry.onSeeMore ? (
<Button
type="button"
variant="outline"
size="xs"
className="h-6 shrink-0 px-2 text-xs font-medium text-foreground hover:bg-accent"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
entry.onSeeMore?.()
inputRef.current?.focus()
}}
>
<span className="h-6 shrink-0 rounded-md border border-input bg-background px-2 text-xs font-medium leading-6 text-foreground">
{translate('worktreeJumpPalette.seeMore', 'See more')}
</Button>
</span>
) : null}
</div>
</CommandItem>
)
}
@@ -113,7 +113,8 @@ describe('AutomationRunHistory unanswered history', () => {
expect(container.textContent).not.toContain('No runs yet.')
// "0 runs" is a count of something nobody managed to read.
expect(container.textContent).not.toContain('0 runs')
expect(container.textContent).toContain('Run history could not be loaded')
expect(container.textContent).toContain('Run history is unavailable from this host')
expect(container.textContent).toContain('does not mean the automation failed or has no runs')
expect(container.textContent).toContain('web-01 is not connected')
})
@@ -167,7 +167,7 @@ export function AutomationRunHistory({
<p className="text-center text-sm text-foreground">
{translate(
'auto.components.automations.AutomationRunHistory.historyUnavailable',
'Run history could not be loaded for this automation.'
'Run history is unavailable from this host. This does not mean the automation failed or has no runs.'
)}
</p>
<AutomationOwnerConflictNotice notice={notice} onRecover={onRecoverHistory} />
@@ -0,0 +1,88 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AutomationsDetailPane } from './AutomationsDetailPane'
import { makeAutomation } from './automations-page-fixtures'
let root: Root | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(async () => {
await act(async () => root?.unmount())
root = null
document.body.innerHTML = ''
})
async function renderRunsTab(historyUnavailable: boolean): Promise<HTMLButtonElement> {
const container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(
<AutomationsDetailPane
selected={makeAutomation()}
selectedExternal={null}
selectedExternalRunPage={null}
selectedAutomationRunPage={null}
selectedRuns={[]}
selectedRunsNotice={
historyUnavailable
? { message: 'Server update required', recovery: 'update-server', severity: 'failure' }
: null
}
activePaneTab="runs"
relativeNow={0}
externalActionKey={null}
selectedRepoDisplayName="Orca"
selectedRepoDefaultBaseRef={null}
selectedWorkspaceName="Workspace"
selectedHostEntry={null}
hostLabelById={new Map()}
selectedRunNowAvailability={null}
selectedAutomationRunPageWorkspaceDisplay={null}
selectedAutomationRunPageViewState={null}
canRerunSelectedAutomationRunPage={false}
isSelectedAutomationRunPageRerunPending={false}
worktreeMap={new Map()}
fetchExternalAutomationRuns={vi.fn()}
onActivePaneTabChange={vi.fn()}
onClearExternalRunPage={vi.fn()}
onClearAutomationRunPage={vi.fn()}
requestExternalAction={vi.fn()}
openExternalRunPage={vi.fn()}
openEditExternalDialog={vi.fn()}
runNow={vi.fn()}
openEditDialog={vi.fn()}
toggleAutomation={vi.fn()}
requestDeleteAutomation={vi.fn()}
rerunAutomationRun={vi.fn()}
openRunWorkspace={vi.fn()}
openAutomationRunPage={vi.fn()}
onBackToList={vi.fn()}
recoverSelectedRuns={vi.fn()}
/>
)
})
const runsTab = [...container.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find((tab) =>
tab.textContent?.includes('Runs')
)
if (!runsTab) {
throw new Error('Runs tab was not rendered')
}
return runsTab
}
describe('AutomationsDetailPane run count', () => {
it('does not report zero when run history is unavailable', async () => {
expect((await renderRunsTab(true)).textContent?.trim()).toBe('Runs')
})
it('reports zero when the host answered with an empty history', async () => {
expect((await renderRunsTab(false)).textContent).toContain('0')
})
})
@@ -213,7 +213,9 @@ export function AutomationsDetailPane({
</TabsTrigger>
<TabsTrigger value="runs" disabled={!selected}>
{translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}{' '}
<span className="text-xs text-muted-foreground">{selectedRuns.length}</span>
{selectedRunsNotice ? null : (
<span className="text-xs text-muted-foreground">{selectedRuns.length}</span>
)}
</TabsTrigger>
</TabsList>
</div>
@@ -19,6 +19,7 @@ import {
mocks,
renderPage,
runtimeHost,
RUNTIME_ID,
RUNTIME_REPO_ID,
RUNTIME_SELF_FILTER,
RUNTIME_WORKSPACE_ID,
@@ -88,7 +89,11 @@ describe('AutomationsPage notice recovery', () => {
})
expect(settings.open).toHaveBeenCalled()
expect(settings.target).toHaveBeenCalledWith({ pane: 'servers', repoId: null })
expect(settings.target).toHaveBeenCalledWith({
pane: 'servers',
repoId: null,
sectionId: RUNTIME_ID
})
})
it('sends a row Update server to the row own host, not to the unfiltered list', async () => {
@@ -116,6 +121,10 @@ describe('AutomationsPage notice recovery', () => {
// All hosts selects no host, so only the row's captured owner names one.
expect(settings.open).toHaveBeenCalled()
expect(settings.target).toHaveBeenCalledWith({ pane: 'servers', repoId: null })
expect(settings.target).toHaveBeenCalledWith({
pane: 'servers',
repoId: null,
sectionId: RUNTIME_ID
})
})
})
@@ -92,6 +92,12 @@ describe('captured owner availability', () => {
const availability = automationActionAvailability(legacy, 'delete')
expect(availability.kind).toBe('blocked')
expect(availability.kind === 'blocked' && availability.block.recovery).toBe('update-server')
expect(availability.kind === 'blocked' && availability.block.message).toContain(
'needs an update'
)
expect(availability.kind === 'blocked' && availability.block.message).toContain(
'Scheduled runs may still continue'
)
})
it('reports a row with no metadata as uncaptured rather than blocked', () => {
@@ -71,7 +71,7 @@ function unfencedBlock(): AutomationActionBlock {
reason: 'unfenced',
message: translate(
'auto.components.automations.capturedOwner.unfenced',
'This host cannot confirm which automation it is changing, so this automation is read-only here.'
'Orca on this host needs an update before you can view run history or manage this automation. Scheduled runs may still continue on the host.'
),
recovery: 'update-server'
}
@@ -35,9 +35,10 @@ const DESKTOP_SSH = entry({
stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } },
kind: 'ssh'
})
const RUNTIME_ENVIRONMENT_ID = 'gpu'
const RUNTIME_SSH = entry({
stableRef: {
authority: { kind: 'runtime', environmentId: 'gpu' },
authority: { kind: 'runtime', environmentId: RUNTIME_ENVIRONMENT_ID },
selector: { kind: 'ssh', targetId: 't1' }
},
kind: 'ssh'
@@ -65,7 +66,7 @@ describe('automation host recovery', () => {
{ ...RUNTIME_SSH, authorityHealth: 'unavailable' },
target
)
expect(target.connectRuntimeEnvironment).toHaveBeenCalledWith('gpu')
expect(target.connectRuntimeEnvironment).toHaveBeenCalledWith(RUNTIME_ENVIRONMENT_ID)
expect(target.connectSshTarget).not.toHaveBeenCalled()
})
@@ -78,10 +79,15 @@ describe('automation host recovery', () => {
expect(target.retry).toHaveBeenCalledWith(desktopSelf)
})
it('sends a runtime to the servers pane, since nothing here updates a host', () => {
it('deep-links a runtime to its update row in Remote Orca Servers settings', () => {
const target = deps()
runAutomationHostRecovery('update-server', RUNTIME_SSH, target)
expect(target.openSettings).toHaveBeenCalledWith({ pane: 'servers', repoId: null })
expect(target.openSettings).toHaveBeenCalledWith({
pane: 'servers',
repoId: null,
// Runtime environment IDs are nested scroll anchors within the pane.
sectionId: RUNTIME_ENVIRONMENT_ID
})
})
it('sends a stale desktop SSH registration to the SSH pane instead', () => {
@@ -20,10 +20,14 @@ export type AutomationHostRecoveryDeps = {
openSettings: (target: SettingsNavigationTarget) => void
}
/** Where a host's version is managed; the app cannot update a host on its own. */
/** The Remote Orca Servers pane owns each runtime's version status and update action. */
function versionSettingsTarget(entry: AutomationHostCatalogEntry): SettingsNavigationTarget {
if (entry.stableRef.authority.kind === 'runtime') {
return { pane: 'servers', repoId: null }
return {
pane: 'servers',
repoId: null,
sectionId: entry.stableRef.authority.environmentId
}
}
// A desktop SSH host with no registration generation is a stale registration,
// repaired by re-adding the target rather than by updating anything.
@@ -89,7 +89,7 @@ describe('automation run output snapshot queue equivalence', () => {
const reference = createShiftReferenceBuffer()
const candidate = createAutomationRunOutputSnapshotBuffer()
let chunkCount = 0
for (let offset = 0; offset < source.length; ) {
for (let offset = 0; offset < source.length;) {
const chunkLength = 1 + (random() % 73)
const chunk = source.slice(offset, offset + chunkLength)
reference.append(chunk)
@@ -248,8 +248,8 @@ export function useAutomationHostCatalog(
void window.api.runtimeEnvironments.connect({ selector: environmentId })
},
openSettings: (target) => {
openSettingsPage()
openSettingsTarget(target)
openSettingsPage()
}
}),
[controller, openSettingsPage, openSettingsTarget, pairingRevision]
@@ -35,7 +35,7 @@ function inlineText(
// scanning only the bounded text we will actually retain.
let normalized = ''
let pendingSpace = false
for (let index = 0; index < content.length && normalized.length < maxLength; ) {
for (let index = 0; index < content.length && normalized.length < maxLength;) {
const code = content.charCodeAt(index)
if (isInlineWhitespaceCode(code)) {
if (code === 13 && content.charCodeAt(index + 1) === 10) {
@@ -14,9 +14,10 @@ const mocks = vi.hoisted(() => ({
onSnapshotRequested: vi.fn(),
getPopoutOpen: vi.fn(async () => false),
publishSnapshot: vi.fn(async (_snapshot: DashboardSnapshot) => undefined),
buildDashboardSnapshot: vi.fn(
(_state: unknown, now: number): DashboardSnapshot => ({ generatedAt: now, cards: [] })
),
buildDashboardSnapshot: vi.fn((_state: unknown, now: number): DashboardSnapshot => ({
generatedAt: now,
cards: []
})),
offRevealAgent: vi.fn(),
offAckAgent: vi.fn(),
offPopoutOpenChanged: vi.fn(),
@@ -1,4 +1,5 @@
import React, { useCallback, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
import type { NodeViewProps } from '@tiptap/react'
@@ -6,159 +7,11 @@ import { Copy, Check } from 'lucide-react'
import { useAppStore } from '@/store'
import MermaidBlock from './MermaidBlock'
import { translate } from '@/i18n/i18n'
/**
* Common languages shown in the selector. The user can also type a language
* name directly in the markdown fence (```rust) and it will be preserved —
* this list is just for quick picking in the UI.
*/
const LANGUAGES = [
{
value: '',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.13822cdfda', 'Plain text')
}
},
{
value: 'bash',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.4227cf50fe', 'Bash')
}
},
{ value: 'c', label: 'C' },
{
value: 'cpp',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.4daed43ae3', 'C++')
}
},
{
value: 'css',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.026653f21f', 'CSS')
}
},
{
value: 'diff',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.bf6ee5caaa', 'Diff')
}
},
{
value: 'go',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.edfcc64182', 'Go')
}
},
{
value: 'graphql',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.706fd85738', 'GraphQL')
}
},
{
value: 'html',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.8c4a3fa02d', 'HTML')
}
},
{
value: 'java',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.36536ad539', 'Java')
}
},
{
value: 'javascript',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.a209c57063', 'JavaScript')
}
},
{
value: 'json',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.78eba32de4', 'JSON')
}
},
{
value: 'kotlin',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.bcb236e2d8', 'Kotlin')
}
},
{
value: 'markdown',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.983b9576b4', 'Markdown')
}
},
{
value: 'mermaid',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.89d6cc14fb', 'Mermaid')
}
},
{
value: 'python',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.2391f9cda9', 'Python')
}
},
{
value: 'ruby',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.96182a2f64', 'Ruby')
}
},
{
value: 'rust',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.e72e6b03f4', 'Rust')
}
},
{
value: 'scss',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.5af8251002', 'SCSS')
}
},
{
value: 'shell',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.d01f55be57', 'Shell')
}
},
{
value: 'sql',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.3009f722b9', 'SQL')
}
},
{
value: 'swift',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.9e384d48dc', 'Swift')
}
},
{
value: 'typescript',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.88d777bc07', 'TypeScript')
}
},
{
value: 'xml',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.5ef5605cb7', 'XML')
}
},
{
value: 'yaml',
get label() {
return translate('auto.components.editor.RichMarkdownCodeBlock.74eab1d9b2', 'YAML')
}
}
]
import {
getCodeBlockLanguageLabel,
getCodeBlockLanguages,
isKnownCodeBlockLanguage
} from './rich-markdown-code-block-languages'
export function RichMarkdownCodeBlock({
node,
@@ -167,6 +20,10 @@ export function RichMarkdownCodeBlock({
useTranslation()
const language = (node.attrs.language as string) || ''
const [copied, setCopied] = useState(false)
// Why: ProseMirror renders every node view in the document, so eagerly
// mounting the full language list cost ~25 <option> elements per code block —
// in a large document that dwarfs the prose DOM and slows every keystroke.
const [languageListMounted, setLanguageListMounted] = useState(false)
const copiedResetTimerRef = useRef<number | null>(null)
// Why: clipboard IPC can resolve after the node view unmounts; avoid
// starting a reset timer that will outlive the component.
@@ -195,6 +52,15 @@ export function RichMarkdownCodeBlock({
[clearCopiedResetTimer]
)
const mountLanguageList = useCallback(() => {
if (languageListMounted) {
return
}
// Why: the native popup opens as this same discrete event's default action,
// so the list must be in the DOM before React's normal flush would land.
flushSync(() => setLanguageListMounted(true))
}, [languageListMounted])
const onChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
updateAttributes({ language: e.target.value })
@@ -233,15 +99,25 @@ export function RichMarkdownCodeBlock({
contentEditable={false}
value={language}
onChange={onChange}
onMouseDown={mountLanguageList}
onFocus={mountLanguageList}
>
{LANGUAGES.map((lang) => (
<option key={lang.value} value={lang.value}>
{lang.label}
</option>
))}
{languageListMounted ? (
getCodeBlockLanguages().map((lang) => (
<option key={lang.value} value={lang.value}>
{lang.label}
</option>
))
) : (
// Why: a closed <select> only paints its selected option, so until the
// user reaches for the list one entry renders the same visible label.
<option value={language}>{getCodeBlockLanguageLabel(language)}</option>
)}
{/* If the document has a language not in our list, show it as-is */}
{language && !LANGUAGES.some((l) => l.value === language) ? (
<option value={language}>{language}</option>
{languageListMounted && language && !isKnownCodeBlockLanguage(language) ? (
<option key={language} value={language}>
{language}
</option>
) : null}
</select>
<button

Some files were not shown because too many files have changed in this diff Show More