[comment audit] Shorten top-level main process H–K comments (#32290)

* chore: shorten process-host comments

* chore: audit CLI parsing and host-routing comments

* chore: remove stale host-selector restriction comment

* chore: shorten remaining CLI root comments

* Shorten CLI handler comments

* Shorten CLI runtime and command specification comments

* Shorten top-level main process A–G comments

* [comment audit] Shorten main process H–K comments

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-23 09:50:47 -07:00
committed by GitHub
co-authored by OrcaWin
parent 4c7991509f
commit 4935d75e1c
22 changed files with 81 additions and 281 deletions
@@ -10,7 +10,6 @@ vi.mock('./effective-hook-config', () => ({
const REPO: Repo = { id: 'r', path: '/repo', displayName: 'r', badgeColor: '#000', addedAt: 0 }
/** Run a real archive script in a real shell, under a deadline short enough to test. */
async function runArchive(script: string, timeoutMs = 400) {
const { runHook } = await import('./hooks')
const dir = mkdtempSync(join(tmpdir(), 'orca-hook-deadline-'))
@@ -22,14 +21,12 @@ async function runArchive(script: string, timeoutMs = 400) {
}
}
// Why a real shell (#19334): this bug is invisible to a mock. Node's `exec({ timeout })` SIGTERMs
// the child and reports whatever it chose to do, so a hook that traps SIGTERM and exits 0 came
// back as a PASS — a hook cut off mid-archive, indistinguishable from one that finished its work.
// Real shells expose SIGTERM traps that exit 0 after an archive timeout (#19334).
describe.skipIf(process.platform === 'win32')('archive hook deadline', () => {
it('fails a hook that traps SIGTERM and exits zero, despite its zero exit', async () => {
const result = await runArchive("trap 'exit 0' TERM; sleep 30")
expect(result.success).toBe(false)
// Withheld, so the removal gate reads `unverifiable` rather than a pass.
// Missing exitCode keeps the removal verdict unverifiable.
expect(result.exitCode).toBeUndefined()
expect(result.output).toContain('timed out')
}, 20_000)
@@ -38,7 +35,6 @@ describe.skipIf(process.platform === 'win32')('archive hook deadline', () => {
const started = Date.now()
const result = await runArchive("trap '' TERM; sleep 30")
expect(result.success).toBe(false)
// A hook that ignores the signal must not hold a removal open until it finishes.
expect(Date.now() - started).toBeLessThan(10_000)
}, 20_000)
+1 -1
View File
@@ -40,7 +40,7 @@ export function getHookWslContext(
return null
}
// Why: project runtime can route a Windows checkout through WSL, so hooks need the Linux view of the path.
// Project runtime may route a Windows checkout through WSL; hooks need its Linux path.
return {
distro: wslDistro,
linuxPath: toLinuxPath(cwd)
+3 -11
View File
@@ -15,7 +15,6 @@ const alive = (pid: number): boolean => {
}
}
/** Run a hook past its deadline and report which of its real processes survived. */
async function survivorsAfterDeadline(
script: string
): Promise<{ shell: boolean; child: boolean; output: string; pids: number[] }> {
@@ -32,8 +31,7 @@ async function survivorsAfterDeadline(
expect(result.success).toBe(false)
expect(existsSync(pidFile)).toBe(true)
pids = readFileSync(pidFile, 'utf8').trim().split(/\s+/).map(Number)
// Without this, a script that recorded only the shell leaves `pids[1]` undefined, `alive`
// throws, and the missing descendant reads as dead — a test that passes on nothing.
// Require both PIDs so a missing descendant cannot make the liveness assertion pass vacuously.
expect(pids).toHaveLength(2)
expect(pids.every((pid) => Number.isSafeInteger(pid) && pid > 0)).toBe(true)
// SIGTERM lands at the deadline; resistant descendants need the real SIGKILL grace.
@@ -43,25 +41,19 @@ async function survivorsAfterDeadline(
for (const pid of pids) {
try {
process.kill(pid, 'SIGKILL')
} catch {
/* already gone */
}
} catch {}
}
rmSync(dir, { recursive: true, force: true })
}
}
// NO `process.kill` mock, deliberately. The defect this file exists for — `exec` silently ignoring
// `detached`, so the shell was never a group leader and the group signal reached nothing — is
// invisible to a mocked `process.kill`, because the mock makes the signal-0 probe succeed whether
// or not a real group exists. That is the precise condition the bug turns on.
// Mocked kill probes cannot prove a process group exists; exercise real signals here.
describe.skipIf(process.platform === 'win32')('hook termination against real processes', () => {
it('kills the shell and its child when the deadline expires', async () => {
const { shell, child, output } = await survivorsAfterDeadline(
'echo "archive step 3 of 7"\nsleep 120 &\necho "$$ $!" > "$PWD/pids"\nwait'
)
expect({ shell, child }).toEqual({ shell: false, child: false })
// The gate reports this run as `unverifiable`; what the hook printed is the only clue why.
expect(output).toContain('archive step 3 of 7')
}, 30_000)
@@ -10,10 +10,7 @@ vi.mock('./effective-hook-config', () => ({
const REPO: Repo = { id: 'r', path: '/repo', displayName: 'r', badgeColor: '#000', addedAt: 0 }
/**
* A real EventEmitter, so an `error` with no listener throws exactly as Node's would — which is the
* whole point of the stream-error row below. Replays its chunks to whoever subscribes to `data`.
*/
/** EventEmitter preserves Node’s fatal unhandled-error behavior while replaying chunks. */
class FakeStream extends EventEmitter {
constructor(private readonly chunks: string[]) {
super()
@@ -30,7 +27,6 @@ class FakeStream extends EventEmitter {
}
}
/** Minimal ChildProcess stand-in: runHook reads the streams and waits for close/error. */
function fakeChild(
outcome: { code?: number | null; signal?: NodeJS.Signals | null } | Error,
stdoutChunks: string[] = [],
@@ -79,10 +75,7 @@ async function runArchiveWith(
return result
}
// Why (#19334): an ABSENT exitCode is what the removal gate reads as `unverifiable`. Every row here
// is a way a hook can fail to deliver one. The timeout and termination arms of the same contract
// are covered against REAL processes in hook-termination-real-process.test.ts — deliberately not
// here, because a mocked child cannot show whether a process group exists.
// Real-process timeout/group tests live in hook-termination-real-process.test.ts (#19334).
describe('archive hook exit observation', () => {
it('passes a clean run through without an exit code', async () => {
await expect(runArchiveWith({ code: 0 })).resolves.toEqual({ success: true, output: '' })
@@ -99,8 +92,6 @@ describe('archive hook exit observation', () => {
})
it('caps what it retains from a hook that floods stdout', async () => {
// `exec`'s 1 MiB maxBuffer is gone with `spawn`; without a cap a flooding hook grows the main
// process's heap for the whole 120 s deadline.
const megabyte = 'x'.repeat(1024 * 1024)
const result = await runArchiveWith(
{ code: 0 },
@@ -111,8 +102,6 @@ describe('archive hook exit observation', () => {
})
it('survives an error on the output stream', async () => {
// An `error` with no listener is an uncaught exception, and in the main process that is the
// app. `exec` never covered this either — its only `error` listener is on the child.
await expect(
runArchiveWith({ code: 0 }, ['partial'], new Error('EIO: read failed'))
).resolves.toMatchObject({ success: true })
@@ -125,8 +114,7 @@ describe('archive hook exit observation', () => {
it.each([
['was killed by a signal', { code: null, signal: 'SIGKILL' as const }],
// A real spawn failure carries a STRING code; the guard under test is `typeof code ===
// 'number'`, so a bare Error would pass even if that guard regressed.
// Spawn errors carry string codes; a bare Error would not exercise the numeric-code guard.
['never started', Object.assign(new Error('spawn /bin/bash ENOENT'), { code: 'ENOENT' })]
])('withholds the exit code when the hook %s', async (_label, outcome) => {
const result = await runArchiveWith(outcome)
@@ -7,7 +7,6 @@ import {
TEST_WORKTREE_PATH
} from './hooks-test-fixtures'
// Mock fs used by loadHooks
vi.mock('fs', () => ({
readFileSync: vi.fn(),
existsSync: vi.fn(),
@@ -18,7 +17,6 @@ vi.mock('fs', () => ({
}))
describe('getEffectiveHooks', () => {
// We need to dynamically import after mocking
const makeRepo = (hookSettings?: {
mode?: 'auto' | 'override'
setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default'
@@ -31,7 +29,6 @@ describe('getEffectiveHooks', () => {
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo "yaml setup"\n')
// Re-import to pick up mocks
const { getEffectiveHooks } = await import('./hooks')
const repo = makeRepo()
const result = getEffectiveHooks(repo)
+1 -3
View File
@@ -10,7 +10,6 @@ import {
TEST_REPO_PATH
} from './hooks-test-fixtures'
// Mock fs used by loadHooks
vi.mock('fs', () => ({
readFileSync: vi.fn(),
existsSync: vi.fn(),
@@ -28,7 +27,6 @@ vi.mock('child_process', () => ({
exec: vi.fn(),
execFile: vi.fn(),
execFileSync: vi.fn(),
// runner.ts imports spawn from child_process transitively.
spawn: vi.fn()
}))
@@ -203,7 +201,7 @@ describe('createIssueCommandRunnerScript', () => {
['rev-parse', '--git-path', 'orca/issue-command-runner.cmd'],
{ cwd: 'C:\\repo-worktree' }
)
// Why: the runner file is batch (.cmd) while the pane that launches it is still Git Bash.
// The pane remains Git Bash even when it launches a batch runner.
expect(result.shell).toEqual({ family: 'posix' })
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
+1 -4
View File
@@ -1,7 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { parseOrcaYaml } from './hooks'
// Mock fs used by loadHooks
vi.mock('fs', () => ({
readFileSync: vi.fn(),
existsSync: vi.fn(),
@@ -338,9 +337,7 @@ describe('parseOrcaYaml', () => {
expect(result?.worktree?.sharedDirectories).toEqual(['node_modules'])
})
// Why: `resolve()` collapses `.` when the link is created, but Git reports the
// collapsed path — keeping the raw entry would leave a link that every later
// comparison misses, which is the permanently-dirty worktree this feature fixes.
// Resolve collapses path segments; raw entries would never match Git’s normalized paths.
it('drops sharedDirectories entries that still need path collapsing', () => {
const result = parseOrcaYaml(
[
+6 -14
View File
@@ -22,8 +22,6 @@ vi.mock('fs', () => ({
vi.mock('child_process', () => ({
exec: vi.fn(),
execFileSync: execFileSyncMock,
// runner.ts imports these from child_process; stubs prevent
// "missing export" errors when the mock is resolved transitively.
execFile: vi.fn(),
spawn: vi.fn()
}))
@@ -63,7 +61,6 @@ describe('createSetupRunnerScript', () => {
ORCA_WORKTREE_PATH: 'C:\\repo\\feature\\',
ORCA_WORKSPACE_NAME: 'feature'
}),
// Why: native Windows worktrees without a configured setup shell keep the cmd runner.
shell: { family: 'cmd' }
})
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
@@ -111,7 +108,7 @@ describe('createSetupRunnerScript', () => {
ORCA_WORKTREE_PATH: '/c/repo/feature',
CONDUCTOR_ROOT_PATH: '/c/Users/jinwo/git/orca',
GHOSTX_ROOT_PATH: '/c/Users/jinwo/git/orca',
// Why: a display name, never a path — it must survive the conversion untouched.
// Workspace names are labels and must not undergo path conversion.
ORCA_WORKSPACE_NAME: 'feature'
}),
shell: { family: 'posix' }
@@ -121,7 +118,7 @@ describe('createSetupRunnerScript', () => {
'#!/usr/bin/env bash\nset -e\npnpm install\n',
'utf-8'
)
// Why: chmod over a native Windows path is meaningless; only the WSL branch sets the bit.
// Only guest Linux paths need chmod; native Windows paths do not.
expect(vi.mocked(fs.chmodSync)).not.toHaveBeenCalledWith(
'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh',
0o755
@@ -185,8 +182,7 @@ describe('createSetupRunnerScript', () => {
const runner = buildWindowsRunnerScript('echo hello!world!')
// Why: launchers invoke the runner under cmd /v:on, so the runner must disable delayed
// expansion itself or `!world!` is consumed as a variable reference.
// Disable inherited cmd delayed expansion so !world! remains literal.
expect(runner).toContain('setlocal EnableExtensions DisableDelayedExpansion')
expect(runner).toContain('call echo hello!world!')
})
@@ -194,10 +190,7 @@ describe('createSetupRunnerScript', () => {
it('refuses to run a shebang script as a batch command', async () => {
const { buildWindowsRunnerScript } = await import('./setup-runner-script-text')
// Regression: a POSIX script reaching the cmd runner (no Git Bash terminal, or an SSH
// Windows host) must not have its interpreter-agnostic prefix executed under cmd —
// `pnpm install` would run and only a later bash-only line would fail, leaving the
// worktree half set up.
// Reject POSIX scripts before cmd runs even their portable prefix and leaves setup incomplete.
const runner = buildWindowsRunnerScript('#!/usr/bin/env bash\npnpm install\nsource .env')
expect(runner).not.toContain('call pnpm install')
@@ -218,8 +211,7 @@ describe('createSetupRunnerScript', () => {
it.skipIf(process.platform === 'win32')(
'runs a script whose shebang carries invocation-only bash flags',
async () => {
// Regression: `set` rejects `-l`/`-s`/`-i` with exit 2, and the runner's own `set -e` turns
// that into an aborted setup before the first user line.
// Reapplying login/interactive flags through set would abort before the first user line.
const { buildPosixRunnerScript } = await import('./setup-runner-script-text')
const { mkdtempSync, rmSync, writeFileSync } = await vi.importActual<typeof NodeFs>('node:fs')
const { execFileSync } = await vi.importActual<typeof NodeChildProcess>('node:child_process')
@@ -435,7 +427,7 @@ describe('createIssueCommandRunnerScript', () => {
{ wslDistro: 'Ubuntu' }
)
// Why: the runner path is written back in native Windows form, so the launch needs /mnt again.
// Runner paths are stored in Windows form and need /mnt translation again at launch.
expect(result.runnerScriptPath).toBe('C:\\repo\\.git\\orca\\issue-command-runner.sh')
expect(result.shell).toEqual({ family: 'posix', executable: 'wsl.exe' })
} finally {
+5 -13
View File
@@ -3,7 +3,6 @@ import type * as GitRunner from './git/runner'
import { describe, expect, it, vi } from 'vitest'
import { makeHookTestRepo } from './hooks-test-fixtures'
// Mock fs used by the runner-script writers
vi.mock('fs', () => ({
readFileSync: vi.fn(),
existsSync: vi.fn(),
@@ -21,7 +20,6 @@ vi.mock('child_process', () => ({
exec: vi.fn(),
execFile: vi.fn(),
execFileSync: vi.fn(),
// runner.ts imports spawn from child_process transitively.
spawn: vi.fn()
}))
@@ -131,8 +129,7 @@ describe('createSetupRunnerScript', () => {
})
it('keeps batch setup scripts on cmd.exe when the terminal is Git Bash', async () => {
// Regression (#6967): a Git Bash terminal preference used to hand pre-existing
// batch setup scripts to bash, where `copy`/`xcopy`/`if errorlevel` do not exist.
// Batch scripts must retain cmd semantics even when the pane uses Git Bash (#6967).
gitExecFileSyncMock.mockReset()
gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.cmd\n')
const fs = await import('node:fs')
@@ -162,8 +159,7 @@ describe('createSetupRunnerScript', () => {
)
expect(result).toMatchObject({
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.cmd',
// Why: `shell` is the pane that types the launch command — still Git Bash here. The
// runner's own .cmd extension is what says the file is batch.
// shell describes the pane; the .cmd extension selects the runner’s interpreter.
shell: { family: 'posix' }
})
} finally {
@@ -172,8 +168,7 @@ describe('createSetupRunnerScript', () => {
})
it('hands a Git Bash pane a launch command MSYS cannot rewrite for a cmd runner', async () => {
// Regression (#6896): `cmd.exe /c "C:\...\setup-runner.cmd"` typed into Git Bash has its
// `/c` switch rewritten into a drive path, so cmd opens interactively and setup never runs.
// MSYS rewrites cmd.exe /c into a drive path, leaving setup stuck at an interactive prompt (#6896).
gitExecFileSyncMock.mockReset()
gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.cmd\n')
const originalPlatform = process.platform
@@ -193,7 +188,6 @@ describe('createSetupRunnerScript', () => {
const command = buildSetupRunnerCommand(result.runnerScriptPath, 'windows', result.shell)
expect(command).not.toContain('cmd.exe /c')
// Why: the batch runner must never be handed to bash either.
expect(command).not.toContain('bash ')
expect(command).toContain('powershell.exe')
} finally {
@@ -202,8 +196,7 @@ describe('createSetupRunnerScript', () => {
})
it('replays interpreter flags declared on the shebang line', async () => {
// Regression: the runner is launched as `bash <path>`, so `-euo pipefail` on the script's
// own `#!` line never reaches the interpreter unless the runner re-applies it.
// bash <path> ignores the script’s shebang options unless the runner reapplies them.
gitExecFileSyncMock.mockReset()
gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.sh\n')
const fs = await import('node:fs')
@@ -463,8 +456,7 @@ describe('resolveSetupRunnerShell', () => {
const { resolveSetupRunnerShell } = await import('./worktree-runner-script')
const { resolveWindowsGitBashShellPath } = await import('./git-bash')
// Regression: a stale configured path used to commit setup to a .sh runner the
// PTY could never spawn, hanging wait-for-setup until the 2h timeout.
// Reject stale shells before setup waits on a runner the PTY cannot spawn.
expect(
resolveSetupRunnerShell(
{ terminalWindowsShell: 'C:\\Program Files\\Git\\bin\\bash.exe' },
-1
View File
@@ -9,7 +9,6 @@ export const TEST_WORKTREE_ORCA_YAML_PATH = join(TEST_WORKTREE_PATH, 'orca.yaml'
export const TEST_ISSUE_COMMAND_PATH = join(TEST_REPO_PATH, '.orca', 'issue-command')
export const TEST_GITIGNORE_PATH = join(TEST_REPO_PATH, '.gitignore')
/** Minimal repo record the hooks module reads; hookSettings shape varies per suite. */
export const makeHookTestRepo = (hookSettings?: unknown): Repo =>
({
id: 'test-id',
+4 -13
View File
@@ -3,7 +3,6 @@ import type * as GitRunner from './git/runner'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { makeHookTestRepo } from './hooks-test-fixtures'
// Mock fs used by loadHooks
vi.mock('fs', () => ({
readFileSync: vi.fn(),
existsSync: vi.fn(),
@@ -20,8 +19,6 @@ const { spawnMock, runWslProcessMock, gitExecFileSyncMock } = vi.hoisted(() => (
}))
vi.mock('child_process', () => ({
// One `spawn` for both: hooks.ts runs the script through it, and runner.ts imports it
// transitively. A second key here silently shadowed the first.
spawn: spawnMock,
execFileSync: vi.fn()
}))
@@ -35,7 +32,6 @@ vi.mock('./git/runner', async () => ({
gitExecFileSync: gitExecFileSyncMock
}))
/** Minimal ChildProcess stand-in: hooks.ts reads the streams and waits for close/error. */
function fakeChild(exit: { code?: number | null; signal?: NodeJS.Signals | null } = { code: 0 }) {
const listeners: Record<string, ((...args: unknown[]) => void)[]> = {}
const stream = { setEncoding: () => {}, on: () => {} }
@@ -59,7 +55,6 @@ function fakeChild(exit: { code?: number | null; signal?: NodeJS.Signals | null
}
beforeEach(() => {
// Clear as well as re-arm: these assertions are order-sensitive and calls otherwise accumulate.
spawnMock.mockClear()
spawnMock.mockImplementation(() => fakeChild())
})
@@ -113,8 +108,7 @@ describe('runHook', () => {
})
it('does not run setup scripts with a half-activated conda env', async () => {
// Why: setup scripts source conda exactly like a shell rc does, so the
// orphaned CONDA_SHLVL sentinel surfaces as an opaque hook failure (#14195).
// An orphaned CONDA_SHLVL sentinel breaks hook environment activation (#14195).
let capturedEnv: Record<string, string> | undefined
spawnMock.mockImplementation((_script, options) => {
capturedEnv = (options as { env: Record<string, string> }).env
@@ -180,8 +174,7 @@ describe('runHook', () => {
expect.objectContaining({
cwd: '/repo/worktree',
shell: '/bin/bash',
// Setup hooks run unattended: git in them must not pop the OS
// credential helper's OAuth window and loop it (issue #7652).
// Unattended Git must not open credential-helper OAuth windows (#7652).
env: expect.objectContaining({
GIT_TERMINAL_PROMPT: '0',
GCM_INTERACTIVE: 'never'
@@ -237,8 +230,7 @@ describe('runHook', () => {
loginPath: 'preferred',
script: 'echo hello',
cwd: '/home/jin/feature',
// #7652 regression: the unattended WSL hook branch must carry the
// credential guard into the guest env.
// WSL hooks must receive the credential guard inside the guest (#7652).
env: expect.objectContaining({
ORCA_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca',
ORCA_WORKTREE_PATH: '/home/jin/feature',
@@ -369,8 +361,7 @@ describe('runHook', () => {
})
it('settles WSL hooks when wsl.exe never reports completion', async () => {
// Why no fake timers: the timeout is now runProcess's own, internal to the
// mocked runWslProcess -- there is nothing left in hooks.ts to advance.
// The mocked runWslProcess owns the timeout; hooks.ts has no timer to advance.
spawnMock.mockReset()
spawnMock.mockImplementation(() => fakeChild())
runWslProcessMock.mockReset()
+18 -68
View File
@@ -21,18 +21,11 @@ import {
} from '@orca/process-host/process-tree-termination'
import { createOutputSink } from '@orca/process-host/bounded-output-sink'
const HOOK_TIMEOUT = 120_000 // 2 minutes
const HOOK_TIMEOUT = 120_000
type HookProcessOutcome = { success: boolean; output: string; exitCode?: number }
/**
* Turn a finished process into a hook verdict.
*
* Why `timedOut` decides before `code` (#19334): a hook that traps SIGTERM and exits 0 reports a
* zero exit for a run we cut off mid-archive. The exit code of something we stopped is not
* evidence it finished, so a timeout withholds the code and the removal gate reads that as
* `unverifiable` rather than as a pass.
*/
/** Timed-out hooks cannot prove completion, even if their SIGTERM handler exits 0 (#19334). */
function classifyHookProcessResult(
result: { code: number | null; stdout: string; stderr: string; timedOut: boolean },
context: { hookName: string; cwd: string; timeoutMs: number }
@@ -44,8 +37,7 @@ function classifyHookProcessResult(
return { success: false, output: `${streams}\n${message}`.trim() }
}
if (result.code !== 0) {
// `null` means signalled: there is no exit code, and saying "exit code null" reads as a
// reporting glitch rather than the `unverifiable` verdict the gate is about to give it.
// A signaled exit has no code for the removal gate to verify.
const message =
result.code === null
? 'Command was terminated without reporting an exit code.'
@@ -63,12 +55,7 @@ function classifyHookProcessResult(
const SIGTERM_GRACE_MS = 2_000
/**
* `exec` capped output at 1 MiB and killed the hook on overflow; `spawn` has no cap at all, and a
* hook flooding stdout for the full deadline can take the main process's heap with it. Truncation
* is reported in the output rather than as a failure — a chatty hook that exits 0 did succeed, and
* failing it for being chatty is the `exec` behaviour this is replacing.
*/
/** Bound retained output without failing chatty hooks that otherwise succeed. */
const HOOK_OUTPUT_LIMIT_BYTES = 10 * 1024 * 1024
function readSink(sink: ReturnType<typeof createOutputSink>): string {
@@ -77,7 +64,6 @@ function readSink(sink: ReturnType<typeof createOutputSink>): string {
: sink.text()
}
/** A spawn failure: the process never started, so no exit was ever observed. */
function hookProcessError(
error: Error,
stdout: string,
@@ -93,7 +79,6 @@ function hookProcessError(
}
}
/** A hook that never started reported no exit, so the code stays withheld. */
function hookSpawnFailure(
error: unknown,
context: { hookName: string; cwd: string }
@@ -113,9 +98,6 @@ function getHookShell(): string | undefined {
export { parseOrcaYaml }
/**
* Load hooks from orca.yaml in the given repo root.
*/
export function loadHooks(repoPath: string): OrcaHooks | null {
const yamlPath = join(repoPath, 'orca.yaml')
if (!existsSync(yamlPath)) {
@@ -130,14 +112,11 @@ export function loadHooks(repoPath: string): OrcaHooks | null {
}
}
/**
* Check whether an orca.yaml exists for a repo.
*/
export function hasHooksFile(repoPath: string): boolean {
return existsSync(join(repoPath, 'orca.yaml'))
}
// Why: detect unrecognised keys so the UI can suggest an update instead of showing a "could not be parsed" error.
// Unknown keys suggest a newer config format; let the UI offer an update.
const RECOGNIZED_ORCA_YAML_KEYS = new Set([
'scripts',
'setupAgentStartupPolicy',
@@ -147,12 +126,11 @@ const RECOGNIZED_ORCA_YAML_KEYS = new Set([
'worktree'
])
/** True when `orca.yaml` has a top-level key this version of Orca does not handle. */
export function hasUnrecognizedOrcaYamlKeys(repoPath: string): boolean {
try {
const content = readFileSync(join(repoPath, 'orca.yaml'), 'utf-8')
for (const line of iterateLfScriptLines(content)) {
// Why: match bare `key:` at end-of-line too, since a mapping with a block value on the next line is valid YAML.
// A bare key: is valid YAML when its block value follows on the next line.
const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(\s|$)/)
if (m != null && !RECOGNIZED_ORCA_YAML_KEYS.has(m[1])) {
return true
@@ -197,19 +175,14 @@ export function getSetupCommandSource(
return null
}
/**
* Run a named hook script in the given working directory.
*/
export function runHook(
hookName: 'setup' | 'archive',
cwd: string,
repo: Repo,
hooksPath?: string,
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget,
/** Deadline override. Production uses HOOK_TIMEOUT; tests use it to exercise the timeout path. */
timeoutMs: number = HOOK_TIMEOUT
// Why (#19334): an absent exitCode means no exit was ever observed. The archive-hook removal
// gate reads that as `unverifiable` rather than folding it into a zero.
// Failed hooks without an observed exit are unverifiable to the removal gate (#19334).
): Promise<{ success: boolean; output: string; exitCode?: number }> {
const hooks = getEffectiveHooks(repo, hooksPath)
const script = hooks?.scripts[hookName]
@@ -222,15 +195,13 @@ export function runHook(
const wslInfo = getHookWslContext(cwd, runtimeTarget)
if (wslInfo) {
// Why: hook scripts run inside WSL, so translate the ORCA_* Windows UNC paths to Linux paths.
// WSL hooks need Linux paths for ORCA_* values, including Windows UNC paths.
const envVars = getSetupEnvVars(repo, cwd)
const wslEnv: Record<string, string> = {}
for (const [key, value] of Object.entries(envVars)) {
wslEnv[key] = toLinuxPath(value)
}
// Why: same unattended-git guard as the non-WSL branch below (issue
// #7652) — only the guard flags and any indexed git-config protocol are
// meant to reach the guest; askpass stays host-side, same as before.
// Forward unattended Git flags and indexed config to WSL; keep host askpass out of the guest.
const guardedEnv = promptGuardShellEnv(wslEnv)
const guestEnv: Record<string, string> = { ...wslEnv }
for (const [key, value] of Object.entries(guardedEnv)) {
@@ -248,9 +219,7 @@ export function runHook(
distro: wslInfo.distro ?? undefined,
loginPath: 'preferred',
script,
// Why pinned: these are user-authored orca.yaml scripts and the native
// path runs /bin/bash. Defaulting to sh would fail bash-only hooks on WSL
// only -- a downgrade the user never asked for.
// Match native Bash hook semantics; sh would reject Bash-only scripts inside WSL.
shell: 'bash',
cwd: wslInfo.linuxPath,
env: guestEnv,
@@ -264,11 +233,7 @@ export function runHook(
dropIncoherentCondaActivationEnv(shellHookEnv)
return new Promise<HookProcessOutcome>((resolve) => {
// Why we own the deadline (#19334): Node's `exec({ timeout })` SIGTERMs the child and then
// reports whatever it chose to do, so a hook that traps SIGTERM and exits 0 came back as a
// PASS — a hook cut off mid-archive, indistinguishable from one that finished. Settle on the
// deadline instead, and settle AT it, so a hook that traps and keeps running cannot hold a
// removal open.
// Settle at the deadline even if the hook traps SIGTERM or keeps running (#19334).
let settled = false
let deadline: NodeJS.Timeout | undefined
const settle = (result: HookProcessOutcome): void => {
@@ -281,32 +246,23 @@ export function runHook(
}
resolve(result)
}
// Why `spawn` and not `exec` (#19334 follow-up): `detached` is a spawn-only option — `exec`
// accepts and ignores it, so the shell never became a group leader and the group signal below
// had nothing to reach. Passing `shell` as a string keeps Node's own platform invocation, which
// is what `exec` was being kept for: `cmd.exe /d /s /c` on Windows rather than a bare `-c`.
// exec ignores detached; spawn creates the process group needed for tree termination.
const child = spawn(script, {
cwd,
shell: getHookShell(),
// Why: hooks run unattended; block Git Credential Manager's interactive prompt while keeping cached auth (issue #7652).
// Unattended hooks must suppress interactive Git prompts while retaining cached auth (#7652).
env: promptGuardShellEnv(shellHookEnv),
stdio: ['ignore', 'pipe', 'pipe'],
// Pinned, not left to Node's default, for the same reason `runProcess` pins it: a `cmd.exe`
// hook otherwise flashes a console window and takes focus. Pre-existing — `exec` did not set
// it either — but AGENTS.md asks for it pinned on every Windows spawn.
// Hide Windows hook consoles to prevent flashes and focus theft.
windowsHide: true,
// Make the shell a group leader so its children can be reached. Not on Windows, which has no
// process groups in this sense and where `detached` means a new console instead.
// POSIX groups let signals reach descendants; Windows detached would create a new console.
...(process.platform === 'win32' ? {} : { detached: true })
})
const stdout = createOutputSink(HOOK_OUTPUT_LIMIT_BYTES)
const stderr = createOutputSink(HOOK_OUTPUT_LIMIT_BYTES)
child.stdout?.on('data', (chunk: Buffer | string) => stdout.write(chunk))
child.stderr?.on('data', (chunk: Buffer | string) => stderr.write(chunk))
// Why listeners that do nothing: an unhandled `error` on a stream is an uncaught exception, and
// in the Electron main process that is the whole app. `exec` never covered this either — its
// only `error` listener is on the child — so this is a pre-existing gap, closed the way
// `runProcess` closes it. Losing output is not worth a crash; the exit code still gets through.
// Unhandled stream errors crash main; retain exit reporting even when output fails.
for (const stream of [child.stdin, child.stdout, child.stderr]) {
stream?.on('error', () => {})
}
@@ -316,7 +272,6 @@ export function runHook(
child.on('close', (code, signal) => {
settle(
classifyHookProcessResult(
// A signalled exit reports no code, which stays `unverifiable` rather than becoming a 0.
{
code: signal ? null : code,
stdout: readSink(stdout),
@@ -327,21 +282,16 @@ export function runHook(
)
)
})
// Why guarded: a spawn failure can settle before the deadline is armed, and arming one on a
// finished run would later signal a pid that is gone — and may by then belong to something else.
// Arming after settlement could later signal a reused PID.
if (!settled) {
deadline = setTimeout(() => {
settle(
classifyHookProcessResult(
// Keep what the hook printed: it is the only clue to why the removal gate says
// `unverifiable`.
{ code: null, stdout: readSink(stdout), stderr: readSink(stderr), timedOut: true },
{ hookName, cwd, timeoutMs }
)
)
// Orca's own tree terminator: POSIX process groups, `taskkill /t /f` on Windows (where a
// bare `child.kill` reaches only the shell and leaves its descendants running), and the
// recycled-pid guard that hazard needs. SIGTERM first so a well-behaved hook can clean up.
// Terminate descendants too, with a SIGTERM grace period for cooperative hooks to clean up.
void signalProcessTree(child, 'SIGTERM')
setTimeout(() => {
void forceTerminateProcessTree(child)
@@ -13,14 +13,7 @@ import { dirname, join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { removeTreeSync } from '../shared/windows-transient-lock-removal'
/**
* Why the real binary: Electron patches `fs` so a `*.asar` file reports `isDirectory() === true`, so
* a recursive `rm` descends into the archive, `rmdir`s a real file, and fails the parent with
* ENOTEMPTY. Plain Node has no such shim, so no in-process unit test can reproduce it — and every
* worktree that has run `pnpm install` carries a `default_app.asar`, which is what stranded 267
* trash entries on the reporting machine. This runs the shipped `removeHostTree` against a real
* archive under the real binary.
*/
/** Electron’s asar fs shim makes recursive removal fail; plain Node cannot reproduce it. */
const requireFromTest = createRequire(import.meta.url)
const electronBinary = requireFromTest('electron') as string
const electronDist = join(dirname(requireFromTest.resolve('electron/package.json')), 'dist')
@@ -29,7 +22,6 @@ const FIXTURE_ASAR = [
join(electronDist, 'resources/default_app.asar')
].find((candidate) => existsSync(candidate))
// Mirrors the residue reported on the failing machine, down to the depth of the blocking leaf.
const ENTRY_NAME = 'wt-1700000000000-abcdef01'
const ASAR_PARENT = 'node_modules/.pnpm/electron/node_modules/electron/dist/App/Contents/Resources'
@@ -40,7 +32,7 @@ afterAll(() => {
try {
removeTreeSync(root)
} catch {
// A fixture the shim strands is exactly what this file is about; never fail teardown on it.
// Failed cases can strand archives; cleanup must not obscure the assertion.
}
}
})
@@ -51,8 +43,7 @@ function buildDriver(bundlePath: string, target: string, resultPath: string): st
return [
`const fs = require('node:fs')`,
`const { removeHostTree } = require(${JSON.stringify(bundlePath)})`,
// Why noAsar for the read-back: the shim would report the stranded archive as a directory here
// too, so the residue listing has to be taken with real filesystem semantics.
// Inspect residue without the shim treating remaining archives as directories.
`const withoutAsar = (fn) => { const prev = process.noAsar; process.noAsar = true; try { return fn() } finally { process.noAsar = prev } }`,
`;(async () => {`,
` let failure = null`,
@@ -77,11 +68,7 @@ async function bundleHostTreeRemoval(outFile: string): Promise<void> {
ssr: true,
rollupOptions: {
input: 'src/main/host-tree-removal.ts',
// Why mirror `isExternalMainModule` from electron.vite.config.ts exactly — CJS, and
// `original-fs` deliberately *not* externalized: the shipped bundle does not list it either,
// so if the archive-aware `rm` ever became a static import (or the bundler learned to fold
// `createRequire(...)('original-fs')`) production would silently degrade to the shimmed `fs`
// while a test that pre-externalized it kept passing.
// Match production externals: externalizing original-fs here could hide a bundling regression.
output: { format: 'cjs' },
external: (id: string) => isBuiltin(id) || id === 'electron' || id.startsWith('electron/')
}
@@ -123,8 +110,6 @@ describe('removeHostTree against a tree holding an asar archive', () => {
expect(run.status, run.stderr?.slice(-2000)).toBe(0)
const probe = JSON.parse(readFileSync(resultPath, 'utf8')) as ProbeResult
// Without an asar-transparent `rm` this is `ENOTEMPTY` and the residue stops at the archive,
// on every attempt, forever — it is not a race a retry can win.
expect(probe).toEqual({ failure: null, residue: [] })
},
120_000
+5 -13
View File
@@ -1,8 +1,4 @@
// Why: every recursive host delete Orca performs (worktrees, terminal history, quarantined recovery
// generations) hits the same two hazards, so one helper exists so no call site forgets either.
// Windows stickiness — AV/indexers/late handle releases surface transient EBUSY/ENOTEMPTY/EPERM on a
// tree Node just emptied — and Electron's asar shim, which strands any tree holding a `*.asar`
// (see `asar-transparent-fs`).
// Host deletion needs Windows lock retries and unpatched fs for Electron’s .asar handling.
import { win32 } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
@@ -15,8 +11,7 @@ const WINDOWS_REMOVE_RETRY_DELAYS_MS = [250, 500, 1_000, 2_000]
/** Convert a native host filesystem path to the Win32 long-path namespace. */
export function toHostFilesystemPath(targetPath: string): string {
// POSIX paths are used by WSL callers even while the Electron process runs
// on Windows; do not reinterpret those as drive-relative Win32 paths.
// WSL callers supply POSIX paths even on Windows; do not interpret them as drive-relative paths.
return process.platform === 'win32' &&
isWindowsAbsolutePathLike(targetPath) &&
!isWslUncPath(targetPath)
@@ -25,8 +20,7 @@ export function toHostFilesystemPath(targetPath: string): string {
}
export function toHostRemovalPath(targetPath: string): string {
// Why: Git for Windows can fail long recursive deletes even after Orca has
// proven the worktree target; Node's host deletion should use Win32 long paths.
// Node deletion uses Win32 long paths even after Git has verified the target.
return toHostFilesystemPath(targetPath)
}
@@ -46,8 +40,7 @@ function isTransientWindowsRemovalError(error: unknown): boolean {
export async function removeHostTree(targetPath: string): Promise<void> {
const removalPath = toHostRemovalPath(targetPath)
const retryDelays = process.platform === 'win32' ? WINDOWS_REMOVE_RETRY_DELAYS_MS : []
// Why: large Windows trees commonly surface transient ENOTEMPTY/EPERM while Node walks and
// removes nested directories; Node's own retries absorb that before the loop below has to.
// Node’s retries absorb transient directory locks before the outer retry loop.
const rmOptions = transientLockRemovalOptions()
let attempt = 0
@@ -59,8 +52,7 @@ export async function removeHostTree(targetPath: string): Promise<void> {
if (attempt >= retryDelays.length || !isTransientWindowsRemovalError(error)) {
throw error
}
// Why: Git/Node recursive deletes on Windows can observe a just-emptied
// directory before antivirus/indexers/handles release it.
// Antivirus, indexers, and late handle release can keep just-emptied Windows directories locked.
await delay(retryDelays[attempt])
attempt += 1
}
+1 -2
View File
@@ -3,7 +3,6 @@ export type ImageFileSignature = {
extension: 'png' | 'jpg' | 'gif' | 'webp'
}
/** Leading bytes needed to recognize every signature below. */
export const IMAGE_SIGNATURE_BYTES = 12
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
@@ -24,7 +23,7 @@ function startsWithAscii(buffer: Uint8Array, text: string, offset = 0): boolean
)
}
// Why: magic bytes, not a decoder — enough to name a file or reject a non-image.
// Signature checks identify file types without proving the image decodes.
export function detectImageFileSignature(buffer: Uint8Array): ImageFileSignature | null {
if (startsWith(buffer, PNG_SIGNATURE)) {
return { mimeType: 'image/png', extension: 'png' }
+2 -12
View File
@@ -1,12 +1,4 @@
/**
* Shares one in-flight promise per key so concurrent callers that want the same
* result run the work once. Purely a concurrency collapse, never a cache: the
* entry is dropped the moment the run settles, so the next caller starts fresh
* and re-reads whatever state the run depends on.
*
* Callers own the map, which keeps each lane's exact result type (including
* nullable ones) without a cast at every read.
*/
/** Share concurrent work only; settled results are never cached. */
export function dedupeInFlightRun<T>(
runs: Map<string, Promise<T>>,
key: string,
@@ -18,9 +10,7 @@ export function dedupeInFlightRun<T>(
}
const run = start()
runs.set(key, run)
// Why: clear on rejection too, or one failure would wedge every later caller
// onto the same rejected promise. The identity check keeps a late settle from
// evicting a newer entry for the same key.
// Clear rejected runs too, without letting a late settlement evict a newer run.
const clear = (): void => {
if (runs.get(key) === run) {
runs.delete(key)
+8 -16
View File
@@ -51,11 +51,11 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
state.mainWindow?.webContents.send('ui:openSkillShare', shareId)
})
state.osOpenedDocuments.capture(argv, publishOsOpenedDocuments)
// Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935).
// Duplicate serve launches must not activate a desktop on a headless server (#11935).
if (!shouldActivateDesktopForSecondInstance(argv)) {
return
}
// Why: the sidecar's throwaway profile must never become the user's window.
// The sidecar’s throwaway profile must never become the user’s window.
if (isOrcadSidecarLaunch()) {
requestDesktopHandoff()
return
@@ -63,19 +63,13 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
state.desktopActivationGate?.requestActivation()
}
/**
* Hands buffered OS-opened document paths to a renderer that has proven it is listening.
*
* Until that proof arrives the paths stay buffered, because `webContents.send` to a renderer
* with no listener attached is dropped silently and the queue would be gone.
*/
/** Buffer documents until listener readiness; webContents.send silently drops unhandled messages. */
function publishOsOpenedDocuments(): void {
const targetWindow = state.mainWindow
if (!state.osDocumentOpenListenerReady || !targetWindow || targetWindow.isDestroyed()) {
return
}
// Why consumed before the await: a renderer pull racing this resolve must not take the same
// batch again. The restore() calls hand it back if delivery turns out to be impossible.
// Consume before awaiting to prevent duplicate renderer pulls; restore if delivery fails.
const filePaths = state.osOpenedDocuments.consume()
if (filePaths.length === 0) {
return
@@ -106,7 +100,7 @@ const preflightReady = runMainProcessPreflight({
requestDesktopActivation
})
// Why: when another process holds the lock we've already exited; skip file-writing side effects so this transient process never touches userData.
// Skip userData writes after losing the single-instance lock.
if (preflightReady) {
void leaseAppImageGenerationForCurrentProcess()
app.on('open-url', (event, url) => {
@@ -116,21 +110,19 @@ if (preflightReady) {
event.preventDefault()
requestDesktopActivation([url])
})
// Why: macOS delivers "Open With" as open-file, often before `ready`, and only to a handler
// that claims the event. Unsupported paths stay unclaimed.
// macOS can deliver Open With before ready; claim only supported paths.
app.on('open-file', (event, filePath) => {
if (!state.osOpenedDocuments.captureFilePaths([filePath], publishOsOpenedDocuments)) {
return
}
event.preventDefault()
// Why gated on isReady: pre-ready the cold-start window is already on its way, and
// activating the gate here would try to open one before Electron can. The sidecar has none.
// Cold-start activation must wait for Electron readiness; sidecars have no pending window.
if (app.isReady() || isOrcadSidecarLaunch()) {
requestDesktopActivation()
}
})
state.skillShareDeepLinks.capture(process.argv)
// Why no publish: nothing is listening this early, so the first renderer pulls these on mount.
// The first renderer pulls on mount; nothing listens this early.
state.osOpenedDocuments.capture(process.argv)
registerMainProcessIpcHandlers()
installMainProcessQuitHandlers()
+11 -27
View File
@@ -17,8 +17,7 @@ import {
type IntegrationCredentialService
} from '../shared/integration-credential-errors'
// Why: connection status treats a token file as a saved credential; empty
// files read as "missing", so counting them would split-brain getStatus.
// Empty files mean no saved credential; keep status consistent with token reads.
export function credentialFileHasContent(path: string): boolean {
try {
return statSync(path).size > 0
@@ -27,8 +26,7 @@ export function credentialFileHasContent(path: string): boolean {
}
}
// Falls back to 0600 plaintext when the OS keyring is unavailable, matching the
// Linear/Jira token writers so a keyring-less box still connects.
// Owner-only plaintext permits connections when the OS keyring is unavailable.
export function writeEncryptedCredential(
service: IntegrationCredentialService,
path: string,
@@ -44,29 +42,24 @@ export function writeEncryptedCredential(
writeCredentialFileAtomic(path, Buffer.from(value, 'utf-8'))
}
// Why (STA-3941): a direct write can truncate the previous credential if it
// fails or the app dies mid-write, leaving nothing to authenticate with. Write
// a sibling temp file, fsync it, then rename — readers only ever observe the
// old bytes or the complete new ones.
// Stage and fsync before rename so failed writes cannot truncate the saved credential (STA-3941).
export function writeCredentialFileAtomic(
path: string,
data: Buffer,
shouldPublish?: () => boolean
): boolean {
// Why unique: a concurrent writer sharing one temp name could truncate the bytes we publish.
// Unique temp names prevent concurrent writers from truncating staged bytes.
const tempPath = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
let handle: number | null = null
try {
if (process.platform === 'win32') {
// Why born restricted: a sibling would otherwise inherit the directory's DACL and replace
// the previous credential's owner-only ACL.
// Create owner-only staging files so replacement cannot inherit a permissive Windows DACL.
writeRestrictedWindowsFile(tempPath, data)
fsyncFileSync(tempPath)
return publishStagedCredential(tempPath, path, shouldPublish)
}
handle = openSync(tempPath, 'w', 0o600)
// Why: write(2) is allowed to return a short count, so a single writeSync
// could publish a truncated credential — the very corruption this avoids.
// writeSync can return a short count; publish only after all bytes are written.
let written = 0
while (written < data.length) {
const bytes = writeSync(handle, data, written, data.length - written)
@@ -83,15 +76,11 @@ export function writeCredentialFileAtomic(
if (handle !== null) {
try {
closeSync(handle)
} catch {
// Already closed or invalid; the unlink below is what matters.
}
} catch {}
}
try {
unlinkSync(tempPath)
} catch {
// Nothing to clean up.
}
} catch {}
throw error
}
}
@@ -110,9 +99,7 @@ function publishStagedCredential(
return true
}
// Why: writeFileSync's `mode` only applies when it creates the file, so
// rewriting an existing credential would silently keep looser permissions.
// Best-effort: Windows has no POSIX modes; its staging file is created owner-only instead.
// Creation mode does not tighten existing files; Windows hardens the staging ACL instead.
export function restrictCredentialFileToOwner(path: string): void {
try {
chmodSync(path, 0o600)
@@ -128,9 +115,7 @@ export class CredentialDecryptionError extends Error {
}
}
// Returns the stored token, null when the file is empty, and throws
// CredentialDecryptionError when the file holds ciphertext we cannot decrypt
// (e.g. the user denied the OS keychain prompt after an app re-sign).
// Undecryptable ciphertext throws CredentialDecryptionError; an empty file returns null.
export function readStoredCredentialToken(
service: IntegrationCredentialService,
raw: Buffer
@@ -155,8 +140,7 @@ function readPlaintextLegacyCredential(
raw: Buffer
): string | null {
const plaintext = decodeUtf8(raw)
// Why: legacy plaintext tokens are printable UTF-8; sealed ciphertext
// such as macOS v10 blobs must not be decoded into auth-header junk.
// Legacy plaintext is printable UTF-8; reject ciphertext masquerading as an auth-header value.
if (plaintext === null || hasControlCharacter(plaintext)) {
throw new CredentialDecryptionError(service)
}
@@ -90,7 +90,6 @@ describe('createIntegrationStateFileCache', () => {
})
it('reloads when a file appears after an absent first read', () => {
// The desktop app commonly starts before any integration is connected.
const path = tempFile()
const cache = createIntegrationStateFileCache({
filePath: () => path,
@@ -133,7 +132,6 @@ describe('createIntegrationStateFileCache', () => {
})
it('recovers when another process writes between the stamp and the read', () => {
// A concurrent write must force another read instead of pinning stale content.
const path = tempFile()
writeFileSync(path, 'v1')
let reads = 0
@@ -155,12 +153,11 @@ describe('createIntegrationStateFileCache', () => {
cache.invalidate()
writeDuringRead = true
// Reads 'theirs' but stamped against 'ours', so the next look must reload.
// Content read after a concurrent write still carries the earlier stamp, forcing one more read.
expect(cache.get()).toEqual({ body: 'theirs' })
expect(cache.get()).toEqual({ body: 'theirs' })
expect(reads).toBe(3)
// Stamp and content now agree, so a further look is served from memory.
expect(cache.get()).toEqual({ body: 'theirs' })
expect(reads).toBe(3)
})
-1
View File
@@ -14,7 +14,6 @@ function readStamp(path: string): string | null {
}
}
// Every Orca process on a host reads the same integration state files.
export function createIntegrationStateFileCache<T>(args: {
filePath: () => string
readFromDisk: () => T
+2 -10
View File
@@ -1,4 +1,4 @@
// Why: `.orca/issue-command` is the per-user override; `orca.yaml` is the tracked project default.
// .orca/issue-command is local; orca.yaml supplies the tracked project default.
import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { loadHooks } from './hooks'
@@ -26,9 +26,6 @@ export type ResolvedIssueCommand = {
source: 'local' | 'shared' | 'none'
}
/**
* Resolve the GitHub issue command using local override first, then tracked repo config.
*/
export function readIssueCommand(repoPath: string): ResolvedIssueCommand {
const filePath = getIssueCommandFilePath(repoPath)
let localContent: string | null = null
@@ -54,10 +51,7 @@ export function readIssueCommand(repoPath: string): ResolvedIssueCommand {
}
}
/**
* Write the per-user issue command override to `{repoRoot}/.orca/issue-command`.
* Empty content deletes the override so the shared `orca.yaml` command applies again.
*/
/** Empty content removes the local override so the shared orca.yaml command applies. */
export async function writeIssueCommand(
repoPath: string,
content: string,
@@ -82,7 +76,6 @@ export async function writeIssueCommand(
writeFileSync(filePath, `${trimmed}\n`, 'utf-8')
} catch (err) {
console.error('[hooks] Failed to write issue command:', err)
// Why: re-throw so the IPC handler surfaces the write failure to the renderer's .catch().
throw err
}
}
@@ -107,7 +100,6 @@ async function isIssueCommandIgnoredByGit(
}
}
/** Ensure `.orca` is in `.gitignore` so the per-user directory is never committed. */
function ensureOrcaDirIgnored(repoPath: string): void {
const gitignorePath = join(repoPath, '.gitignore')
try {
+3 -25
View File
@@ -5,11 +5,7 @@ import {
stripMatchingQuotes
} from './editor-launcher-name'
// Why: JetBrains installers ship `*64.exe` GUI-subsystem binaries. The
// Toolbox/installer `.cmd`/`.bat` shims (and short-name console `idea.exe`
// stubs) chain through helpers that allocate a visible Command Prompt even
// when the parent spawn uses windowsHide — the STA-3040 prompt-window loop.
// Maps each short console launcher name to its GUI sibling.
// JetBrains console shims can spawn visible helpers despite windowsHide; prefer GUI siblings.
const JETBRAINS_WINDOWS_CONSOLE_TO_GUI: Readonly<Record<string, string>> = {
idea: 'idea64',
webstorm: 'webstorm64',
@@ -26,7 +22,6 @@ const JETBRAINS_WINDOWS_CONSOLE_TO_GUI: Readonly<Record<string, string>> = {
const JETBRAINS_WINDOWS_GUI_NAMES = new Set(Object.values(JETBRAINS_WINDOWS_CONSOLE_TO_GUI))
/** GUI binary basename for a console or GUI launcher name, or null if unknown. */
function getGuiExecutableName(launcherBaseName: string): string | null {
if (JETBRAINS_WINDOWS_GUI_NAMES.has(launcherBaseName)) {
return launcherBaseName
@@ -34,19 +29,7 @@ function getGuiExecutableName(launcherBaseName: string): string | null {
return JETBRAINS_WINDOWS_CONSOLE_TO_GUI[launcherBaseName] ?? null
}
/**
* The GUI `*64.exe` beside an already-resolved console entry point, or null.
*
* Why sibling-only: a bare PATH lookup for `idea64` can land in a different,
* stale install, and it costs a second full PATH walk on Electron's main
* thread. Toolbox script directories ship no exe, so those keep the shim and
* rely on the detached-GUI spawn instead.
*
* Upgrades:
* - `idea.cmd` / `idea.bat` / `idea64.cmd` → colocated `idea64.exe` when present
* - short-name console stubs `idea.exe` → colocated `idea64.exe` when present
* - leaves `idea64.exe` and non-JetBrains launchers unchanged
*/
/** Search only beside the resolved launcher; PATH could select another install or block main again. */
export function resolveColocatedJetBrainsGuiExecutable(
resolvedCommand: string,
fileExists: (path: string) => boolean
@@ -59,7 +42,6 @@ export function resolveColocatedJetBrainsGuiExecutable(
return null
}
// .exe: only short console names (idea.exe), never the GUI binary itself.
if (isExe) {
const guiName = JETBRAINS_WINDOWS_CONSOLE_TO_GUI[baseName]
if (!guiName) {
@@ -69,7 +51,6 @@ export function resolveColocatedJetBrainsGuiExecutable(
return fileExists(candidate) ? candidate : null
}
// .cmd/.bat: idea.cmd and idea64.cmd both prefer idea64.exe when colocated.
const guiName = getGuiExecutableName(baseName)
if (!guiName) {
return null
@@ -78,10 +59,7 @@ export function resolveColocatedJetBrainsGuiExecutable(
return fileExists(candidate) ? candidate : null
}
// Why: only JetBrains batch shims chain through console helpers that outlive
// the batch and re-open a prompt. Other GUI shims (code.cmd, cursor.cmd) exit
// on their own, and running them under `start` would re-parse their quoted
// WSL/SSH remote argv. detachedGui only rewrites batch targets.
// Detach JetBrains shims with persistent console helpers; other GUI shims must retain quoted argv.
export function isJetBrainsConsoleShim(command: string, platform: NodeJS.Platform): boolean {
return (
platform === 'win32' &&