Merge origin/main into brennanb2025/chat-recorded-outcomes-from-journal

Takes main's side for the files #24710 also changed; this branch's remaining
changes are re-applied on top in focused commits.
This commit is contained in:
Brennan Benson
2026-10-04 15:11:24 -07:00
409 changed files with 19146 additions and 3023 deletions
+2 -2
View File
@@ -164,7 +164,7 @@ jobs:
cache-pnpm-store-lookup-only: 'true'
# Design D7 upgrade and rollback: the last Bun orcad, built from a main commit that shipped
# it, beside this checkout's Node slot; the live-terminal hand-over skips once PROTOCOL_VERSION
# moves past the Bun daemon's. Its build uses this checkout's installed dependencies.
# moves past the Bun daemon's. Install its pinned dependencies independently of this checkout.
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
if: runner.os == 'Linux'
with:
@@ -179,7 +179,7 @@ jobs:
if [ "$RUNNER_OS" != Linux ]; then exit 0; fi
git fetch --no-tags --depth=1 origin "$BUN_ORCAD_COMMIT"
git worktree add --detach "$RUNNER_TEMP/bun-orcad-source" "$BUN_ORCAD_COMMIT"
ln -s "$GITHUB_WORKSPACE/node_modules" "$RUNNER_TEMP/bun-orcad-source/node_modules"
pnpm --dir "$RUNNER_TEMP/bun-orcad-source" install --frozen-lockfile --ignore-scripts
node "$RUNNER_TEMP/bun-orcad-source/config/scripts/build-orcad-bun.mjs" --out-dir "$RUNNER_TEMP/bun-orcad"
echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT"
echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT"
+78 -42
View File
@@ -154,15 +154,17 @@ jobs:
echo "No specs requiring the reusable E2E workflow"
fi
static_analysis:
name: static analysis
preflight:
name: static analysis and typecheck
needs: [code_paths]
if: needs.code_paths.outputs.static_analysis == 'true'
if: needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true'
# Why ARM: measured 128s against 172s on ubuntu-latest, with every compute step faster --
# type-aware 24s->15s, anti-slop 28->19s, localization extraction 67->46s, the orcad smoke
# 39->14s. Both lint engines ship linux-arm64 and the Bun target follows process.arch, so
# the whole toolchain resolves. Free for public repositories, same as the typecheck job.
# the whole toolchain resolves. Free for public repositories.
runs-on: ubuntu-24.04-arm
outputs:
shards: ${{ steps.unit-plan.outputs.shards }}
steps:
- name: Checkout
@@ -197,21 +199,35 @@ jobs:
# Keep each check in its own log while sharing this runner.
- name: Lint
if: '!cancelled()'
id: root-lint
background: true
run: pnpm exec oxlint --format github
env:
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
run: |
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
pnpm exec oxlint --format github
- name: Reject low-evidence patterns
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run audit:anti-slop
- wait: root-lint
- name: Enforce focused code-quality plugins
if: '!cancelled()'
id: native-code-quality
background: true
run: pnpm run audit:code-quality:native
env:
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
run: |
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
pnpm run audit:code-quality:native
- name: Enforce type-aware code-quality baseline
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run audit:code-quality:type-aware
# Mobile installation changes import resolution for the native cycle check.
@@ -221,28 +237,39 @@ jobs:
# resolves types from mobile/node_modules. Without the install every mobile type
# degrades to an `error` type — reported as phantom findings against the changed lines.
- uses: ./.github/actions/install-mobile-dependencies
if: needs.code_paths.outputs.mobile_dependencies == 'true'
if: needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true'
- name: Enforce changed-code quality
if: '!cancelled()'
id: changed-code-quality
background: true
run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
env:
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
run: |
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
- name: Enforce React Doctor on changed lines
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}"
- wait: changed-code-quality
- name: Check Zustand selector fan-out budget
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run check:zustand-selector-fanout
- name: Check reliability gate manifest
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run check:reliability-gates
- name: Enforce dead design-system classes
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run check:dead-classes
- name: Check VM runtime rollback compatibility
if: needs.code_paths.outputs.static_analysis == 'true'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
@@ -264,25 +291,33 @@ jobs:
config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts
- name: Enforce max-lines ratchet
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run check:max-lines-ratchet
- name: Enforce ts-nocheck ratchet
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run check:ts-nocheck-ratchet
- name: Enforce runtime Electron-import ratchet
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run check:runtime-electron-ratchet
- name: Check Node runtime pin
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run check:node-runtime-pin
# Why: extraction writes sorted evidence to an isolated temporary path,
# so feature PRs need one normalized AST pass rather than a three-OS matrix.
- name: Verify localization extraction
if: '!cancelled()'
id: localization-extraction
background: true
env:
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
# Detection failures run the full check; renames retain the removed input path.
DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/localization-changes" &&
@@ -296,6 +331,7 @@ jobs:
# which is a property of the import graph. This proves the Node artifact it enables
# actually boots, pairs, creates a worktree and round-trips a real PTY.
- name: Boot orcad and round-trip a terminal
if: needs.code_paths.outputs.static_analysis == 'true'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
ORCA_BACKGROUND_LAUNCH: '1'
@@ -310,20 +346,30 @@ jobs:
fi
- name: Verify the generated RPC params catalog
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run verify:rpc-params-catalog
- name: Verify bundled skill guides
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run verify:bundled-skill-guides
- name: Verify skill freshness manifest
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run verify:skill-bundle-manifest
- name: Verify localization catalogs
if: '!cancelled()'
id: localization-catalogs
background: true
run: pnpm run verify:localization-catalogs
env:
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
run: |
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
pnpm run verify:localization-catalogs
- name: Verify localization coverage
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run verify:localization-coverage
- wait: [localization-catalogs, localization-extraction]
@@ -334,6 +380,7 @@ jobs:
# in .d.ts to `any`, which is how #1186 shipped a broken IPC signature
# past typecheck. See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts.
- name: Guard against project-owned .d.ts in preload/shared
if: needs.code_paths.outputs.static_analysis == 'true'
run: |
matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true)
if [ -n "$matches" ]; then
@@ -346,33 +393,19 @@ jobs:
fi
- name: Check feature wall asset budget
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm check:feature-wall-assets
- name: Verify macOS entitlements
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm verify:macos-entitlements
typecheck:
needs: [code_paths]
if: needs.code_paths.outputs.typecheck == 'true'
# Typechecking uses no native runtime, so it can use the free public ARM runner.
runs-on: ubuntu-24.04-arm
outputs:
shards: ${{ steps.unit-plan.outputs.shards }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 2
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
# Why: every project is `composite`, so tsc already writes a .tsbuildinfo that lets
# the next run skip unchanged files. Share one cache entry across commits while the
# PR base stays stable; actions/cache keeps the first successful graph and the
# compiler still invalidates stale files from its content hashes.
- name: Cache TypeScript incremental state
if: needs.code_paths.outputs.typecheck == 'true'
uses: actions/cache@v5
with:
path: config/*.tsbuildinfo
@@ -382,17 +415,24 @@ jobs:
# Planning shares setup and stays off the compiler's critical path.
- name: Plan unit selection
if: '!cancelled()'
id: unit-plan
background: true
env:
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.typecheck == 'true' }}
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
ORCA_UNIT_SELECTION_MODE: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }}
run: node config/scripts/ci-unit-plan.mjs
run: |
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
node config/scripts/ci-unit-plan.mjs
- run: pnpm run typecheck
if: needs.code_paths.outputs.typecheck == 'true'
- wait: unit-plan
- uses: actions/upload-artifact@v7
if: needs.code_paths.outputs.typecheck == 'true'
with:
name: unit-selection-attempt-${{ github.run_attempt }}
path: ci-shards/unit-selection.json
@@ -680,6 +720,7 @@ jobs:
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
src/main/fish-xdg-data-dirs-handoff.test.ts \
src/main/shell-startup-feature-channel.test.ts \
src/main/zsh-deferred-startup-line-init.live-shell.test.ts \
src/main/terminal-history-fish-session.node-pty.test.ts \
src/main/zsh-scoped-histfile.live-shell.test.ts \
src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
@@ -691,20 +732,19 @@ jobs:
src/shared/startup-shell-portability.live-shell.test.ts \
src/shared/posix-command-path-lookup.test.ts
# Static analysis saves the Node cache; typecheck publishes the plan before tests fan out.
# Preflight saves the Node cache and publishes the plan before tests fan out.
test:
needs: [code_paths, static_analysis, typecheck]
needs: [code_paths, preflight]
# Cancellation and failed prerequisites stop the expensive matrix.
if: >-
!cancelled() &&
needs.code_paths.outputs.test == 'true' &&
needs.static_analysis.result == 'success' &&
needs.typecheck.result == 'success'
needs.preflight.result == 'success'
uses: ./.github/workflows/unit-tests.yml
with:
node_versions: '["24"]'
runner: ubuntu-24.04-arm
shards: ${{ needs.typecheck.outputs.shards }}
shards: ${{ needs.preflight.outputs.shards }}
# Why a sibling and not part of the test workflow: it is advisory, so it must not delay the
# gate. Inside unit-tests.yml a caller's `needs: test` waited for it, holding verify ~36s
@@ -902,7 +942,7 @@ jobs:
package:
name: package
needs: [code_paths, static_analysis, typecheck]
needs: [code_paths, preflight]
if: needs.code_paths.outputs.package == 'true'
runs-on: ubuntu-latest
# Let the serial Docker gates reach their own deadlines and report cleanup failures.
@@ -1059,7 +1099,7 @@ jobs:
package_windows:
name: package (windows)
needs: [code_paths, static_analysis, typecheck]
needs: [code_paths, preflight]
if: needs.code_paths.outputs.package_windows == 'true'
runs-on: windows-2022
timeout-minutes: 30
@@ -1267,8 +1307,7 @@ jobs:
if: ${{ !cancelled() }}
needs:
- code_paths
- static_analysis
- typecheck
- preflight
- git_compatibility
- codex_index_heal_contract
- xterm_patch_sync
@@ -1297,10 +1336,8 @@ jobs:
env:
CODE_PATHS: ${{ needs.code_paths.result }}
SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }}
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }}
TYPECHECK: ${{ needs.typecheck.result }}
TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
PREFLIGHT: ${{ needs.preflight.result }}
PREFLIGHT_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }}
CODEX_INDEX_HEAL_CONTRACT: ${{ needs.codex_index_heal_contract.result }}
@@ -1346,8 +1383,7 @@ jobs:
fi
}
# Require success when the PR has code-relevant changes
check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN"
check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN"
check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN"
check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN"
check_job codex_index_heal_contract "$CODEX_INDEX_HEAL_CONTRACT" "$CODEX_INDEX_HEAL_CONTRACT_SHOULD_RUN"
check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN"
@@ -189,7 +189,7 @@ describe('committed pin', () => {
it('runs in the static analysis job', () => {
const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
const commands = workflow.jobs.static_analysis.steps.map((step) => step.run ?? '')
const commands = workflow.jobs.preflight.steps.map((step) => step.run ?? '')
expect(commands).toContain('pnpm run check:node-runtime-pin')
})
})
@@ -156,8 +156,7 @@ describe('README local link check', () => {
])
})
// Why the ungated job: static_analysis is skipped for docs-only diffs, which is
// exactly the kind of PR that deletes a docs-site GIF the README embeds.
// Docs-only diffs skip preflight, so the detector must check README links.
it('runs on every PR through the ungated detector and in the lint script', () => {
const { scripts } = JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8'))
const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
@@ -20,8 +20,7 @@ function assertJoinedBefore(steps, id, consumer) {
describe('CI background step barriers', () => {
it('joins every background check without suppressing failures', () => {
for (const job of [
pr.jobs.static_analysis,
pr.jobs.typecheck,
pr.jobs.preflight,
pr.jobs.mobile_web_app,
pr.jobs.package,
pr.jobs.shell_contracts,
@@ -52,7 +51,7 @@ describe('CI background step barriers', () => {
it('joins planning before publishing the unit artifact', () => {
assertJoinedBefore(
pr.jobs.typecheck.steps,
pr.jobs.preflight.steps,
'unit-plan',
(step) => step.uses === 'actions/upload-artifact@v7'
)
@@ -88,7 +87,7 @@ describe('CI background step barriers', () => {
})
it('finishes native import-cycle analysis before mobile installation changes resolution', () => {
const steps = pr.jobs.static_analysis.steps
const steps = pr.jobs.preflight.steps
assertJoinedBefore(steps, 'native-code-quality', (step) =>
step.uses?.endsWith('/install-mobile-dependencies')
)
@@ -28,7 +28,7 @@ it('warms the same Linux Node runtime the PR shards restore', () => {
const install = arm.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const primer = readWorkflow('pr').jobs.static_analysis
const primer = readWorkflow('pr').jobs.preflight
expect(arm['runs-on']).toBe(primer['runs-on'])
expect(arm.steps.at(-1).run).toBe('node config/scripts/ensure-native-runtime.mjs --check-only')
expect(install.with).toMatchObject(primer.steps.find((step) => step.uses === install.uses).with)
@@ -51,7 +51,7 @@ it('populates shared Electron archives on both Linux architectures without chang
it('publishes incremental state under a key and prefix that new PRs restore', () => {
const cache = steps.find((step) => step.id === 'typecheck-cache')
const prCache = readWorkflow('pr').jobs.typecheck.steps.find((step) => step.name === cache.name)
const prCache = readWorkflow('pr').jobs.preflight.steps.find((step) => step.name === cache.name)
expect(cache.with.path).toBe(prCache.with.path)
expect(cache.with['restore-keys']).toBe(prCache.with['restore-keys'])
expect(cache.with.key).toBe(
+1
View File
@@ -23,6 +23,7 @@ export const UNIT_EXCLUDE = [
'src/main/pty/omp-shell-wrapper-alias-safety.test.ts',
'src/main/pty/omp-shell-wrapper.node-pty.test.ts',
'src/main/shell-startup-feature-channel.test.ts',
'src/main/zsh-deferred-startup-line-init.live-shell.test.ts',
'src/main/terminal-history-fish-session.node-pty.test.ts',
'src/main/zsh-scoped-histfile.live-shell.test.ts',
'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts',
@@ -0,0 +1,65 @@
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
export const JSON_PARSER_CASES = [
{ name: 'rg-10k', kind: 'rg', count: 10_000, cap: 2000 },
{ name: 'rg-100k', kind: 'rg', count: 100_000, cap: 2000 },
{ name: 'rg-100k-cap1', kind: 'rg', count: 100_000, cap: 1 },
{ name: 'rg-unicode', kind: 'rg', count: 10_000, cap: 2000, unicode: true },
{ name: 'rg-large-dense', kind: 'rg', count: 900_000, cap: 2000, large: true },
{ name: 'rg-fast-path', kind: 'rg', count: 1, cap: 2000, large: true },
{ name: 'session-messages', kind: 'session' },
{ name: 'session-skipped-objects', kind: 'session' },
{ name: 'session-skipped-string', kind: 'session' },
{ name: 'session-selected-string', kind: 'session' }
]
export function writeJsonParserFixtures(directory) {
for (const fixture of JSON_PARSER_CASES) {
let value
if (fixture.kind === 'rg') {
const text = fixture.unicode
? '\ufeff日本語😀x'
: fixture.large && fixture.count > 1
? 'xxxx'
: 'x'
const matchBytes = Buffer.byteLength(text)
value = {
type: 'match',
data: {
path: { text: `${text}.ts` },
lines: {
text: text.repeat(fixture.count === 1 ? 4 * 1024 * 1024 : fixture.count)
},
line_number: 1,
submatches: Array.from({ length: fixture.count }, (_, index) => ({
match: { text },
start: index * matchBytes,
end: (index + 1) * matchBytes
}))
}
}
} else {
value = { id: 'synthetic', messages: [{ text: 'one' }] }
if (fixture.name === 'session-messages') {
value.agent = { model: 'model', other: 'ignored' }
value.messages = Array.from({ length: 20_000 }, (_, index) => ({
role: index % 2 ? 'assistant' : 'user',
text: '\ufeff日本語😀 hello world '.repeat(16),
timestamp: index,
metadata: { model: 'synthetic', tokens: 512 }
}))
} else if (fixture.name === 'session-skipped-objects') {
value.ignored = Array.from({ length: 200_000 }, (_, index) => ({
id: index,
data: { text: 'x'.repeat(64), values: [1, 2, 3] }
}))
} else if (fixture.name === 'session-skipped-string') {
value.ignored = '日本語😀x'.repeat(1_500_000)
} else {
value.messages = [{ text: '日本語😀x'.repeat(1_500_000) }]
}
}
writeFileSync(join(directory, `${fixture.name}.json`), JSON.stringify(value))
}
}
@@ -0,0 +1,166 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { createReadStream, readFileSync, statSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs'
import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs'
import { JSON_PARSER_CASES, writeJsonParserFixtures } from './json-parser-benchmark-fixtures.mjs'
// Bundle each revision's two consumers as {baseline,candidate}-{rg,session}.mjs first.
const [bundleDirectory, workerFixture, workerArm] = process.argv.slice(2)
if (!bundleDirectory || !global.gc) {
throw new Error('Usage: node --expose-gc json-parser-migration-benchmark.mjs BUNDLE_DIRECTORY')
}
async function load(arm, kind) {
return import(pathToFileURL(resolve(bundleDirectory, `${arm}-${kind}.mjs`)).href)
}
function prepareRun(module, fixture, file) {
if (fixture.kind === 'rg') {
const text = readFileSync(file, 'utf8')
return () =>
module.parseRipgrepMatchJson(text, fixture.cap, {
structuralTokens: 32 * 1024,
nestingDepth: 16
})
}
return () =>
module.readStreamedSessionDocument({
bytes: createReadStream(file, { highWaterMark: 64 * 1024 }),
arrayKey: 'messages',
fields: ['id'],
objectFields: { agent: ['model'] },
create: () => ({ count: 0, textLength: 0 }),
consume(state, value) {
state.count++
state.textLength += typeof value?.text === 'string' ? value.text.length : 0
}
})
}
function digest(value) {
return createHash('sha256').update(JSON.stringify(value)).digest('hex')
}
async function consumedContentDigest(module, file) {
const result = await module.readStreamedSessionDocument({
bytes: createReadStream(file, { highWaterMark: 64 * 1024 }),
arrayKey: 'messages',
fields: ['id'],
objectFields: { agent: ['model'] },
create: () => createHash('sha256'),
consume(hash, value) {
hash.update(JSON.stringify(value)).update('\n')
}
})
return { record: result.record, consumedSha256: result.state.digest('hex') }
}
if (workerFixture) {
const fixture = JSON_PARSER_CASES.find((item) => workerFixture.endsWith(`${item.name}.json`))
assert(fixture)
const module = await load(workerArm, fixture.kind)
const run = prepareRun(module, fixture, workerFixture)
global.gc()
const before = process.memoryUsage()
let running = true
let maxLoopGapMs = 0
let previous = performance.now()
const observe = () => {
const now = performance.now()
maxLoopGapMs = Math.max(maxLoopGapMs, now - previous)
previous = now
if (running) {
setImmediate(observe)
}
}
setImmediate(observe)
const started = performance.now()
const result = await run()
const elapsedMs = performance.now() - started
await new Promise((done) => setImmediate(done))
running = false
const peakRssMiB = process.resourceUsage().maxRSS / 1024
global.gc()
const retainedHeapDeltaMiB = (process.memoryUsage().heapUsed - before.heapUsed) / 1024 ** 2
console.log(
JSON.stringify({
elapsedMs,
peakRssMiB,
retainedHeapDeltaMiB,
maxLoopGapMs,
digest: digest(result)
})
)
} else {
const directory = mkdtempSync(join(tmpdir(), 'orca-json-parser-benchmark-'))
try {
writeJsonParserFixtures(directory)
global.gc()
const results = []
for (const fixture of JSON_PARSER_CASES) {
const file = join(directory, `${fixture.name}.json`)
const runs = {}
const contents = {}
for (const arm of ['baseline', 'candidate']) {
const module = await load(arm, fixture.kind)
runs[arm] = prepareRun(module, fixture, file)
if (fixture.kind === 'session') {
contents[arm] = await consumedContentDigest(module, file)
}
}
assert.deepEqual(contents.candidate, contents.baseline)
for (let warmup = 0; warmup < 3; warmup++) {
assert.deepEqual(await runs.candidate(), await runs.baseline())
}
const samples = { baseline: [], candidate: [] }
for (const pair of buildCounterbalancedSchedule(12, 'baseline', 'candidate')) {
for (const arm of pair) {
const started = performance.now()
await runs[arm]()
samples[arm].push(performance.now() - started)
}
}
const memory = { baseline: [], candidate: [] }
for (const pair of buildCounterbalancedSchedule(2, 'baseline', 'candidate')) {
for (const arm of pair) {
const child = spawnSync(
process.execPath,
['--expose-gc', import.meta.filename, bundleDirectory, file, arm],
{
encoding: 'utf8',
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
windowsHide: true
}
)
assert.equal(child.status, 0, child.stderr)
memory[arm].push(JSON.parse(child.stdout))
}
}
for (const sample of [...memory.baseline, ...memory.candidate]) {
assert.equal(sample.digest, memory.baseline[0].digest)
}
results.push({
name: fixture.name,
bytes: statSync(file).size,
baseline: summarizeBenchmarkSamples(samples.baseline),
candidate: summarizeBenchmarkSamples(samples.candidate),
samples,
memory
})
}
console.log(
JSON.stringify(
{ node: process.version, platform: process.platform, arch: process.arch, results },
null,
2
)
)
} finally {
rmSync(directory, { recursive: true, force: true })
}
}
@@ -39,10 +39,10 @@ it('preserves deleted and renamed inputs and falls back to extraction on detecti
const workflow = parse(
readFileSync(new URL('../../.github/workflows/pr.yml', import.meta.url), 'utf8')
)
const step = workflow.jobs.static_analysis.steps.find(
const step = workflow.jobs.preflight.steps.find(
(candidate) => candidate.name === 'Verify localization extraction'
)
expect(step.env).toEqual({
expect(step.env).toMatchObject({
BASE_SHA: '${{ github.event.pull_request.base.sha }}'
})
// The base side comes from the merge ref's first parent, so the gate needs no merge base and
@@ -310,6 +310,10 @@ it('runs the Bun and Node cross-runtime tests on Linux against pinned inputs', (
expect(build.if).toBeUndefined()
expect(build['continue-on-error']).toBeUndefined()
expect(build.run).toMatch(/^if \[ "\$RUNNER_OS" != Linux \]; then exit 0; fi\n/)
expect(build.run).toContain(
'pnpm --dir "$RUNNER_TEMP/bun-orcad-source" install --frozen-lockfile --ignore-scripts'
)
expect(build.run).not.toContain('"$GITHUB_WORKSPACE/node_modules"')
expect(build.run).toContain('echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT"')
expect(build.run).toContain('echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT"')
expect(build.run).not.toContain('GITHUB_ENV')
@@ -92,7 +92,7 @@ it('only skips the unchanged smoke after a successful diff and dependency analys
const workflow = parse(
readFileSync(new URL('../../.github/workflows/pr.yml', import.meta.url), 'utf8')
)
const step = workflow.jobs.static_analysis.steps.find(
const step = workflow.jobs.preflight.steps.find(
(candidate) => candidate.name === 'Boot orcad and round-trip a terminal'
)
expect(step.env).toEqual({
+11 -9
View File
@@ -587,14 +587,16 @@ describe('PR Checks skip wiring', () => {
expect(prWorkflow.jobs.code_paths.outputs.mobile_dependencies).toBe(
'${{ steps.filter.outputs.mobile_dependencies }}'
)
const steps = prWorkflow.jobs.static_analysis.steps
const steps = prWorkflow.jobs.preflight.steps
const install = steps.findIndex(
(step) => step.uses === './.github/actions/install-mobile-dependencies'
)
const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality')
expect(install).toBeGreaterThan(-1)
expect(install).toBeLessThan(gate)
expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'")
expect(steps[install].if).toBe(
"needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true'"
)
// The install itself moved into the action the packaging jobs share; assert it there so
// this job cannot keep the step while the action stops installing anything.
const action = parse(
@@ -621,21 +623,21 @@ describe('PR Checks skip wiring', () => {
})
it('gates each expensive job on its classifier and cache prerequisite', () => {
for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) {
for (const jobName of expensiveJobs.filter(
(jobName) => !['test', 'static_analysis', 'typecheck'].includes(jobName)
)) {
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(
['package', 'package_windows'].includes(jobName)
? ['code_paths', 'static_analysis', 'typecheck']
? ['code_paths', 'preflight']
: ['code_paths']
)
expect(prWorkflow.jobs[jobName].if, jobName).toBe(
`needs.code_paths.outputs.${jobName} == 'true'`
)
}
expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'static_analysis', 'typecheck'])
expect(prWorkflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'")
expect(prWorkflow.jobs.test.if).toContain("needs.typecheck.result == 'success'")
expect(prWorkflow.jobs.test.if).toContain("needs.preflight.result == 'success'")
expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'")
expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}')
expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}')
expect(prWorkflow.jobs.unit_plan).toBeUndefined()
expect(prWorkflow.jobs.test_native_cache).toBeUndefined()
})
@@ -659,7 +661,7 @@ describe('PR Checks skip wiring', () => {
expect(verifyStep.run).toContain('expected skipped')
expect(verifyStep.run).toContain('expected success')
for (const job of prWorkflow.jobs.verify.needs) {
if (job === 'code_paths') {
if (job === 'code_paths' || job === 'preflight') {
continue
}
const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN`
+2 -2
View File
@@ -45,7 +45,7 @@ const nativeImeSpec = readFileSync(
const filterStep = prWorkflow.jobs.code_paths.steps.find(
(step) => step.name === 'Filter changed E2E specs'
)
const rollbackStep = prWorkflow.jobs.static_analysis.steps.find(
const rollbackStep = prWorkflow.jobs.preflight.steps.find(
(step) => step.name === 'Check VM runtime rollback compatibility'
)
const verifyStep = prWorkflow.jobs.verify.steps.find(
@@ -133,7 +133,7 @@ describe('PR E2E gate contract', () => {
for (const job of prWorkflow.jobs.verify.needs) {
const envVar = job.replaceAll('-', '_').toUpperCase()
expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`)
if (job === 'code_paths') {
if (job === 'code_paths' || job === 'preflight') {
continue
}
expect(successLoop).toContain(`"$${envVar}"`)
+142 -33
View File
@@ -1,29 +1,21 @@
import { readFileSync } from 'node:fs'
import { runInNewContext } from 'node:vm'
import { expect, it } from 'vitest'
import { parse } from 'yaml'
import { classifyPrJobs } from './pr-code-change-scope.mjs'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const typecheck = workflow.jobs.typecheck
const steps = typecheck.steps
const preflight = workflow.jobs.preflight
const steps = preflight.steps
const compiler = steps.find((step) => step.run === 'pnpm run typecheck')
const plan = steps.find((step) => step.id === 'unit-plan')
it('shares planning setup while keeping the heavy checks on separate runners', () => {
expect(workflow.jobs.unit_plan).toBeUndefined()
expect(workflow.jobs.test_native_cache).toBeUndefined()
expect(typecheck.needs).toEqual(['code_paths'])
expect(workflow.jobs.static_analysis.needs).toEqual(['code_paths'])
expect(
steps.filter((step) => step.uses === './.github/actions/install-node-dependencies')
).toHaveLength(1)
expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2)
expect(compiler.background).toBeUndefined()
expect(plan.background).toBe(true)
expect(plan.run).toBe('node config/scripts/ci-unit-plan.mjs')
expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE')
expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler))
const installs = workflow.jobs.static_analysis.steps.filter(
it('shares one setup and runs the unchanged compiler after static checks finish', () => {
expect(workflow.jobs.static_analysis).toBeUndefined()
expect(workflow.jobs.typecheck).toBeUndefined()
expect(preflight.needs).toEqual(['code_paths'])
expect(preflight['runs-on']).toBe('ubuntu-24.04-arm')
const installs = steps.filter(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(installs).toHaveLength(2)
@@ -32,35 +24,152 @@ it('shares planning setup while keeping the heavy checks on separate runners', (
expect(install.with['node-version']).toBe('24')
expect(install.with['persist-native-cache']).not.toBe('false')
}
expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2)
expect(compiler.background).toBeUndefined()
expect(plan.background).toBe(true)
expect(plan.run.trim().split('\n')).toEqual([
'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi',
'node config/scripts/ci-unit-plan.mjs'
])
expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE')
expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler))
expect(steps.indexOf(compiler)).toBeGreaterThan(
steps.findIndex((step) => step.wait?.includes('localization-extraction'))
)
})
it('requires compiler success and joined planning before publishing shards and admitting tests', () => {
it('requires physical preflight success before publishing shards and admitting consumers', () => {
const join = steps.findIndex((step) => step.wait === 'unit-plan')
const upload = steps.findIndex((step) => step.uses === 'actions/upload-artifact@v7')
expect(join).toBeGreaterThan(steps.indexOf(compiler))
expect(upload).toBeGreaterThan(join)
expect(steps[upload]['continue-on-error']).toBeUndefined()
expect(steps[upload].with.name).toBe('unit-selection-attempt-${{ github.run_attempt }}')
expect(typecheck.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}')
expect(workflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}')
expect(preflight.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}')
expect(workflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}')
for (const job of ['test', 'package', 'package_windows']) {
expect(workflow.jobs[job].needs).toEqual(['code_paths', 'static_analysis', 'typecheck'])
expect(workflow.jobs[job].needs).toEqual(['code_paths', 'preflight'])
}
expect(workflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'")
expect(workflow.jobs.test.if).toContain("needs.typecheck.result == 'success'")
expect(workflow.jobs.verify.needs).toContain('static_analysis')
expect(workflow.jobs.verify.needs).toContain('typecheck')
for (const result of ['success', 'failure', 'cancelled', 'skipped']) {
const admitted = runInNewContext(workflow.jobs.test.if, {
cancelled: () => false,
needs: { code_paths: { outputs: { test: 'true' } }, preflight: { result } }
})
expect(admitted, result).toBe(result === 'success')
}
const verify = workflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
expect(verify.env.PREFLIGHT).toBe('${{ needs.preflight.result }}')
expect(verify.env.PREFLIGHT_SHOULD_RUN).toBe(
"${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }}"
)
expect(verify.run).toContain('check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN"')
expect(workflow.jobs.verify.needs).toContain('preflight')
expect(workflow.jobs.verify.needs).not.toContain('typecheck')
})
it.each(
[['README.md'], ['mobile/src/App.tsx'], ['cloud/package.json'], ['src/main/index.ts'], []].map(
(changed) => ({ changed })
it('pins every foreground and background step to its selected phase', () => {
const staticPhase = "needs.code_paths.outputs.static_analysis == 'true'"
const typePhase = "needs.code_paths.outputs.typecheck == 'true'"
const foreground = steps.filter(
(step) => !step.background && /outputs\.(static_analysis|typecheck)/.test(step.if ?? '')
)
)('keeps desktop typechecking and planning off unrelated paths: $changed', ({ changed }) => {
expect(foreground.map((step) => [step.name ?? step.run ?? step.uses, step.if])).toEqual([
['Reject low-evidence patterns', staticPhase],
['Enforce type-aware code-quality baseline', staticPhase],
[
'./.github/actions/install-mobile-dependencies',
`${staticPhase} && needs.code_paths.outputs.mobile_dependencies == 'true'`
],
['Enforce React Doctor on changed lines', staticPhase],
['Check Zustand selector fan-out budget', staticPhase],
['Check reliability gate manifest', staticPhase],
['Enforce dead design-system classes', staticPhase],
['Check VM runtime rollback compatibility', staticPhase],
['Enforce max-lines ratchet', staticPhase],
['Enforce ts-nocheck ratchet', staticPhase],
['Enforce runtime Electron-import ratchet', staticPhase],
['Check Node runtime pin', staticPhase],
['Boot orcad and round-trip a terminal', staticPhase],
['Verify the generated RPC params catalog', staticPhase],
['Verify bundled skill guides', staticPhase],
['Verify skill freshness manifest', staticPhase],
['Verify localization coverage', staticPhase],
['Guard against project-owned .d.ts in preload/shared', staticPhase],
['Check feature wall asset budget', staticPhase],
['Verify macOS entitlements', staticPhase],
['Cache TypeScript incremental state', typePhase],
['pnpm run typecheck', typePhase],
['actions/upload-artifact@v7', typePhase]
])
expect(
steps
.filter((step) => step.background)
.map((step) => [step.id, step.env.PREFLIGHT_PHASE_SELECTED])
).toEqual([
['root-lint', `\${{ ${staticPhase} }}`],
['native-code-quality', `\${{ ${staticPhase} }}`],
['changed-code-quality', `\${{ ${staticPhase} }}`],
['localization-extraction', `\${{ ${staticPhase} }}`],
['localization-catalogs', `\${{ ${staticPhase} }}`],
['unit-plan', `\${{ ${typePhase} }}`]
])
})
it.each([
{ changed: ['README.md'], static_analysis: false, typecheck: false, mobile_dependencies: false },
{
changed: ['mobile/src/App.tsx'],
static_analysis: true,
typecheck: false,
mobile_dependencies: true
},
{
changed: ['cloud/package.json'],
static_analysis: false,
typecheck: false,
mobile_dependencies: false
},
{
changed: ['src/main/index.ts'],
static_analysis: true,
typecheck: true,
mobile_dependencies: false
},
{
changed: ['mobile/src/App.tsx', 'src/main/index.ts'],
static_analysis: true,
typecheck: true,
mobile_dependencies: true
},
{ changed: [], static_analysis: true, typecheck: true, mobile_dependencies: true }
])('preserves exact phase selection for unrelated paths: $changed', ({ changed, ...expected }) => {
const scope = classifyPrJobs(changed)
expect(typecheck.if).toBe("needs.code_paths.outputs.typecheck == 'true'")
expect(scope.test).toBe(scope.typecheck)
if (scope.test) {
expect(scope.static_analysis).toBe(true)
expect({
static_analysis: scope.static_analysis,
typecheck: scope.typecheck,
mobile_dependencies: scope.mobile_dependencies
}).toEqual(expected)
const needs = {
code_paths: {
outputs: Object.fromEntries(Object.entries(scope).map(([key, value]) => [key, String(value)]))
}
}
expect(runInNewContext(preflight.if, { needs })).toBe(
expected.static_analysis || expected.typecheck
)
expect(runInNewContext(compiler.if, { needs })).toBe(expected.typecheck)
expect(scope.test).toBe(expected.typecheck)
})
it('registers successful no-op background work when a phase is unselected or already failed', () => {
for (const step of steps.filter((step) => step.background)) {
expect(step.if).toBe('!cancelled()')
expect(step.env.PREFLIGHT_PHASE_SELECTED).toContain('needs.code_paths.outputs.')
expect(step.env.PREFLIGHT_PRIOR_SUCCESS).toBe("${{ job.status == 'success' }}")
expect(step.run.split('\n')[0]).toBe(
'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi'
)
}
})
+7 -3
View File
@@ -7,10 +7,14 @@ import { PR_CHECK_JOBS } from './pr-code-change-scope.mjs'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const gate = workflow.jobs.verify.steps.find((step) => step.name === 'Require successful checks')
const variable = (job) => job.replaceAll('-', '_').toUpperCase()
const requiredJobs = [
'preflight',
...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck')
]
function requiredResults(shouldRun) {
return Object.fromEntries(
PR_CHECK_JOBS.flatMap((job) => [
requiredJobs.flatMap((job) => [
[variable(job), shouldRun ? 'success' : 'skipped'],
[`${variable(job)}_SHOULD_RUN`, String(shouldRun)]
])
@@ -42,7 +46,7 @@ describe.skipIf(process.platform === 'win32')(
})
it('rejects every missing, failed or cancelled required result when reuse is unavailable', async () => {
for (const job of PR_CHECK_JOBS) {
for (const job of requiredJobs) {
for (const result of ['', 'skipped', 'failure', 'cancelled']) {
const verdict = await verify({ ...requiredResults(true), [variable(job)]: result })
expect(verdict.code, `${job}: ${result}`).toBe(1)
@@ -57,7 +61,7 @@ describe.skipIf(process.platform === 'win32')(
})
it('rejects unexpected downstream execution when the proven plan requires skips', async () => {
for (const job of PR_CHECK_JOBS) {
for (const job of requiredJobs) {
const verdict = await verify({ ...requiredResults(false), [variable(job)]: 'success' })
expect(verdict.code, job).toBe(1)
}
+5 -1
View File
@@ -157,7 +157,11 @@ describe('ready-for-review required check reuse', () => {
"github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true'"
)
expect(workflow.jobs.verify.if).toBe('${{ !cancelled() }}')
expect(workflow.jobs.verify.needs).toEqual(['code_paths', ...PR_CHECK_JOBS])
expect(workflow.jobs.verify.needs).toEqual([
'code_paths',
'preflight',
...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck')
])
})
it.each(['pr-test-loc.yml', 'mobile.yml'])(
@@ -27,6 +27,7 @@ const shellContractFiles = [
'src/main/pty/omp-shell-wrapper-alias-safety.test.ts',
'src/main/pty/omp-shell-wrapper.node-pty.test.ts',
'src/main/shell-startup-feature-channel.test.ts',
'src/main/zsh-deferred-startup-line-init.live-shell.test.ts',
'src/main/zsh-scoped-histfile.live-shell.test.ts',
'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts',
'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts',
@@ -54,7 +55,7 @@ const realZshUsage =
describe('PR workflow parallelism', () => {
it('keeps lightweight orchestration jobs on the free slim runner', () => {
expect(workflow.jobs.code_paths['runs-on']).toBe('ubuntu-slim')
expect(workflow.jobs.typecheck['runs-on']).toBe('ubuntu-24.04-arm')
expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm')
expect(workflow.jobs.verify['runs-on']).toBe('ubuntu-slim')
expect(prTestLocWorkflow.jobs.loc['runs-on']).toBe('ubuntu-slim')
expect(releasePolicyWorkflow.jobs.enforce['runs-on']).toBe('ubuntu-slim')
@@ -79,7 +80,7 @@ describe('PR workflow parallelism', () => {
const installStep = sharedTest.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const staticInstall = workflow.jobs.static_analysis.steps.find(
const staticInstall = workflow.jobs.preflight.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const nodeNextPrimerInstall = nodeNextWorkflow.jobs.test_native_cache.steps.find(
@@ -91,7 +92,7 @@ describe('PR workflow parallelism', () => {
expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['24', '26'])
expect(workflow.jobs.test.with.runner).toBe('ubuntu-24.04-arm')
expect(workflow.jobs.static_analysis['runs-on']).toBe('ubuntu-24.04-arm')
expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm')
expect(sharedTest['runs-on']).toBe('${{ inputs.runner }}')
expect(unitTestWorkflow.on.workflow_call.inputs.runner.default).toBe('ubuntu-latest')
expect(nodeNextWorkflow.jobs.test.with.runner).toBeUndefined()
@@ -121,7 +122,7 @@ describe('PR workflow parallelism', () => {
}
expect(staticInstall.with['native-runtime']).toBe('node')
expect(staticInstall.with['node-version']).toBe('24')
expect(workflow.jobs.test.needs).toContain('static_analysis')
expect(workflow.jobs.test.needs).toContain('preflight')
expect(workflow.jobs.test_native_cache).toBeUndefined()
expect(nodeNextPrimerInstall.with['native-runtime']).toBe('node')
expect(nodeNextPrimerInstall.with['node-version']).toBe('${{ matrix.node }}')
@@ -348,11 +349,11 @@ describe('PR workflow parallelism', () => {
(step) => step.uses === './.github/actions/install-node-dependencies'
)
for (const jobName of ['typecheck', 'git_compatibility']) {
for (const jobName of ['git_compatibility']) {
expect(installFor(jobName).with, jobName).toBeUndefined()
}
expect(installFor('xterm_patch_sync')).toBeUndefined()
expect(installFor('static_analysis').with['native-runtime']).toBe('node')
expect(installFor('preflight').with['native-runtime']).toBe('node')
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
expect(sharedTestInstall.with['native-runtime']).toBe('node')
expect(installFor('package').with['native-runtime']).toBe('electron')
@@ -478,7 +479,7 @@ describe('PR workflow parallelism', () => {
})
it('reuses TypeScript incremental state across typecheck runs', () => {
const steps = workflow.jobs.typecheck.steps
const steps = workflow.jobs.preflight.steps
const cacheIndex = steps.findIndex((step) => step.name === 'Cache TypeScript incremental state')
const checkIndex = steps.findIndex((step) => step.run === 'pnpm run typecheck')
@@ -543,8 +544,7 @@ describe('PR workflow parallelism', () => {
it('keeps verify as the aggregate required check', () => {
expect(workflow.jobs.verify.needs).toEqual([
'code_paths',
'static_analysis',
'typecheck',
'preflight',
'git_compatibility',
'codex_index_heal_contract',
'xterm_patch_sync',
+2 -2
View File
@@ -258,8 +258,8 @@
],
"compilerOptions": {
"composite": true,
// TypeScript 7 removed node10 resolution; Node16 preserves CommonJS emit for this package.
"module": "Node16",
// The CLI runs on Node 24; Node20 models synchronous ESM imports from CommonJS.
"module": "Node20",
"moduleResolution": "Node16",
"rootDir": "../src",
"outDir": "../out"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"extends": "./tsconfig.cli.json",
"compilerOptions": {
"module": "node16",
"module": "Node20",
"moduleResolution": "node16"
}
}
+332 -8
View File
@@ -46,6 +46,112 @@ used 42 aggregate runner-minutes across 11 test jobs. The
estimates 34.9 headless runner-hours, including 23.4 in cancelled runs. These are
baseline observations; post-merge savings have not yet been measured.
## October 4 clock, byte and import test fixtures
These changes retain production behavior, original case names and platform
outcomes. The paired pilots use three alternating one-worker Node 24 invocations
on Ubuntu 24 ARM. Every median below is a complete focused test invocation;
they do not establish whole-shard savings or queue-delay improvements.
| Workload | Baseline median | Candidate median | Reduction | Hosted evidence |
| ---------------------------------------------------- | --------------- | ---------------- | --------- | ------------------------------------------------------------------------ |
| Codex settlement and Claude stop deadlines, 35 cases | 35.914s | 10.142s | 71.8% | [37186232658](https://github.com/stablyai/orca/actions/runs/37186232658) |
| Native-chat delivery, 15 cases | 22.321s | 7.376s | 67.0% | [37183731823](https://github.com/stablyai/orca/actions/runs/37183731823) |
| Six profile-storage byte suites, 118 cases | 12.629s | 9.978s | 21.0% | [37183141654](https://github.com/stablyai/orca/actions/runs/37183141654) |
| Encrypted account storage, six cases | 18.277s | 0.958s | 94.8% | [37184007241](https://github.com/stablyai/orca/actions/runs/37184007241) |
| SSH remote commands, 27 cases | 6.840s | 6.078s | 11.1% | [37184454532](https://github.com/stablyai/orca/actions/runs/37184454532) |
| OpenCode subscription, 28 cases | 47.347s | 6.284s | 86.7% | [37184858823](https://github.com/stablyai/orca/actions/runs/37184858823) |
| Window-service attachment, 31 cases | 11.910s | 1.619s | 86.4% | [37186340840](https://github.com/stablyai/orca/actions/runs/37186340840) |
| OpenCode 2 TUI ownership, 41 cases | 16.811s | 2.429s | 85.6% | [37187699312](https://github.com/stablyai/orca/actions/runs/37187699312) |
| Title-send authorization, nine cases | 29.545s | 12.995s | 56.0% | [37188423318](https://github.com/stablyai/orca/actions/runs/37188423318) |
| Range selection, 15 cases | 9.737s | 7.130s | 26.8% | [37188996198](https://github.com/stablyai/orca/actions/runs/37188996198) |
Provider tests wait for the real fake-child write/ready barrier and drain the
host stream before installing a scoped parent clock. The original 2.5/5-second
Codex and 3-second Claude deadlines remain; before/at assertions check their
boundaries. Early rejection and refusal resolve without waiting on an unreachable
barrier; finally and suite teardown restore clocks and spies even after a native
Vitest timeout. Four healthy failure-path controls pass; old-helper hangs and
removed teardown are caught. Missing-child failure retains a real ten-second observation window.
Six faults for early/late stop deadlines, late queued resend and missing child
output fail the intended assertions. Native-chat tests still use the real React
outbox hooks. Their scoped clock retains probe, retry, churn and target-switch
windows, drains async act work, and unmounts before clock restoration. All eight
hook faults fail; two boundary faults pass the old coarse tests and fail the new
before/at assertions. Node/web typecheck, lint and formatting pass.
Native Buffer.equals replaces deep per-byte assertion traversal. It checks the
complete original bytes and length; fixtures, SQLite operations, encryption and
processes are unchanged. The six profile suites retain 114 passes and four
existing Linux case-sensitivity skips; all 118 pass locally on macOS. Seven
profile last-byte/length faults and two encrypted-vault last-byte/length faults
fail their exact byte assertions. All six encrypted-vault cases still exercise
52 accounts near the 4 MiB encrypted cap, refused growth, restart/readback and
private permissions.
The old SSH fixture created 15,197 short stage paths but never exceeded the real
1,048,576 UTF-16-character transport tail cap; its two valid entries also left
the 64-result assertion vacuous. The replacement uses about 1,300 real excluded
stage directories under long Unicode path components, a real shell channel and
the production execCommand limiter. Actual find output exceeds that cap, while
the generated filter retains both original valid entries. A separate population
of 65 valid directories proves the first-64 limit and native find ordering. File,
symlink, nested-install and failed-enumeration checks remain. All 27 case outcomes
match across treatments (23 passes and four unavailable PowerShell 5.1 skips on
the hosted image). Eight cap, ordering, filtering and failure faults are caught.
Paths use platform utilities; local PowerShell availability retains its original
skip policy. The deadline and SSH fixtures reuse existing process/stream code.
OpenCode subscription tests batch only uninterrupted fixture writes between the
original observation barriers. Both SQLite schema versions keep every row, rowid,
read cap, poll, clock position and case. No durability pragma or production code
changes. All 28 cases pass in every pair. Separate captures compare ordered
schema and rows, transaction state, pragmas, signals, page requests/results and
subscriber callbacks: 124,754,101 payload bytes match, canonical digest
`ba0eb6f09281746071d73fae88e2e8eb45332f36892b90998b374b1b8c59b3e3`.
Missing rows, collapsed frontier rowids, read-cap overruns, missing commit and
missing rollback each fail the intended case in both schemas. Positive rollback
controls pass. The unmeasured full-suite timing report ranked this fixture at
134.229 seconds; the paired 47.347-second figure above is the relevant focused
baseline, and the two figures must not be mixed into a claimed saving.
Window attachment tests mock five unrelated registrar modules using their actual
types. The existing window ownership, reload, media permission, native file drop,
hydration barrier and updater scheduling cases remain real. Exact store/runtime/
window arguments and daemon registration after the PTY handler are now asserted;
nine actual production wiring/order faults fail. The registrar implementations
retain separate handler tests. Concrete existing stubs moved to one fixture to
stay within the line limit. All 31 cases and statuses match across all pairs. The
final source differs from the timed source only by a required type-assertion
safety comment.
OpenCode 2 TUI tests use a scoped async clock only after the first real native
module import. The unchanged generated plugin runs against the existing fake TUI
and fetch. Original poll, permission, retry, preview, slow POST and endpoint
windows remain. Before/at assertions pin the 100 ms poll, 500 ms permission,
120 ms slow POST and 5-second endpoint boundaries. All 41 original case outcomes
match. Separate ordered captures preserve 678 TUI/event rows and 362 complete
POST start/completion rows; wall timestamps and cross-stream interleaving are
excluded from equivalence. Eleven generated-source faults fail their intended
identity, reload, order, deadline or timer-cleanup assertions. Four import/setup/
disposer rejection and timeout controls confirm restoration of clocks, fetch,
argv and environment. Teardown holds its fake clock through bounded native cleanup and pending-timer checks, then restores real clocks. The [teardown qualification](https://github.com/stablyai/orca/actions/runs/37195736834) passes all 41 cases and 12 failure and restoration control invocations, including interval and retry leaks missed by the earlier teardown.
Title-send authorization tests retain real terminal creation, graph binding and
positive evidence paths. Negative process-evidence probes use scoped clocks
after setup: both 150 ms polling loops retain their 6,500 ms budget, crossed at
6,600 ms, and the send guard retains its 1,050 ms deadline. Before/at assertions
check 6,599/6,600 and 1,049/1,050 ms. All nine original cases remain. Actual
early/late wrapper and guard deadlines, false spinner identity and unknown-agent
authorization faults fail their intended assertions; native clocks and runtime
instance spies are restored after each failure.
Range-selection tests stub only the saved-note send menu, which their empty
comment populations never render. A facade typed from the actual menu props
throws if invoked, and an afterEach assertion verifies no call with unconditional
mock clearing in finally. The real hook, Monaco constants/model, line and range
drag behavior, draft-card lifecycle and open-inline-card chord remain. All 15
original test bodies are byte-unchanged. Three actual range/hunk/draft faults
fail their original assertions; a real draft-render menu call hits the facade
and sentinel, and an outer cleanup check proves mock state cleared after failure.
The actual saved-note menu retains its independent component tests.
## October 2 headless detector compiler cache
The deferred detector already avoids dependency setup for known build inputs.
@@ -601,6 +707,8 @@ All commands passed and plans matched within each comparison. These command
timings exclude setup and queues; compiler variation contributes to the cold
difference. The retained arrangement showed no cold compiler penalty.
The sequential gate in [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity) supersedes the earlier rejection below.
Combining static analysis too was rejected. An
[alternating same-runner comparison](https://github.com/stablyai/orca/actions/runs/36835091650)
saved runner occupancy, but cold compilation slowed from 61–64 to 83–89 seconds
@@ -1739,6 +1847,8 @@ these local body measurements do not establish hosted or whole-PR time savings.
## Sequential static analysis and typecheck: retain separate jobs
The earlier recommendation below is superseded by [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity).
A four-trial hosted screen kept the slim router unchanged and compared the two
independent ARM jobs with one ARM job running their unchanged checks sequentially.
The [compiler/planner census](https://github.com/stablyai/orca/actions/runs/37069472888)
@@ -1858,6 +1968,32 @@ the imported helper has no top-level side effects, and the Linux rebuild branch
is unchanged. Focused tests verify its Linux/macOS no-op behavior. Final-head PR
checks qualify separately.
## October 4 reusable cells for terminal context scans
Terminal cursor-context scans now request one reusable cell per invocation when
the adapter offers getNullCell, and pass it through all unchanged text/style
scans. Adapters without that optional method keep the existing allocating path.
The scratch cell is local and no cell reference escapes into returned context.
Browser composer/readiness text, colors, bold flags and wrapping are unchanged.
Three alternating one-worker ARM pairs in
[37182789677](https://github.com/stablyai/orca/actions/runs/37182789677)
ran all 19 original cases from readiness census suite 2. Baseline complete
invocations were 37.141 / 37.879 / 37.090 seconds; candidate invocations were
33.887 / 33.387 / 32.916 seconds. Median 37.141 to 33.387 seconds saves 10.1%.
This is a focused workload measurement, not a whole-shard or queue-delay claim.
Separate baseline/candidate captures retained all 192 cases across six census
suites. Every context and visible projection matched: 643,926 of each, with
7,465,308,324 complete length-prefixed payload bytes hashed per test/type/order.
The canonical capture digest was
`f7440c0f1b5bbb57127cd29245530029415c8e9e243c1744359f330b3c7ace19`.
These captures run outside the timing samples. All 41 cursor/composer/browser
consumer checks passed. Seven faults for lost dim filtering, wide continuation,
bold prompt, custom foreground, wrap preservation, adapter fallback and scratch
reuse failed their intended assertions. Node and web typecheck, lint and format
passed. Two added controls prove per-call scratch lifetime and adapter parity.
## October 3 producer follow-up: automatic selection for the measured profile
The first producer rollout in [#24927](https://github.com/stablyai/orca/pull/24927)
@@ -1895,6 +2031,36 @@ automatic selection and cold publication, not a new timing result. Local
verification passed eight suites / 184 tests, the changed-code quality gate and
compiled-composite actionlint.
## October 4 shared PR preflight capacity
Static analysis and the unchanged compiler now share one ARM runner and guarded
Node 24 install. Static checks finish and all background work joins before the
compiler starts; unit planning still overlaps compilation. Each phase keeps its
classifier output. Successful no-op background bodies register every required
join when a phase is unselected or an earlier step failed. Unit and package
consumers depend on physical job success, including action cleanup.
Three counterbalanced pairs in
[37180613601](https://github.com/stablyai/orca/actions/runs/37180613601)
used the same frozen checkout `f199a20c3acd`, Node 24.21.0, pnpm 12.8.1,
policy hashes, native cache hits, warm TypeScript cache and 10,787-file unit plan.
Both arms used the PR root-only download-store policy. Total active job time was
152 / 153 / 151 seconds separately and 138 / 133 / 129 combined. Excluding the
extra measurement-only evidence steps gives 151 / 151 / 149 versus
136 / 132 / 128 seconds: median 151 to 132, saving 19 seconds (12.6%).
Two heavy runner admissions become one. This saves capacity; it does not prove a
whole-PR latency or queue gain. The median active dependency barrier increases
from 116 to 132 seconds because compilation follows static checks.
The separate physical-failure run
[37180755694](https://github.com/stablyai/orca/actions/runs/37180755694)
proved that an included TypeScript error failed the actual compiler, its planner
still joined, and unit/package admissions skipped. A registered late action post
failure also blocked both consumers after successful foreground checks and
published shards. All 12 unselected/prior-failure no-op backgrounds joined, and
the downstream audit passed. Local workflow contracts passed 239 tests across
12 suites; lint and formatting passed.
## October 3 retired-cache collection observation
The same owner-collection assertion failed in unit shard 3 of
@@ -1912,6 +2078,47 @@ The source was restored afterward. Extra collection turns therefore preserve the
strong-retention oracle. Hosted qualification is still required; these observations
do not prove a particular VM-retention cause or quantify avoided retries.
## October 4 terminal oracle execution
Three measured test-support changes preserve the original seeds, payloads,
chunk boundaries and meaningful assertions. Serializer comparisons reuse cells
and format only the first mismatch instead of allocating descriptors for every
cell. The terminal parity writer submits every original chunk in FIFO order and
awaits the final parser callback. The independent legacy frame oracle memoizes
measured code-point widths. Its discarded algebra-only case never called
production and still passed when production always threw.
Three alternating one-worker hosted ARM pairs measured complete invocations:
| Cohort | Baseline median | Candidate median | Saving |
| ---------------------------------------- | --------------- | ---------------- | ------ |
| Serializer replay/fuzz/descriptor checks | 71.675s | 46.581s | 35.0% |
| Emulator/reconciliation/color parity | 24.095s | 5.411s | 77.5% |
| Frame equivalence | 18.472s | 13.736s | 25.6% |
[37180517143](https://github.com/stablyai/orca/actions/runs/37180517143)
retained 116 timed serializer passes and three existing/paired-control skips.
Separate captures matched all 190,796,645 raw bytes over 1,611 scenarios and
8,617 checkpoints (SHA256 `00ab219cfb31456af2ecd5e766d1b82d47abc751f6f2de0d7f795e36a936d3c7`),
including complete outputs and diagnostic payloads. Twenty candidate controls
passed; formatting/color/blank/clipping fault controls detected regressions.
[37181073275](https://github.com/stablyai/orca/actions/runs/37181073275)
retained all 16 parity cases and default fuzz counts. Captures matched 2,325
batches, 28,182 original chunks and 1,698,285 input bytes, with identical
terminal state and serialization per terminal/batch. Independent terminal
completion order differs, so comparison uses canonical per-terminal ordering
(SHA256 `c38ac1dbbefb9f6dc33ecfe7c495d65b707c1664614544622af93cfc1850e421`).
All 73 callback/parser/other-consumer controls passed; first-callback, reversed
chunks, missing empty boundary and early-completion faults failed.
The frame candidate passed all 19 retained cases directly against the original
uncached legacy oracle, preserving 4,000 short and 800 near-cap seeded trials.
Sequence, surrogate width, byte width and span-transform faults failed real
assertions. A part-array alternative was rejected after adding time locally.
Hosted Node typecheck passed. These are focused workload savings, not measured
whole-shard or queue-delay improvements; application behavior is unchanged.
## October 3 unit-selection evidence: include failed references
The caller's `needs.test.result == 'success'` condition prevented the advisory
@@ -1962,11 +2169,11 @@ Three alternating one-worker hosted ARM pairs in
[37182181976](https://github.com/stablyai/orca/actions/runs/37182181976)
measured these complete invocations:
| Cohort | Baseline seconds | Candidate seconds | Median saving |
| --- | --- | --- | --- |
| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% |
| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% |
| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% |
| Cohort | Baseline seconds | Candidate seconds | Median saving |
| --------------------------------- | ------------------------ | ------------------------ | ------------- |
| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% |
| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% |
| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% |
The final runtime cohort has seven real cases versus 16 including the copied
loops; its new runtime case is included in candidate timing. Recovery has 50
@@ -2048,9 +2255,9 @@ Three alternating one-worker hosted ARM pairs in
[37180614492](https://github.com/stablyai/orca/actions/runs/37180614492)
measured these complete focused invocations:
| Suite | Baseline seconds | Candidate seconds | Median saving |
| --- | --- | --- | --- |
| Git admission storm | 26.619 / 26.635 / 26.582 | 1.017 / 1.018 / 1.016 | 25.602s (96.2%) |
| Suite | Baseline seconds | Candidate seconds | Median saving |
| --------------------- | ------------------------ | ------------------------ | --------------- |
| Git admission storm | 26.619 / 26.635 / 26.582 | 1.017 / 1.018 / 1.016 | 25.602s (96.2%) |
| Antigravity readiness | 27.347 / 27.910 / 27.550 | 13.855 / 13.894 / 13.800 | 13.695s (49.7%) |
Each candidate passed its original meaningful checks. Hosted Node typecheck
@@ -2059,3 +2266,120 @@ FIFO-only priority failed the queued-contention or interactive-start assertion.
Two additional local transcript faults failed the original picker-rejection and
repaint-readiness assertions. These are focused suite savings; whole-shard time
and queue delay were not measured by this experiment.
## October 4 aggregate unit-test comparison
A [counterbalanced hosted comparison](https://github.com/stablyai/orca/actions/runs/37197643399)
measured 128.605 seconds less summed test-process time (4.36%) and a 37.694-second
reduction in the slowest shard (5.98%). It compares the accepted optimizations
with their original file snapshots on the same source, five fixed shard
assignments, Node 24, Ubuntu ARM and four workers per process. This is one paired
trial, not a population estimate or a measurement of PR queue delay.
| Test process | Original snapshots | Accepted optimizations |
| ----------------------------- | ------------------ | ---------------------- |
| Shard 1 | 574.221s | 533.116s |
| Shard 2 | 572.553s | 569.593s |
| Shard 3 | 630.629s | 592.935s |
| Shard 4 | 565.412s | 546.759s |
| Shard 5 | 609.410s | 581.218s |
| Sum: runner time during tests | 2952.225s | 2823.621s |
| Maximum: test critical path | 630.629s | 592.935s |
Both arms cover exactly 10,838 timed modules on source `9574c8adb253` and tree
`4d5487e8b827`. One added eight-case batching qualification passes in a separate
0.770-second invocation outside the table, completing the 10,839-module ordinary
census. The complete timed case and outcome
comparison accounts for eight approved coverage changes: 105,231 original cases
versus 105,242 candidate cases. Removed copied simulations and an algebra-only
case are accompanied by real runtime, retention, recovery and reusable-cell
regressions. No unexpected case or outcome difference is accepted.
Each arm starts with distinct empty transform and result caches; Node compilation
caching is disabled. Cold-cache execution ordering remains Vitest's default and
can change with source size. Source snapshots, assignments, raw reports and case outcomes
are checked. Only three case-title fields containing random temporary paths or
a UUID use stable identities, bound to the exact two test-source hashes; raw
titles remain in the artifacts.
The five dependency setups total 84.284 seconds and are shared by both arms.
The actual paired jobs consumed 5,969 seconds and spanned 2,031 seconds from the
first start to the last completion. Those job figures include both treatments,
setup, uploads and staggered starts; they cannot be assigned to either arm or
used as a workflow saving. The table measures test-process wall time, not CPU
time or the complete CI workflow. Focused-suite percentages elsewhere in this
report are separate measurements and must not be summed into these results.
The [earlier aggregate trial](https://github.com/stablyai/orca/actions/runs/37193799646)
is rejected because a real test failed; its timings do not qualify a gain. Two
local invocations sharing one XDG directory reproduced the Muse refresher failure.
The fixture now isolates and restores that setting in both arms. The historical
serializer case ledger was also independently corrected from the original source
before this fresh trial; its seven original cases and twenty candidate cases are
an explicit coverage change rather than an assumed equal census.
## October 4 shard-weight holdouts
Fresh shard weights were generated with the production importer from a complete
successful run of the accepted source. Two subsequent hosted holdouts used those
same weights and assignments without retraining. The
[first pair](https://github.com/stablyai/orca/actions/runs/37199891967) alternated
existing and fresh assignments across the five jobs; the
[second pair](https://github.com/stablyai/orca/actions/runs/37201939057) reversed
each job's treatment order.
| Test-process measurement | First: existing | First: fresh | Reversed: existing | Reversed: fresh |
| ------------------------ | --------------- | ------------ | ------------------ | --------------- |
| Sum across five shards | 2813.595s | 2776.421s | 2924.689s | 2878.481s |
| Maximum shard wall | 578.735s | 584.709s | 603.995s | 614.408s |
Fresh weights reduced summed test-process time by 1.32% and 1.58%, but increased
the slowest shard's time by 1.03% and 1.72%. The small capacity saving comes with
a repeated critical-path regression, so the existing weights remain. The 3.01%
improvement projected from training module durations is not a measured speed
gain.
Every arm covers the same 10,839 modules and 105,250 case outcomes on source
`9574c8adb253`, tree `4d5487e8b827`, Node 24.21.0, Ubuntu ARM and four workers.
Both trials use cold caches and the same training data, weights, plans and case
identity rules. Complete raw reports, assignments, hashes and opposite treatment
orders are checked before combining the results. Two pairs supply no statistical
confidence or account-wide queue measurement. The second run's jobs started 119
seconds apart; that stagger and the paired jobs' setup and upload costs are
separate from the treatment timings above.
## October 4 remaining unit-test opportunities
The audit retained real child-process, PTY, SSH and crash-boundary tests. It
removed copied simulations or algebra-only cases after fault controls showed
that they could pass with production behavior broken. The retained or replacement
tests exercise production behavior directly. The focused timings in this report
use the final qualified checks. They must not be added together to estimate a
whole-workflow saving.
Several further changes did not justify promotion:
- A synchronous readiness-clock screen retained all 49 shard cases and their
outcomes, but complete invocation time changed only from 34.210 to 33.518
seconds in one local pair. That 2% result was too small to ship without a
stronger result; the original implementation remains.
- A larger local transform-cache screen reduced warm test execution. Separately
measured medians for warm tests (17.251 seconds), extraction (3.539 seconds) and
archive creation (3.092 seconds) sum to 23.882 seconds, versus 23.473 seconds
with caching disabled. This component estimate excludes transfer costs; it is not an
end-to-end measurement. Unkeyed plugin options, inherited configuration and
import priority also produced stale reuse. Persisted test transforms remain
disabled.
- Two successful full-run shadow references identified about 1.9% of
recorded worker time as omittable. That is advisory worker time, not measured
runner occupancy. It does not supply the failed-reference evidence or a
complete selected-run comparison needed to enable test selection.
- Six sampled failed PR runs contained no failed unit job that could trigger
unit-matrix fail-fast. Successful unit siblings of failures in other jobs
cannot be counted as savings from that policy. The sample is too small to
establish a population-wide rate, and the policy remains unchanged.
These screens reject the examined changes; they do not establish that every
future optimization is exhausted. Shorter admitted jobs and one shared preflight
reduce demand on the existing runner allowance. They do not increase that
allowance or prove lower queue delay under different account traffic.
-163
View File
@@ -1,163 +0,0 @@
# jcode hook events
What jcode actually emits, and why Orca's status mapping is shaped the way it is.
Everything below was captured from jcode **v0.87.1 (944f747e9)** by pointing every
`[hooks]` entry in `config.toml` at a script that appends `$JCODE_HOOK_PAYLOAD` to
a log, then running real turns. Re-capture before changing the mapping; do not
edit it from memory.
## The six events
jcode's `[hooks]` table (`crates/jcode-base/src/hooks.rs`) has six lifecycle
points. Five are **observers** — detached, fire-and-forget, they can never slow
the agent. One, `pre_tool`, is a **gate**: jcode spawns it, writes the tool input
to its stdin, and waits for it to exit before the tool runs.
| Event | When | Orca state | Notable payload fields |
| --------------- | -------------------------------------------- | ---------- | --------------------------------------------------------- |
| `session_start` | TUI open, attach, or `--resume` | none | `source` = `create`/`attach`/`resume`, `model` |
| `turn_start` | prompt submitted, before the model generates | `working` | `source`, `model` |
| `pre_tool` | before each tool call (gate) | `working` | `tool_name`, `tool_input` (argument JSON as a string) |
| `post_tool` | after each tool call | `working` | `tool_name`, `status`, `duration_ms`, `output_bytes`/`error` |
| `turn_end` | turn finished | `done` | `status`, `duration_ms`, `model`, `last_assistant_text`, `error` |
| `session_end` | session closed | `done` | `source` = `close` |
`session_start` is identity-only. jcode fires it on an idle TUI open, so mapping
it to `working` would spin before the user has typed anything (same reason Devin
does not map its `SessionStart`).
## Captured payloads
A `jcode run` turn that read one file and wrote another:
```json
{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-haiku-4-5","session_id":"session_pawprint_1790149117838_071c2a106812396c","source":"create"}
{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"file_path\":\"sample.txt\",\"intent\":\"Read sample.txt to get its contents\"}","tool_name":"read"}
{"cwd":"/private/tmp/jcode-work","duration_ms":"0","event":"post_tool","output_bytes":"12","session_id":"session_pawprint_…","status":"ok","tool_name":"read"}
{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"content\":\"HELLO\",\"file_path\":\"out.txt\",\"intent\":\"Write uppercased contents of sample.txt to out.txt\"}","tool_name":"write"}
{"cwd":"/private/tmp/jcode-work","duration_ms":"9","event":"post_tool","output_bytes":"137","session_id":"session_pawprint_…","status":"ok","tool_name":"write"}
```
A TUI turn that failed upstream (note `turn_start`, which the `run` path does not emit):
```json
{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-opus-5","session_id":"session_snail_…","source":"create"}
{"cwd":"/private/tmp/jcode-work","event":"turn_start","model":"claude-opus-5","session_id":"session_snail_…","source":"chat"}
{"cwd":"/private/tmp/jcode-work","duration_ms":"6868","error":"Anthropic API error (503 Service Unavailable): …","event":"turn_end","model":"claude-opus-5","session_id":"session_snail_…","status":"error"}
```
Three consequences the mapping depends on:
- **`turn_start` only fires on the streaming turn path** (TUI, desktop, swarm
workers, headless sessions), not `jcode run`. It is what fills the otherwise
blank window between a submitted prompt and the first tool call.
- **Only `pre_tool` carries `tool_input`.** `post_tool` reports the name and the
outcome, so the tool preview has to be held from the matching `pre_tool`.
- **Every jcode tool schema has an `intent` string** the model fills in. It is
the preview fallback when no tool-specific key (`file_path`, `command`, …)
matches.
## Why Orca subscribes to the gate
`pre_tool` is the only event that can report a tool *while it runs*. Without it a
three-minute `bash` shows no tool at all until it finishes. Two rules keep the
gate from ever costing the agent anything:
1. **The POST is detached.** jcode calls `child.wait_with_output()`, which waits
for the process *and* reads its stderr to EOF — a backgrounded child that
inherited stderr would hold the gate open for as long as it ran. The managed
script runs the POST as `orca_post_jcode_event >/dev/null 2>&1 &`, so the
inherited pipes are closed and the script exits immediately.
2. **stdin is drained first.** jcode `write_all`s the full tool input to the
hook's stdin. A tool input larger than the pipe buffer (a big `write`) would
block that write until the gate timed out if nobody read it, so the script
drains stdin before any exit path.
Orca never blocks a jcode tool call: the script always exits 0.
## Questions and permissions
jcode has **no interactive per-tool approval prompt**. Its safety model
(`crates/jcode-app-core/src/tool/bash_destructive_gate.rs`) either denies a
command outright or asks the model to justify it — both inside the tool, with no
human in the loop. There is therefore no hook, and no terminal-title state, for
"jcode is waiting on you" during ordinary tool use.
The one tool a *human* answers is ambient mode's `request_permission`
(`crates/jcode-app-core/src/tool/ambient.rs`), resolved out of band with
`jcode permissions`. Orca maps a `pre_tool` for it to `waiting` and publishes the
tool input as the question card. `post_tool` for the same tool is *not* mapped —
by then the human has already answered.
Matching is by exact tool name. jcode's live tool set is `agentgrep, apply_patch,
bash, batch, bg, browser, compile_remote, conversation_search, edit, gmail,
integration_tools, ls, macos_computer_use, maintainer_feedback, mcp, memory,
multiedit, open, panel, patch, read, schedule, session_search, side_panel,
skill_manage, swarm, todo, webfetch, websearch, write` plus the ambient tools;
a substring rule over that set would be matching on coincidence.
## Terminal titles
jcode paints OSC 0 titles roughly once a second. Captured sequence from one TUI
session:
```
jcode → 🐍 jcode Snake → 🐍 jcode/creek Snake → 🌐 jcode Snake · work ~0s → … → 🌐 jcode Snake · last ~6s
```
The format is `<emoji> jcode <session-name>[ · +N -M][ · work|last ~<duration>]`
(`crates/jcode-tui/src/tui/app/terminal_title.rs`). Orca uses it for tab-bar
identity only — status comes from hooks, never from a parsed title. Note there is
no "needs input" title state; that is the same gap as above, not an omission in
the parser.
## Per-pane daemons
jcode runs one server/client daemon per runtime dir, and lifecycle hooks fire
*inside the daemon*. Every TUI client connects the daemon the first pane started,
so without isolation a second jcode pane's events carry the first pane's
`ORCA_PANE_KEY` and its status lands on the wrong tab.
jcode does forward a client's terminal identity to hooks
(`CLIENT_TERMINAL_ENV_VARS` in `crates/jcode-terminal-launch/src/lib.rs`), but
that allowlist covers tmux/zellij/herdr and the terminal emulators — not
`ORCA_PANE_KEY`. Until it does, Orca stamps a per-pane `JCODE_RUNTIME_DIR` so
each pane gets its own daemon, socket, and lock. The value is a 16-hex hash of
the pane key because the socket path is capped at `SUN_LEN` (104 bytes) and a
full pane key never fits.
## Windows hook launcher
Use Jcode **v0.89.0 or newer** on Windows. Earlier observer hooks launch with
`DETACHED_PROCESS`, leaving their children without a console to inherit. A
console program such as the managed hook's `curl.exe` can then open a Windows
Terminal tab on every event. Jcode's launcher fix uses `CREATE_NO_WINDOW` for
observer hooks and the `pre_tool` gate, keeping their descendants invisible.
Changing the managed script alone cannot repair an older Jcode launcher.
The managed Windows hook redirects its payload file into curl directly, avoiding
the extra shells that a `type ... | curl` pipeline starts. Existing managed scripts
are refreshed on Orca startup without changing the user's hook configuration.
Report: https://github.com/stablyai/orca/pull/22539#issuecomment-5809618574
Launcher fix: https://github.com/1jehuang/jcode/pull/1490
## Config shape
`[hooks]` values accept a string or an array of strings (`HookCommands` in
`crates/jcode-config-types/src/lib.rs`), and jcode re-reads the config on reload,
so hooks can be added without restarting. jcode parses a hook command line
shell-style but **executes it directly, not through a shell** — the managed value
must be the script path, never an `if [ -f … ]` wrapper.
That shell-style parse is `parse_hook_command`
(`crates/jcode-terminal-launch/src/lib.rs`), and it is why Orca stores the path
**shell-quoted**. The tokenizer splits on unquoted whitespace and consumes every
unquoted backslash as an escape, so a bare Windows path reaches `exec` as
`C:Usersme.orcaagent-hooksjcode-hook.cmd` and no hook fires at all; a POSIX home
with a space splits into two arguments. Single quotes pass a path through
verbatim — backslashes are literal inside them — so Orca single-quotes by
default and falls back to double quotes (escaping `\` and `"`) only for a path
that itself contains a single quote. The value is then TOML-quoted on the way
into the file, so neither the raw path nor the shell-quoted string appears
alone.
+2 -1
View File
@@ -13,7 +13,8 @@ import {
import packageJson from './package.json' with { type: 'json' }
const BUNDLED_MAIN_DEPENDENCIES = new Set([
'@streamparser/json',
'stream-json',
'stream-chain',
'@xterm/headless',
'@xterm/addon-serialize',
'tldts',
+8
View File
@@ -15,6 +15,7 @@ export type PickerOption<T extends string = string> = {
type Props<T extends string = string> = {
visible: boolean
title: string
subtitle?: string
options: PickerOption<T>[]
selected: T
onSelect: (value: T) => void
@@ -32,6 +33,7 @@ type PickerModalContentProps<T extends string = string> = Pick<
export function PickerModal<T extends string = string>({
visible,
title,
subtitle,
options,
selected,
onSelect,
@@ -44,6 +46,7 @@ export function PickerModal<T extends string = string>({
<BottomDrawer visible={visible} onClose={onClose} onAfterClose={onAfterClose} zIndex={zIndex}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
</View>
<PickerModalContent
@@ -130,6 +133,11 @@ const styles = StyleSheet.create({
fontWeight: '500',
color: colors.textMuted
},
subtitle: {
fontSize: 11,
color: colors.textMuted,
marginTop: 2
},
group: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
@@ -12,7 +12,8 @@ import { hostNewWorktreeSessionRoute } from '../host-route-action-state'
import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity'
import {
WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS,
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS,
WORKSPACE_VIEW_SHARED_NOTE
} from '../worktree/workspace-list-picker-options'
import { isWorktreePinned } from '../worktree/workspace-list-sections'
import { hostScreenStyles as styles } from './host-screen-styles'
@@ -37,6 +38,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
<PickerModal
visible={state.showSortPicker}
title="Sort By"
subtitle={WORKSPACE_VIEW_SHARED_NOTE}
options={SORT_OPTIONS}
selected={state.sortMode}
onSelect={settings.handleSortChange}
@@ -46,6 +48,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
<PickerModal
visible={state.showGroupPicker}
title="Group By"
subtitle={WORKSPACE_VIEW_SHARED_NOTE}
options={GROUP_OPTIONS}
selected={state.groupMode}
onSelect={settings.handleGroupChange}
@@ -54,7 +57,10 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
<BottomDrawer visible={state.showFilterModal} onClose={() => state.setShowFilterModal(false)}>
<View style={styles.filterModalHeader}>
<Text style={styles.filterModalTitle}>Filter</Text>
<View style={styles.filterModalHeading}>
<Text style={styles.filterModalTitle}>Filter</Text>
<Text style={styles.filterModalSubtitle}>{WORKSPACE_VIEW_SHARED_NOTE}</Text>
</View>
{settings.activeFilterCount > 0 && (
<Pressable onPress={settings.clearFilters}>
<Text style={styles.clearFiltersText}>Clear filters</Text>
@@ -47,11 +47,19 @@ export const hostScreenSecondaryStyles = StyleSheet.create({
paddingHorizontal: spacing.xs,
marginBottom: spacing.md
},
filterModalHeading: {
flexShrink: 1
},
filterModalTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
filterModalSubtitle: {
fontSize: 11,
color: colors.textMuted,
marginTop: 2
},
clearFiltersText: {
fontSize: 13,
color: colors.textSecondary
@@ -158,7 +158,11 @@ export function useMobileStructuredAgentSession(args: {
})
const messages = useMemo(
() => projectStructuredAgentSessionMessages(state.items, [], state.submissions),
// Off: the phone hands a rejected message back to its composer, so a row would show it twice.
() =>
projectStructuredAgentSessionMessages(state.items, [], state.submissions, {
rejectedInPlace: false
}),
[state.items, state.submissions]
)
const turnId = activeStructuredAgentSessionTurnId(state.items)
@@ -1,6 +1,9 @@
import type { PickerOption } from '../components/PickerModal'
import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings'
// Why: the host may be headless, so the note can't promise a desktop sidebar.
export const WORKSPACE_VIEW_SHARED_NOTE = 'Synced across your devices'
export const WORKSPACE_SORT_OPTIONS: PickerOption<MobileSortMode>[] = [
// Why: desktop and persisted state keep the `smart` key, while mobile shows the product label.
{
@@ -11,7 +14,7 @@ export const WORKSPACE_SORT_OPTIONS: PickerOption<MobileSortMode>[] = [
{ value: 'name', label: 'Name', subtitle: 'Alphabetical by name' },
{ value: 'recent', label: 'Recent', subtitle: 'Most recent output first' },
{ value: 'repo', label: 'Repo', subtitle: 'Repository, then workspace name' },
{ value: 'manual', label: 'Manual', subtitle: 'Server order' }
{ value: 'manual', label: 'Manual', subtitle: 'Desktop drag order' }
]
export const WORKSPACE_GROUP_OPTIONS: PickerOption<MobileGroupMode>[] = [
@@ -7,7 +7,7 @@ import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/typ
import { coerceMobileWorkspaceStatuses } from './mobile-workspace-statuses'
export type MobileGroupMode = 'none' | 'workspaceStatus' | 'repo' | 'prStatus'
// Desktop sort adds 'manual'; mobile renders it but sorts by server order.
// Desktop sort adds 'manual'; mobile orders it by the desktop's drag ranks.
export type MobileSortMode = 'smart' | 'name' | 'recent' | 'repo' | 'manual'
// Desktop PersistedUIState fields this screen syncs (a structural subset).
+2 -1
View File
@@ -182,7 +182,6 @@
"@floating-ui/dom": "1.8.0",
"@linear/sdk": "^97.0.0",
"@parcel/watcher": "^2.5.6",
"@streamparser/json": "0.0.26",
"@xterm/addon-serialize": "0.15.0-beta.300",
"@xterm/headless": "6.1.0-beta.302",
"agent-browser": "~0.27.0",
@@ -198,6 +197,8 @@
"sherpa-onnx": "1.12.37",
"smol-toml": "1.8.0",
"ssh2": "^1.17.0",
"stream-chain": "4.2.6",
"stream-json": "3.7.0",
"tldts": "7.4.16",
"tweetnacl": "^1.0.3",
"ws": "^8.22.0",
+18 -8
View File
@@ -196,9 +196,6 @@ importers:
'@parcel/watcher':
specifier: ^2.5.6
version: 2.5.6
'@streamparser/json':
specifier: 0.0.26
version: 0.0.26
'@xterm/addon-serialize':
specifier: 0.15.0-beta.300
version: 0.15.0-beta.300(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393))
@@ -244,6 +241,12 @@ importers:
ssh2:
specifier: ^1.17.0
version: 1.17.0
stream-chain:
specifier: 4.2.6
version: 4.2.6
stream-json:
specifier: 3.7.0
version: 3.7.0
tldts:
specifier: 7.4.16
version: 7.4.16
@@ -3185,9 +3188,6 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@streamparser/json@0.0.26':
resolution: {integrity: sha512-46597LNFI+MFdUnzX2QJWwmdTRdq0XVD+vVNJTtGVzIrnCuhG9pFo1OAzbNBqci8UJgk/X5KJZ6LcV+y7PTuDQ==}
'@swc/core-darwin-arm64@1.15.46':
resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==}
engines: {node: '>=10'}
@@ -7354,6 +7354,12 @@ packages:
resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==}
engines: {node: '>=18'}
stream-chain@4.2.6:
resolution: {integrity: sha512-1zeJ8CrtJfmiba26ui8jXkq/xLRFvhzkdH02D5QLO9Cnovgeb28IJxg88DdraWnjnkbOol/++uIAybJbJhk7ig==}
stream-json@3.7.0:
resolution: {integrity: sha512-rCSBdcBP/bPk6T8QFcxAj1MSzAuc5i49cYW6IE7sYObNEPccBJIUiL6fU9c9BWt40aJK0siVynLX7lWaW8alXw==}
strict-event-emitter@0.5.1:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
@@ -10208,8 +10214,6 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@streamparser/json@0.0.26': {}
'@swc/core-darwin-arm64@1.15.46':
optional: true
@@ -14965,6 +14969,12 @@ snapshots:
stdin-discarder@0.3.2: {}
stream-chain@4.2.6: {}
stream-json@3.7.0:
dependencies:
stream-chain: 4.2.6
strict-event-emitter@0.5.1:
optional: true
@@ -30,6 +30,12 @@ __orca_restore_agent_teams_path() {
export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
__orca_restore_agent_teams_path
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
case "${PATH:-}" in
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
esac
fi
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the Orca-managed config dir before the first prompt.
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
@@ -45,6 +45,40 @@ __orca_osc133_preexec() {
# which prints a warning above every command under warn_create_global.
builtin typeset -g __orca_in_command=1
}
__orca_deferred_line_init() {
builtin emulate -L zsh
(( ${+functions[__orca_deferred_init]} )) || return 0
local __orca_direct_line_init=0
[[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
__orca_deferred_init
if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
zle zle-line-init "$@"
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
local __orca_prev_line_init_fn=""
__orca_prompt_mark "$@"
fi
}
# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
__orca_deferred_sched_init() {
local __orca_prompt_status=$?
builtin emulate -L zsh
(( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
builtin unset __orca_deferred_sched_armed
builtin unfunction __orca_deferred_sched_init
return $__orca_prompt_status
}
__orca_arm_deferred_line_init() {
builtin emulate -L zsh
if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
if (( ${+widgets[zle-line-init]} )); then
zle -A zle-line-init __orca_saved_line_init
fi
zle -N zle-line-init __orca_deferred_line_init
fi
if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
fi
}
__orca_deferred_init() {
# Why first: this body runs after the user's own config, so it would otherwise
# inherit whatever options that config left set. Under NO_UNSET an unset
@@ -54,12 +88,27 @@ __orca_deferred_init() {
(( $+_orca_deferred_init_done )) && return 0
builtin typeset -g _orca_deferred_init_done=1
builtin typeset -g precmd_functions
if (( ${+widgets[__orca_saved_line_init]} )); then
if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
zle -A __orca_saved_line_init zle-line-init
fi
zle -D __orca_saved_line_init
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
zle -D zle-line-init
fi
if __orca_has_feature markers; then
precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd})
(( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd)
preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]})
else
precmd_functions=(${precmd_functions:#__orca_deferred_init})
fi
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
case "${PATH:-}" in
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
esac
fi
if __orca_has_feature overlay; then
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
__orca_restore_agent_teams_path() {
@@ -202,13 +251,25 @@ __orca_deferred_init() {
# the permanent hook has not run yet and the first prompt would lose its mark.
__orca_has_feature markers && __orca_osc133_precmd
builtin unset _orca_shell_features _orca_histfile
builtin unfunction __orca_deferred_init __orca_has_feature
(( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
local __orca_widget __orca_line_init_bound=0
for __orca_widget in "${(v)widgets[@]}"; do
if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
__orca_line_init_bound=1
break
fi
done
(( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
}
{
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
} always {
builtin unset _orca_user_zshenv
builtin typeset -ag precmd_functions
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
if (( ! $+_orca_deferred_init_done )); then
builtin typeset -ag precmd_functions
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
__orca_arm_deferred_line_init
fi
}
@@ -33,6 +33,12 @@ __orca_restore_agent_teams_path() {
export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
__orca_restore_agent_teams_path
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
case "${PATH:-}" in
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
esac
fi
if [ -n "${ORCA_WSL_CLI_DIR:-}" ]; then
if [ -x "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" ]; then
export PATH="$ORCA_WSL_CLI_DIR${PATH:+:$PATH}"
@@ -45,6 +45,40 @@ __orca_osc133_preexec() {
# which prints a warning above every command under warn_create_global.
builtin typeset -g __orca_in_command=1
}
__orca_deferred_line_init() {
builtin emulate -L zsh
(( ${+functions[__orca_deferred_init]} )) || return 0
local __orca_direct_line_init=0
[[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
__orca_deferred_init
if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
zle zle-line-init "$@"
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
local __orca_prev_line_init_fn=""
__orca_prompt_mark "$@"
fi
}
# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
__orca_deferred_sched_init() {
local __orca_prompt_status=$?
builtin emulate -L zsh
(( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
builtin unset __orca_deferred_sched_armed
builtin unfunction __orca_deferred_sched_init
return $__orca_prompt_status
}
__orca_arm_deferred_line_init() {
builtin emulate -L zsh
if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
if (( ${+widgets[zle-line-init]} )); then
zle -A zle-line-init __orca_saved_line_init
fi
zle -N zle-line-init __orca_deferred_line_init
fi
if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
fi
}
__orca_deferred_init() {
# Why first: this body runs after the user's own config, so it would otherwise
# inherit whatever options that config left set. Under NO_UNSET an unset
@@ -54,8 +88,17 @@ __orca_deferred_init() {
(( $+_orca_deferred_init_done )) && return 0
builtin typeset -g _orca_deferred_init_done=1
builtin typeset -g precmd_functions
if (( ${+widgets[__orca_saved_line_init]} )); then
if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
zle -A __orca_saved_line_init zle-line-init
fi
zle -D __orca_saved_line_init
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
zle -D zle-line-init
fi
if __orca_has_feature markers; then
precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd})
(( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd)
preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]})
else
precmd_functions=(${precmd_functions:#__orca_deferred_init})
@@ -67,6 +110,12 @@ __orca_deferred_init() {
printf 'Orca CLI unavailable: cannot run %s. Check WSL Windows-drive mount options.\n' "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" >&2
fi
fi
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
case "${PATH:-}" in
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
esac
fi
if __orca_has_feature overlay; then
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
__orca_restore_agent_teams_path() {
@@ -219,13 +268,25 @@ __orca_deferred_init() {
# the permanent hook has not run yet and the first prompt would lose its mark.
__orca_has_feature markers && __orca_osc133_precmd
builtin unset _orca_shell_features _orca_histfile
builtin unfunction __orca_deferred_init __orca_has_feature
(( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
local __orca_widget __orca_line_init_bound=0
for __orca_widget in "${(v)widgets[@]}"; do
if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
__orca_line_init_bound=1
break
fi
done
(( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
}
{
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
} always {
builtin unset _orca_user_zshenv
builtin typeset -ag precmd_functions
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
if (( ! $+_orca_deferred_init_done )); then
builtin typeset -ag precmd_functions
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
__orca_arm_deferred_line_init
fi
}
@@ -26,6 +26,12 @@ fi
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"
[[ -n "${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
case "${PATH:-}" in
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
esac
fi
# Why: OMP does not auto-load Orca's managed status extension; wrap only
# interactive launch invocations so subcommands such as `omp config` keep
# their normal argv shape.
@@ -31,6 +31,40 @@ builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}"
builtin unset ORCA_HISTFILE
__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) }
__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$"
__orca_deferred_line_init() {
builtin emulate -L zsh
(( ${+functions[__orca_deferred_init]} )) || return 0
local __orca_direct_line_init=0
[[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1
__orca_deferred_init
if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then
zle zle-line-init "$@"
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then
local __orca_prev_line_init_fn=""
__orca_prompt_mark "$@"
fi
}
# Why: scheduled callbacks run after user prompt hooks without copying their function metadata.
__orca_deferred_sched_init() {
local __orca_prompt_status=$?
builtin emulate -L zsh
(( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init
builtin unset __orca_deferred_sched_armed
builtin unfunction __orca_deferred_sched_init
return $__orca_prompt_status
}
__orca_arm_deferred_line_init() {
builtin emulate -L zsh
if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then
if (( ${+widgets[zle-line-init]} )); then
zle -A zle-line-init __orca_saved_line_init
fi
zle -N zle-line-init __orca_deferred_line_init
fi
if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then
builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1
fi
}
__orca_deferred_init() {
# Why first: this body runs after the user's own config, so it would otherwise
# inherit whatever options that config left set. Under NO_UNSET an unset
@@ -40,7 +74,21 @@ __orca_deferred_init() {
(( $+_orca_deferred_init_done )) && return 0
builtin typeset -g _orca_deferred_init_done=1
builtin typeset -g precmd_functions
if (( ${+widgets[__orca_saved_line_init]} )); then
if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
zle -A __orca_saved_line_init zle-line-init
fi
zle -D __orca_saved_line_init
elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then
zle -D zle-line-init
fi
precmd_functions=(${precmd_functions:#__orca_deferred_init})
if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then
case "${PATH:-}" in
"$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;;
*) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;;
esac
fi
if __orca_has_feature overlay; then
# Why: remote startup files can re-export user defaults after relay spawn.
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
@@ -170,13 +218,25 @@ __orca_deferred_init() {
zle -N zle-line-init __orca_prompt_mark
fi
builtin unset _orca_shell_features _orca_histfile
builtin unfunction __orca_deferred_init __orca_has_feature
(( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init
local __orca_widget __orca_line_init_bound=0
for __orca_widget in "${(v)widgets[@]}"; do
if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then
__orca_line_init_bound=1
break
fi
done
(( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init
builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init
}
{
builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv"
[[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv"
} always {
builtin unset _orca_user_zshenv
builtin typeset -ag precmd_functions
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
if (( ! $+_orca_deferred_init_done )); then
builtin typeset -ag precmd_functions
(( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init)
__orca_arm_deferred_line_init
fi
}
@@ -147,8 +147,10 @@ describe('managed hook script refresh', () => {
homedirMock.mockReturnValue(home)
const previousGrokHome = process.env.GROK_HOME
const previousKimiHome = process.env.KIMI_CODE_HOME
const previousXdgConfigHome = process.env.XDG_CONFIG_HOME
delete process.env.GROK_HOME
delete process.env.KIMI_CODE_HOME
delete process.env.XDG_CONFIG_HOME
try {
await withPlatform('win32', () => {
for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) {
@@ -187,6 +189,11 @@ describe('managed hook script refresh', () => {
} else {
process.env.KIMI_CODE_HOME = previousKimiHome
}
if (previousXdgConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME
} else {
process.env.XDG_CONFIG_HOME = previousXdgConfigHome
}
rmSync(home, { recursive: true, force: true })
}
})
@@ -0,0 +1,146 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { AgentHookServer } from './server'
import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file'
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt'
import { PANE } from './server.test-fixtures'
describe('startup prompt control with status hooks disabled', () => {
it.each([false, true])(
'persists a pending terminal status at shutdown after starting with hooks %s',
async (statusHooksEnabled) => {
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-terminal-persist-'))
const server = new AgentHookServer()
try {
await server.start({ userDataPath: dir, statusHooksEnabled })
server.setStatusHooksEnabled(false)
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'folder-1',
payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' }
})
expect(server.getStatusSnapshotForPane(PANE)[0]?.state).toBe('done')
const statusPath = server.lastStatusPath
if (!statusPath) {
throw new Error('missing status persistence path')
}
server.stop()
expect(existsSync(statusPath)).toBe(true)
expect(JSON.parse(readFileSync(statusPath, 'utf8')).entries[PANE]).toMatchObject({
paneKey: PANE,
worktreeId: 'folder-1',
payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' }
})
} finally {
server.stop()
rmSync(dir, { recursive: true, force: true })
}
}
)
it('enables and disables status without restarting the control listener', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-toggle-'))
class IsolatedHookServer extends AgentHookServer {
constructor() {
super()
this._setOpenCodeBinderDepsForTests({
dbPath: () => join(dir, 'no-user-db'),
listSessions: async () => [],
listPanes: () => [],
sweep: async () => []
})
}
}
const server = new IsolatedHookServer()
try {
await server.start({ userDataPath: dir, statusHooksEnabled: false })
const endpoint = server.endpointFilePath
if (!endpoint) {
throw new Error('missing control endpoint')
}
const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))
const post = (path: string) =>
fetch(`http://127.0.0.1:${coords.port}${path}`, {
method: 'POST',
headers: { 'x-orca-agent-hook-token': coords.token },
body: '{}'
})
expect((await post('/hook/opencode')).status).toBe(404)
await server.start({ statusHooksEnabled: true })
expect(server.buildPtyEnv()).toHaveProperty('ORCA_AGENT_HOOK_PORT', coords.port)
expect((await post('/hook/opencode')).status).toBe(204)
server.setStatusHooksEnabled(false)
expect(server.buildPtyEnv()).toEqual({})
expect((await post('/hook/opencode')).status).toBe(404)
await server.start()
expect((await post('/hook/opencode')).status).toBe(404)
server.setStartupPromptClaimListener(
() => 'pending',
() => {}
)
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH)).json()).toEqual({
allowed: false,
pending: true
})
server.setStatusHooksEnabled(true)
expect((await post('/hook/opencode')).status).toBe(204)
expect(server.endpointFilePath).toBe(endpoint)
expect(parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))).toEqual(coords)
} finally {
server.stop()
rmSync(dir, { recursive: true, force: true })
}
})
it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-'))
const server = new AgentHookServer()
try {
await server.start({ userDataPath: dir, statusHooksEnabled: false })
expect(server.buildPtyEnv()).toEqual({})
const statusPath = server.lastStatusPath
if (!statusPath) {
throw new Error('missing status persistence path')
}
writeFileSync(statusPath, 'existing status must survive control-only shutdown')
const endpoint = server.endpointFilePath
if (!endpoint) {
throw new Error('missing control endpoint')
}
const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))
const post = (path: string, body: string, token = coords.token) =>
fetch(`http://127.0.0.1:${coords.port}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-orca-agent-hook-token': token },
body
})
expect((await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}', 'wrong')).status).toBe(403)
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({
allowed: false
})
const clear = vi.fn()
const claim = vi.fn(() => true)
server.setStartupPromptClaimListener(claim, clear)
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({
allowed: true
})
expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{')).json()).toEqual({
allowed: false
})
expect(claim).toHaveBeenCalledTimes(1)
expect((await post('/hook/opencode', '{}')).status).toBe(404)
expect((await post('/statusline/claude', '{}')).status).toBe(404)
server.stop()
expect(clear).toHaveBeenCalledTimes(1)
expect(readFileSync(statusPath, 'utf8')).toBe(
'existing status must survive control-only shutdown'
)
} finally {
server.stop()
rmSync(dir, { recursive: true, force: true })
}
})
})
@@ -2,9 +2,11 @@
// short of its own Content-Length. The listener fails open on every request error, so the only
// way this stays diagnosable is if the truncation is classified before it is swallowed.
import { connect } from 'node:net'
import { ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HookTransportInterferenceReport } from '../../shared/agent-hook-transport-interference'
import { AgentHookServer } from './server'
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt'
async function postTruncatedHook(
port: number,
@@ -40,11 +42,15 @@ async function postTruncatedHook(
}
/** Opens a POST that announces a body and then never sends it, so Orca's own slowloris cap ends it. */
async function postStalledHook(port: number, token: string): Promise<void> {
async function postStalledHook(
port: number,
token: string,
pathname = '/hook/claude'
): Promise<void> {
const socket = connect({ port, host: '127.0.0.1' })
await new Promise<void>((resolve) => socket.on('connect', () => resolve()))
socket.write(
`POST /hook/claude HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n`
`POST ${pathname} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n`
)
await new Promise<void>((resolve) => {
socket.on('close', () => resolve())
@@ -118,6 +124,41 @@ describe('AgentHookServer transport interference', () => {
expect(reports).toHaveLength(1)
}, 20_000)
it('classifies an interrupted startup claim as retryable without consuming it', async () => {
const { server, port, token, reports } = await startServer()
const claim = vi.fn(() => true)
server.setStartupPromptClaimListener(claim, () => {})
const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead')
try {
await postTruncatedHook(port, token, {
pathname: OPENCODE_STARTUP_PROMPT_CLAIM_PATH,
sentBytes: '{"nonce":',
announcedLength: 1000
})
// The reset peer cannot receive this response; observe the real handler's classification.
expect(writeHead.mock.calls).toEqual([[503]])
expect(claim).not.toHaveBeenCalled()
expect(reports).toEqual([])
} finally {
writeHead.mockRestore()
}
})
it('keeps a startup claim stopped by its own slowloris cap as a denial', async () => {
const { server, port, token, reports } = await startServer()
const claim = vi.fn(() => true)
server.setStartupPromptClaimListener(claim, () => {})
const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead')
try {
await postStalledHook(port, token, OPENCODE_STARTUP_PROMPT_CLAIM_PATH)
expect(writeHead.mock.calls).toEqual([[200, { 'content-type': 'application/json' }]])
expect(claim).not.toHaveBeenCalled()
expect(reports).toEqual([])
} finally {
writeHead.mockRestore()
}
}, 30_000)
it('never reports for POSTs that deliver their whole body', async () => {
const { port, token, reports } = await startServer()
+46 -30
View File
@@ -11,21 +11,26 @@ import { readRequestBody } from '../../../shared/agent-hook-listener/request-bod
import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing'
import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits'
import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference'
import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool'
import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state'
import { trackEmptyPaneKeyHook } from './server-transport-rules'
import { AgentHookServerRuntimeEnv } from './server-runtime-env'
import { AgentHookServerStatusHookLifecycle } from './server-status-hook-lifecycle'
import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt'
export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv {
export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle {
/** Start the loopback listener after hydration and spool replay have settled. */
async start(options?: {
env?: string
userDataPath?: string
endpointNamespace?: string
statusHooksEnabled?: boolean
}): Promise<void> {
if (this.server) {
if (options?.statusHooksEnabled !== undefined) {
this.setStatusHooksEnabled(options.statusHooksEnabled)
}
return
}
this.statusHooksEnabled = options?.statusHooksEnabled !== false
if (options?.env) {
this.env = options.env
@@ -37,30 +42,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
this.token = randomUUID()
this.endpointFileWritten = false
this.lastWrittenJson = null
if (!this.ownerStateInitialized) {
// Why: hydrate before binding the listener so an early hook POST runs against a populated map.
if (this.lastStatusFilePath) {
this.hydrateLastStatusFromDisk()
}
this.captureHydratedAuthorityCommitments()
// Drain before binding the listener so replay cannot race a live hook during startup.
if (this.endpointDir) {
const replayedPaneKeys = new Set<string>()
drainAgentHookSpool({
endpointDir: this.endpointDir,
getPersistedLaunchTokenHash: (paneKey) =>
this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)),
ingest: (record: SpoolRecord) => {
this.ingestSpoolRecord(record)
replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey))
}
})
// Why: the owner may have died while Orca was down; check each replayed pane once.
for (const paneKey of replayedPaneKeys) {
void this.checkAgentPresence(paneKey)
}
}
this.ownerStateInitialized = true
if (this.statusHooksEnabled) {
this.initializeStatusHookOwner()
}
const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
if (req.method !== 'POST') {
@@ -84,6 +67,22 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
try {
const body = await readRequestBody(req)
if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) {
res.writeHead(200, { 'content-type': 'application/json' })
const claim = this.onStartupPromptClaim?.(body)
res.end(
JSON.stringify({
allowed: claim === true,
...(claim === 'pending' ? { pending: true } : {})
})
)
return
}
if (!this.statusHooksEnabled) {
res.writeHead(404)
res.end()
return
}
if (pathname === CLAUDE_STATUSLINE_PATHNAME) {
const statusLineEvent = parseClaudeStatusLineBody(body)
if (statusLineEvent) {
@@ -152,6 +151,16 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
res.writeHead(204)
res.end()
} catch (error) {
if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) {
if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) {
res.writeHead(503)
res.end()
return
}
res.writeHead(200, { 'content-type': 'application/json' })
res.end('{"allowed":false}')
return
}
// Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut
// by something on the loopback path, not by a bad payload. Fail open as before, but count it —
// this is the one failure mode that silently stops status for every runtime at once.
@@ -192,7 +201,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
this.rollbackTransportStart()
throw error
}
this.startOpenCodeBinderLoop()
if (this.statusHooksEnabled) {
this.startOpenCodeBinderLoop()
}
}
private rollbackTransportStart(): void {
@@ -204,14 +215,19 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
}
stop(): void {
// Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch.
this.flushStatusPersistSync()
// Terminal status may still have a pending write while hook ingress is disabled.
if (this.statusHooksEnabled || this.statusPersistTimer) {
this.flushStatusPersistSync()
}
this.stopOpenCodeBinderLoop()
this.stopTmuxStatus()
this.rollbackTransportStart()
this.env = 'production'
this.onAgentStatus = null
this.onClaudeStatusLine = null
this.clearStartupPromptClaims?.()
this.clearStartupPromptClaims = null
this.onStartupPromptClaim = null
this.onPaneStatusCleared = null
this.onTransportInterference = null
this.transportInterference.reset()
@@ -26,6 +26,14 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row'
const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER
export abstract class AgentHookServerListeners extends AgentHookServerState {
setStartupPromptClaimListener(
listener: (body: unknown) => boolean | 'pending',
clear: () => void
): void {
this.onStartupPromptClaim = listener
this.clearStartupPromptClaims = clear
}
protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void {
this.onAgentStatus?.(enriched)
for (const listener of this.enrichedStatusListeners) {
@@ -11,7 +11,7 @@ import { AgentHookServerIngestRemote } from './server-ingest-remote'
export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote {
buildPtyEnv(): Record<string, string> {
if (this.port <= 0 || !this.token) {
if (!this.statusHooksEnabled || this.port <= 0 || !this.token) {
return {}
}
const env: Record<string, string> = {
@@ -89,6 +89,9 @@ export abstract class AgentHookServerState {
protected env = 'production'
protected onAgentStatus: ServerAgentStatusListener = null
protected onClaudeStatusLine: ServerStatusLineListener = null
protected onStartupPromptClaim: ((body: unknown) => boolean | 'pending') | null = null
protected clearStartupPromptClaims: (() => void) | null = null
protected statusHooksEnabled = true
protected onPaneStatusCleared: PaneStatusClearListener | null = null
protected paneStatusClearListeners = new Set<PaneStatusClearListener>()
protected statusDropListeners = new Set<StatusDropListener>()
@@ -0,0 +1,52 @@
import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool'
import { AgentHookServerRuntimeEnv } from './server-runtime-env'
export abstract class AgentHookServerStatusHookLifecycle extends AgentHookServerRuntimeEnv {
setStatusHooksEnabled(enabled: boolean): void {
if (enabled === this.statusHooksEnabled) {
return
}
if (!enabled) {
this.flushStatusPersistSync()
this.stopOpenCodeBinderLoop()
for (const timer of this.assistantMessageRetryTimers.values()) {
clearTimeout(timer)
}
this.assistantMessageRetryTimers.clear()
this.clearAllTranscriptPolls()
}
this.statusHooksEnabled = enabled
if (enabled && this.server) {
this.initializeStatusHookOwner()
this.startOpenCodeBinderLoop()
}
}
protected initializeStatusHookOwner(): void {
if (!this.ownerStateInitialized) {
// Why: hydrate before binding the listener so an early hook POST runs against a populated map.
if (this.lastStatusFilePath) {
this.hydrateLastStatusFromDisk()
}
this.captureHydratedAuthorityCommitments()
// Drain before binding the listener so replay cannot race a live hook during startup.
if (this.endpointDir) {
const replayedPaneKeys = new Set<string>()
drainAgentHookSpool({
endpointDir: this.endpointDir,
getPersistedLaunchTokenHash: (paneKey) =>
this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)),
ingest: (record: SpoolRecord) => {
this.ingestSpoolRecord(record)
replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey))
}
})
// Why: the owner may have died while Orca was down; check each replayed pane once.
for (const paneKey of replayedPaneKeys) {
void this.checkAgentPresence(paneKey)
}
}
this.ownerStateInitialized = true
}
}
}
+39
View File
@@ -6,6 +6,7 @@ import {
readFileSync,
realpathSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
@@ -122,6 +123,44 @@ describe('markCopilotFolderTrusted', () => {
}
})
it('keeps a token-bearing config.json owner-only', () => {
if (process.platform === 'win32') {
return
}
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json')
// Why: a restrictive runner umask would mask a dropped mode.
const originalUmask = process.umask(0o022)
try {
mkdirSync(join(testState.fakeHomeDir, '.copilot'), { recursive: true })
writeFileSync(configPath, JSON.stringify({ copilotTokens: { a: 'secret' } }), {
mode: 0o600
})
markCopilotFolderTrusted(workspace, testState.fakeHomeDir)
expect(statSync(configPath).mode & 0o777).toBe(0o600)
expect(JSON.parse(readFileSync(configPath, 'utf-8')).copilotTokens).toEqual({ a: 'secret' })
} finally {
process.umask(originalUmask)
rmSync(workspace, { recursive: true, force: true })
}
})
it('creates a missing config.json owner-only', () => {
if (process.platform === 'win32') {
return
}
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
const originalUmask = process.umask(0o022)
try {
markCopilotFolderTrusted(workspace, testState.fakeHomeDir)
const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json')
expect(statSync(configPath).mode & 0o777).toBe(0o600)
} finally {
process.umask(originalUmask)
rmSync(workspace, { recursive: true, force: true })
}
})
it('preserves existing config keys and dedups already-trusted folders', () => {
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
const realpath = realpathSync(workspace)
+2 -1
View File
@@ -97,7 +97,8 @@ export function markCopilotFolderTrusted(workspacePath: string, home: string): v
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true })
}
writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`)
// Why: config.json can hold copilotTokens, so it must stay owner-only (also on shared SSH hosts).
writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
}
/**
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest'
import { readStreamedSessionDocument } from './session-document-stream'
async function* bytes(content: string, chunkSize = 7): AsyncGenerator<Buffer> {
const buffer = Buffer.from(content)
for (let offset = 0; offset < buffer.length; offset += chunkSize) {
yield buffer.subarray(offset, offset + chunkSize)
}
}
function read(
content: string,
overrides: Partial<Parameters<typeof readStreamedSessionDocument<unknown[]>>[0]> = {}
) {
return readStreamedSessionDocument({
bytes: bytes(content),
arrayKey: 'messages',
fields: ['id'],
objectFields: { agent: ['model'] },
create: (): unknown[] => [],
consume: (state, value) => {
state.push(value)
},
...overrides
})
}
describe('streamed session document contracts', () => {
it.each(['[]', 'null', 'false', '0', '"not an array"', '{}'])(
'a later messages value %s clears an earlier fold',
async (last) => {
expect(await read(`{"messages":[1,2],"messages":${last}}`)).toEqual({ record: {}, state: [] })
}
)
it('replaces a failed earlier array and continues with its successor', async () => {
const consume = (state: unknown[], value: unknown): void => {
if (value === 'bad') {
throw new Error('discarded failure')
}
state.push(value)
}
expect(await read('{"messages":["bad",1],"messages":[2,3]}', { consume })).toEqual({
record: {},
state: [2, 3]
})
expect(await read('{"messages":["bad"],"messages":[]}', { consume })).toEqual({
record: {},
state: []
})
})
it('keeps the first consumer failure unless a later array replaces it', async () => {
const failure = new Error('consumer failed')
let calls = 0
await expect(
read('{"messages":[1,2]}', {
consume: () => {
calls++
throw failure
}
})
).rejects.toBe(failure)
expect(calls).toBe(1)
})
it('gives malformed trailing JSON precedence over a consumer failure', async () => {
await expect(
read('{"messages":[1]} trailing', {
consume: () => {
throw new Error('consumer')
}
})
).rejects.toBeInstanceOf(SyntaxError)
})
it('keeps projected object resets and full-field overlap precedence', async () => {
expect(await read('{"agent":{"model":"old"},"agent":null}')).toEqual({
record: { agent: {} },
state: []
})
expect(await read('{"agent":{"model":"m","extra":[1,2]}}', { fields: ['agent'] })).toEqual({
record: { agent: { model: 'm', extra: [1, 2] } },
state: []
})
expect(await read('{"messages":[1,2]}', { objectFields: { messages: ['model'] } })).toEqual({
record: { messages: {} },
state: [1, 2]
})
expect(
await read('{"messages":{"model":"m","ignored":1}}', {
objectFields: { messages: ['model'] }
})
).toEqual({ record: { messages: { model: 'm' } }, state: [] })
})
it('preserves selected prototype keys as own properties', async () => {
const content =
'{"id":{"__proto__":{"polluted":true}},"agent":{"model":{"constructor":"value","__proto__":{"x":1}}}}'
const parsed = await read(content)
expect(parsed?.record).toEqual(JSON.parse(content))
expect(Object.getPrototypeOf(parsed?.record)).toBeNull()
expect(Object.prototype).not.toHaveProperty('polluted')
})
it.each(['', ' ', '{', '{"messages":[1,]}', '{"messages":[]}{"messages":[]}'])(
'rejects malformed input %j',
async (content) => {
await expect(read(content)).rejects.toBeInstanceOf(SyntaxError)
}
)
it.each(['null', '[]', '123', '"text"'])(
'does not publish a nonobject document %s',
async (content) => {
expect(await read(content)).toBeNull()
}
)
it('validates discarded subtrees and closes their source on malformed input', async () => {
let closed = false
async function* malformed() {
try {
yield Buffer.from('{"ignored":[{"deep":1},]}')
throw new Error('must not request another chunk')
} finally {
closed = true
}
}
await expect(read('', { bytes: malformed() })).rejects.toBeInstanceOf(SyntaxError)
expect(closed).toBe(true)
})
it('preserves source failure and closes the source even after a consumer failure', async () => {
const failure = new Error('disk failure')
let closed = false
async function* failing() {
try {
yield Buffer.from('{"messages":[1]')
throw failure
} finally {
closed = true
}
}
await expect(
read('', {
bytes: failing(),
consume: () => {
throw new Error('consumer')
}
})
).rejects.toBe(failure)
expect(closed).toBe(true)
})
it('preserves escaped astral text across large and byte-sized chunks', async () => {
const value = `${'x'.repeat(65530)}日本語😀\\literal`
const content = JSON.stringify({ messages: [value, '\ud800', '\udc00'] })
for (const size of [1, 65536, content.length * 4]) {
expect(await read(content, { bytes: bytes(content, size) })).toEqual({
record: {},
state: [value, '\ud800', '\udc00']
})
}
})
})
+101 -104
View File
@@ -1,6 +1,7 @@
import { StringDecoder } from 'node:string_decoder'
import { JSONParser, TokenizerError, TokenParserError, TokenType } from '@streamparser/json'
import { FlexAssembler, arrayRule, objectRule } from 'stream-json/core/utils/flex-assembler.js'
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import { createJsonTokenReader } from '../../shared/json-token-reader'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
/** Fold one root array while retaining only the root fields the agent parser uses. */
@@ -13,92 +14,108 @@ export async function readStreamedSessionDocument<T>(args: {
consume: (state: T, value: unknown) => void
signal?: AbortSignal
}): Promise<{ record: Record<string, unknown>; state: T } | null> {
const parser = new JSONParser({
paths: [
...args.fields.map((field) => `$.${field}`),
...Object.entries(args.objectFields ?? {}).flatMap(([root, fields]) =>
fields.map((field) => `$.${root}.${field}`)
),
...(args.arrayKey ? [`$.${args.arrayKey}`, `$.${args.arrayKey}.*`] : [])
],
keepStack: false,
stringBufferSize: 64 * 1024
})
const record: Record<string, unknown> = Object.create(null)
const fields = new Set(args.fields)
let depth = 0
let expectingRootKey = false
parser.onToken = ({ token, value }) => {
if (depth === 1 && expectingRootKey && token === TokenType.STRING) {
if (typeof value === 'string' && Object.hasOwn(args.objectFields ?? {}, value)) {
record[value] = Object.create(null)
}
expectingRootKey = false
}
if (token === TokenType.LEFT_BRACE || token === TokenType.LEFT_BRACKET) {
if (depth === 0 && token === TokenType.LEFT_BRACE) {
expectingRootKey = true
}
depth++
} else if (token === TokenType.RIGHT_BRACE || token === TokenType.RIGHT_BRACKET) {
depth--
} else if (token === TokenType.COMMA && depth === 1) {
expectingRootKey = true
}
}
const objectFields = new Map(
Object.entries(args.objectFields ?? {}).map(([root, keys]) => [root, new Set(keys)])
)
const foldedArray = Symbol('folded session array')
let state = args.create()
let currentArray: unknown = null
let consumeFailure: { error: unknown } | undefined
let inFoldedArray = false
const reset = (): void => {
state = args.create()
consumeFailure = undefined
}
const retain = (path: (string | number)[]): boolean => {
const [root, child] = path
return (
typeof root === 'string' &&
((root !== args.arrayKey && fields.has(root)) ||
(inFoldedArray && root === args.arrayKey && path.length >= 2) ||
(typeof child === 'string' && objectFields.get(root)?.has(child) === true))
)
}
// Every discarded container needs a rule; dropping only its parent still builds large children.
const discard = {
filter: (path: (string | number)[]) => !retain(path),
create: () => null,
add: () => {}
}
const assembler = new FlexAssembler({
maxDepth: Infinity,
objectRules: [
objectRule<Record<string, unknown>>({
filter: (path) => path.length === 0,
create: () => record,
add: (target, key, value) => {
if (key === args.arrayKey) {
if (value !== foldedArray) {
reset()
}
} else if (fields.has(key)) {
target[key] = value
}
}
}),
objectRule<Record<string, unknown>>({
filter: (path) =>
path.length === 1 &&
typeof path[0] === 'string' &&
objectFields.has(path[0]) &&
(!fields.has(path[0]) || path[0] === args.arrayKey),
create: (path) => {
const projected: Record<string, unknown> = Object.create(null)
record[String(path[0])] = projected
return projected
},
add: (target, key, value) => {
const root = assembler.path[0]
if (typeof root === 'string' && objectFields.get(root)?.has(key)) {
target[key] = value
}
}
}),
discard
],
arrayRules: [
arrayRule<null>({
filter: (path) => Boolean(args.arrayKey) && path.length === 1 && path[0] === args.arrayKey,
create: () => {
reset()
inFoldedArray = true
return null
},
add: (_target, value) => {
if (!consumeFailure) {
try {
args.consume(state, value)
} catch (error) {
consumeFailure = { error }
}
}
},
finalize: () => {
inFoldedArray = false
return foldedArray
}
}),
discard
]
})
const parser = createJsonTokenReader((token) => {
if (
token.name === 'keyValue' &&
assembler.depth === 1 &&
!assembler.isArray &&
objectFields.has(token.value)
) {
record[token.value] = Object.create(null)
}
assembler.consume(token)
})
const decoder = new StringDecoder('utf8')
let objectRoot: boolean | undefined
parser.onValue = ({ key, value, parent, stack }) => {
if (stack.length === 2 && stack[1].key === args.arrayKey && Array.isArray(parent)) {
if (parent !== currentArray) {
state = args.create()
consumeFailure = undefined
currentArray = parent
}
if (!consumeFailure) {
try {
args.consume(state, value)
} catch (error) {
consumeFailure = { error }
}
}
// The parser's array cursor is independent of retained array slots.
parent.pop()
} else if (
stack.length === 2 &&
typeof stack[1].key === 'string' &&
typeof key === 'string' &&
parent &&
!Array.isArray(parent)
) {
const root = stack[1].key
const projected = record[root]
if (
Object.hasOwn(args.objectFields ?? {}, root) &&
args.objectFields?.[root]?.includes(key) &&
projected &&
typeof projected === 'object'
) {
Reflect.set(projected, key, value)
}
} else if (stack.length === 1 && typeof key === 'string') {
if (key === args.arrayKey) {
if (value !== currentArray || !Array.isArray(value)) {
state = args.create()
consumeFailure = undefined
}
currentArray = null
} else if (fields.has(key)) {
record[key] = value
}
if (parent && typeof parent === 'object') {
Reflect.deleteProperty(parent, key)
}
}
}
for await (const chunk of args.bytes) {
throwIfAiVaultScanCancelled(args.signal)
if (objectRoot === undefined) {
@@ -107,37 +124,17 @@ export async function readStreamedSessionDocument<T>(args: {
objectRoot = first === 123
}
}
parseJson(() => parser.write(decoder.write(chunk)))
parser.write(decoder.write(chunk))
await yieldToEventLoop()
}
const tail = decoder.end()
if (tail) {
parseJson(() => parser.write(tail))
}
if (objectRoot === undefined) {
throw new SyntaxError('Unexpected end of JSON input')
}
if (!parser.isEnded) {
parseJson(() => parser.end(), true)
parser.write(tail)
}
parser.end()
throwIfAiVaultScanCancelled(args.signal)
if (consumeFailure) {
throw consumeFailure.error
}
return objectRoot ? { record, state } : null
}
function parseJson(run: () => void, ending = false): void {
try {
run()
} catch (error) {
if (
error instanceof TokenizerError ||
error instanceof TokenParserError ||
(ending && error instanceof Error)
) {
throw new SyntaxError(error.message)
}
throw error
}
}
@@ -1,4 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type * as workerSpawn from './session-scanner-opencode-sqlite-worker-spawn'
import type { SessionFileDiscovery } from './session-scanner-types'
import type { TranscriptReadOutcome } from './session-transcript-consumers'
@@ -24,6 +27,7 @@ import {
registerTranscriptConsumer,
resetTranscriptConsumersForTests
} from './session-transcript-consumers'
import { runOpenCodeSqliteScanRequest } from './session-scanner-opencode-sqlite-scan-scope'
const file = {
path: '/fixture/opencode.db#session',
@@ -40,6 +44,7 @@ beforeEach(() => {
resetSessionParseCacheForTests()
})
afterEach(() => {
vi.useRealTimers()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
})
@@ -48,7 +53,11 @@ function configure(agent: 'opencode' | 'opencode2') {
readers.discover.mockResolvedValue([{ agent, rootDir: '/fixture', files: [file] }])
const accumulator = createAccumulator({ agent, file, sessionId: 'session' })
accumulator.title = 'SQLite session'
return finalizeSession(accumulator, 'linux')
const session = finalizeSession(accumulator, 'linux')
if (!session) {
throw new Error('Configured SQLite session was empty')
}
return session
}
function untilAborted(signal: AbortSignal | undefined): Promise<never> {
@@ -61,6 +70,83 @@ function untilAborted(signal: AbortSignal | undefined): Promise<never> {
}
describe.each(['opencode', 'opencode2'] as const)('%s scan cancellation', (agent) => {
it('reports a deadline while retaining completed sessions and other agents, then retries', async () => {
vi.useFakeTimers()
const root = mkdtempSync(join(tmpdir(), 'orca-scan-deadline-'))
try {
const session = configure(agent)
const blocked = { ...file, path: '/fixture/opencode.db#blocked' }
const claudePath = join(root, 'claude.jsonl')
writeFileSync(
claudePath,
`${JSON.stringify({
type: 'user',
sessionId: 'retained-claude',
timestamp: '2026-05-01T10:00:00.000Z',
cwd: root,
message: { role: 'user', content: 'Retain this other-agent session' }
})}\n`
)
readers.discover.mockResolvedValue([
{ agent, rootDir: '/fixture', files: [file, blocked] },
{ agent: 'claude', rootDir: root, files: [{ ...file, path: claudePath }] }
])
readers.parse.mockImplementation(({ sessionId, signal }) =>
sessionId === 'blocked'
? runOpenCodeSqliteScanRequest(signal, untilAborted)
: Promise.resolve(session)
)
const pending = scanAiVaultSessions({ platform: 'linux' })
await vi.waitFor(() => expect(readers.parse).toHaveBeenCalledTimes(2))
await vi.advanceTimersByTimeAsync(45_000)
const result = await pending
expect(result.sessions.map((row) => row.sessionId)).toEqual(
expect.arrayContaining(['session', 'retained-claude'])
)
expect(result.sessions).toHaveLength(2)
expect(result.issues).toEqual([
expect.objectContaining({
agent,
path: blocked.path,
message: expect.stringContaining('45s work budget')
})
])
readers.parse.mockResolvedValue({ ...session, sessionId: 'blocked', filePath: blocked.path })
const recovered = await scanAiVaultSessions({ platform: 'linux' })
expect(recovered.sessions).toHaveLength(3)
expect(
readers.parse.mock.calls.filter(([args]) => args.sessionId === 'blocked')
).toHaveLength(2)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('marks a deadline capture incomplete instead of caching a failed history', async () => {
vi.useFakeTimers()
const session = configure(agent)
const outcomes: TranscriptReadOutcome[] = []
registerTranscriptConsumer({
beginRead: () => ({ message() {}, finish: (outcome) => outcomes.push(outcome) })
})
readers.capture.mockImplementationOnce(({ signal }) =>
runOpenCodeSqliteScanRequest(signal, untilAborted)
)
const pending = scanAiVaultSessions({ platform: 'linux' })
await vi.waitFor(() => expect(readers.capture).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(45_000)
const result = await pending
expect(result.sessions).toEqual([])
expect(result.issues).toEqual([
expect.objectContaining({ message: expect.stringContaining('45s work budget') })
])
expect(outcomes).toEqual([{ session: null, byteOffset: 0, incomplete: true }])
readers.capture.mockResolvedValue({ session, messages })
expect((await scanAiVaultSessions({ platform: 'linux' })).sessions).toHaveLength(1)
expect(readers.capture).toHaveBeenCalledTimes(2)
expect(outcomes.at(-1)?.incomplete).toBe(false)
})
it.each(['parse', 'capture'] as const)(
'cancels an active %s and retries the uncached read',
async (mode) => {
@@ -0,0 +1,138 @@
import { afterEach, expect, it, vi } from 'vitest'
import {
OPENCODE_SQLITE_SCAN_BUDGET_MS,
runOpenCodeSqliteScanRequest,
withOpenCodeSqliteScanScope
} from './session-scanner-opencode-sqlite-scan-scope'
afterEach(() => vi.useRealTimers())
function waitForAbort(signal: AbortSignal | undefined): Promise<never> {
if (!signal) {
throw new Error('Missing scoped request signal')
}
return new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
})
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
it('spends the budget while admission is pending and refuses later work in that scan', async () => {
vi.useFakeTimers()
const admitted = vi.fn()
const outcome = withOpenCodeSqliteScanScope(async () => {
const error = await runOpenCodeSqliteScanRequest(undefined, waitForAbort).catch((err) => err)
expect(error).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
await expect(runOpenCodeSqliteScanRequest(undefined, admitted)).rejects.toBe(error)
})
await vi.advanceTimersByTimeAsync(OPENCODE_SQLITE_SCAN_BUDGET_MS)
await outcome
expect(admitted).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
it('banks only outstanding work across legs and does not spend other-agent time', async () => {
vi.useFakeTimers()
const outcome = withOpenCodeSqliteScanScope(async () => {
await runOpenCodeSqliteScanRequest(undefined, () => wait(20_000))
await wait(70_000)
return runOpenCodeSqliteScanRequest(undefined, waitForAbort)
}).catch((error) => error)
await vi.advanceTimersByTimeAsync(90_000)
let completed = false
void outcome.then(() => {
completed = true
})
await vi.advanceTimersByTimeAsync(24_999)
expect(completed).toBe(false)
await vi.advanceTimersByTimeAsync(1)
expect(await outcome).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
expect(vi.getTimerCount()).toBe(0)
})
it('counts overlapping preparation and worker waits once', async () => {
vi.useFakeTimers()
const outcome = withOpenCodeSqliteScanScope(() =>
runOpenCodeSqliteScanRequest(undefined, () =>
Promise.all([
runOpenCodeSqliteScanRequest(undefined, waitForAbort),
runOpenCodeSqliteScanRequest(undefined, waitForAbort)
])
)
).catch((error) => error)
await vi.advanceTimersByTimeAsync(OPENCODE_SQLITE_SCAN_BUDGET_MS - 1)
expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(1)
expect(await outcome).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
expect(vi.getTimerCount()).toBe(0)
})
it('keeps concurrent scans independent and gives the next scan a fresh owner and budget', async () => {
vi.useFakeTimers()
const owners: unknown[] = []
const first = withOpenCodeSqliteScanScope(() =>
runOpenCodeSqliteScanRequest(undefined, (signal, owner) => {
owners.push(owner)
return waitForAbort(signal)
})
).catch((error) => error)
await vi.advanceTimersByTimeAsync(30_000)
const second = withOpenCodeSqliteScanScope(() =>
runOpenCodeSqliteScanRequest(undefined, (signal, owner) => {
owners.push(owner)
return waitForAbort(signal)
})
).catch((error) => error)
await vi.advanceTimersByTimeAsync(15_000)
expect(await first).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(30_000)
expect(await second).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
await withOpenCodeSqliteScanScope(() =>
runOpenCodeSqliteScanRequest(undefined, async (_signal, owner) => {
owners.push(owner)
})
)
expect(new Set(owners).size).toBe(3)
expect(vi.getTimerCount()).toBe(0)
})
it('preserves the caller cancellation reason and disposes its timer', async () => {
vi.useFakeTimers()
const controller = new AbortController()
const reason = new Error('caller cancelled')
const outcome = withOpenCodeSqliteScanScope(() =>
runOpenCodeSqliteScanRequest(controller.signal, waitForAbort)
).catch((error) => error)
controller.abort(reason)
expect(await outcome).toBe(reason)
expect(vi.getTimerCount()).toBe(0)
})
it('leaves unrelated native-chat and Zcode calls unscoped and retires the scan signal', async () => {
vi.useFakeTimers()
let scopedSignal: AbortSignal | undefined
const caller = new AbortController()
await withOpenCodeSqliteScanScope(async () => {
for (const agent of ['zcode', 'native-chat'] as const) {
await runOpenCodeSqliteScanRequest(
caller.signal,
async (signal, owner) => {
expect(signal).toBe(caller.signal)
expect(owner).toBeUndefined()
expect(vi.getTimerCount()).toBe(0)
},
agent
)
}
await runOpenCodeSqliteScanRequest(undefined, async (signal) => {
scopedSignal = signal
})
})
expect(scopedSignal?.aborted).toBe(true)
expect(caller.signal.aborted).toBe(false)
expect(vi.getTimerCount()).toBe(0)
})
@@ -0,0 +1,75 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { throwIfSignalAborted } from '../../shared/abort-signal-reason'
import type { WorkerThreadRequestOwner } from '../worker-thread-request-queue'
export const OPENCODE_SQLITE_SCAN_BUDGET_MS = 45_000
class OpenCodeSqliteScanScope implements WorkerThreadRequestOwner {
private readonly controller = new AbortController()
readonly signal = this.controller.signal
private remainingMs = OPENCODE_SQLITE_SCAN_BUDGET_MS
private outstanding = 0
private armedAt = 0
private timer: NodeJS.Timeout | undefined
async run<T>(
callerSignal: AbortSignal | undefined,
fn: (signal: AbortSignal, owner: WorkerThreadRequestOwner) => Promise<T>
): Promise<T> {
throwIfSignalAborted(callerSignal)
throwIfSignalAborted(this.signal)
if (this.outstanding++ === 0) {
this.armedAt = Date.now()
this.timer = setTimeout(() => {
const error = new Error(
`OpenCode SQLite scan exceeded its ${OPENCODE_SQLITE_SCAN_BUDGET_MS / 1000}s work budget`
)
error.name = 'OpenCodeSqliteScanDeadlineError'
this.controller.abort(error)
}, this.remainingMs)
this.timer.unref?.()
}
const signal = callerSignal ? AbortSignal.any([callerSignal, this.signal]) : this.signal
try {
return await fn(signal, this)
} finally {
if (--this.outstanding === 0) {
this.pause()
}
}
}
dispose(): void {
this.pause()
this.controller.abort(new Error('OpenCode SQLite scan ended'))
}
private pause(): void {
if (this.timer) {
clearTimeout(this.timer)
this.timer = undefined
this.remainingMs = Math.max(0, this.remainingMs - (Date.now() - this.armedAt))
}
}
}
const scanScope = new AsyncLocalStorage<OpenCodeSqliteScanScope>()
export async function withOpenCodeSqliteScanScope<T>(fn: () => Promise<T>): Promise<T> {
const scope = new OpenCodeSqliteScanScope()
try {
return await scanScope.run(scope, fn)
} finally {
scope.dispose()
}
}
// The clock covers outstanding SQLite work, including admission and WSL preparation.
export function runOpenCodeSqliteScanRequest<T>(
signal: AbortSignal | undefined,
fn: (signal?: AbortSignal, owner?: WorkerThreadRequestOwner) => Promise<T>,
agent?: 'opencode2' | 'zcode' | 'native-chat'
): Promise<T> {
const scope = agent === 'zcode' || agent === 'native-chat' ? undefined : scanScope.getStore()
return scope ? scope.run(signal, fn) : fn(signal)
}
@@ -12,6 +12,7 @@ import type {
OpenCodeSqliteWorkerResponse
} from './session-scanner-opencode-sqlite-worker-protocol'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { withOpenCodeSqliteScanScope } from './session-scanner-opencode-sqlite-scan-scope'
// A worker_threads stand-in the tests drive directly: it records posted requests
// and lets a test emit message/error/exit without a built worker bundle.
@@ -75,6 +76,63 @@ function makeFactory(workers: FakeWorker[]): () => Worker {
}
describe('OpenCodeSqliteWorkerClient', () => {
it('expires a scan in the FIFO without cancelling ordinary reads or a later scan', async () => {
vi.useFakeTimers()
const workers: FakeWorker[] = []
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
const issues: AiVaultScanIssue[] = []
try {
const ordinary = [1, 2].map((id) =>
client.list({
dbPaths: [`/ordinary-${id}.db`],
limit: 1,
issues: []
})
)
const scan = withOpenCodeSqliteScanScope(() =>
client.list({
dbPaths: ['/scan.db'],
limit: 1,
issues
})
)
await vi.advanceTimersByTimeAsync(25_000)
workers[0].emit('message', {
id: workers[0].lastId(),
ok: true,
value: { candidates: [], issues: [] }
})
await vi.advanceTimersByTimeAsync(20_000)
await expect(scan).resolves.toEqual([])
expect(issues).toEqual([
expect.objectContaining({
kind: 'scope',
message: expect.stringContaining('45s work budget')
})
])
expect(workers[0].postedRequests).toHaveLength(2)
expect(workers[0].terminated).toBe(false)
workers[0].emit('message', {
id: workers[0].lastId(),
ok: true,
value: { candidates: [], issues: [] }
})
await expect(Promise.all(ordinary)).resolves.toEqual([[], []])
const next = withOpenCodeSqliteScanScope(() =>
client.list({ dbPaths: ['/next.db'], limit: 1, issues: [] })
)
workers[0].emit('message', {
id: workers[0].lastId(),
ok: true,
value: { candidates: [], issues: [] }
})
await expect(next).resolves.toEqual([])
} finally {
client.dispose()
vi.useRealTimers()
}
})
it('correlates responses by id and ignores stale ids', async () => {
const workers: FakeWorker[] = []
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
@@ -188,7 +246,7 @@ describe('OpenCodeSqliteWorkerClient', () => {
client.list({ dbPaths: ['/tmp/opencode.db'], limit: 10, issues: listIssues })
).resolves.toEqual([])
expect(
listIssues.some((issue) => /background scanner could not start/.test(issue.message))
listIssues.some((issue) => issue.message.includes('background scanner could not start'))
).toBe(true)
await expect(
client.parse({ dbPath: '/tmp/opencode.db', sessionId: 'ses_skipped', platform: 'darwin' })
@@ -264,7 +322,7 @@ describe('OpenCodeSqliteWorkerClient', () => {
const first = await client.list({ dbPaths: ['/db'], limit: 10, issues: firstIssues })
expect(first).toEqual([])
expect(
firstIssues.some((issue) => /background scanner could not start/.test(issue.message))
firstIssues.some((issue) => issue.message.includes('background scanner could not start'))
).toBe(true)
await expect(
client.parse({ dbPath: '/db', sessionId: 'ses_heal', platform: 'darwin' })
@@ -12,6 +12,7 @@ import type {
import { parseOpenCodeSqliteCaptureValue } from './session-scanner-opencode-sqlite-worker-response'
import type { SessionFileCandidate } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
import { runOpenCodeSqliteScanRequest } from './session-scanner-opencode-sqlite-scan-scope'
// Why (#8864): a lazily-spawned, unref'd worker runs OpenCode SQLite reads off
// the main-process event loop. This module owns only the OpenCode legs; the
@@ -117,7 +118,8 @@ export class OpenCodeSqliteWorkerClient {
...(args.agent ? { agent: args.agent } : {})
}),
LIST_TIMEOUT_MS,
args.signal
args.signal,
args.agent
)) as OpenCodeSqliteListValue
args.issues.push(...value.issues)
return value.candidates
@@ -178,7 +180,8 @@ export class OpenCodeSqliteWorkerClient {
...(args.agent ? { agent: args.agent } : {})
}),
PARSE_TIMEOUT_MS,
args.signal
args.signal,
args.agent
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the worker's parse leg returns exactly this, built by the repo's own reader on the other side of a structured clone.
return value as AiVaultSession | null
@@ -217,7 +220,8 @@ export class OpenCodeSqliteWorkerClient {
...(args.agent ? { agent: args.agent } : {})
}),
CAPTURE_TIMEOUT_MS,
args.signal
args.signal,
args.agent
)
return parseOpenCodeSqliteCaptureValue(value)
} catch (err) {
@@ -229,7 +233,12 @@ export class OpenCodeSqliteWorkerClient {
args: Omit<OpenCodeNativeChatReadRequest, 'id'>,
signal?: AbortSignal
): Promise<OpenCodeNativeChatReadValue> {
const value = await this.dispatch((id) => ({ ...args, id }), PARSE_TIMEOUT_MS, signal)
const value = await this.dispatch(
(id) => ({ ...args, id }),
PARSE_TIMEOUT_MS,
signal,
'native-chat'
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only this build's internal worker dispatch constructs page/signal results; they are not client-supplied paths or frames.
return value as OpenCodeNativeChatReadValue
}
@@ -241,13 +250,20 @@ export class OpenCodeSqliteWorkerClient {
private async dispatch(
buildRequest: (id: number) => OpenCodeSqliteWorkerRequest,
timeoutMs: number,
signal?: AbortSignal
signal?: AbortSignal,
agent?: 'opencode2' | 'zcode' | 'native-chat'
): Promise<unknown> {
const deadline = this.requestTimeoutMs ?? timeoutMs
const response = await this.requests.dispatch(
(id) => ({ ...buildRequest(id), timeoutMs: deadline }),
deadline,
signal
const response = await runOpenCodeSqliteScanRequest(
signal,
(requestSignal, owner) =>
this.requests.dispatch(
(id) => ({ ...buildRequest(id), timeoutMs: deadline }),
deadline,
requestSignal,
owner
),
agent
)
if (!response.ok) {
throw new Error(response.error)
@@ -16,6 +16,7 @@ import {
openCodeWslPath
} from './session-scanner-opencode-wsl-client'
import { findForeignSqliteReaderEntry } from '../foreign-sqlite-readers/foreign-sqlite-reader-entry-path'
import { runOpenCodeSqliteScanRequest } from './session-scanner-opencode-sqlite-scan-scope'
// Why: resolve the built worker entry + own the process-wide shared client so
// the client class stays free of Electron (require'd lazily here) and the
@@ -179,8 +180,14 @@ async function listForHost(
const first = paths.values().next().value!
const issues: AiVaultScanIssue[] = []
try {
const client = await openCodeWslClient(distro, first, args.signal)
const result = await client.list({ ...args, dbPaths: [...paths.keys()], issues })
const result = await runOpenCodeSqliteScanRequest(
args.signal,
async (signal) => {
const client = await openCodeWslClient(distro, first, signal)
return client.list({ ...args, signal, dbPaths: [...paths.keys()], issues })
},
args.agent
)
return result.flatMap((candidate) => {
const parsed = splitOpenCodeSqliteCandidate(candidate.file.path, args.agent)
const original = parsed && paths.get(parsed.dbPath)
@@ -223,8 +230,14 @@ async function parseForHost(
if (!wsl) {
return getSharedClient().parse(args)
}
const client = await openCodeWslClient(wsl.distro, args.dbPath, args.signal)
const session = await client.parse({ ...args, dbPath: wsl.linuxPath, platform: 'linux' })
const session = await runOpenCodeSqliteScanRequest(
args.signal,
async (signal) => {
const client = await openCodeWslClient(wsl.distro, args.dbPath, signal)
return client.parse({ ...args, signal, dbPath: wsl.linuxPath, platform: 'linux' })
},
args.agent
)
return mapOpenCodeWslSession(session, args.dbPath)
}
@@ -235,8 +248,14 @@ async function captureForHost(
if (!wsl) {
return getSharedClient().capture(args)
}
const client = await openCodeWslClient(wsl.distro, args.dbPath, args.signal)
const capture = await client.capture({ ...args, dbPath: wsl.linuxPath, platform: 'linux' })
const capture = await runOpenCodeSqliteScanRequest(
args.signal,
async (signal) => {
const client = await openCodeWslClient(wsl.distro, args.dbPath, signal)
return client.capture({ ...args, signal, dbPath: wsl.linuxPath, platform: 'linux' })
},
args.agent
)
return { ...capture, session: mapOpenCodeWslSession(capture.session, args.dbPath) }
}
@@ -3,6 +3,7 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { createAccumulator, finalizeSession } from './session-scanner-accumulator'
import type { SessionFileCandidate } from './session-scanner-types'
import type * as wslClientModule from './session-scanner-opencode-wsl-client'
import { withOpenCodeSqliteScanScope } from './session-scanner-opencode-sqlite-scan-scope'
const mocks = vi.hoisted(() => ({
native: {
@@ -59,9 +60,53 @@ beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
})
afterEach(() => vi.restoreAllMocks())
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('OpenCode SQLite execution-host routes', () => {
it('budgets WSL preparation while retaining a native source that already answered', async () => {
vi.useFakeTimers()
mocks.native.list.mockResolvedValueOnce([row(native)])
mocks.guest.mockImplementationOnce((_distro, _path, signal: AbortSignal | undefined) => {
if (!signal) {
throw new Error('Missing scoped preparation signal')
}
return new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
)
})
const issues: AiVaultScanIssue[] = []
const result = withOpenCodeSqliteScanScope(() =>
listOpenCodeSqliteSessionsViaWorker({
dbPaths: [native, ubuntu],
limit: 2,
issues
})
)
await vi.advanceTimersByTimeAsync(45_000)
expect((await result).map((entry) => entry.file.path)).toEqual([`${native}#same-session`])
expect(issues).toEqual([
expect.objectContaining({
path: ubuntu,
kind: 'scope',
message: expect.stringContaining('45s work budget')
})
])
mocks.guest.mockResolvedValueOnce({ list: vi.fn(async () => [row(guest)]) })
const recoveredIssues: AiVaultScanIssue[] = []
const recovered = await withOpenCodeSqliteScanScope(() =>
listOpenCodeSqliteSessionsViaWorker({
dbPaths: [ubuntu],
limit: 2,
issues: recoveredIssues
})
)
expect(recovered).toHaveLength(1)
expect(recoveredIssues).toEqual([])
})
it('separates native and distro databases and preserves equal IDs in different distros', async () => {
const list = vi.fn(async (args) => [row(args.dbPaths[0])])
mocks.guest.mockResolvedValue({ list })
+5
View File
@@ -45,6 +45,7 @@ import { clampPositiveInteger, errorMessage } from './session-scanner-values'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { DEFAULT_AI_VAULT_SCAN_LIMIT } from '../../shared/ai-vault-session-depth'
import { withDevinSessionsDbScan } from './session-scanner-devin-db'
import { withOpenCodeSqliteScanScope } from './session-scanner-opencode-sqlite-scan-scope'
const SESSION_PARSE_CONCURRENCY = 8
const SESSION_PARSE_CANDIDATE_MULTIPLIER = 2
@@ -61,6 +62,10 @@ const SESSION_PARSE_CANDIDATE_MULTIPLIER = 2
export async function scanAiVaultSessions(
options: AiVaultScanOptions = {}
): Promise<AiVaultListResult> {
return withOpenCodeSqliteScanScope(() => scanAiVaultSessionStores(options))
}
async function scanAiVaultSessionStores(options: AiVaultScanOptions): Promise<AiVaultListResult> {
// The span makes scan cost visible in the local trace file: STA-1278-style
// "one core pegged" reports need to show whether transcript scanning is the
// subsystem burning CPU, and how much of each scan the cache absorbed.
@@ -67,11 +67,11 @@ describe('protected Antigravity account snapshots', () => {
const service = new AntigravityAccountService(store, h.backend)
h.setNative(paddedCredential('new-account', 60000))
await expect(service.addCurrentAccount()).rejects.toThrow('could not be saved')
expect(readFileSync(path)).toEqual(before)
expect(readFileSync(path).equals(before)).toBe(true)
expect(store.read().accounts).toHaveLength(52)
h.setNative(paddedCredential('large-51', 65000))
await expect(service.listAccounts()).rejects.toThrow('could not be saved')
expect(readFileSync(path)).toEqual(before)
expect(readFileSync(path).equals(before)).toBe(true)
expect(store.read().accounts).toHaveLength(52)
})
@@ -115,7 +115,7 @@ describe('protected Antigravity account snapshots', () => {
'Protected secret storage'
)
expect(() => store.read()).toThrow('Protected secret storage')
expect(readFileSync(path)).toEqual(before)
expect(readFileSync(path).equals(before)).toBe(true)
}
)
+14 -2
View File
@@ -27,6 +27,7 @@ describe('prependOrcaCliDirToChildPath', () => {
platform: 'linux'
})
expect(env.PATH).toBe(`${SHIM_DIR}:/usr/local/bin:/usr/bin`)
expect(env.ORCA_CLI_BIN_DIR).toBe(SHIM_DIR)
expect(shim.ensureLinuxTerminalOrcaCliShimDir).toHaveBeenCalledWith({
userDataPath: USER_DATA
})
@@ -44,13 +45,14 @@ describe('prependOrcaCliDirToChildPath', () => {
it('leaves packaged Linux PATH untouched when no shim could be written', () => {
shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(null)
const env: Record<string, string> = { PATH: '/usr/bin' }
const env: Record<string, string> = { PATH: '/usr/bin', ORCA_CLI_BIN_DIR: '/old-host/cli' }
prependOrcaCliDirToChildPath(env, {
isPackaged: true,
userDataPath: USER_DATA,
platform: 'linux'
})
expect(env.PATH).toBe('/usr/bin')
expect(env.ORCA_CLI_BIN_DIR).toBeUndefined()
})
it('leads packaged macOS PATH with the bundled CLI dir', () => {
@@ -62,11 +64,16 @@ describe('prependOrcaCliDirToChildPath', () => {
platform: 'darwin'
})
expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`)
expect(env.ORCA_CLI_BIN_DIR).toBe(join(RESOURCES, 'bin'))
expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled()
})
it('leads packaged Windows PATH with the bundled CLI dir under the env block spelling', () => {
const env: Record<string, string> = { Path: 'C:\\Windows\\System32' }
const env: Record<string, string> = {
Path: 'C:\\Windows\\System32',
ORCA_CLI_BIN_DIR: '/parent-host/cli',
ORCA_WSL_CLI_DIR: '/guest/orca/bin'
}
prependOrcaCliDirToChildPath(env, {
isPackaged: true,
userDataPath: USER_DATA,
@@ -75,6 +82,8 @@ describe('prependOrcaCliDirToChildPath', () => {
})
expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows\\System32`)
expect(env.PATH).toBeUndefined()
expect(env.ORCA_CLI_BIN_DIR).toBeUndefined()
expect(env.ORCA_WSL_CLI_DIR).toBe('/guest/orca/bin')
})
it('leaves a packaged darwin/win32 PATH alone with no resources root', () => {
@@ -101,6 +110,9 @@ describe('prependOrcaCliDirToChildPath', () => {
platform
})
expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}${pathDelimiter}/usr/bin`)
expect(env.ORCA_CLI_BIN_DIR).toBe(
platform === 'win32' ? undefined : join(USER_DATA, 'cli', 'bin')
)
expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled()
})
+8
View File
@@ -39,12 +39,16 @@ export function prependOrcaCliDirToChildPath(
opts: OrcaCliChildPathOptions
): string | null {
const platform = opts.platform ?? process.platform
delete env.ORCA_CLI_BIN_DIR
// Why: matches node:path's `delimiter` for the running platform, but stays correct when a test
// drives a foreign platform through the seam.
const pathDelimiter = platform === 'win32' ? ';' : delimiter
// Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca.
if (!opts.isPackaged) {
const devCliBin = join(opts.userDataPath, 'cli', 'bin')
if (platform !== 'win32') {
env.ORCA_CLI_BIN_DIR = devCliBin
}
const inheritedPath = readInheritedPath(env, platform)
// Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter.
env[resolvePathEnvKey(env, platform)] = inheritedPath
@@ -55,6 +59,7 @@ export function prependOrcaCliDirToChildPath(
// Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904).
const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath })
if (shimDir) {
env.ORCA_CLI_BIN_DIR = shimDir
const inheritedEntries = readInheritedPath(env, platform)
.split(pathDelimiter)
.filter((entry) => entry.length > 0 && entry !== shimDir)
@@ -64,6 +69,9 @@ export function prependOrcaCliDirToChildPath(
} else if (opts.resourcesPath && (platform === 'darwin' || platform === 'win32')) {
// Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI.
const bundledCliBin = join(opts.resourcesPath, 'bin')
if (platform === 'darwin') {
env.ORCA_CLI_BIN_DIR = bundledCliBin
}
const inheritedPath = readInheritedPath(env, platform)
env[resolvePathEnvKey(env, platform)] = inheritedPath
? `${bundledCliBin}${pathDelimiter}${inheritedPath}`
@@ -88,6 +88,49 @@ describe('legacy shared Codex config compatibility', () => {
expect(readFileSync(join(sharedRuntimeHome, 'auth.json'), 'utf-8')).toBe(staleSharedAuth)
})
it('keeps an Orca-added MCP server while settings and trust still refresh', () => {
const baselinePath = join(sharedRuntimeHome, '.orca-config-settings-baseline.json')
const baseline = JSON.stringify({ version: 3, settings: {}, mcpServers: [] })
writeFileSync(baselinePath, baseline)
writeFileSync(
join(systemCodexHome, 'config.toml'),
['model = "canonical"', '', '[projects."/revoked"]', 'trust_level = "untrusted"', ''].join(
'\n'
)
)
writeFileSync(
join(sharedRuntimeHome, 'config.toml'),
[
'model = "stale"',
'',
'[projects."/trusted-in-orca"]',
'trust_level = "trusted"',
'',
'[projects."/revoked"]',
'trust_level = "trusted"',
'',
'[hooks.state."orca:stop:0:0"]',
'enabled = true',
'',
'[mcp_servers.demo-mcp]',
'command = "demo"',
''
].join('\n')
)
syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome })
const sharedConfig = readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8')
expect(sharedConfig).toContain('model = "canonical"')
expect(sharedConfig).not.toContain('model = "stale"')
expect(sharedConfig).toContain('[projects."/trusted-in-orca"]\ntrust_level = "trusted"')
expect(sharedConfig).toContain('[projects."/revoked"]\ntrust_level = "untrusted"')
expect(sharedConfig).not.toContain('[projects."/revoked"]\ntrust_level = "trusted"')
expect(sharedConfig).toContain('[hooks.state."orca:stop:0:0"]')
expect(sharedConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "demo"')
expect(readFileSync(baselinePath, 'utf-8')).toBe(baseline)
})
it('does not delete config when the canonical source is transiently missing', () => {
const staleConfig = 'model_provider = "stale-provider"\n'
writeFileSync(join(sharedRuntimeHome, 'config.toml'), staleConfig, 'utf-8')
@@ -28,7 +28,9 @@ import { THREAD_ID } from './codex-structured-session-adapter-fixture'
/** The delegation the parent's newest tool run reads as, the row a running chat's frontier judges. */
async function newestRunDelegation(frames: Frame[]): Promise<NativeChatSubagentDelegation | null> {
const { conversation } = projectNativeChatTranscript(
projectStructuredAgentSessionMessages(await publishedRows(frames), [], [])
projectStructuredAgentSessionMessages(await publishedRows(frames), [], [], {
rejectedInPlace: true
})
)
const runs = conversation.filter((message: NativeChatMessage) =>
message.blocks.some((block) => block.type === 'tool-call')
@@ -47,7 +47,9 @@ function positionalRuns(rows: AgentJournalRenderItem[]): {
})
})
const transcript = projectNativeChatTranscript(
projectStructuredAgentSessionMessages(rows, [], []).map(withoutCallIds)
projectStructuredAgentSessionMessages(rows, [], [], { rejectedInPlace: true }).map(
withoutCallIds
)
)
// The conversation's runs, then each helper's section's.
const runs = [
@@ -21,6 +21,20 @@ beforeEach(() => {
afterEach(() => rmSync(root, { recursive: true, force: true }))
const BASELINE_FILE = '.orca-config-settings-baseline.json'
type StoredBaselineFixture = {
version: 1 | 3
settings: Record<string, string | null>
mcpServers?: string[]
}
function writeBaseline(baseline: StoredBaselineFixture): string {
const serialized = JSON.stringify(baseline)
writeFileSync(join(runtimeHomePath, BASELINE_FILE), serialized)
return serialized
}
describe('canonical MCP ownership during config mirroring', () => {
it('does not duplicate a server defined inline in the canonical MCP table', () => {
writeFileSync(
@@ -130,6 +144,60 @@ describe('MCP ownership migration', () => {
)
})
it('keeps the retained shared home canonical under a pre-ownership baseline', () => {
writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.removed]\ncommand = "old"\n')
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n')
writeBaseline({ version: 1, settings: {} })
syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain(
'[mcp_servers.'
)
})
it('keeps Orca-only servers in the retained shared home and drops ones removed from ~/.codex', () => {
writeFileSync(
join(runtimeHomePath, 'config.toml'),
[
'[mcp_servers.demo-mcp]',
'command = "orca-only"',
'[mcp_servers.removed]',
'command = "mirrored"',
'[mcp_servers.kept]',
'command = "stale"',
''
].join('\n')
)
writeFileSync(
join(systemHomePath, 'config.toml'),
'model = "system"\n[mcp_servers.kept]\ncommand = "system"\n'
)
const baseline = writeBaseline({ version: 3, settings: {}, mcpServers: ['removed', 'kept'] })
syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')
expect(runtimeConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "orca-only"')
expect(runtimeConfig).not.toContain('[mcp_servers.removed]')
expect(runtimeConfig).toContain('[mcp_servers.kept]\ncommand = "system"')
expect(runtimeConfig).not.toContain('"stale"')
expect(readFileSync(join(runtimeHomePath, BASELINE_FILE), 'utf-8')).toBe(baseline)
})
it('leaves the retained shared home untouched when its baseline cannot be read', () => {
const runtimeConfig = 'model = "retained"\n[mcp_servers.demo-mcp]\ncommand = "orca-only"\n'
writeFileSync(join(runtimeHomePath, 'config.toml'), runtimeConfig)
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n')
mkdirSync(join(runtimeHomePath, BASELINE_FILE))
expect(() =>
syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
).toThrow()
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toBe(runtimeConfig)
})
it('tracks commented CRLF names so their later removal remains authoritative', () => {
writeFileSync(
join(runtimeHomePath, 'config.toml'),
+20 -4
View File
@@ -16,7 +16,7 @@ import {
type CodexSettingsPromotionHomes,
type CodexSettingsPromotionPlan
} from './config-settings-promotion'
import { readCodexSettingsBaseline } from './config-settings-baseline'
import { observeCodexSettingsBaseline, readCodexSettingsBaseline } from './config-settings-baseline'
import { getCodexConfigSyncStatus, reportCodexConfigSyncOutcome } from './config-sync-stall'
import { preserveRuntimeConflictValues } from './codex-config-settings-preservation'
import { applyCodexDaemonSocketGuard } from './codex-daemon-socket-path-guard'
@@ -161,15 +161,13 @@ export function syncSystemConfigIntoLegacySharedCodexHome(
let mirroredRuntimeConfig = runtimeConfigBeforeMirror ?? ''
if (rawSystemConfig.trim() !== '') {
const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath)
// The retired home has no ownership baseline; its entire MCP root stays canonical.
mirroredRuntimeConfig =
runtimeConfigBeforeMirror !== null
? mergeSystemCodexConfigIntoRuntime(
runtimeConfigBeforeMirror,
prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir),
sourceConfigDir,
new Set(),
true
...readLegacySharedHomeMcpOwnership(homes.runtimeHomePath)
)
: prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir)
}
@@ -186,6 +184,24 @@ export function syncSystemConfigIntoLegacySharedCodexHome(
writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfigBeforeMirror, nextRuntimeConfig)
}
/**
* Why: MCP servers added from an Orca terminal exist only in the retired home,
* so its last mirror's baseline decides which ones ~/.codex owns. With no
* baseline the whole root stays canonical, as before; an unreadable one throws
* rather than guess. Read-only: this one-way refresh never advances it.
*/
function readLegacySharedHomeMcpOwnership(
runtimeHomePath: string
): [mirroredMcpServerNames: ReadonlySet<string>, mirroredMcpServerRoot: boolean] {
const observation = observeCodexSettingsBaseline(runtimeHomePath)
if (observation.kind === 'indeterminate') {
throw new Error('Codex settings baseline could not be read')
}
return observation.kind === 'present'
? [observation.baseline.mcpServers, observation.baseline.mcpServerRoot]
: [new Set(), true]
}
type CodexConfigMirrorResult =
| { status: 'skipped-missing-source' }
| { status: 'refused-indeterminate'; error: unknown }
@@ -10,6 +10,7 @@ import {
} from '../runtime/structured-worker-identity'
const DEV_CLI_BIN_FIRST = /^[^:;]*[\\/]cli[\\/]bin[:;]/
const DEV_CLI_BIN_DIR = /^[^:;]*[\\/]cli[\\/]bin$/
// The dev launcher by absolute path: a login shell's profile cannot reorder it behind a global.
const DEV_CLI_LAUNCHER = /^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/
@@ -23,7 +24,11 @@ describe('buildCodexStructuredChildEnvironment', () => {
cwd: '/worktree',
codexHome: '/pinned/home',
resumeThreadId: null,
env: { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' }
env: {
EXAMPLE_GATEWAY_TOKEN: 'shell-exported',
CODEX_HOME: '/shell/home',
ORCA_CLI_BIN_DIR: '/inherited/unowned-cli'
}
},
'spawn-token',
'session-not-a-worker'
@@ -35,6 +40,9 @@ describe('buildCodexStructuredChildEnvironment', () => {
ORCA_AGENT_SESSION_ID: 'session-not-a-worker',
ORCA_STRUCTURED_SESSION: '1',
ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER),
...(process.platform !== 'win32'
? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) }
: {}),
ORCA_USER_DATA_PATH: expect.any(String),
// The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH.
PATH: expect.stringMatching(DEV_CLI_BIN_FIRST)
@@ -57,6 +65,9 @@ describe('buildCodexStructuredChildEnvironment', () => {
ORCA_AGENT_SESSION_ID: sessionId,
ORCA_STRUCTURED_SESSION: '1',
ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER),
...(process.platform !== 'win32'
? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) }
: {}),
ORCA_USER_DATA_PATH: expect.any(String),
PATH: expect.stringMatching(DEV_CLI_BIN_FIRST)
})
@@ -97,6 +108,7 @@ const ENV_REPORTING_APP_SERVER = String.raw`
result: {
sessionId: process.env.ORCA_AGENT_SESSION_ID ?? null,
cliCommand: process.env.ORCA_CLI_COMMAND ?? null,
cliBinDir: process.env.ORCA_CLI_BIN_DIR ?? null,
path: process.env.PATH ?? process.env.Path ?? null
}
})
@@ -135,6 +147,7 @@ describe('the spawned Codex child', () => {
await expect(connection.request('test/env')).resolves.toEqual({
sessionId,
cliCommand: expect.stringMatching(DEV_CLI_LAUNCHER),
cliBinDir: process.platform === 'win32' ? null : expect.stringMatching(DEV_CLI_BIN_DIR),
path: expect.stringMatching(DEV_CLI_BIN_FIRST)
})
} finally {
@@ -206,6 +206,7 @@ function drawnPromptRows(): string[][] {
client.items,
[],
client.submissions,
{ rejectedInPlace: true },
projectStructuredQuestionMessages
)
)
@@ -243,9 +244,9 @@ describe('a Codex ask with several questions', () => {
])
// Mobile draws the shared projection in journal order, one row per question.
expect(
projectStructuredAgentSessionMessages(client.items, [], client.submissions).map(
({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null)
)
projectStructuredAgentSessionMessages(client.items, [], client.submissions, {
rejectedInPlace: false
}).map(({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null))
).toEqual(ASKED.map(({ question }) => question))
})
@@ -38,6 +38,9 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
ORCA_AGENT_SESSION_ID: 'session-1',
ORCA_STRUCTURED_SESSION: '1',
ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/),
...(process.platform !== 'win32'
? { ORCA_CLI_BIN_DIR: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin$/) }
: {}),
ORCA_USER_DATA_PATH: expect.any(String),
// The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH.
PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/)
@@ -1,4 +1,5 @@
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import { ORCA_CLI_POSIX_PATH_RESTORE } from '../../shared/orca-cli-shell-path'
import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell'
import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function'
import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition'
@@ -35,6 +36,7 @@ __orca_restore_agent_teams_path() {
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
__orca_restore_agent_teams_path
${ORCA_CLI_POSIX_PATH_RESTORE}
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the Orca-managed config dir before the first prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { Terminal } from '@xterm/headless'
import { bufferRows, cellDescriptor } from './serialize-grid-cell-descriptors'
import { cellDescriptor, compareBufferRows } from './serialize-grid-cell-descriptors'
import { createFuzzTerminal, writeTerminal } from './serialize-grid-roundtrip'
// Frozen allocating oracle from f69052e; a reused cell must preserve every descriptor.
@@ -78,48 +78,6 @@ function allocatingBufferRows(
}
describe('serialize oracle cell reuse', () => {
it.each([false, true])(
'matches allocating cells across SGR, wide text, buffers and resize (ConPTY=%s)',
(conpty) => {
const terminal = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty })
try {
const writes = [
'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n',
'\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n',
'\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩‍💻é\r\n',
'scroll1\r\nscroll2\r\nscroll3\r\n',
'\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m',
'\x1b[?1049l\x1b[2J\x1b[Hclear'
]
for (const data of writes) {
writeTerminal(terminal, data)
for (const buffer of [
terminal.buffer.normal,
terminal.buffer.alternate,
terminal.buffer.active
]) {
expect(bufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2)).toEqual(
allocatingBufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2)
)
}
terminal.resize(terminal.cols === 14 ? 9 : 14, 4)
expect(
bufferRows(terminal.buffer.active, 0, terminal.buffer.active.length, terminal.cols)
).toEqual(
allocatingBufferRows(
terminal.buffer.active,
0,
terminal.buffer.active.length,
terminal.cols
)
)
}
} finally {
terminal.dispose()
}
}
)
it('preserves the order of every text flag combination while reloading a plain cell', () => {
const terminal = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 })
try {
@@ -167,50 +125,11 @@ describe('serialize oracle cell reuse', () => {
const scratch = terminal.buffer.active.getNullCell()
expect(cellDescriptor(line, 3, 4, scratch)).toBe('CLIPPED')
expect(cellDescriptor(line, 3, 4, scratch)).toBe(allocatingCellDescriptor(line, 3, 4))
expect(bufferRows(terminal.buffer.active, 0, 1, 4)).toEqual(
allocatingBufferRows(terminal.buffer.active, 0, 1, 4)
)
} finally {
terminal.dispose()
}
})
it('loads every cell into one traversal-local scratch object and retains immutable descriptors', () => {
const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 })
try {
writeTerminal(terminal, '\x1b[1mA\x1b[0m B界')
const buffer = terminal.buffer.active
const scratch = buffer.getNullCell()
const allocate = vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch)
const getLine = buffer.getLine.bind(buffer)
const loaded: unknown[] = []
vi.spyOn(buffer, 'getLine').mockImplementation((y) => {
const line = getLine(y)
if (line) {
const getCell = line.getCell.bind(line)
vi.spyOn(line, 'getCell').mockImplementation((x, cell) => {
loaded.push(cell)
return getCell(x, cell)
})
}
return line
})
const expected = allocatingBufferRows(buffer, 0, 2, 4)
loaded.length = 0
const actual = bufferRows(buffer, 0, 2, 4)
expect(actual).toEqual(expected)
expect(allocate).toHaveBeenCalledTimes(1)
expect(loaded).toHaveLength(8)
expect(loaded.every((cell) => cell === scratch)).toBe(true)
writeTerminal(terminal, '\x1b[2J\x1b[Hnew')
bufferRows(buffer, 0, 2, 4)
expect(actual).toEqual(expected)
} finally {
terminal.dispose()
vi.restoreAllMocks()
}
})
it('keeps missing lines and invalid columns blank after a styled cell occupied the scratch', () => {
const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 })
try {
@@ -230,3 +149,202 @@ describe('serialize oracle cell reuse', () => {
}
})
})
function allocatingRowDiff(stage: string, expected: string[][], actual: string[][]) {
for (let y = 0; y < Math.max(expected.length, actual.length); y++) {
const expectedRow = expected[y]
const actualRow = actual[y]
if (
!expectedRow ||
!actualRow ||
expectedRow.length !== actualRow.length ||
!expectedRow.every(
(cell, x) => cell === actualRow[x] || (cell === CLIPPED && actualRow[x]?.startsWith('▯'))
)
) {
return { stage, row: y, expected: expectedRow?.join('|'), actual: actualRow?.join('|') }
}
}
return null
}
function expectComparisonParity(
expected: Buffer,
actual: Buffer,
cols: number,
expectedStart = 0,
expectedEnd = expected.length,
actualStart = 0,
actualEnd = actual.length
): ReturnType<typeof compareBufferRows> {
const frozen = allocatingRowDiff(
'parity',
allocatingBufferRows(expected, expectedStart, expectedEnd, cols),
allocatingBufferRows(actual, actualStart, actualEnd, cols)
)
expect(
compareBufferRows(
'parity',
expected,
expectedStart,
expectedEnd,
actual,
actualStart,
actualEnd,
cols
)
).toEqual(frozen)
return frozen
}
describe('serialize oracle streaming comparison', () => {
it('reuses one cell per buffer and stops before rows after the first difference', () => {
const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 })
const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 })
try {
writeTerminal(source, 'first\r\nsecond\r\nthird\r\nlast')
writeTerminal(replay, 'wrong\r\nsecond\r\nthird\r\nlast')
for (const buffer of [source.buffer.active, replay.buffer.active]) {
const scratch = buffer.getNullCell()
vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch)
const getLine = buffer.getLine.bind(buffer)
vi.spyOn(buffer, 'getLine').mockImplementation((y) => {
const line = getLine(y)
if (line) {
const getCell = line.getCell.bind(line)
vi.spyOn(line, 'getCell').mockImplementation((x, cell) => {
expect(cell).toBe(scratch)
return getCell(x, cell)
})
}
return line
})
}
const diff = compareBufferRows(
'visible-grid',
source.buffer.active,
0,
4,
replay.buffer.active,
0,
4,
8
)
expect(diff?.row).toBe(0)
for (const buffer of [source.buffer.active, replay.buffer.active]) {
expect(buffer.getNullCell).toHaveBeenCalledTimes(1)
expect(buffer.getLine).toHaveBeenCalledTimes(2)
expect(buffer.getLine).toHaveBeenCalledWith(0)
expect(buffer.getLine).toHaveBeenCalledWith(3)
}
writeTerminal(source, '\x1b[2J\x1b[Hchanged')
expect(diff?.expected).toContain('f·w1·f0:-1·b0:-1·0000000')
} finally {
source.dispose()
replay.dispose()
vi.restoreAllMocks()
}
})
it.each([false, true])(
'preserves first-row diagnostics through buffer changes (ConPTY=%s)',
(conpty) => {
const source = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty })
const replay = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty })
try {
for (const [index, data] of [
'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n',
'\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n',
'\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩‍💻é\r\n',
'scroll1\r\nscroll2\r\nscroll3\r\n',
'\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m',
'\x1b[?1049l\x1b[2J\x1b[Hclear'
].entries()) {
writeTerminal(source, data)
writeTerminal(replay, data)
for (const [expected, actual] of [
[source.buffer.active, replay.buffer.active],
[source.buffer.normal, replay.buffer.normal],
[source.buffer.alternate, replay.buffer.alternate]
]) {
expect(expectComparisonParity(expected!, actual!, source.cols)).toBeNull()
expectComparisonParity(expected!, actual!, source.cols + 2, -1, expected!.length + 1)
}
const corruption = `\x1b[H\x1b[0mFAULT${index}`
writeTerminal(replay, corruption)
expect(
expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols)
).not.toBeNull()
writeTerminal(source, corruption)
source.resize(source.cols === 14 ? 9 : 14, 4)
replay.resize(source.cols, 4)
expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols)
}
} finally {
source.dispose()
replay.dispose()
}
}
)
it('detects every text-flag loss and compares all combinations against the allocating oracle', () => {
const source = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 })
const replay = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 })
try {
const sgr = [1, 2, 3, 4, 7, 8, 9]
const writeFlags = (terminal: Terminal, mask: number): void => {
const codes = sgr.filter((_code, bit) => (mask & (1 << bit)) !== 0)
writeTerminal(terminal, `\x1b[H\x1b[0m\x1b[${codes.length ? codes.join(';') : 0}mA\x1b[0mB`)
}
for (let mask = 0; mask < 128; mask++) {
writeFlags(source, mask)
writeFlags(replay, mask)
expect(expectComparisonParity(source.buffer.active, replay.buffer.active, 4)).toBeNull()
for (let bit = 0; bit < sgr.length; bit++) {
if ((mask & (1 << bit)) !== 0) {
writeFlags(replay, mask & ~(1 << bit))
expect(
expectComparisonParity(source.buffer.active, replay.buffer.active, 4)
).not.toBeNull()
}
}
}
} finally {
source.dispose()
replay.dispose()
}
})
it.each([
['abc界', 'abc ', 4, false],
['abc界', 'abc\x1b[48;2;1;2;3m ', 4, false],
['abc ', 'abc界', 4, true],
['é', 'è', 8, true],
['\x1b[32mA', '\x1b[38;5;2mA', 8, false],
['\x1b[42m ', '\x1b[48;5;2m ', 8, false],
['\x1b[7;31m ', '\x1b[7;32m ', 8, true],
['\x1b[4m\x1b[2J', '\x1b[2J', 8, false],
['\x1b[4m ', ' ', 8, true],
['\x1b[4m \x1b[0mB', ' B', 8, true],
['text\r\n', 'text', 8, false],
['text\r\n\x1b[48;2;1;2;3m\x1b[2K', 'text', 8, true]
] as const)(
'preserves blank, clipped and color policy for %j / %j',
(expected, actual, cols, differs) => {
const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 })
const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 })
try {
writeTerminal(source, expected)
writeTerminal(replay, actual)
expect(
Boolean(expectComparisonParity(source.buffer.active, replay.buffer.active, cols))
).toBe(differs)
expectComparisonParity(source.buffer.active, replay.buffer.active, cols, -1, 6, -1, 5)
expectComparisonParity(source.buffer.active, replay.buffer.active, cols, 1, 5, 0, 4)
} finally {
source.dispose()
replay.dispose()
}
}
)
})
@@ -7,6 +7,7 @@ export type GridDiff = { stage: string; row?: number; expected: unknown; actual:
type BufferLine = NonNullable<ReturnType<Terminal['buffer']['active']['getLine']>>
type Buffer = Terminal['buffer']['active']
type Cell = ReturnType<Buffer['getNullCell']>
const COLOR_MODE_P16 = 16777216
const COLOR_MODE_P256 = 33554432
@@ -64,33 +65,158 @@ function cellsMatch(expected: string, actual: string): boolean {
return expected === actual || (expected === CLIPPED && actual.startsWith('▯'))
}
function rowsMatch(expected: string[], actual: string[]): boolean {
return expected.length === actual.length && expected.every((e, i) => cellsMatch(e, actual[i]!))
function sameColor(
expectedMode: number,
expectedColor: number,
actualMode: number,
actualColor: number
): boolean {
return (
expectedColor === actualColor &&
canonicalColorMode(expectedMode, expectedColor) === canonicalColorMode(actualMode, actualColor)
)
}
export function bufferRows(buffer: Buffer, start: number, end: number, cols: number): string[][] {
const rows: string[][] = []
const reusableCell = buffer.getNullCell()
for (let y = start; y < end; y++) {
rows.push(rowCells(buffer.getLine(y), cols, reusableCell))
// Equal public fields imply identical descriptors; differing fields still use the frozen policy.
function sameCellFields(expected: Cell, actual: Cell): boolean {
if (
expected.getWidth() !== actual.getWidth() ||
!sameColor(
expected.getBgColorMode(),
expected.getBgColor(),
actual.getBgColorMode(),
actual.getBgColor()
)
) {
return false
}
while (rows.length > 0 && rows.at(-1)!.every((c) => c === DEFAULT_BLANK)) {
rows.pop()
const expectedChars = expected.getChars()
const actualChars = actual.getChars()
const expectedBlank = expectedChars === '' || expectedChars === ' '
const actualBlank = actualChars === '' || actualChars === ' '
if (expectedBlank || actualBlank) {
return (
expectedBlank &&
actualBlank &&
(expectedChars === ' ' && expected.isUnderline() !== 0) ===
(actualChars === ' ' && actual.isUnderline() !== 0) &&
(expectedChars === ' ' && expected.isStrikethrough() !== 0) ===
(actualChars === ' ' && actual.isStrikethrough() !== 0) &&
(expectedChars === ' ' && expected.isOverline() !== 0) ===
(actualChars === ' ' && actual.isOverline() !== 0) &&
(expected.isInverse() !== 0) === (actual.isInverse() !== 0) &&
(expected.isInverse() === 0 ||
sameColor(
expected.getFgColorMode(),
expected.getFgColor(),
actual.getFgColorMode(),
actual.getFgColor()
))
)
}
return rows
return (
expectedChars === actualChars &&
sameColor(
expected.getFgColorMode(),
expected.getFgColor(),
actual.getFgColorMode(),
actual.getFgColor()
) &&
(expected.isBold() !== 0) === (actual.isBold() !== 0) &&
(expected.isDim() !== 0) === (actual.isDim() !== 0) &&
(expected.isItalic() !== 0) === (actual.isItalic() !== 0) &&
(expected.isUnderline() !== 0) === (actual.isUnderline() !== 0) &&
(expected.isInverse() !== 0) === (actual.isInverse() !== 0) &&
(expected.isInvisible() !== 0) === (actual.isInvisible() !== 0) &&
(expected.isStrikethrough() !== 0) === (actual.isStrikethrough() !== 0)
)
}
export function compareRowSets(
function trimmedEnd(
buffer: Buffer,
start: number,
end: number,
cols: number,
scratch: Cell
): number {
while (end > start) {
const line = buffer.getLine(end - 1)
let blank = true
for (let x = 0; x < cols; x++) {
if (cellDescriptor(line, x, cols, scratch) !== DEFAULT_BLANK) {
blank = false
break
}
}
if (!blank) {
break
}
end--
}
return end
}
function lineCellsMatch(
expected: BufferLine | undefined,
actual: BufferLine | undefined,
cols: number,
expectedScratch: Cell,
actualScratch: Cell
): boolean {
for (let x = 0; x < cols; x++) {
const expectedCell =
expected && x < expected.length ? expected.getCell(x, expectedScratch) : null
const actualCell = actual && x < actual.length ? actual.getCell(x, actualScratch) : null
const clipped =
x === cols - 1 &&
((expected && expected.length > cols && (expectedCell?.getWidth() ?? 0) > 1) ||
(actual && actual.length > cols && (actualCell?.getWidth() ?? 0) > 1))
if (expectedCell && actualCell && !clipped && sameCellFields(expectedCell, actualCell)) {
continue
}
if (
!cellsMatch(
cellDescriptor(expected, x, cols, expectedScratch),
cellDescriptor(actual, x, cols, actualScratch)
)
) {
return false
}
}
return true
}
/** Compares in place and formats only the first differing row, preserving diagnostic bytes. */
export function compareBufferRows(
stage: string,
expected: string[][],
actual: string[][]
expected: Buffer,
expectedStart: number,
expectedEnd: number,
actual: Buffer,
actualStart: number,
actualEnd: number,
cols: number
): GridDiff | null {
const length = Math.max(expected.length, actual.length)
for (let y = 0; y < length; y++) {
const e = expected[y]
const a = actual[y]
if (!e || !a || !rowsMatch(e, a)) {
return { stage, row: y, expected: e?.join('|'), actual: a?.join('|') }
const expectedScratch = expected.getNullCell()
const actualScratch = actual.getNullCell()
const expectedLength =
trimmedEnd(expected, expectedStart, expectedEnd, cols, expectedScratch) - expectedStart
const actualLength = trimmedEnd(actual, actualStart, actualEnd, cols, actualScratch) - actualStart
for (let y = 0; y < Math.max(expectedLength, actualLength); y++) {
const expectedLine = expected.getLine(expectedStart + y)
const actualLine = actual.getLine(actualStart + y)
if (
y >= expectedLength ||
y >= actualLength ||
!lineCellsMatch(expectedLine, actualLine, cols, expectedScratch, actualScratch)
) {
return {
stage,
row: y,
expected:
y < expectedLength ? rowCells(expectedLine, cols, expectedScratch).join('|') : undefined,
actual: y < actualLength ? rowCells(actualLine, cols, actualScratch).join('|') : undefined
}
}
}
return null
+17 -12
View File
@@ -18,12 +18,7 @@ import {
variantTrailingBackgroundRows
} from './serialize-grid-variant-scope'
import type { GridDiff } from './serialize-grid-cell-descriptors'
import {
bufferRows,
cellDescriptor,
CLIPPED,
compareRowSets
} from './serialize-grid-cell-descriptors'
import { cellDescriptor, CLIPPED, compareBufferRows } from './serialize-grid-cell-descriptors'
export type NamedSerializer = { name: string; create: () => SerializeAddon }
@@ -156,15 +151,25 @@ async function compareReplay(
(src.type === dst.type
? null
: { stage: 'active-buffer', expected: src.type, actual: dst.type }) ??
compareRowSets(
compareBufferRows(
'visible-grid',
bufferRows(src, src.baseY, src.baseY + rows, cols),
bufferRows(dst, dst.baseY, dst.baseY + rows, cols)
src,
src.baseY,
src.baseY + rows,
dst,
dst.baseY,
dst.baseY + rows,
cols
) ??
compareRowSets(
compareBufferRows(
'normal-buffer',
bufferRows(source.buffer.normal, normalStart, source.buffer.normal.length, cols),
bufferRows(replay.buffer.normal, 0, replay.buffer.normal.length, cols)
source.buffer.normal,
normalStart,
source.buffer.normal.length,
replay.buffer.normal,
0,
replay.buffer.normal.length,
cols
) ??
(src.cursorX === dst.cursorX && src.cursorY === dst.cursorY
? null
@@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import type * as DaemonBashRcfileModule from './daemon-bash-shell-ready-rcfile'
import { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready-bash-rcfile'
import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile'
import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path'
import {
OVERLAY_ONLY_FEATURES,
STARTUP_COMMAND_FEATURES
@@ -57,6 +60,45 @@ describePosix('daemon shell-ready bash wrapper', () => {
vi.restoreAllMocks()
})
itWithBash.each([
['daemon', getDaemonBashShellReadyRcfileContent],
['local', getBashShellReadyRcfileContent]
] as const)('keeps this app CLI first after %s Bash profiles reset PATH', (_lane, content) => {
const cliBin = join(userDataPath, 'cli', 'bin')
const ambientBin = join(userDataPath, 'ambient-bin')
mkdirSync(cliBin, { recursive: true })
mkdirSync(ambientBin)
for (const bin of [cliBin, ambientBin]) {
const launcher = join(bin, 'orca-dev')
writeFileSync(launcher, '#!/bin/sh\nexit 0\n')
chmodSync(launcher, 0o755)
}
const env: Record<string, string> = {
HOME: userDataPath,
USERPROFILE: userDataPath,
PATH: `${ambientBin}:/usr/bin:/bin`,
ORCA_BACKGROUND_LAUNCH: '1'
}
const expectedLauncher = prependOrcaCliDirToChildPath(env, {
isPackaged: false,
userDataPath
})
writeFileSync(
join(userDataPath, '.bash_profile'),
'export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n'
)
const rcfile = join(userDataPath, 'cli-path-rcfile')
writeFileSync(rcfile, content())
const result = spawnSync('bash', ['-c', '. "$1"; command -v orca-dev', 'bash', rcfile], {
env,
encoding: 'utf8',
timeout: 5000
})
expect(result.error).toBeUndefined()
expect(result.status).toBe(0)
expect(result.stdout.split('\x1b]133;C\x07').join('').trim()).toBe(expectedLauncher)
})
// Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows.
it('emits OSC 133 C/D markers in the daemon bash wrapper', async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
@@ -9,7 +9,96 @@ function writeSync(terminal: Terminal, data: string): void {
core.writeSync(data)
}
type Cell = ReturnType<Terminal['buffer']['active']['getNullCell']>
function observeCellReads(terminal: Terminal) {
const allocated: Cell[] = []
const targets: (Cell | undefined)[] = []
const active = terminal.buffer.active
const source = {
rows: terminal.rows,
modes: terminal.modes,
buffer: {
active: {
baseY: active.baseY,
cursorX: active.cursorX,
cursorY: active.cursorY,
viewportY: active.viewportY,
getNullCell: () => {
const cell = active.getNullCell()
allocated.push(cell)
return cell
},
getLine: (row: number) => {
const line = active.getLine(row)
if (!line) {
return undefined
}
return {
isWrapped: line.isWrapped,
length: line.length,
translateToString: line.translateToString.bind(line),
getCell: (column: number, reusableCell?: Cell) => {
targets.push(reusableCell)
return line.getCell(column, reusableCell)
}
}
}
}
}
}
return { source, allocated, targets }
}
describe('readTerminalCursorLineContext', () => {
it('reuses one cell across every scan without retaining it between reads', () => {
const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true })
try {
writeSync(terminal, '\x1b[1m❯\x1b[22m 界e\u0301\x1b7\r\n\x1b[31mfooter\x1b8')
const rig = observeCellReads(terminal)
const first = readTerminalCursorLineContext(rig.source, terminal.rows)
expect(rig.allocated).toHaveLength(1)
expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2)
expect(rig.targets.every((cell) => cell === rig.allocated[0])).toBe(true)
rig.targets.length = 0
expect(readTerminalCursorLineContext(rig.source, terminal.rows)).toEqual(first)
expect(rig.allocated).toHaveLength(2)
expect(rig.allocated[1]).not.toBe(rig.allocated[0])
expect(rig.targets.every((cell) => cell === rig.allocated[1])).toBe(true)
} finally {
terminal.dispose()
}
})
it('preserves full context for an adapter without reusable cells', () => {
const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true })
try {
writeSync(
terminal,
'\x1b[1m❯\x1b[22m typed\x1b7\x1b[2m hint\x1b[22m\r\n\x1b[38;2;1;2;3m界e\u0301\x1b[0m\x1b8'
)
const rig = observeCellReads(terminal)
const source = {
...rig.source,
buffer: { active: { ...rig.source.buffer.active, getNullCell: undefined } }
}
const context = readTerminalCursorLineContext(source, terminal.rows)
expect(context).toEqual(readTerminalCursorLineContext(terminal, terminal.rows))
expect(context?.typedRows).toEqual(['❯ typed'])
expect(context?.typedRowsBelow[0]).toBe('界e\u0301')
expect(context?.beforeCursor).toBe('❯ typed')
expect(context?.afterCursor).toBe('')
expect(context?.rawAfterCursor).toBe(' hint')
expect(context?.promptGlyphBoldRows).toEqual([true])
expect(context?.rowsBelowCustomForeground?.[0]).toBe(true)
expect(rig.allocated).toEqual([])
expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2)
expect(rig.targets.every((cell) => cell === undefined)).toBe(true)
} finally {
terminal.dispose()
}
})
it.each([
{ cols: 19, cursorRowTail: 'proceed with the ', continuation: 'release' },
{ cols: 18, cursorRowTail: 'proceed with the', continuation: ' release' }
@@ -20,6 +20,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map<string, number>([
['main/bitbucket/client.ts', 1],
['main/bitbucket/user-request.ts', 1],
['main/gitea/client.ts', 1],
// Generated OpenCode claim source consumes JSON or cancels its body in finally.
['main/opencode/opencode-startup-prompt-source.ts', 1],
['main/orca-profiles/profile-cloud-client.ts', 1],
['main/orca-profiles/profile-cloud-org-members-client.ts', 1],
['main/rate-limits/codex-fetcher.ts', 3],
@@ -62,7 +62,7 @@ describe('listQuickOpenFiles name filter', () => {
it('counts only matches against the ripgrep cap', async () => {
wslAwareSpawnMock
.mockImplementationOnce(() => fakeRipgrep('a.ts\nb.ts\nc.ts\nios/AppDelegate.swift\n'))
.mockImplementationOnce(() => fakeRipgrep('a.ts\0b.ts\0c.ts\0ios/AppDelegate.swift\0'))
.mockImplementationOnce(() => fakeRipgrep(''))
const files = await listQuickOpenFiles(
@@ -80,7 +80,7 @@ describe('listQuickOpenFiles name filter', () => {
it('rejects with the bundled-ripgrep error when the filtered ignored pass cannot start', async () => {
wslAwareSpawnMock
.mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n'))
.mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0'))
.mockImplementationOnce(() => fakeRipgrep('', null, -2))
await expect(
@@ -98,7 +98,7 @@ describe('listQuickOpenFiles name filter', () => {
it('keeps primary matches when the ignored-file pass fails during a filtered scan', async () => {
wslAwareSpawnMock
.mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n'))
.mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0'))
.mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL'))
await expect(
@@ -116,7 +116,7 @@ describe('listQuickOpenFiles name filter', () => {
it('still rejects an ignored-pass failure for unfiltered listings', async () => {
wslAwareSpawnMock
.mockImplementationOnce(() => fakeRipgrep('a.ts\n'))
.mockImplementationOnce(() => fakeRipgrep('a.ts\0'))
.mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL'))
await expect(
+49 -22
View File
@@ -88,6 +88,33 @@ describe('filesystem-list-files', () => {
)
})
it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => {
const child = createMockProcess()
spawnMock.mockReturnValue(child)
const store: Store = Object.create(null)
const promise = listQuickOpenFiles('/repo', store)
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1))
child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82]))
if (kind === 'incomplete') {
child.emit('close', 0, null)
}
await expect(promise).rejects.toThrow('not valid UTF-8')
if (kind === 'invalid') {
expect(child.kill).toHaveBeenCalled()
}
})
it('counts NUL-delimited filenames containing newlines as one result each', async () => {
const child = createMockProcess()
spawnMock.mockReturnValue(child)
const store: Store = Object.create(null)
const promise = listQuickOpenFiles('/repo', store, undefined, undefined, 2)
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1))
child.stdout?.emit('data', 'first\nsecond.ts\0trailing\r\0third.ts\0')
await expect(promise).resolves.toEqual(['first\nsecond.ts', 'trailing\r'])
expect(child.kill).toHaveBeenCalled()
})
it('rejects a synchronous launch failure before cleanup has been initialized', async () => {
spawnMock.mockImplementationOnce(() => {
throw Object.assign(new Error('spawn EMFILE'), { code: 'EMFILE' })
@@ -122,7 +149,7 @@ describe('filesystem-list-files', () => {
)
setTimeout(() => {
;(p1.stdout as unknown as EventEmitter).emit('data', 'one.ts\ntwo.ts')
p1.stdout?.emit('data', 'one.ts\0two.ts')
p1.emit('close', 0, null)
}, 0)
const result = await promise
@@ -154,12 +181,12 @@ describe('filesystem-list-files', () => {
await flushMicrotasks()
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--no-ignore-vcs')
source.stdout?.emit('data', 'source.ts\n')
source.stdout?.emit('data', 'source.ts\0')
source.emit('close', 0, null)
await flushMicrotasks()
expect(spawnMock).toHaveBeenCalledTimes(2)
expect(spawnMock.mock.calls[1]?.[1]).toContain('--no-ignore-vcs')
broad.stdout?.emit('data', 'ignored-file.ts\n')
broad.stdout?.emit('data', 'ignored-file.ts\0')
await expect(listing).resolves.toEqual(['source.ts'])
expect(broad.kill).toHaveBeenCalledOnce()
})
@@ -177,18 +204,18 @@ describe('filesystem-list-files', () => {
// Simulate stdout output for normal files
setTimeout(() => {
p1.stdout?.emit('data', 'file1.ts\n')
p1.stdout?.emit('data', 'node_modules/bad.js\n')
p1.stdout?.emit('data', '.git/config\n')
p1.stdout?.emit('data', '.github/workflows/ci.yml\n')
p1.stdout?.emit('data', 'file1.ts\0')
p1.stdout?.emit('data', 'node_modules/bad.js\0')
p1.stdout?.emit('data', '.git/config\0')
p1.stdout?.emit('data', '.github/workflows/ci.yml\0')
p1.stdout?.emit('data', 'dir1/') // incomplete line
p1.stdout?.emit('data', 'file2.js\n')
p1.stdout?.emit('data', 'file2.js\0')
// The broad pass includes ignored files too.
p1.stdout?.emit('data', '.env.local\n')
p1.stdout?.emit('data', 'dist/generated.js\n')
p1.stdout?.emit('data', 'file1.ts\n') // Duplicate
p1.stdout?.emit('data', 'node_modules/ignored.js\n')
p1.stdout?.emit('data', '.env.local\0')
p1.stdout?.emit('data', 'dist/generated.js\0')
p1.stdout?.emit('data', 'file1.ts\0') // Duplicate
p1.stdout?.emit('data', 'node_modules/ignored.js\0')
p1.emit('close', 0, null)
}, 10)
@@ -213,7 +240,7 @@ describe('filesystem-list-files', () => {
const promise = listQuickOpenFiles('C:\\repo', storeMock)
setTimeout(() => {
;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
p1.stdout?.emit('data', 'src/index.ts\0')
p1.emit('close', 0, null)
}, 10)
@@ -237,7 +264,7 @@ describe('filesystem-list-files', () => {
const promise = listQuickOpenFiles('C:\\repo', storeMock)
setTimeout(() => {
;(p1.stdout as unknown as EventEmitter).emit('data', '/mnt/c/repo/src/index.ts\n')
p1.stdout?.emit('data', '/mnt/c/repo/src/index.ts\0')
p1.emit('close', 0, null)
}, 10)
@@ -310,7 +337,7 @@ describe('filesystem-list-files', () => {
const promise = listQuickOpenFiles('/mock/root', storeMock)
setTimeout(() => {
;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
p1.stdout?.emit('data', 'src/index.ts\0')
p1.emit('close', 2, null)
}, 10)
@@ -332,7 +359,7 @@ describe('filesystem-list-files', () => {
await Promise.resolve()
await Promise.resolve()
;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\npartial')
p1.stdout?.emit('data', 'src/index.ts\0partial')
const rejection = expect(promise).rejects.toThrow('rg list timed out')
await vi.advanceTimersByTimeAsync(10000)
@@ -376,12 +403,12 @@ describe('filesystem-list-files', () => {
const promise = listQuickOpenFiles('/mock/root', storeMock)
setTimeout(() => {
;(p1.stdout as unknown as EventEmitter).emit('data', '.next/cache/1.js\n')
;(p1.stdout as unknown as EventEmitter).emit('data', '.cache/data.json\n')
;(p1.stdout as unknown as EventEmitter).emit('data', '.stably/config.json\n')
;(p1.stdout as unknown as EventEmitter).emit('data', '.vscode/settings.json\n')
;(p1.stdout as unknown as EventEmitter).emit('data', '.idea/workspace.xml\n')
;(p1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\n')
p1.stdout?.emit('data', '.next/cache/1.js\0')
p1.stdout?.emit('data', '.cache/data.json\0')
p1.stdout?.emit('data', '.stably/config.json\0')
p1.stdout?.emit('data', '.vscode/settings.json\0')
p1.stdout?.emit('data', '.idea/workspace.xml\0')
p1.stdout?.emit('data', 'valid.ts\0')
p1.emit('close', 0, null)
}, 10)
+29 -38
View File
@@ -1,3 +1,5 @@
import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode'
import { RipgrepFilenameDecoder, RipgrepFilenameError } from '../../shared/ripgrep-filename-decoder'
import { sep } from 'node:path'
import type { ChildProcess } from 'node:child_process'
import type { Store } from '../persistence'
@@ -8,7 +10,6 @@ import {
buildExcludePathPrefixes,
buildRgArgsForQuickOpen,
normalizeQuickOpenRgLine,
type RgOutputMode,
shouldExcludeQuickOpenRelPath,
shouldIncludeQuickOpenPath
} from '../../shared/quick-open-filter'
@@ -79,6 +80,10 @@ export async function listQuickOpenFiles(
const runRg = (args: string[]): Promise<void> => {
return new Promise((resolve, reject) => {
const filenameDecoder = new RipgrepFilenameDecoder((error) => {
killSpawnedRipgrepProcess(child)
finish(error)
}, Boolean(wslDistroForOutput))
let buf = ''
let done = false
let parseablePathCount = 0
@@ -138,18 +143,22 @@ export async function listQuickOpenFiles(
return
}
let timer: ReturnType<typeof setTimeout>
const handleStdoutData = (chunk: string): void => {
buf += chunk
const handleStdoutData = (chunk: Buffer | string): void => {
const decoded = filenameDecoder.decode(chunk)
if (decoded === null) {
return
}
buf += decoded
let start = 0
let newlineIdx = buf.indexOf('\n', start)
while (newlineIdx !== -1) {
if (processLine(buf.substring(start, newlineIdx))) {
let delimiterIdx = buf.indexOf('\0', start)
while (delimiterIdx !== -1) {
if (processLine(buf.substring(start, delimiterIdx))) {
buf = ''
finishAtLimit()
return
}
start = newlineIdx + 1
newlineIdx = buf.indexOf('\n', start)
start = delimiterIdx + 1
delimiterIdx = buf.indexOf('\0', start)
}
buf = start < buf.length ? buf.substring(start) : ''
}
@@ -211,14 +220,15 @@ export async function listQuickOpenFiles(
finish(new Error(`rg killed by ${signal}`))
return
}
if (!filenameDecoder.finish()) {
return
}
if (buf && processLine(buf)) {
buf = ''
finishAtLimit()
return
}
if (code === 0 || code === 1) {
finish()
} else if (code === 2 && parseablePathCount > 0) {
if (code === 0 || code === 1 || (code === 2 && parseablePathCount > 0)) {
// rg can return 2 for unreadable subdirectories while still listing
// usable files from the rest of the root.
finish()
@@ -257,7 +267,6 @@ export async function listQuickOpenFiles(
children.push({ child, isDone: () => done, finish })
child.stdout?.setEncoding('utf-8')
child.stdout?.on('data', handleStdoutData)
child.stderr?.on('data', handleStderrData)
child.once('error', handleError)
@@ -290,16 +299,9 @@ export async function listQuickOpenFiles(
}
function finishAtLimit(): void {
for (const entry of children) {
if (entry.isDone()) {
continue
}
entry.finish()
if (entry.child.exitCode === null && entry.child.signalCode === null) {
killSpawnedRipgrepProcess(entry.child)
}
}
killSurvivors()
}
try {
if (maxResults === undefined && maxSerializedBytes === undefined) {
// The broader pass already includes source files; an unbounded listing needs only one scan.
@@ -314,7 +316,12 @@ export async function listQuickOpenFiles(
) {
// Why: a filtered scan walks the whole tree; an ignored-pass timeout keeps primary matches.
await runRg(ignoredPass).catch((err: unknown) => {
if (!pathFilter || signal?.aborted || err instanceof RipgrepUnavailableError) {
if (
!pathFilter ||
signal?.aborted ||
err instanceof RipgrepUnavailableError ||
err instanceof RipgrepFilenameError
) {
throw err
}
})
@@ -329,19 +336,3 @@ export async function listQuickOpenFiles(
? result
: limitQuickOpenFilesBySerializedBytes(result, maxSerializedBytes)
}
function getQuickOpenRgOutputMode(
rawLine: string,
translatedLine: string,
rootPath: string
): RgOutputMode {
if (
translatedLine !== rawLine ||
rawLine.startsWith('/') ||
/^[A-Za-z]:[\\/]/.test(rawLine) ||
rawLine.startsWith('\\\\')
) {
return { kind: 'absolute', rootPath }
}
return { kind: 'cwd-relative' }
}
@@ -81,6 +81,64 @@ describe('searchQuickOpenFilePaths', () => {
)
})
it('preserves cancellation while an incomplete UTF-8 scalar is buffered', async () => {
const child = createMockProcess()
wslAwareSpawnMock.mockReturnValue(child)
const controller = new AbortController()
const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, {
query: 'file',
limit: 2,
signal: controller.signal
})
await flushMicrotasks()
child.stdout?.emit('data', Buffer.from([0xf0, 0x9f]))
controller.abort()
await expect(promise).rejects.toSatisfy(isFileListingCancellation)
})
it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => {
const child = createMockProcess()
wslAwareSpawnMock.mockReturnValue(child)
const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { query: 'file', limit: 2 })
await flushMicrotasks()
child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82]))
if (kind === 'incomplete') {
child.emit('close', 0, null)
}
await expect(promise).rejects.toThrow('not valid UTF-8')
if (kind === 'invalid') {
expect(child.kill).toHaveBeenCalled()
}
})
it('preserves control characters within ranked paths', async () => {
const child = createMockProcess()
wslAwareSpawnMock.mockReturnValue(child)
const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, {
query: 'target',
limit: 2
})
await flushMicrotasks()
child.stdout?.emit('data', 'first\ntarget.ts\0target.ts\r\0')
child.emit('close', 0, null)
expect((await promise).paths.sort()).toEqual(['first\ntarget.ts', 'target.ts\r'].sort())
})
it('rejects and stops an oversized path rather than ranking a truncated suffix', async () => {
const child = createMockProcess()
wslAwareSpawnMock.mockReturnValue(child)
const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, {
query: 'target',
limit: 2
})
await flushMicrotasks()
child.stdout?.emit('data', 'x'.repeat(64 * 1024 + 1))
await expect(promise).rejects.toThrow('file path exceeds the listing limit')
expect(child.kill).toHaveBeenCalledTimes(1)
child.stdout?.emit('data', 'target.ts\0')
child.emit('close', 0, null)
})
it('finds fuzzy matches after 100k paths without returning excluded worktrees', async () => {
const child = createMockProcess()
wslAwareSpawnMock.mockReturnValue(child)
@@ -94,14 +152,11 @@ describe('searchQuickOpenFilePaths', () => {
expect(wslAwareSpawnMock).toHaveBeenCalledTimes(1)
expect(wslAwareSpawnMock.mock.calls[0][0]).toBe('/bundled/rg')
expect(wslAwareSpawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs')
;(child.stdout as unknown as EventEmitter).emit(
child.stdout?.emit(
'data',
`${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\n')}\n`
)
;(child.stdout as unknown as EventEmitter).emit(
'data',
'nested/src/sta-4354-target.ts\nsrc/sta-4354-target.ts\n'
`${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\0')}\0`
)
child.stdout?.emit('data', 'nested/src/sta-4354-target.ts\0src/sta-4354-target.ts\0')
child.emit('close', 0, null)
await expect(promise).resolves.toEqual({
@@ -138,7 +193,7 @@ describe('searchQuickOpenFilePaths', () => {
limit: 32
})
await flushMicrotasks()
;(child.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n')
child.stdout?.emit('data', 'src/target.ts\0')
child.emit('close', 0, null)
await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] })
@@ -164,10 +219,7 @@ describe('searchQuickOpenFilePaths', () => {
await flushMicrotasks()
expect(wslAwareSpawnMock).toHaveBeenCalledTimes(2)
;(succeeded.stdout as unknown as EventEmitter).emit(
'data',
'data/chunk-077568/sta-4354-gitignored-target.bin\n'
)
succeeded.stdout?.emit('data', 'data/chunk-077568/sta-4354-gitignored-target.bin\0')
succeeded.emit('close', 0, null)
await expect(promise).resolves.toEqual({
@@ -189,7 +241,7 @@ describe('searchQuickOpenFilePaths', () => {
limit: 32
})
await flushMicrotasks()
;(succeeded.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n')
succeeded.stdout?.emit('data', 'src/target.ts\0')
succeeded.emit('close', 0, null)
await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] })
+22 -16
View File
@@ -1,3 +1,5 @@
import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode'
import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder'
import { sep } from 'node:path'
import type { Store } from '../persistence'
import { fileListingCancellationError } from '../../shared/file-listing-cancellation'
@@ -6,8 +8,7 @@ import {
buildRgArgsForQuickOpen,
normalizeQuickOpenRgLine,
shouldExcludeQuickOpenRelPath,
shouldIncludeQuickOpenPath,
type RgOutputMode
shouldIncludeQuickOpenPath
} from '../../shared/quick-open-filter'
import { isQuickOpenQueryTooLarge, QuickOpenPathRanker } from '../../shared/quick-open-path-search'
import {
@@ -110,7 +111,11 @@ function scanRipgrepPaths(args: {
return Promise.reject(fileListingCancellationError(args.signal))
}
return new Promise((resolve, reject) => {
const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0x0a)
const filenameDecoder = new RipgrepFilenameDecoder((error) => {
killSpawnedRipgrepProcess(child)
finish(error)
}, Boolean(args.wslDistroForOutput))
const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0)
let done = false
let parseablePathCount = 0
let processErrorObserved = false
@@ -141,7 +146,7 @@ function scanRipgrepPaths(args: {
: rawLine
const relPath = normalizeQuickOpenRgLine(
translated,
getOutputMode(rawLine, translated, args.authorizedRootPath)
getQuickOpenRgOutputMode(rawLine, translated, args.authorizedRootPath)
)
if (relPath === null) {
return
@@ -178,11 +183,19 @@ function scanRipgrepPaths(args: {
resolve()
}
}
const handleStdoutData = (chunk: string): void => {
pathAccumulator.push(chunk, (path) => {
const handleStdoutData = (chunk: Buffer | string): void => {
const decoded = filenameDecoder.decode(chunk)
if (decoded === null) {
return
}
const result = pathAccumulator.push(decoded, (path) => {
processLine(path)
return true
})
if (result === 'path-too-large') {
killSpawnedRipgrepProcess(child)
finish(new Error('Quick Open file path exceeds the listing limit'))
}
}
const handleStderrData = (): void => {
/* drain */
@@ -233,6 +246,9 @@ function scanRipgrepPaths(args: {
finish(new Error(`rg killed by ${signal}`))
return
}
if (!filenameDecoder.finish()) {
return
}
const trailingPath = pathAccumulator.finish()
if (trailingPath) {
processLine(trailingPath)
@@ -249,7 +265,6 @@ function scanRipgrepPaths(args: {
finish(fileListingCancellationError(args.signal))
}
child.stdout?.setEncoding('utf-8')
child.stdout?.on('data', handleStdoutData)
child.stderr?.on('data', handleStderrData)
child.once('error', handleError)
@@ -265,12 +280,3 @@ function scanRipgrepPaths(args: {
}
})
}
function getOutputMode(rawLine: string, translatedLine: string, rootPath: string): RgOutputMode {
return translatedLine !== rawLine ||
rawLine.startsWith('/') ||
/^[A-Za-z]:[\\/]/.test(rawLine) ||
rawLine.startsWith('\\\\')
? { kind: 'absolute', rootPath }
: { kind: 'cwd-relative' }
}
@@ -199,7 +199,7 @@ describe('filesystem rg search timeout', () => {
}
)
it('keeps post-spawn errors on the existing empty-result path', async () => {
it('rejects post-spawn errors instead of returning an empty result', async () => {
const child = createMockProcess()
Object.defineProperty(child, 'pid', { value: 1 })
wslAwareSpawnMock.mockReturnValue(child)
@@ -212,7 +212,7 @@ describe('filesystem rg search timeout', () => {
await flushMicrotasks()
child.emit('error', new Error('post-spawn failure'))
await expect(promise).resolves.toMatchObject({ files: [] })
await expect(promise).rejects.toThrow('post-spawn failure')
})
// Why close(97): the WSL wrapper's "cd failed" code. It is above rg's own 0/1/2, so a handler
@@ -298,6 +298,34 @@ describe('filesystem rg search timeout', () => {
expect(wslAwareSpawnMock.mock.calls[0]?.[0]).toBe('/bundled/linux/rg')
})
it('marks WSL filenames that UNC cannot represent as incomplete', async () => {
const child = createMockProcess()
wslAwareSpawnMock.mockReturnValue(child)
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all store access is mocked for this handler.
registerFilesystemHandlers({} as never)
const promise = handlers.get('fs:search')!(
{ sender: { id: 7 } },
{ rootPath: 'C:\\repo', query: 'hello' }
)
await flushMicrotasks()
child.stdout?.emit(
'data',
`${JSON.stringify({
type: 'match',
data: {
path: { text: './a\\b.txt' },
lines: { text: 'hello\n' },
line_number: 1,
submatches: [{ start: 0, end: 5 }]
}
})}\n`
)
child.emit('close', 0, null)
await expect(promise).resolves.toMatchObject({ files: [], truncated: true })
expect(toWindowsWslPathMock).not.toHaveBeenCalled()
})
it('translates WSL rg output for Windows-path project search results', async () => {
const child = createMockProcess()
wslAwareSpawnMock.mockReturnValue(child)
@@ -1,3 +1,4 @@
import { RipgrepSearchDiagnostics } from '../../../shared/ripgrep-search-diagnostics'
import { SearchSubprocessLineAccumulator } from '../../../shared/search-subprocess-lines'
import { ipcMain } from 'electron'
import type { ChildProcess } from 'node:child_process'
@@ -65,7 +66,7 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro
return new Promise<SearchResult>((resolvePromise, rejectPromise) => {
const rgArgs = buildRgArgs(args.query, rootPath, args)
const rgArgs = buildRgArgs(args.query, '.', args)
// Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on.
const previousChild = activeTextSearches.get(searchKey)
if (previousChild) {
@@ -73,7 +74,8 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
}
const acc = createAccumulator()
const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER)
const lines = new SearchSubprocessLineAccumulator()
const diagnostics = new RipgrepSearchDiagnostics()
let resolved = false
let processErrorObserved = false
let unavailableExitObserved = false
@@ -81,8 +83,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
let killTimeout: ReturnType<typeof setTimeout>
const transformAbsPath = wslDistroForOutput
? (path: string): string =>
path.startsWith('/') ? toWindowsWslPath(path, wslDistroForOutput) : path
? (path: string): string | null =>
path.includes('\\')
? null
: path.startsWith('/')
? toWindowsWslPath(path, wslDistroForOutput)
: path
: undefined
const finish = (result: SearchResult | PromiseLike<SearchResult>): void => {
@@ -108,7 +114,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
}
resolvePromise(result)
}
const resolveOnce = (): void => finish(finalize(acc))
const resolveOnce = (code = 0, signal: NodeJS.Signals | null = null): void => {
const error = diagnostics.failure(code, signal, acc)
finish(error ? Promise.reject(error) : finalize(acc))
}
const rejectUnavailable = (): void =>
finish(Promise.reject(bundledRipgrepUnavailableError()))
const processLine = (line: string): void => {
@@ -138,10 +147,16 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
activeTextSearches.set(searchKey, nextChild)
const handleStdoutData = (chunk: string): void => {
lines.push(chunk, processLine)
if (!lines.push(chunk, processLine)) {
acc.truncated = true
if (child) {
killSpawnedRipgrepProcess(child)
}
resolveOnce()
}
}
const handleStderrData = (): void => {
// Drain stderr so rg cannot block on a full pipe.
const handleStderrData = (chunk: Buffer): void => {
diagnostics.append(chunk)
}
const handleError = (error: NodeJS.ErrnoException): void => {
processErrorObserved = true
@@ -151,18 +166,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
return
}
if (child && isRipgrepUnavailableExit(child, null, null)) {
// Why the cwd check first: spawn reports a missing cwd as ENOENT too, and blaming the
// binary for it tells the user to reinstall Orca over a workspace that simply moved.
// Why detach close first: a failed spawn emits error THEN close(code < 0), and
// close settles synchronously, so this probe would otherwise race it on a sub-ms
// margin -- two measurements disagreed on which wins. Detaching makes it deterministic.
// Distinguish a missing workspace from a missing binary before close can settle.
child.off('close', handleClose)
// Why catch: a failed probe must not strand the search; fall back to the prior verdict.
void isRipgrepSpawnCwdUsable(rootPath)
.catch(() => true)
.then((usable) => {
// Why re-check: finish() drops its argument once settled, so a rejected promise
// built after the close handler already won would go unhandled.
// A late rejected promise must not escape after close settles the search.
if (resolved) {
return
}
@@ -174,7 +183,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
})
return
}
resolveOnce()
finish(Promise.reject(error))
if (child) {
killSpawnedRipgrepProcess(child)
}
}
const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => {
// Why first: this code is above rg's own 0/1/2, so the unavailable check would otherwise
@@ -193,11 +205,11 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
rejectUnavailable()
return
}
const tail = lines.finish()
const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null
if (tail !== null) {
processLine(tail)
}
resolveOnce()
resolveOnce(code ?? -1, signal)
}
nextChild.stdout?.setEncoding('utf-8')
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest'
import type * as NodePath from 'node:path'
vi.mock('node:path', async (importOriginal) => {
const actual = await importOriginal<typeof NodePath>()
return { ...actual, extname: actual.win32.extname }
})
import { isMarkdownDocumentName, markdownDocumentFromRelativePath } from './markdown-documents'
describe('remote Markdown filenames on a Windows client', () => {
it('keeps the local filename helper on Windows semantics', () => {
expect(isMarkdownDocumentName('notes\\.md')).toBe(false)
expect(isMarkdownDocumentName('notes.md\\')).toBe(true)
})
it.each(['notes\\.md', 'a\\b.MDX', '..\\a.markdown', 'nested/README.md'])(
'preserves POSIX filename %s and its extension',
(relativePath) => {
const basename = relativePath.slice(relativePath.lastIndexOf('/') + 1)
expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toEqual({
filePath: `/home/repo/${relativePath}`,
relativePath,
basename,
name: basename.slice(0, basename.lastIndexOf('.'))
})
}
)
it.each(['notes.md\\', 'nested/.md', '../outside.md'])(
'rejects non-Markdown or escaping POSIX filename %s',
(relativePath) => {
expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toBeNull()
}
)
it('normalizes Windows remote separators before reading the basename', () => {
expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\README.MD')).toEqual({
filePath: 'C:\\repo/notes/README.MD',
relativePath: 'notes/README.MD',
basename: 'README.MD',
name: 'README'
})
expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\.md')).toBeNull()
expect(markdownDocumentFromRelativePath('C:\\repo', '..\\outside.md')).toBeNull()
})
})
@@ -47,6 +47,24 @@ describe('Markdown document ripgrep lifecycle', () => {
expect(child.listenerCount('close')).toBe(0)
})
it('preserves timeout when an incomplete UTF-8 scalar is abandoned', async () => {
vi.useFakeTimers()
const result = listMarkdownDocuments(root)
const outcome = expect(result).rejects.toThrow('timed out')
child.stdout.write(Buffer.from([0xf0, 0x9f]))
await vi.advanceTimersByTimeAsync(15_000)
await outcome
})
it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => {
const result = listMarkdownDocuments(root)
child.stdout.write(Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82]))
if (kind === 'incomplete') {
child.emit('close', 0, null)
}
await expect(result).rejects.toThrow('not valid UTF-8')
})
it('accepts an empty listing', async () => {
const result = listMarkdownDocuments(root)
child.emit('close', 1, null)
+35 -1
View File
@@ -1,7 +1,41 @@
import { describe, expect, it } from 'vitest'
import { markdownDocumentFromFilePath } from './markdown-documents'
import {
markdownDocumentFromFilePath,
markdownDocumentFromRelativePath
} from './markdown-documents'
describe('markdownDocumentFromFilePath', () => {
it.skipIf(process.platform === 'win32')('preserves local literal backslash names', () => {
expect(markdownDocumentFromFilePath('/repo\\', '/repo\\/a\\b.md')).toMatchObject({
filePath: '/repo\\/a\\b.md',
relativePath: 'a\\b.md',
basename: 'a\\b.md'
})
})
it('preserves POSIX remote filenames and trailing root backslashes', () => {
for (const name of ['a\\b.md', 'a/b.md', '..\\a.md']) {
expect(markdownDocumentFromRelativePath('/repo\\', name)).toMatchObject({
filePath: `/repo\\/${name}`,
relativePath: name,
basename: name.slice(name.lastIndexOf('/') + 1)
})
}
expect(markdownDocumentFromRelativePath('/repo\\', '../a.md')).toBeNull()
})
it.each(['C:\\repo\\', '\\\\server\\share\\repo\\'])(
'preserves Windows separator handling under %s',
(root) => {
expect(markdownDocumentFromRelativePath(root, 'a\\b.md')).toMatchObject({
filePath: `${root.slice(0, -1)}/a/b.md`,
relativePath: 'a/b.md',
basename: 'b.md'
})
expect(markdownDocumentFromRelativePath(root, '..\\a.md')).toBeNull()
}
)
it('keeps in-root path segments that merely start with parent traversal text', () => {
expect(markdownDocumentFromFilePath('/workspace', '/workspace/..notes/file.md')).toMatchObject({
filePath: '/workspace/..notes/file.md',
+46 -25
View File
@@ -1,4 +1,15 @@
import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'node:path'
import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import { normalizeRelativePath } from '../../shared/text-search-paths'
import {
basename as pathBasename,
extname,
isAbsolute,
join,
posix,
relative,
resolve
} from 'node:path'
import type { FileDocument, MarkdownDocument } from '../../shared/filesystem-entry-types'
import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn'
import { parseWslPath } from '../wsl'
@@ -7,36 +18,34 @@ import {
ripgrepMissingCwdError
} from '../../shared/ripgrep-process-availability'
function normalizeRelativePath(path: string): string {
return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
export function isMarkdownDocumentName(name: string): boolean {
return isMarkdownExtension(extname(name))
}
export function isMarkdownDocumentName(name: string): boolean {
const extension = extname(name).toLowerCase()
return extension === '.md' || extension === '.mdx' || extension === '.markdown'
function isMarkdownExtension(extension: string): boolean {
const normalized = extension.toLowerCase()
return normalized === '.md' || normalized === '.mdx' || normalized === '.markdown'
}
function basenameFromRelativePath(relativePath: string): string {
const normalizedPath = relativePath.replaceAll('\\', '/')
return normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1)
return relativePath.slice(relativePath.lastIndexOf('/') + 1)
}
function isSafeRelativePath(relativePath: string): boolean {
return !relativePath.split('/').includes('..')
}
function hasParentTraversalSegment(relativePath: string): boolean {
return relativePath.split(/[\\/]+/).includes('..')
}
function rootRelativePath(rootPath: string, filePath: string): string | null {
const resolvedRoot = resolve(rootPath)
const resolvedFile = resolve(filePath)
const relativePath = relative(resolvedRoot, resolvedFile)
if (hasParentTraversalSegment(relativePath) || isAbsolute(relativePath)) {
if (
!isSafeRelativePath(normalizeRelativePath(relativePath, rootPath)) ||
isAbsolute(relativePath)
) {
return null
}
return normalizeRelativePath(relativePath)
return normalizeRelativePath(relativePath, rootPath)
}
export function fileDocumentFromFilePath(
@@ -50,7 +59,7 @@ export function fileDocumentFromFilePath(
rootRelativePath(rootPath, filePath) ??
(options.outsideRootRelativePath === 'basename'
? basename
: normalizeRelativePath(relative(rootPath, filePath)))
: normalizeRelativePath(relative(rootPath, filePath), rootPath))
return {
filePath,
relativePath,
@@ -65,18 +74,22 @@ export function markdownDocumentFromRelativePath(
rootPath: string,
relativePath: string
): MarkdownDocument | null {
const normalizedRelativePath = normalizeRelativePath(relativePath)
const normalizedRelativePath = normalizeRelativePath(relativePath, rootPath)
// Why: SSH providers should return root-relative paths; reject escape
// segments before building a synthetic absolute path for renderer use.
if (!isSafeRelativePath(normalizedRelativePath)) {
return null
}
const basename = basenameFromRelativePath(normalizedRelativePath)
if (!isMarkdownDocumentName(basename)) {
// Remote separators are already normalized; a POSIX backslash stays part of the name.
const extension = posix.extname(basename)
if (!isMarkdownExtension(extension)) {
return null
}
const extension = extname(basename)
const normalizedRoot = rootPath.replace(/[\\/]+$/, '')
const normalizedRoot = rootPath.replace(
isWindowsAbsolutePathLike(rootPath) ? /[\\/]+$/ : /\/+$/,
''
)
return {
filePath: `${normalizedRoot}/${normalizedRelativePath}`,
relativePath: normalizedRelativePath,
@@ -131,6 +144,10 @@ export async function listMarkdownDocuments(
)
return new Promise((resolveListing, reject) => {
const filenameDecoder = new RipgrepFilenameDecoder(
(error) => finish(error),
Boolean(parseWslPath(rootPath)?.distro ?? options.wslDistro)
)
const documents: MarkdownDocument[] = []
let carry = ''
let stderr = ''
@@ -172,8 +189,12 @@ export async function listMarkdownDocuments(
const onStderr = (chunk: string): void => {
stderr = (stderr + chunk).slice(0, 4096)
}
const onData = (chunk: string): void => {
carry += chunk
const onData = (chunk: Buffer | string): void => {
const decoded = filenameDecoder.decode(chunk)
if (decoded === null) {
return
}
carry += decoded
let start = 0
let end: number
while ((end = carry.indexOf('\0', start)) !== -1) {
@@ -201,10 +222,11 @@ export async function listMarkdownDocuments(
finish(ripgrepMissingCwdError(rootPath))
} else if (signal || (code !== 0 && code !== 1)) {
finish(new Error(`Markdown document listing failed (${signal ?? code}): ${stderr.trim()}`))
} else if (carry) {
finish(new Error('Incomplete path in Markdown document listing'))
} else {
finish()
if (!filenameDecoder.finish()) {
return
}
finish(carry ? new Error('Incomplete path in Markdown document listing') : undefined)
}
}
const timer = setTimeout(
@@ -212,7 +234,6 @@ export async function listMarkdownDocuments(
MARKDOWN_LISTING_TIMEOUT_MS
)
timer.unref?.()
child.stdout?.setEncoding('utf8')
child.stderr?.setEncoding('utf8')
child.stdout?.on('data', onData)
child.stderr?.on('data', onStderr)
@@ -1,3 +1,4 @@
import { openCodeHookServiceModuleMock } from './pty-ipc-mock-registry'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({
@@ -46,18 +47,7 @@ vi.mock('node-pty', () => ({
})
}))
vi.mock('../opencode/hook-service', () => ({
openCodeHookService: {
buildPtyEnv: () => ({}),
refreshLegacySharedPlugin: vi.fn(),
clearPty: vi.fn()
},
openCode2HookService: {
buildPtyEnv: () => ({}),
refreshLegacySharedPlugin: vi.fn(),
clearPty: vi.fn()
}
}))
vi.mock('../opencode/hook-service', () => openCodeHookServiceModuleMock())
vi.mock('../pi/titlebar-extension-service', () => ({
piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() }
+3
View File
@@ -106,6 +106,9 @@ export const childProcessModuleMock = (original: Record<string, unknown>) => ({
})
export const openCodeHookServiceModuleMock = () => ({
OpenCodeHookService: class {
buildPtyEnv = vi.fn(() => ({}))
},
openCodeHookService: {
buildPtyEnv: openCodeBuildPtyEnvMock,
refreshLegacySharedPlugin: vi.fn<() => void>(),
@@ -11,6 +11,7 @@ import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty'
import { buildJcodeRuntimeDir, shouldInjectJcodeRuntimeDir } from '../../shared/jcode-runtime-dir'
import { makePaneKey } from '../../shared/stable-pane-id'
import { selectShellStartupFeatures } from '../shell-startup-features'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
@@ -60,6 +61,41 @@ describe('registerPtyHandlers', () => {
const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite()
describe('spawn environment', () => {
it.each(['/bin/bash', '/bin/zsh'])(
'does not wrap a bare %s pane merely to expose this app CLI',
(shellPath) => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
try {
const env = buildPtyHostEnv(
'bare-cli-pane',
{},
{
isPackaged: false,
userDataPath: '/tmp/orca-user-data',
selectedCodexHomePath: null,
agentStatusHooksEnabled: false
}
)
expect(env.ORCA_CLI_BIN_DIR).toBe('/tmp/orca-user-data/cli/bin')
expect(
selectShellStartupFeatures({
shellPath,
env,
hasStartupCommand: false,
waitsForShellReady: false,
emitsStartupIdentity: false
})
).toEqual([])
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
}
)
it('does not install managed Pi extensions when Pi is disabled', () => {
piBuildPtyEnvMock.mockClear()
+11 -60
View File
@@ -1,15 +1,10 @@
import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing'
import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command'
import {
detectExplicitPiAgentKindFromCommand,
isPiCompatibleAgentType
} from '../../../../shared/pi-agent-kind'
import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard'
import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service'
import {
OPENCODE_CONFIG_DIR_ENV_KEYS,
isOpenCodeLegacySharedConfigDir
} from '../../../opencode/legacy-shared-config-dir'
import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer'
import { mimoCodeHookService } from '../../../mimo/hook-service'
import { agentHookServer } from '../../../agent-hooks/server'
import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager'
@@ -28,13 +23,13 @@ import {
exposePiManagedExtensionEnv,
isMimoLaunchCommand,
resolveMimocodeSourceHome,
resolveOpenCodeSourceConfigDir,
resolvePiAgentSourceDir,
resolveScopedPiAgentSourceDir,
restoreOrStripOverlayEnv
} from './pi-agent'
import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys'
import { applyManagedDataAccountEnvironment } from '../../../managed-data-accounts/launch-environment'
import { applyOpenCodeStatusPluginEnv, captureOpenCodeSourceConfig } from './opencode-config'
/**
* Mutates `baseEnv` in place with all host-local PTY env vars and returns it.
@@ -50,32 +45,9 @@ export function buildPtyHostEnv(
mergePersistedWindowsPath(baseEnv)
Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings))
// Why: pre-1.4.209 panes exported Orca's retired shared hooks dir; inheriting it hides the user's global OpenCode config.
const isLegacyOpenCodeHooksDir = (dir: string | undefined): boolean =>
isOpenCodeLegacySharedConfigDir(dir, opts.userDataPath)
const inheritedOpenCodeEnv: NodeJS.ProcessEnv = {}
for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) {
if (isLegacyOpenCodeHooksDir(baseEnv[key])) {
delete baseEnv[key]
}
if (!isLegacyOpenCodeHooksDir(process.env[key])) {
inheritedOpenCodeEnv[key] = process.env[key]
}
}
// A daemon or sibling shell can retain a retired path that main no longer sees.
openCodeHookService.refreshLegacySharedPlugin()
openCode2HookService.refreshLegacySharedPlugin()
const resolvedOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv, inheritedOpenCodeEnv)
const preexistingOpenCodeConfigDir = isLegacyOpenCodeHooksDir(resolvedOpenCodeConfigDir)
? undefined
: resolvedOpenCodeConfigDir
const openCodeConfig = captureOpenCodeSourceConfig(baseEnv, opts.userDataPath)
const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand)
applyManagedDataAccountEnvironment(baseEnv, { ...opts, launchCommand: launchCommandHint })
const openCodeAgent = selectOpenCodeHookAgent(
opts.launchAgent,
launchCommandHint,
(agent) => opts.agentStatusHooksEnabled && isTuiAgentEnabled(agent, opts.disabledTuiAgents)
)
const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent)
? opts.launchAgent
: opts.launchAgent === undefined
@@ -110,37 +82,13 @@ export function buildPtyHostEnv(
? resolvePiAgentSourceDir(baseEnv, 'prime-agent')
: resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent')
restoreOrStripOverlayEnv(
const openCodeAgent = applyOpenCodeStatusPluginEnv(
id,
baseEnv,
{
primary: 'OPENCODE_CONFIG_DIR',
overlay: 'ORCA_OPENCODE_CONFIG_DIR',
source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR',
preserveExplicitPrimary: true
},
inheritedOpenCodeEnv
openCodeConfig,
opts,
launchCommandHint
)
delete baseEnv.ORCA_OPENCODE_AGENT
if (openCodeAgent) {
// Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md.
const openCodeStatusService =
openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService
baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent
// WSL owns its config writes; only the guest overlay may enter a WSL pane.
if (!opts.isWsl) {
Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir))
}
if (baseEnv.OPENCODE_CONFIG_DIR) {
// Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value.
baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR
if (preexistingOpenCodeConfigDir) {
// Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays.
baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir
} else {
delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR
}
}
}
if (opts.agentStatusHooksEnabled) {
if (isMimoLaunchCommand(launchCommandHint)) {
const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv)
@@ -343,5 +291,8 @@ export function buildPtyHostEnv(
// process.env when baseEnv carries none, which is the daemon path's normal shape.
stripLegacyTerminalShimEnv(baseEnv, process.platform)
if (!opts.isWsl) {
ensureOpenCodeStartupPromptForLaunch(baseEnv)
}
return baseEnv
}
@@ -0,0 +1,84 @@
import {
OPENCODE_CONFIG_DIR_ENV_KEYS,
isOpenCodeLegacySharedConfigDir
} from '../../../opencode/legacy-shared-config-dir'
import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service'
import { resolveOpenCodeSourceConfigDir, restoreOrStripOverlayEnv } from './pi-agent'
import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command'
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
import type { BuildPtyHostEnvOptions } from './types'
type OpenCodeSourceConfig = {
inheritedEnv: NodeJS.ProcessEnv
directory: string | undefined
}
export function captureOpenCodeSourceConfig(
env: Record<string, string>,
userDataPath: string
): OpenCodeSourceConfig {
const isLegacyDirectory = (dir: string | undefined): boolean =>
isOpenCodeLegacySharedConfigDir(dir, userDataPath)
const inheritedEnv: NodeJS.ProcessEnv = {}
for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) {
if (isLegacyDirectory(env[key])) {
delete env[key]
}
if (!isLegacyDirectory(process.env[key])) {
inheritedEnv[key] = process.env[key]
}
}
// A daemon or sibling shell can retain a retired path that main no longer sees.
openCodeHookService.refreshLegacySharedPlugin()
openCode2HookService.refreshLegacySharedPlugin()
const directory = resolveOpenCodeSourceConfigDir(env, inheritedEnv)
return { inheritedEnv, directory: isLegacyDirectory(directory) ? undefined : directory }
}
export function applyOpenCodeStatusPluginEnv(
id: string,
env: Record<string, string>,
config: OpenCodeSourceConfig,
options: Pick<
BuildPtyHostEnvOptions,
'launchAgent' | 'agentStatusHooksEnabled' | 'disabledTuiAgents' | 'isWsl'
>,
command: string | undefined
): 'opencode' | 'opencode2' | null {
const agent = selectOpenCodeHookAgent(
options.launchAgent,
command,
(candidate) =>
options.agentStatusHooksEnabled && isTuiAgentEnabled(candidate, options.disabledTuiAgents)
)
restoreOrStripOverlayEnv(
env,
{
primary: 'OPENCODE_CONFIG_DIR',
overlay: 'ORCA_OPENCODE_CONFIG_DIR',
source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR',
preserveExplicitPrimary: true
},
config.inheritedEnv
)
delete env.ORCA_OPENCODE_AGENT
if (!agent) {
return null
}
const service = agent === 'opencode2' ? openCode2HookService : openCodeHookService
env.ORCA_OPENCODE_AGENT = agent
// WSL owns its config writes; only the guest overlay may enter a WSL pane.
if (!options.isWsl) {
Object.assign(env, service.buildPtyEnv(id, config.directory))
}
if (env.OPENCODE_CONFIG_DIR) {
// Shell startup can re-export the default; preserve this pane's overlay and original source.
env.ORCA_OPENCODE_CONFIG_DIR = env.OPENCODE_CONFIG_DIR
if (config.directory) {
env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = config.directory
} else {
delete env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
}
}
return agent
}
@@ -77,6 +77,28 @@ afterEach(() => {
})
describe('OpenCode installation uses the current enabled agents', () => {
it('refuses spawn if a prepared startup intent loses its owned installer', () => {
mkdirSync(fixture.userData, { recursive: true })
writeFileSync(
join(fixture.userData, 'opencode-startup-prompt-overlays'),
'blocked fixture root'
)
expect(() =>
buildPtyHostEnv(
'owned-launch',
{
OPENCODE_CONFIG_DIR: custom,
ORCA_OPENCODE_PLUGIN_API: 'v2',
ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'fixture-nonce',
ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'original caller brief'
},
{ ...options, agentStatusHooksEnabled: false }
)
).toThrow('launch was canceled')
expect(readFileSync(join(custom, 'opencode.json'), 'utf8')).toBe('{"model":"fixture"}')
expect(readFileSync(join(custom, 'plugins', 'user.js'), 'utf8')).toBe('// user plugin')
})
const combinations = [
{ disabled: [], fallback: 'opencode' },
{ disabled: ['opencode'], fallback: 'opencode2' },
+6
View File
@@ -22,8 +22,13 @@ import { admitPtyReattachOwnership, registerPersistedPtySpawn } from '../pane/sp
import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size'
import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore'
import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority'
import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner'
export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawnResult> {
return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedPtyIpcSpawn(ctx))
}
async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawnResult> {
const args = ctx.args
admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId)
if (ctx.nativeWindowsConptySpawn) {
@@ -100,6 +105,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
ctx.pendingRegistrationPtyId = null
}
publishPtyIpcSpawnCommit(ctx, committedSize)
// Admission must precede reflow: a replaced spawn cannot resize its successor's model.
reflowHeadlessTerminalToCommittedGrid({
result: ctx.result,
+3 -1
View File
@@ -65,7 +65,7 @@ export async function buildPtyIpcSpawnOptions(
ctx.combinedEnvToDelete = removeCodexHomeDeletionRequests(ctx.combinedEnvToDelete)
}
deleteRequestedEnvKeys(ctx.spawnEnv, ctx.combinedEnvToDelete)
ctx.spawnEnv = await prepareOpenCodePtyLaunch({
const openCodeLaunch = await prepareOpenCodePtyLaunch({
command: ctx.launchCommand,
agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
env: ctx.spawnEnv,
@@ -77,6 +77,8 @@ export async function buildPtyIpcSpawnOptions(
? { wsl: { distro: ctx.expectedWslDistro ?? undefined } }
: {})
})
ctx.spawnEnv = openCodeLaunch.env
ctx.launchCommand = openCodeLaunch.command
promoteAgentTeamsShimPath(ctx.spawnEnv, ctx.requestedAgentTeamsPath)
ctx.spawnOptions = {
cols: args.cols,
+6
View File
@@ -30,6 +30,7 @@ import { resolvePaneSpawnReservation } from '../pane/spawn-reservation'
import { admitProviderReattachLaunchIdentity } from '../pane/launch-authority'
import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span'
import type { RuntimePtySpawnState } from './spawn-state'
import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner'
import {
admitPtyReattachOwnership,
discardUnpersistedPtySpawn,
@@ -37,6 +38,10 @@ import {
} from '../pane/spawn-registration'
export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedRuntimePtySpawn(ctx))
}
async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) {
const args = ctx.args
admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId)
const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result)
@@ -205,6 +210,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
if (ctx.result.incarnationId) {
ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId)
}
claimSshPaneLease({
store: ctx.deps.store,
connectionId: args.connectionId,
+3 -1
View File
@@ -104,7 +104,7 @@ export async function buildRuntimePtySpawnOptions(
env: ctx.env,
envToDelete: ctx.spawnOptions.envToDelete
})
ctx.env = await prepareOpenCodePtyLaunch({
const openCodeLaunch = await prepareOpenCodePtyLaunch({
command: ctx.launchCommand,
agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
env: ctx.env,
@@ -116,6 +116,8 @@ export async function buildRuntimePtySpawnOptions(
? { wsl: { distro: ctx.expectedWslDistro ?? undefined } }
: {})
})
ctx.env = openCodeLaunch.env
ctx.launchCommand = openCodeLaunch.command
ctx.spawnOptions.env = ctx.env
promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath)
const noDaemonLaunch = planCodexNoDaemonLaunch({
-3
View File
@@ -132,9 +132,6 @@ turn_end = "~/bin/mine" # replaces agent-hooks/jcode-hook.sh
})
it('repoints a managed entry left behind by a copied home or a platform switch', () => {
// Why: isManaged matches any agent-hooks/jcode-hook path, but getStatus demands
// the exact script path — a stale entry stuck the install on `partial` forever
// with no Orca action able to repair it.
const stale = '/Users/old/.orca/agent-hooks/jcode-hook.sh'
const source = `[hooks]\nturn_end = ${tomlQuoteString(stale)}\n`
const result = applyJcodeManagedHooks(source, EVENTS, MANAGED_COMMAND, 'jcode-hook.sh')
+121 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
@@ -17,6 +17,7 @@ import { JcodeHookService } from './hook-service'
import {
getJcodeConfigPath,
getJcodeManagedCommand,
getJcodeManagedScriptFileName,
getJcodeManagedScriptPath,
JCODE_HOOK_EVENTS
} from './hook-settings'
@@ -72,6 +73,123 @@ describe('JcodeHookService', () => {
expect(script).toContain('payload="$JCODE_HOOK_PAYLOAD"')
})
it('reports a missing managed script and repairs it without changing config', () => {
const service = new JcodeHookService()
service.install()
const config = readFileSync(getJcodeConfigPath(), 'utf8')
rmSync(getJcodeManagedScriptPath())
expect(service.getStatus()).toMatchObject({
state: 'partial',
managedHooksPresent: true,
detail: 'Managed hook script missing'
})
expect(readFileSync(getJcodeConfigPath(), 'utf8')).toBe(config)
expect(service.install().state).toBe('installed')
expect(readFileSync(getJcodeManagedScriptPath(), 'utf8')).toContain('/hook/jcode')
expect(readFileSync(getJcodeConfigPath(), 'utf8')).toBe(config)
})
it('reports missing scripts alongside incomplete event coverage and user hooks', () => {
const service = new JcodeHookService()
service.install()
writeFileSync(
getJcodeConfigPath(),
`[hooks]\nsession_start = ${tomlQuoteString(getJcodeManagedCommand(getJcodeManagedScriptPath()))}\nturn_end = "~/bin/my-turn-notify"\n`,
'utf8'
)
rmSync(getJcodeManagedScriptPath())
const status = service.getStatus()
expect(status.state).toBe('partial')
expect(status.detail).toContain('Managed hook script missing')
expect(status.detail).toContain(
'Managed hook missing for events: turn_start, pre_tool, post_tool, session_end'
)
expect(status.detail).toContain('User-owned hooks kept for events: turn_end')
expect(service.install().state).toBe('partial')
expect(service.getStatus().detail).not.toContain('script missing')
expect(readFileSync(getJcodeConfigPath(), 'utf8')).toContain(
'turn_end = "~/bin/my-turn-notify"'
)
})
it.each([
'/Users/previous/.orca/agent-hooks/jcode-hook.sh',
'C:\\Users\\previous\\.orca\\agent-hooks\\jcode-hook.cmd'
])('recognizes a stale managed command %s without a local script', (stalePath) => {
const service = new JcodeHookService()
const configPath = getJcodeConfigPath()
mkdirSync(dirname(configPath), { recursive: true })
writeFileSync(
configPath,
`[hooks]\nturn_end = ${tomlQuoteString(getJcodeManagedCommand(stalePath))}\n`,
'utf8'
)
const status = service.getStatus()
expect(status.state).toBe('partial')
expect(status.managedHooksPresent).toBe(true)
expect(status.detail).toContain('Managed hook command outdated for events: turn_end')
expect(status.detail).toContain('Managed hook script missing')
expect(status.detail).not.toContain('User-owned')
expect(service.install().state).toBe('installed')
expect(readFileSync(configPath, 'utf8')).not.toContain('previous')
})
it('does not report complete stale event coverage as installed when the current script exists', () => {
const service = new JcodeHookService()
service.install()
const staleCommand = getJcodeManagedCommand('/Users/previous/.orca/agent-hooks/jcode-hook.sh')
writeFileSync(
getJcodeConfigPath(),
`[hooks]\n${JCODE_HOOK_EVENTS.map((event) => `${event} = ${tomlQuoteString(staleCommand)}`).join('\n')}\n`,
'utf8'
)
const status = service.getStatus()
expect(status.state).toBe('partial')
expect(status.managedHooksPresent).toBe(true)
expect(status.detail).toBe(
`Managed hook command outdated for events: ${JCODE_HOOK_EVENTS.join(', ')}`
)
expect(service.install().state).toBe('installed')
})
it('reports legacy unquoted commands as outdated even when the script exists', () => {
const service = new JcodeHookService()
service.install()
writeFileSync(
getJcodeConfigPath(),
`[hooks]\n${JCODE_HOOK_EVENTS.map((event) => `${event} = ${tomlQuoteString(getJcodeManagedScriptPath())}`).join('\n')}\n`,
'utf8'
)
const status = service.getStatus()
expect(status.state).toBe('partial')
expect(status.detail).toContain('Managed hook command outdated for events:')
expect(status.detail).not.toContain('script missing')
expect(status.detail).not.toContain('User-owned')
expect(service.install().state).toBe('installed')
})
it('does not mistake a user command mentioning the managed script in a comment for Orca ownership', () => {
const configPath = getJcodeConfigPath()
mkdirSync(dirname(configPath), { recursive: true })
writeFileSync(
configPath,
`[hooks]\nturn_end = "~/bin/my-turn-notify" # replaces agent-hooks/${getJcodeManagedScriptFileName()}\n`,
'utf8'
)
const status = new JcodeHookService().getStatus()
expect(status.state).toBe('partial')
expect(status.managedHooksPresent).toBe(false)
expect(status.detail).toContain('User-owned hooks kept for events: turn_end')
expect(status.detail).not.toContain('command outdated')
expect(status.detail).not.toContain('script missing')
})
it('preserves unrelated config tables when installing hooks', () => {
const configPath = getJcodeConfigPath()
mkdirSync(dirname(configPath), { recursive: true })
@@ -107,6 +225,8 @@ describe('JcodeHookService', () => {
expect(before).toContain('turn_end')
const status = new JcodeHookService().remove()
expect(status.state).toBe('not_installed')
expect(status.detail).toBeNull()
expect(existsSync(getJcodeManagedScriptPath())).toBe(true)
const after = readFileSync(getJcodeConfigPath(), 'utf8')
expect(after).not.toContain(getJcodeManagedScriptPath())
})
+16 -6
View File
@@ -4,6 +4,7 @@ import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
buildWindowsAgentHookPostCommand,
createManagedCommandMatcher,
writeManagedScript
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
@@ -128,33 +129,42 @@ export class JcodeHookService {
}
const scriptPresent = existsSync(scriptPath)
const managedCommand = getJcodeManagedCommand(scriptPath)
const isManaged = createManagedCommandMatcher(getJcodeManagedScriptFileName())
const missing: string[] = []
const outdated: string[] = []
const userOwned: string[] = []
let managedCount = 0
for (const event of JCODE_HOOK_EVENTS) {
const value = table[event]
// Why both forms: installs before the quoting fix stored the bare path, and
// install() repoints those — reporting them user-owned would hide the repair.
if (value === managedCommand || value === scriptPath) {
if (value === managedCommand) {
managedCount += 1
} else if (isManaged(value)) {
outdated.push(event)
} else if (value === undefined) {
missing.push(event)
} else {
userOwned.push(event)
}
}
const managedHooksPresent = managedCount > 0 || scriptPresent
const hasManagedEntries = managedCount > 0 || outdated.length > 0
const managedHooksPresent = hasManagedEntries || scriptPresent
let state: AgentHookInstallState
let detail: string | null
if (missing.length === 0 && userOwned.length === 0) {
if (managedCount === JCODE_HOOK_EVENTS.length && scriptPresent) {
state = 'installed'
detail = null
} else if (managedCount === 0 && missing.length === JCODE_HOOK_EVENTS.length) {
} else if (!hasManagedEntries && missing.length === JCODE_HOOK_EVENTS.length) {
state = 'not_installed'
detail = null
} else {
state = 'partial'
const parts: string[] = []
if (hasManagedEntries && !scriptPresent) {
parts.push('Managed hook script missing')
}
if (outdated.length > 0) {
parts.push(`Managed hook command outdated for events: ${outdated.join(', ')}`)
}
if (missing.length > 0) {
parts.push(`Managed hook missing for events: ${missing.join(', ')}`)
}
@@ -5,7 +5,11 @@ import {
agentModelCatalogFingerprintForRecord
} from './agent-model-catalog-fingerprint'
import { createAgentModelCatalogService } from './agent-model-catalog-service'
import { AgentModelCatalogStore, type AgentModelCatalogSuccess } from './agent-model-catalog-store'
import {
AGENT_MODEL_CATALOG_FRESH_MS,
AgentModelCatalogStore,
type AgentModelCatalogSuccess
} from './agent-model-catalog-store'
function record(accountHomePath: string): AgentSessionRecord {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome and location; the rest of the record is irrelevant here.
@@ -55,7 +59,8 @@ describe('agent model catalog service', () => {
probes: { codex: probe }
})
expect(await service.read({ agent: 'codex', sessionId: 'session-1' })).toEqual({
origin: 'unknown'
origin: 'unknown',
listingInProgress: true
})
// A second read while the probe is in flight must not start another, and a
// record-scoped read probes the RECORD's pinned home, not the selection.
@@ -81,7 +86,10 @@ describe('agent model catalog service', () => {
probes: { codex: probe }
})
// The record-less read follows the CURRENT selection: unknown, never gpt-old.
expect(await service.read({ agent: 'codex' })).toEqual({ origin: 'unknown' })
expect(await service.read({ agent: 'codex' })).toEqual({
origin: 'unknown',
listingInProgress: true
})
expect(probe).toHaveBeenCalledWith('/homes/new')
await vi.waitFor(async () => {
const result = await service.read({ agent: 'codex' })
@@ -139,7 +147,8 @@ describe('agent model catalog service', () => {
probes: { codex: probe }
})
expect(await service.read({ agent: 'codex', sessionId: 'session-1' })).toEqual({
origin: 'unknown'
origin: 'unknown',
listingInProgress: true
})
await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(1))
// Still a clean unknown — and the failure TTL suppresses a probe storm.
@@ -164,6 +173,94 @@ describe('agent model catalog service', () => {
expect(probe).not.toHaveBeenCalled()
})
describe('a read that waits for the first listing', () => {
function deferredListing() {
let resolve!: (success: AgentModelCatalogSuccess) => void
let reject!: (error: Error) => void
const promise = new Promise<AgentModelCatalogSuccess>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function coldService(probe: (home: string) => Promise<AgentModelCatalogSuccess>) {
const store = new AgentModelCatalogStore()
const service = createAgentModelCatalogService({
store,
getRecord: () => undefined,
resolveAccountHome: async () => CODEX_HOME('/homes/selected'),
probes: { codex: probe }
})
return { store, service }
}
it('joins the listing the first read started and answers with it', async () => {
const pending = deferredListing()
const probe = vi.fn(() => pending.promise)
const { service } = coldService(probe)
expect(await service.read({ agent: 'codex' })).toEqual({
origin: 'unknown',
listingInProgress: true
})
const waited = service.read({ agent: 'codex', waitForListing: true })
pending.resolve(listing('gpt-listed'))
const result = await waited
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-listed')
expect(probe).toHaveBeenCalledTimes(1)
})
it('answers a plain unknown when the listing fails', async () => {
const pending = deferredListing()
const { service } = coldService(() => pending.promise)
const waited = service.read({ agent: 'codex', waitForListing: true })
pending.reject(new Error('spawn failed'))
expect(await waited).toEqual({ origin: 'unknown' })
})
it('does not wait or report a listing while a failure is inside its TTL', async () => {
const probe = vi.fn(async (): Promise<AgentModelCatalogSuccess> => {
throw new Error('spawn failed')
})
const { store, service } = coldService(probe)
store.recordFailure(selectedHomeFingerprint('/homes/selected'), 'spawn failed')
expect(await service.read({ agent: 'codex', waitForListing: true })).toEqual({
origin: 'unknown'
})
expect(await service.read({ agent: 'codex' })).toEqual({ origin: 'unknown' })
expect(probe).not.toHaveBeenCalled()
})
it('reports no listing where the host has no lister for the account', async () => {
const store = new AgentModelCatalogStore()
const service = createAgentModelCatalogService({
store,
getRecord: () => undefined,
resolveAccountHome: async () => CODEX_HOME('/homes/selected')
})
expect(await service.read({ agent: 'codex', waitForListing: true })).toEqual({
origin: 'unknown'
})
})
it('serves an aged entry at once and refreshes it behind the answer', async () => {
let now = 0
const store = new AgentModelCatalogStore({ now: () => now })
store.recordSuccess(selectedHomeFingerprint('/homes/selected'), 'codex', listing('gpt-old'))
now = AGENT_MODEL_CATALOG_FRESH_MS
const probe = vi.fn(() => new Promise<AgentModelCatalogSuccess>(() => {}))
const service = createAgentModelCatalogService({
store,
getRecord: () => undefined,
resolveAccountHome: async () => CODEX_HOME('/homes/selected'),
probes: { codex: probe }
})
const result = await service.read({ agent: 'codex', waitForListing: true })
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-old')
expect(probe).toHaveBeenCalledTimes(1)
})
})
describe('a read for the workspace a new chat runs in', () => {
function serviceWith(mayOverride: boolean) {
const store = new AgentModelCatalogStore()

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