From 83dcc7dcb76e1da40c20cbb6e97a9fe7f0e96954 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:27:26 -0700 Subject: [PATCH] [CI] Reuse costly test setup and report an explicit full result (#29228) * ci: publish an explicit result from the PR aggregate * test(codex): preserve review text across OSC terminators * perf(ci): reuse worker fixtures and validated launcher output * fix(test): use typed orchestration database access * fix(ci): exclude test fixtures from focus ratchet --- .github/scripts/check-required-pr-context.mjs | 15 +- .github/workflows/pr.yml | 98 +---- .../build-windows-cli-launcher.test.mjs | 6 +- config/scripts/check-focus-read-ratchet.mjs | 5 +- .../scripts/check-focus-read-ratchet.test.mjs | 1 + config/scripts/pr-aggregate-gate-context.mjs | 4 - config/scripts/pr-aggregate-gate.test.mjs | 180 ++------- config/scripts/pr-ready-check-gate.test.mjs | 198 ++++------ config/scripts/pr-ready-check-reuse.test.mjs | 2 +- .../scripts/required-pr-check-name.test.mjs | 31 ++ ...ex-hook-file-entry-binary-contract.test.ts | 7 +- .../worker/worker-list-run-scope-rpc.spec.ts | 347 ++++++++++++++++++ .../worker/worker-list-run-scope-rpc.test.ts | 342 ----------------- .../worker/worker-release-archive.spec.ts | 249 +++++++++++++ .../worker/worker-release-archive.test.ts | 247 ------------- ...worker-release-authority-contracts.test.ts | 5 + .../worker-release-evidence-contracts.test.ts | 5 + .../worker/worker-release-inventory.spec.ts | 194 ++++++++++ .../worker/worker-release-inventory.test.ts | 194 ---------- .../worker-release-ownership-guard.spec.ts | 210 +++++++++++ .../worker-release-ownership-guard.test.ts | 204 ---------- ...worker-release-runtime-incarnation.spec.ts | 88 +++++ ...worker-release-runtime-incarnation.test.ts | 79 ---- .../worker-run-dispatch-contracts.test.ts | 5 + .../worker-start-terminal-target.spec.ts | 267 ++++++++++++++ .../worker-start-terminal-target.test.ts | 259 ------------- ...ation-mutation-request-show-legacy.test.ts | 1 + ...cted-agents-environment-prune-leak.test.ts | 2 +- ...me-detected-agents-pairing.test-fixture.ts | 22 ++ .../runtime-detected-agents-pairing.test.ts | 2 +- .../runtime-detected-agents.test-fixture.ts | 17 + .../slices/runtime-detected-agents.test.ts | 2 +- .../store/slices/runtime-detected-agents.ts | 22 +- .../runtime-environment-disconnect-toast.ts | 7 +- .../runtime-status-catalog-identity.test.ts | 2 +- ...untime-status-skill-cache-eviction.test.ts | 2 +- .../store/slices/runtime-status-snapshot.ts | 11 +- .../src/store/slices/runtime-status-types.ts | 26 ++ .../slices/runtime-status.test-fixture.ts | 13 + .../src/store/slices/runtime-status.ts | 17 +- 40 files changed, 1656 insertions(+), 1732 deletions(-) create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.spec.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-release-archive.spec.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-release-archive.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-release-authority-contracts.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-release-evidence-contracts.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-release-inventory.spec.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-release-inventory.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.spec.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-release-runtime-incarnation.spec.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-release-runtime-incarnation.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-run-dispatch-contracts.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.spec.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts create mode 100644 src/renderer/src/store/slices/runtime-detected-agents-pairing.test-fixture.ts create mode 100644 src/renderer/src/store/slices/runtime-detected-agents.test-fixture.ts create mode 100644 src/renderer/src/store/slices/runtime-status.test-fixture.ts diff --git a/.github/scripts/check-required-pr-context.mjs b/.github/scripts/check-required-pr-context.mjs index f957cb329df..270bd975b5b 100644 --- a/.github/scripts/check-required-pr-context.mjs +++ b/.github/scripts/check-required-pr-context.mjs @@ -10,10 +10,19 @@ export function verifyRequiredPrContext(parseYaml) { const workflow = parseYaml(readFileSync(join(directory, file), 'utf8')) return Object.entries(workflow.jobs) .filter(([id, job]) => (job.name ?? id) === 'PR checks passed') - .map(([id]) => `${file}:${id}`) + .map(([id, job]) => ({ owner: `${file}:${id}`, job })) }) - if (providers.length !== 1 || providers[0] !== 'pr.yml:verify') { - throw new Error(`Required PR context must belong only to pr.yml:verify; found ${providers}`) + const owners = providers.map((provider) => provider.owner) + if (providers.length !== 1 || owners[0] !== 'pr.yml:verify') { + throw new Error(`Required PR context must belong only to pr.yml:verify; found ${owners}`) + } + const gate = providers[0].job + const condition = gate.if?.replace(/^\$\{\{\s*|\s*\}\}$/g, '').trim() + if (condition !== '!cancelled()') { + throw new Error('Required PR aggregate must run unless the workflow is cancelled.') + } + if (gate['continue-on-error'] || gate.steps?.some((step) => step['continue-on-error'])) { + throw new Error('Required PR aggregate must not ignore failures.') } } diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index eabc717c391..77600d83a74 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -51,7 +51,6 @@ jobs: package: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package }} package_windows: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package_windows }} layout_oracle: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.layout_oracle }} - verify_fast_path: ${{ steps.verify_fast_path.outputs.canonical }} e2e_should_run: ${{ steps.e2e_filter.outputs.should_run }} test_files: ${{ steps.e2e_filter.outputs.test_files }} e2e_run_changed: ${{ steps.e2e_filter.outputs.e2e_run_changed }} @@ -137,33 +136,6 @@ jobs: fi printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs "${MOBILE_CI_ARGS[@]}" | tee -a "$GITHUB_OUTPUT" - # Actions comparisons ignore case; validate exact routing before allowing a skipped gate. - - name: Validate aggregate routing - id: verify_fast_path - env: - STATIC_ANALYSIS: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.static_analysis }} - TYPECHECK: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.typecheck }} - GIT_COMPATIBILITY: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.git_compatibility }} - CODEX_INDEX_HEAL_CONTRACT: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.codex_index_heal_contract }} - XTERM_PATCH_SYNC: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.xterm_patch_sync }} - SHELL_CONTRACTS: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.shell_contracts }} - TEST: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.test }} - MOBILE_WEB_APP: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.mobile_web_app }} - CROSS_VERSION_WIRE: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.cross-version-wire }} - MANAGED_HOOK_NODE18: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.managed_hook_node18 }} - PACKAGE: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package }} - PACKAGE_WINDOWS: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package_windows }} - LAYOUT_ORACLE: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.layout_oracle }} - run: | - canonical=1 - for selected in "$STATIC_ANALYSIS" "$TYPECHECK" "$GIT_COMPATIBILITY" "$CODEX_INDEX_HEAL_CONTRACT" "$XTERM_PATCH_SYNC" "$SHELL_CONTRACTS" "$TEST" "$MOBILE_WEB_APP" "$CROSS_VERSION_WIRE" "$MANAGED_HOOK_NODE18" "$PACKAGE" "$PACKAGE_WINDOWS" "$LAYOUT_ORACLE"; do - case "$selected" in - true|false) ;; - *) canonical=0 ;; - esac - done - echo "canonical=$canonical" >> "$GITHUB_OUTPUT" - # Reuse the path-detector checkout instead of queuing another runner. - name: Filter changed E2E specs id: e2e_filter @@ -1528,62 +1500,8 @@ jobs: verify: name: PR checks passed - # GitHub accepts skipped required jobs; only mismatches need a runner to fail with details. - if: >- - !cancelled() && !( - needs.code_paths.result == 'success' && - needs.code_paths.outputs.verify_fast_path == '1' && - (needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.static_analysis == 'false') && - (needs.code_paths.outputs.typecheck == 'true' || needs.code_paths.outputs.typecheck == 'false') && - ( - ((needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true') && needs.preflight.result == 'success') || - (needs.code_paths.outputs.static_analysis == 'false' && needs.code_paths.outputs.typecheck == 'false' && needs.preflight.result == 'skipped') - ) && - ( - (needs.code_paths.outputs.git_compatibility == 'true' && needs.git_compatibility.result == 'success') || - (needs.code_paths.outputs.git_compatibility == 'false' && needs.git_compatibility.result == 'skipped') - ) && - ( - (needs.code_paths.outputs.codex_index_heal_contract == 'true' && needs.codex_index_heal_contract.result == 'success') || - (needs.code_paths.outputs.codex_index_heal_contract == 'false' && needs.codex_index_heal_contract.result == 'skipped') - ) && - ( - (needs.code_paths.outputs.xterm_patch_sync == 'true' && needs.xterm_patch_sync.result == 'success') || - (needs.code_paths.outputs.xterm_patch_sync == 'false' && needs.xterm_patch_sync.result == 'skipped') - ) && - ( - (needs.code_paths.outputs.shell_contracts == 'true' && needs.shell_contracts.result == 'success') || - (needs.code_paths.outputs.shell_contracts == 'false' && needs.shell_contracts.result == 'skipped') - ) && - ( - (needs.code_paths.outputs.test == 'true' && needs.test.result == 'success') || - (needs.code_paths.outputs.test == 'false' && needs.test.result == 'skipped') - ) && - ( - (needs.code_paths.outputs.mobile_web_app == 'true' && needs.mobile_web_app.result == 'success') || - (needs.code_paths.outputs.mobile_web_app == 'false' && needs.mobile_web_app.result == 'skipped') - ) && - ( - (needs.code_paths.outputs['cross-version-wire'] == 'true' && needs['cross-version-wire'].result == 'success') || - (needs.code_paths.outputs['cross-version-wire'] == 'false' && needs['cross-version-wire'].result == 'skipped') - ) && - ( - (needs.code_paths.outputs.managed_hook_node18 == 'true' && needs.managed_hook_node18.result == 'success') || - (needs.code_paths.outputs.managed_hook_node18 == 'false' && needs.managed_hook_node18.result == 'skipped') - ) && - ( - (needs.code_paths.outputs.package == 'true' && needs.package.result == 'success') || - (needs.code_paths.outputs.package == 'false' && needs.package.result == 'skipped') - ) && - ( - (needs.code_paths.outputs.package_windows == 'true' && needs.package_windows.result == 'success') || - (needs.code_paths.outputs.package_windows == 'false' && needs.package_windows.result == 'skipped') - ) && - ( - (needs.code_paths.outputs.layout_oracle == 'true' && needs.layout_oracle.result == 'success') || - (needs.code_paths.outputs.layout_oracle == 'false' && needs.layout_oracle.result == 'skipped') - ) - ) + # Publish an explicit result after every required dependency settles. + if: ${{ !cancelled() }} needs: - code_paths - preflight @@ -1598,7 +1516,6 @@ jobs: - package - package_windows - layout_oracle - # Only failures and noncanonical routing allocate this runner. runs-on: ubuntu-slim timeout-minutes: 5 @@ -1616,6 +1533,8 @@ jobs: CODE_PATHS: ${{ needs.code_paths.result }} SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }} PREFLIGHT: ${{ needs.preflight.result }} + STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }} + TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }} 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 }} @@ -1643,6 +1562,10 @@ jobs: if [ "$CODE_PATHS" != "success" ]; then exit 1 fi + case "$STATIC_ANALYSIS_SHOULD_RUN:$TYPECHECK_SHOULD_RUN" in + true:true|true:false|false:true|false:false) ;; + *) echo "preflight: missing or invalid selection"; exit 1 ;; + esac if [ "$SHOULD_RUN" != "true" ]; then echo "Docs-only change; expensive PR checks skipped." fi @@ -1654,11 +1577,14 @@ jobs: echo "$name: expected success, got $result" failed=1 fi - else + elif [ "$should" = "false" ]; then if [ "$result" != "skipped" ]; then echo "$name: expected skipped, got $result" failed=1 fi + else + echo "$name: missing or invalid selection" + failed=1 fi } # Require success when the PR has code-relevant changes diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index abdff17ec7d..ba7ff5adade 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -59,7 +59,7 @@ beforeAll(() => { sharedLauncherPath = join(sharedLauncherRoot, 'orca.exe') const result = runProcessSync({ program: process.execPath, - args: ['config/scripts/build-windows-cli-launcher.mjs', '--output', sharedLauncherPath], + args: ['config/scripts/build-windows-cli-launcher.mjs'], cwd: projectRoot, timeoutMs: 300_000 }) @@ -67,6 +67,10 @@ beforeAll(() => { if (result.code !== 0) { throw new Error(`${result.stdout}\n${result.stderr}`) } + copyFileSync( + join(projectRoot, 'native', 'windows-cli-launcher', '.build', 'orca.exe'), + sharedLauncherPath + ) }, 300_000) afterAll(() => { diff --git a/config/scripts/check-focus-read-ratchet.mjs b/config/scripts/check-focus-read-ratchet.mjs index bed97e87264..3af9936ffb5 100644 --- a/config/scripts/check-focus-read-ratchet.mjs +++ b/config/scripts/check-focus-read-ratchet.mjs @@ -355,7 +355,10 @@ const ADVICE = "Route by the resource's owner. Only a creation flow with no source row may use defaultCreationHost." export function isScannedPath(rel) { - return /\.(ts|tsx)$/.test(rel) && !/\.(test|spec)\.tsx?$/.test(rel) + return ( + /\.(ts|tsx)$/.test(rel) && + !/\.(test|spec)(?:-fixture)?\.tsx?$/.test(rel) + ) } // One read and one discovery per root per run; `--prune` then `main` reuse them. diff --git a/config/scripts/check-focus-read-ratchet.test.mjs b/config/scripts/check-focus-read-ratchet.test.mjs index 738c0f5b168..3ddbaca03f7 100644 --- a/config/scripts/check-focus-read-ratchet.test.mjs +++ b/config/scripts/check-focus-read-ratchet.test.mjs @@ -241,5 +241,6 @@ describe('isScannedPath', () => { expect(isScannedPath('src/renderer/src/a.tsx')).toBe(true) expect(isScannedPath('src/renderer/src/a.test.ts')).toBe(false) expect(isScannedPath('src/renderer/src/a.spec.tsx')).toBe(false) + expect(isScannedPath('src/renderer/src/a.test-fixture.ts')).toBe(false) }) }) diff --git a/config/scripts/pr-aggregate-gate-context.mjs b/config/scripts/pr-aggregate-gate-context.mjs index edaddccfbe5..d6968ec5b58 100644 --- a/config/scripts/pr-aggregate-gate-context.mjs +++ b/config/scripts/pr-aggregate-gate-context.mjs @@ -8,9 +8,6 @@ export const aggregateJob = aggregateWorkflow.jobs.verify export const aggregateShell = aggregateJob.steps.find( (step) => step.name === 'Require successful checks' ) -export const aggregateMarker = aggregateWorkflow.jobs.code_paths.steps.find( - (step) => step.id === 'verify_fast_path' -) export const aggregateSelectors = PR_CHECK_JOBS.filter((job) => job !== 'orcad_browser') export const aggregateRequiredJobs = aggregateJob.needs.filter((job) => job !== 'code_paths') export const aggregateVariable = (job) => job.replaceAll('-', '_').toUpperCase() @@ -47,7 +44,6 @@ export function aggregateEnvironment(needs) { export function aggregateNeeds(selected = 'true', overrides = {}) { const outputs = { should_run: 'true', - verify_fast_path: '1', ...Object.fromEntries(aggregateSelectors.map((job) => [job, selected])), ...overrides } diff --git a/config/scripts/pr-aggregate-gate.test.mjs b/config/scripts/pr-aggregate-gate.test.mjs index ce4e158a286..3d5e07b156b 100644 --- a/config/scripts/pr-aggregate-gate.test.mjs +++ b/config/scripts/pr-aggregate-gate.test.mjs @@ -1,36 +1,20 @@ import { describe, expect, it } from 'vitest' import { aggregateJob, - aggregateMarker, aggregateNeeds, aggregateRequiredJobs, aggregateSelectors, aggregateShell, aggregateShouldRun, - aggregateVariable, aggregateWorkflow, compileAggregateExpression } from './pr-aggregate-gate-context.mjs' const results = ['success', 'failure', 'cancelled', 'skipped', '', 'unknown', undefined, null] -const malformedSelectors = [ - '', - 'TRUE', - 'True', - 'FALSE', - '0', - '1', - 'null', - 'unknown', - ' true', - 'true\n', - undefined, - null -] const admitted = (needs, cancelled = false) => aggregateShouldRun({ needs }, cancelled).coerceString() === 'true' -describe('runner-free PR aggregate success', () => { +describe('explicit PR aggregate result', () => { it('keeps every required dependency and the existing failure reporter', () => { expect(aggregateJob.name).toBe('PR checks passed') expect(aggregateJob.needs).toEqual([ @@ -43,160 +27,44 @@ describe('runner-free PR aggregate success', () => { } expect(aggregateJob['continue-on-error']).toBeUndefined() expect(aggregateShell['continue-on-error']).toBeUndefined() - expect(aggregateJob.if).toMatch(/^!cancelled\(\) && !\(/) + expect(aggregateJob.if).toBe('${{ !cancelled() }}') expect(aggregateShell.run).toContain('"$CODE_PATHS" != "success"') const checked = [...aggregateShell.run.matchAll(/^check_job (\S+) /gm)].map((match) => match[1]) expect(checked).toEqual(aggregateRequiredJobs) }) - it('validates the same effective selectors the detector publishes, after readiness and classification', () => { - const detector = aggregateWorkflow.jobs.code_paths - expect(Object.keys(aggregateMarker.env).sort()).toEqual( - aggregateSelectors.map(aggregateVariable).sort() - ) - for (const job of aggregateSelectors) { - expect(aggregateMarker.env[aggregateVariable(job)], job).toBe(detector.outputs[job]) - } - expect(detector.outputs.verify_fast_path).toBe( - '${{ steps.verify_fast_path.outputs.canonical }}' - ) - const position = detector.steps.indexOf(aggregateMarker) - for (const id of ['readiness', 'filter']) { - expect(position).toBeGreaterThan(detector.steps.findIndex((step) => step.id === id)) - } - expect(aggregateMarker.if).toBeUndefined() - expect(aggregateMarker['continue-on-error']).toBeUndefined() - }) - - it('accepts every canonical routing combination without allocating the aggregate runner', () => { - expect(aggregateSelectors).toHaveLength(13) - const failures = [] - for (let mask = 0; mask < 2 ** aggregateSelectors.length; mask += 1) { - const selectors = Object.fromEntries( - aggregateSelectors.map((job, index) => [job, mask & (1 << index) ? 'true' : 'false']) - ) - if (admitted(aggregateNeeds('false', selectors))) { - failures.push(mask) - } - } - expect(failures).toEqual([]) - }) - - it('requires each job to match its selected or unselected result', () => { - for (const job of aggregateRequiredJobs) { - for (const selected of ['true', 'false']) { + it('runs for code, docs, reused and mixed plans after every dependency outcome', () => { + for (const selected of ['true', 'false']) { + const plan = aggregateNeeds(selected) + expect(admitted(plan)).toBe(true) + for (const job of aggregateJob.needs) { for (const result of results) { const needs = aggregateNeeds(selected) needs[job].result = result - const expected = selected === 'true' ? 'success' : 'skipped' - expect(admitted(needs), `${job}/${selected}/${result}`).toBe(result !== expected) + expect(admitted(needs), `${selected}/${job}/${result}`).toBe(true) + expect(admitted(needs, true), `${selected}/${job}/${result}`).toBe(false) } + const missing = aggregateNeeds(selected) + delete missing[job] + expect(admitted(missing), `${selected}/${job}/missing`).toBe(true) + expect(admitted(missing, true), `${selected}/${job}/missing`).toBe(false) } } + expect(admitted(aggregateNeeds('false', { test: 'true', typecheck: 'true' }))).toBe(true) + expect(admitted({})).toBe(true) + expect(admitted({}, true)).toBe(false) }) - it('requires preflight success if either phase is selected', () => { - for (const static_analysis of ['true', 'false']) { - for (const typecheck of ['true', 'false']) { - for (const result of results) { - const needs = aggregateNeeds('false', { static_analysis, typecheck }) - needs.preflight.result = result - const selected = static_analysis === 'true' || typecheck === 'true' - const expected = selected ? 'success' : 'skipped' - expect(admitted(needs), `${static_analysis}/${typecheck}/${result}`).toBe( - result !== expected - ) - } - } + it('passes raw preflight selectors to the shell before case-insensitive Actions comparisons', () => { + const outputs = aggregateWorkflow.jobs.code_paths.outputs + for (const [variable, output] of [ + ['STATIC_ANALYSIS_SHOULD_RUN', 'static_analysis'], + ['TYPECHECK_SHOULD_RUN', 'typecheck'] + ]) { + expect(aggregateShell.env[variable]).toBe(`\${{ needs.code_paths.outputs.${output} }}`) + expect(outputs[output]).toContain(`steps.filter.outputs.${output}`) } - }) - - it('sends every malformed selector through the unchanged shell', () => { - for (const job of aggregateSelectors) { - for (const selected of malformedSelectors) { - for (const result of results) { - const needs = aggregateNeeds('false', { [job]: selected, verify_fast_path: '0' }) - const affected = ['static_analysis', 'typecheck'].includes(job) ? 'preflight' : job - needs[affected].result = result - expect(admitted(needs), `${job}/${selected}/${result}`).toBe(true) - } - } - } - }) - - it('rejects outputs masked after the canonical-routing marker was emitted', () => { - for (const job of aggregateSelectors) { - for (const selected of ['true', 'false']) { - for (const masked of ['', undefined, null]) { - const needs = aggregateNeeds(selected) - needs.code_paths.outputs[job] = masked - expect(admitted(needs), `${job}/${selected}/${masked}`).toBe(true) - } - } - } - }) - - it('requires successful detection and a numeric canonical marker', () => { - for (const selected of ['true', 'false']) { - for (const result of results) { - const needs = aggregateNeeds(selected) - needs.code_paths.result = result - expect(admitted(needs), `${selected}/${result}`).toBe(result !== 'success') - } - for (const marker of ['', '0', 'true', 'TRUE', 'unknown', undefined, null]) { - expect(admitted(aggregateNeeds(selected, { verify_fast_path: marker }))).toBe(true) - } - const missing = aggregateNeeds(selected) - delete missing.code_paths.outputs - expect(admitted(missing)).toBe(true) - delete missing.code_paths - expect(admitted(missing)).toBe(true) - } - }) - - it('preserves workflow cancellation while detecting individually cancelled dependencies', () => { - for (const job of aggregateJob.needs) { - for (const result of results) { - const needs = aggregateNeeds() - needs[job].result = result - expect(admitted(needs, true), `${job}/${result}`).toBe(false) - if (result === 'cancelled') { - expect(admitted(needs, false), job).toBe(true) - } - } - const missing = aggregateNeeds() - delete missing[job] - expect(admitted(missing, false), job).toBe(true) - expect(admitted(missing, true), job).toBe(false) - } - }) - - it('keeps the informational overall selector independent of the gate', () => { - for (const should_run of ['true', 'false', ...malformedSelectors]) { - for (const selected of ['true', 'false']) { - expect(admitted(aggregateNeeds(selected, { should_run }))).toBe(false) - } - } - }) - - it('uses Actions case-insensitive equality and preserves readiness output masking', () => { const compare = compileAggregateExpression("needs.value == 'true'") expect(compare({ needs: { value: 'TRUE' } }).coerceString()).toBe('true') - for (const job of aggregateSelectors) { - const effective = compileAggregateExpression(aggregateMarker.env[aggregateVariable(job)], [ - 'steps' - ]) - for (const reused of ['true', 'TRUE', 'false', '', undefined]) { - for (const selected of ['true', 'false', ...malformedSelectors]) { - const context = { - steps: { readiness: { outputs: { reused } }, filter: { outputs: { [job]: selected } } } - } - const masked = reused?.toLowerCase() === 'true' - expect(effective(context).coerceString(), `${job}/${reused}/${selected}`).toBe( - masked ? 'false' : (selected ?? '') - ) - } - } - } }) }) diff --git a/config/scripts/pr-ready-check-gate.test.mjs b/config/scripts/pr-ready-check-gate.test.mjs index c68ae739e75..0c966af8f68 100644 --- a/config/scripts/pr-ready-check-gate.test.mjs +++ b/config/scripts/pr-ready-check-gate.test.mjs @@ -1,43 +1,16 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { runProcess } from '@orca/process-host' import { aggregateEnvironment, - aggregateMarker, aggregateNeeds, aggregateRequiredJobs as requiredJobs, aggregateSelectors, - aggregateShell as gate, - aggregateShouldRun, - aggregateVariable as variable + aggregateShell as gate } from './pr-aggregate-gate-context.mjs' -function requiredResults(shouldRun) { - return Object.fromEntries( - requiredJobs.flatMap((job) => [ - [variable(job), shouldRun ? 'success' : 'skipped'], - [`${variable(job)}_SHOULD_RUN`, String(shouldRun)] - ]) - ) -} - -async function verify(results) { - return runProcess({ - program: 'bash', - args: ['-c', gate.run], - env: { - ...process.env, - ORCA_BACKGROUND_LAUNCH: '1', - CODE_PATHS: 'success', - SHOULD_RUN: 'true', - ...results - }, - timeoutMs: 10_000 - }) -} - async function verifyCases(environments, script, env = {}) { const quote = (value) => `'${value.replaceAll("'", "'\\''")}'` const cases = environments.map((environment, index) => { @@ -65,116 +38,91 @@ async function verifyCases(environments, script, env = {}) { } } +const malformedSelectors = [ + '', + 'TRUE', + 'True', + 'FALSE', + '0', + '1', + 'null', + 'unknown', + ' true', + 'true\n', + undefined, + null +] +const outcomes = ['success', 'skipped', 'failure', 'cancelled', '', 'unknown', undefined, null] + // The aggregate runs as Bash on Linux; Windows has no required Bash installation. describe.skipIf(process.platform === 'win32')( - 'readiness reuse through the real aggregate gate', + 'required PR checks through the real aggregate shell', () => { - it('accepts completed required checks or skips authorized by exact-source evidence', async () => { - expect((await verify(requiredResults(true))).code).toBe(0) - expect((await verify(requiredResults(false))).code).toBe(0) - }) - - it('rejects every missing, failed or cancelled required result when reuse is unavailable', async () => { - 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) + it('accepts code, docs, reuse and mixed plans with their required outcomes', async () => { + const plans = [aggregateNeeds('true'), aggregateNeeds('false')] + for (const static_analysis of ['true', 'false']) { + for (const typecheck of ['true', 'false']) { + plans.push(aggregateNeeds('false', { static_analysis, typecheck })) } } + plans.push(aggregateNeeds('false', { test: 'true', package: 'true', typecheck: 'true' })) + for (const should_run of ['true', 'false', ...malformedSelectors]) { + plans.push(aggregateNeeds('true', { should_run })) + } + expect(await verifyCases(plans.map(aggregateEnvironment), gate.run)).toEqual( + plans.map(() => 0) + ) }) - it('requires the detector to succeed even when every downstream job is skipped', async () => { - for (const result of ['', 'skipped', 'failure', 'cancelled']) { - expect((await verify({ ...requiredResults(false), CODE_PATHS: result })).code).toBe(1) - } - }) - - it('rejects unexpected downstream execution when the proven plan requires skips', async () => { - for (const job of requiredJobs) { - const verdict = await verify({ ...requiredResults(false), [variable(job)]: 'success' }) - expect(verdict.code, job).toBe(1) - } - }) - - it('emits the numeric fast-path marker only for exact lowercase routing values', async () => { - const directory = mkdtempSync(join(tmpdir(), 'orca-aggregate-marker-')) - const output = join(directory, 'output') - const valid = Object.fromEntries(aggregateSelectors.map((job) => [variable(job), 'true'])) - const cases = [ - valid, - Object.fromEntries(aggregateSelectors.map((job) => [variable(job), 'false'])) - ] - const expected = ['canonical=1', 'canonical=1'] - for (const job of aggregateSelectors) { - for (const value of [ - 'true', - 'false', - '', - 'TRUE', - 'True', - 'FALSE', - '0', - '1', - 'null', - 'unknown', - ' true', - 'true\n' - ]) { - cases.push({ ...valid, [variable(job)]: value }) - expected.push(value === 'true' || value === 'false' ? 'canonical=1' : 'canonical=0') - } - } - try { - expect(await verifyCases(cases, aggregateMarker.run, { GITHUB_OUTPUT: output })).toEqual( - cases.map(() => 0) - ) - expect(readFileSync(output, 'utf8').trimEnd().split('\n')).toEqual(expected) - } finally { - rmSync(directory, { recursive: true, force: true }) - } - }) - - it('matches the original shell for successful routing, all dependency mismatches and malformed selectors', async () => { - const cases = [aggregateNeeds('true'), aggregateNeeds('false')] + it('rejects missing, failed, cancelled or incorrectly skipped required results', async () => { + const cases = [] + const expected = [] for (const selected of ['true', 'false']) { for (const job of ['code_paths', ...requiredJobs]) { - for (const result of ['success', 'skipped', 'failure', 'cancelled', '', 'unknown']) { + for (const result of outcomes) { const needs = aggregateNeeds(selected) needs[job].result = result - cases.push(needs) + cases.push(aggregateEnvironment(needs)) + const correct = job === 'code_paths' || selected === 'true' ? 'success' : 'skipped' + expected.push(result === correct ? 0 : 1) + } + const missing = aggregateNeeds(selected) + delete missing[job] + cases.push(aggregateEnvironment(missing)) + expected.push(1) + } + } + expect(await verifyCases(cases, gate.run)).toEqual(expected) + }) + + it('rejects every malformed or masked raw selector even when downstream jobs were skipped', async () => { + const cases = [] + for (const job of aggregateSelectors) { + for (const selected of malformedSelectors) { + cases.push(aggregateEnvironment(aggregateNeeds('false', { [job]: selected }))) + const missing = aggregateNeeds('false') + delete missing.code_paths.outputs[job] + cases.push(aggregateEnvironment(missing)) + } + } + expect(await verifyCases(cases, gate.run)).toEqual(cases.map(() => 1)) + }) + + it('rejects malformed preflight phases even when the other phase selects preflight', async () => { + const cases = [] + for (const phase of ['static_analysis', 'typecheck']) { + const other = phase === 'static_analysis' ? 'typecheck' : 'static_analysis' + for (const selected of malformedSelectors) { + for (const otherSelected of ['true', 'false']) { + for (const result of ['success', 'skipped']) { + const needs = aggregateNeeds('false', { [phase]: selected, [other]: otherSelected }) + needs.preflight.result = result + cases.push(aggregateEnvironment(needs)) + } } } } - for (const job of aggregateSelectors) { - for (const selected of [ - '', - 'TRUE', - 'FALSE', - '0', - 'unknown', - ' true', - 'true\n', - undefined - ]) { - const matching = aggregateNeeds('false', { [job]: selected, verify_fast_path: '0' }) - cases.push(matching) - const mismatched = structuredClone(matching) - const affected = ['static_analysis', 'typecheck'].includes(job) ? 'preflight' : job - mismatched[affected].result = - matching[affected].result === 'success' ? 'skipped' : 'success' - cases.push(mismatched) - } - } - const results = await verifyCases(cases.map(aggregateEnvironment), gate.run) - expect(results).toHaveLength(cases.length) - for (const [index, needs] of cases.entries()) { - const runs = aggregateShouldRun({ needs }).coerceString() === 'true' - if (needs.code_paths.outputs.verify_fast_path === '0') { - expect(runs, `fallback case ${index}`).toBe(true) - } else { - expect(runs, `canonical case ${index}`).toBe(results[index] !== 0) - } - } + expect(await verifyCases(cases, gate.run)).toEqual(cases.map(() => 1)) }) } ) diff --git a/config/scripts/pr-ready-check-reuse.test.mjs b/config/scripts/pr-ready-check-reuse.test.mjs index 5401b14e8e9..b37fbb92123 100644 --- a/config/scripts/pr-ready-check-reuse.test.mjs +++ b/config/scripts/pr-ready-check-reuse.test.mjs @@ -151,7 +151,7 @@ describe('ready-for-review required check reuse', () => { expect(detector.steps.find((step) => step.id === 'e2e_filter').if).toBe( "github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true'" ) - expect(workflow.jobs.verify.if).toMatch(/^!cancelled\(\) && !\(/) + expect(workflow.jobs.verify.if).toBe('${{ !cancelled() }}') expect(workflow.jobs.verify.needs).toEqual([ 'code_paths', 'preflight', diff --git a/config/scripts/required-pr-check-name.test.mjs b/config/scripts/required-pr-check-name.test.mjs index 79d187ac118..6cd9199bf64 100644 --- a/config/scripts/required-pr-check-name.test.mjs +++ b/config/scripts/required-pr-check-name.test.mjs @@ -34,3 +34,34 @@ it('checks context names even when workflow-only changes skip root tests', () => expect(checkout.with['sparse-checkout'].split('\n')).toContain(path) } }) + +it('rejects aggregate conditions that hide a successful result', () => { + for (const condition of [undefined, 'success()', 'always()', '!cancelled() && !(true)']) { + const parseChanged = (source) => { + const workflow = parse(source) + if (workflow.jobs.verify?.name === 'PR checks passed') { + workflow.jobs.verify.if = condition + } + return workflow + } + expect(() => verifyRequiredPrContext(parseChanged), String(condition)).toThrow( + 'Required PR aggregate must run unless the workflow is cancelled.' + ) + } +}) + +it('rejects aggregate jobs and steps that ignore failures', () => { + for (const location of ['job', 'step']) { + const parseChanged = (source) => { + const workflow = parse(source) + if (workflow.jobs.verify?.name === 'PR checks passed') { + const target = location === 'job' ? workflow.jobs.verify : workflow.jobs.verify.steps[0] + target['continue-on-error'] = true + } + return workflow + } + expect(() => verifyRequiredPrContext(parseChanged), location).toThrow( + 'Required PR aggregate must not ignore failures.' + ) + } +}) diff --git a/src/main/codex/codex-hook-file-entry-binary-contract.test.ts b/src/main/codex/codex-hook-file-entry-binary-contract.test.ts index 47aee2a70bb..db591be5625 100644 --- a/src/main/codex/codex-hook-file-entry-binary-contract.test.ts +++ b/src/main/codex/codex-hook-file-entry-binary-contract.test.ts @@ -13,6 +13,7 @@ import { createServer, type Server } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' import { StringDecoder } from 'node:string_decoder' +import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' import { createCodexComposerReadyScanner } from '../../shared/codex-composer-ready-scanner' import { createTerminalEscapeScanner } from '../../shared/terminal-escape-scanner' import { promisify } from 'node:util' @@ -500,14 +501,14 @@ describe.runIf(binary)('codex hook file-entry binary contract', { timeout: 180_0 model.close() receiver.close() } - const screen = readFileSync(out, 'utf-8').replace(/\s+/g, '') + const screen = stripAnsiEscapeSequences(readFileSync(out, 'utf-8')).replace(/\s+/g, '') return { screen, posts } } }) // Why a pty: Codex's review screen only exists in the TUI, which `codex exec` never shows. const PTY_DRIVER = ` -import os, pty, re, sys, time, select, signal, struct, fcntl, termios +import os, pty, sys, time, select, signal, struct, fcntl, termios out, prompt, cwd = sys.argv[1], sys.argv[2], sys.argv[3] control, expected = sys.argv[4], sys.argv[5] cmd = sys.argv[sys.argv.index('--') + 1:] @@ -557,8 +558,6 @@ finally: except ProcessLookupError: pass finally: waited_pid, status = os.waitpid(pid, 0) - buf = re.sub(rb'\\x1b\\[[0-9;?<>=]*[A-Za-z]', b'', buf) - buf = re.sub(rb'\\x1b\\][^\\x07]*\\x07', b'', buf) open(out, 'wb').write(buf) ` diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.spec.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.spec.ts new file mode 100644 index 00000000000..67cbe25761e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.spec.ts @@ -0,0 +1,347 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { OrchestrationFleetWorker } from '../../../../../../shared/orchestration-fleet-projection' +import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' +import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' + +export function registerWorkerListRunScopeRpcTests(): void { + type WorkerListReceipt = { workers: { dispatchId: string; runId: string }[] } + + /** The runtime half of the worker-list scope seam: the two RPC questions the CLI handler asks + * (`cli/handlers/orchestration/worker-list-run-scope.ts`) over a real OrchestrationDb. The CLI + * half lives beside the handler; the two cannot share one file across tsconfig projects. */ + describe('orchestration worker-list Run scope (runtime)', () => { + const h = createOrchestrationWorkerReleaseHarness() + + beforeEach(() => h.setup()) + afterEach(() => h.cleanup()) + + function createDispatchInRun(runId: string, handle: string): string { + const task = h.db.createTask({ spec: `task for ${handle}`, runId }) + return createRootDispatch(h.db, task.id, handle).id + } + + function createOtherRun(): string { + return h.db.createRun({ + objective: 'Another Run', + coordinatorHandle: 'term_other', + coordinatorPaneKey: 'tab_other:cccccccc-cccc-4ccc-8ccc-cccccccccccc' + }).id + } + + it('resolves the bound Run from the coordinator handle and lists only its dispatches', async () => { + const boundDispatch = createDispatchInRun(h.activeRunId, 'term_bound') + const otherDispatch = createDispatchInRun(createOtherRun(), 'term_unbound') + + const current = (await h.call('orchestration.runCurrent', { from: 'term_coord' })) as { + run: { id: string } | null + } + expect(current.run?.id).toBe(h.activeRunId) + + const listed = (await h.call('orchestration.workerList', { + paginate: true, + run: current.run!.id + })) as WorkerListReceipt + expect(listed.workers.map((worker) => worker.dispatchId)).toEqual([boundDispatch]) + expect(listed.workers.map((worker) => worker.dispatchId)).not.toContain(otherDispatch) + }) + + it('refuses runCurrent for an unbound handle, and an unscoped list spans every Run', async () => { + const boundDispatch = createDispatchInRun(h.activeRunId, 'term_bound') + const otherDispatch = createDispatchInRun(createOtherRun(), 'term_unbound') + + // The CLI's catch turns this refusal into `scope.source = 'all'`. + await expect( + h.call('orchestration.runCurrent', { from: 'term_unbound_shell' }) + ).rejects.toThrow(/no stable pane identity/) + + const listed = (await h.call('orchestration.workerList', { + paginate: true + })) as WorkerListReceipt + expect(listed.workers.map((worker) => worker.dispatchId).sort()).toEqual( + [boundDispatch, otherDispatch].sort() + ) + }) + }) + + // A plain orchestration.dispatch attempt has no worker_dispatches row, so the retry precondition + // used to reject it and its abandoned Task had no documented route back. + describe('worker-start --retry-of a context-only Dispatch', () => { + const harness = createOrchestrationWorkerReleaseHarness() + beforeEach(() => harness.setup()) + afterEach(() => harness.cleanup()) + + async function dispatchContextOnly( + spec: string + ): Promise<{ taskId: string; dispatchId: string }> { + const task = harness.db.createTask({ spec, runId: harness.activeRunId }) + const result = (await harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: 'term_worker' + })) as { dispatch: { id: string } } + expect(harness.db.getWorkerDispatch(result.dispatch.id)).toBeUndefined() + return { taskId: task.id, dispatchId: result.dispatch.id } + } + + it('restarts the Task after the attempt is abandoned', async () => { + const { taskId, dispatchId } = await dispatchContextOnly('unsupervised attempt') + + await expect( + harness.call('orchestration.workerAbandon', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'abandoned', alreadySettled: false }) + expect(harness.db.getTask(taskId)?.status).toBe('blocked') + + const retried = (await harness.call('orchestration.workerStart', { + task: taskId, + from: 'term_coord', + terminal: 'term_worker', + retryOf: dispatchId + })) as { dispatchId: string; state: string } + + expect(retried.state).toBe('ready') + expect(harness.db.getDispatchContextById(retried.dispatchId)?.retry_of_dispatch_id).toBe( + dispatchId + ) + expect(harness.db.getTask(taskId)?.status).toBe('dispatched') + }) + + it('still refuses to retry an attempt that has not settled', async () => { + const { taskId, dispatchId } = await dispatchContextOnly('live attempt') + + await expect( + harness.call('orchestration.workerStart', { + task: taskId, + from: 'term_coord', + terminal: 'term_worker', + retryOf: dispatchId + }) + ).rejects.toMatchObject({ code: 'task_not_startable' }) + }) + }) + + // A coordinator that records context against its own terminal delegated nothing, so the row it + // leaves behind must not read back as the coordinator's own parent Attempt. + describe('context-only self-dispatch and nesting depth', () => { + const harness = createOrchestrationWorkerReleaseHarness() + beforeEach(() => harness.setup()) + afterEach(() => harness.cleanup()) + + async function selfDispatch(): Promise { + const task = harness.db.createTask({ spec: 'self bookkeeping', runId: harness.activeRunId }) + const result = (await harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: 'term_coord' + })) as { dispatch: { id: string } } + return result.dispatch.id + } + + it('leaves the coordinator able to start a worker', async () => { + const selfDispatchId = await selfDispatch() + expect(harness.db.getDispatchContextById(selfDispatchId)).toMatchObject({ + creator_handle: 'term_coord', + creator_pane_key: harness.coordinatorPaneKey + }) + + const started = await harness.startWorker({ terminal: 'term_worker' }) + + expect(harness.db.getDispatchContextById(started.dispatchId)).toMatchObject({ + depth: 1, + creator_dispatch_id: null + }) + }) + + it('still counts a real assignment to another pane as a nesting parent', async () => { + const task = harness.db.createTask({ spec: 'real delegation', runId: harness.activeRunId }) + const delegated = (await harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: 'term_worker' + })) as { dispatch: { id: string } } + + expect( + harness.db.resolveCreatorDepth({ + kind: 'terminal', + handle: 'term_worker', + paneKey: harness.workerPaneKey + }) + ).toBe(1) + expect( + harness.db.resolveCreatorDispatchId({ + kind: 'terminal', + handle: 'term_worker', + paneKey: harness.workerPaneKey + }) + ).toBe(delegated.dispatch.id) + }) + + it('reports the self-dispatching coordinator as a root', async () => { + await selfDispatch() + + const creator = { + kind: 'terminal', + handle: 'term_coord', + paneKey: harness.coordinatorPaneKey + } as const + expect(harness.db.resolveCreatorDepth(creator)).toBe(0) + expect(harness.db.resolveCreatorDispatchId(creator)).toBeNull() + }) + }) + + type ReadWithProjection = { projection?: OrchestrationFleetWorker | null } + + describe('orchestration worker-read fleet projection', () => { + const h = createOrchestrationWorkerReleaseHarness() + + afterEach(() => h.cleanup()) + + it('publishes the fleet agent verdict beside the PTY verdict on a live read', async () => { + h.setup() + const { dispatchId } = await h.startWorker() + + const read = (await h.call('orchestration.workerRead', { + dispatch: dispatchId + })) as ReadWithProjection & { status: { liveness?: string } } + + expect(read.projection?.dispatchId).toBe(dispatchId) + // The agent verdict is not the PTY verdict; worker-read must carry both. + expect(read.status.liveness).toBe('live') + expect(read.projection?.liveness.verdict).toBe('unverifiable') + }) + + it('carries the projection on an archived read after release', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker() + await h.call('orchestration.workerRelease', { dispatch: dispatchId }) + + const read = (await h.call('orchestration.workerRead', { + dispatch: dispatchId + })) as ReadWithProjection + + expect(read.projection?.dispatchId).toBe(dispatchId) + expect(read.projection?.liveness.verdict).toBe('exited') + }) + }) + + type ListedWorker = { + dispatchId: string + workerState: string + dispatchStatus: string + projection: { + outcome: string + liveness: { verdict: string; reason?: string } + nextAction: { kind: string; argv: string[] } + attention: { categories: string[]; requiresAction: boolean } + } + } + + describe('pre-v3 dispatch rows in worker-list', () => { + const h = createOrchestrationWorkerReleaseHarness() + + afterEach(() => h.cleanup()) + + /** A pre-v3 dispatch: a real dispatch_contexts row settled through the real lifecycle with no + * worker_dispatches row, which is what every dispatch made before supervised workers looks like. */ + function createLegacyDispatch(status: 'completed' | 'failed' | 'dispatched'): string { + const task = h.db.createTask({ spec: `legacy ${status} task`, runId: h.activeRunId }) + const dispatch = createRootDispatch(h.db, task.id, `term_legacy_${status}`) + if (status === 'completed') { + h.db.completeDispatch(dispatch.id) + } + if (status === 'failed') { + h.db.failDispatch(dispatch.id, 'legacy failure') + } + return dispatch.id + } + + async function listWorkers(): Promise> { + const listed = (await h.call('orchestration.workerList', { + paginate: true, + run: h.activeRunId + })) as { workers: ListedWorker[] } + return new Map(listed.workers.map((worker) => [worker.dispatchId, worker])) + } + + it('projects a settled legacy dispatch as settled with nothing to act on', async () => { + h.setup() + const completed = createLegacyDispatch('completed') + + const worker = (await listWorkers()).get(completed)! + + expect(worker.workerState).toBe('unsupervised') + expect(worker.dispatchStatus).toBe('completed') + // `dispatch_contexts.status = 'completed'` is only written from an accepted `succeeded` + // report or a task completion, so the durable record is the whole settlement. + expect(worker.projection.outcome).toBe('succeeded') + // Absence is not a death certificate, so the verdict stays unverifiable — but a dispatch + // that never had a worker row has no process whose absence could require action. + expect(worker.projection.liveness).toEqual({ + verdict: 'unverifiable', + reason: 'unsupervised_settled' + }) + expect(worker.projection.attention.categories).not.toContain('unverifiable') + expect(worker.projection.attention.requiresAction).toBe(false) + expect(worker.projection.nextAction.kind).toBe('none') + }) + + it.each(['completed', 'failed'] as const)( + 'closes a pending question when a legacy dispatch settles as %s', + async (status) => { + h.setup() + const task = h.db.createTask({ + spec: `legacy ${status} with question`, + runId: h.activeRunId + }) + const dispatch = createRootDispatch(h.db, task.id, `term_legacy_q_${status}`) + const asked = h.db.createQuestion({ + runId: h.activeRunId, + dispatchId: dispatch.id, + askerHandle: `term_legacy_q_${status}`, + question: 'Which branch?' + }) + // Both settlement paths a pre-v3 dispatch can take: the task-status path and failDispatch. + if (status === 'completed') { + h.db.updateTaskStatus(task.id, 'completed', 'done') + } else { + h.db.failDispatch(dispatch.id, 'legacy failure') + } + + const worker = (await listWorkers()).get(dispatch.id)! + + expect(h.db.getQuestion(asked.question.message_id)?.status).toBe('closed') + expect(worker.dispatchStatus).toBe(status) + expect(worker.projection.attention.categories).not.toContain('input') + // Nothing can answer a question on a settled Dispatch, so `input` must not outlive it. + expect(worker.projection.attention.requiresAction).toBe(status === 'failed') + } + ) + + it('keeps a legacy failed dispatch actionable on the failure, not on absence', async () => { + h.setup() + const failed = createLegacyDispatch('failed') + + const worker = (await listWorkers()).get(failed)! + + expect(worker.dispatchStatus).toBe('failed') + expect(worker.projection.outcome).toBe('failed') + expect(worker.projection.attention.categories).toEqual(['failure']) + expect(worker.projection.attention.requiresAction).toBe(true) + }) + + it('leaves an unsettled legacy dispatch genuinely unknown', async () => { + h.setup() + const dispatched = createLegacyDispatch('dispatched') + + const worker = (await listWorkers()).get(dispatched)! + + expect(worker.projection.outcome).toBe('in_progress') + expect(worker.projection.liveness).toEqual({ + verdict: 'unverifiable', + reason: 'missing_status' + }) + expect(worker.projection.attention.categories).toContain('unverifiable') + expect(worker.projection.attention.requiresAction).toBe(true) + expect(worker.projection.nextAction).toEqual({ kind: 'none', argv: [] }) + }) + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts deleted file mode 100644 index 06db0da17d8..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts +++ /dev/null @@ -1,342 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { OrchestrationFleetWorker } from '../../../../../../shared/orchestration-fleet-projection' -import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -type WorkerListReceipt = { workers: { dispatchId: string; runId: string }[] } - -/** The runtime half of the worker-list scope seam: the two RPC questions the CLI handler asks - * (`cli/handlers/orchestration/worker-list-run-scope.ts`) over a real OrchestrationDb. The CLI - * half lives beside the handler; the two cannot share one file across tsconfig projects. */ -describe('orchestration worker-list Run scope (runtime)', () => { - const h = createOrchestrationWorkerReleaseHarness() - - beforeEach(() => h.setup()) - afterEach(() => h.cleanup()) - - function createDispatchInRun(runId: string, handle: string): string { - const task = h.db.createTask({ spec: `task for ${handle}`, runId }) - return createRootDispatch(h.db, task.id, handle).id - } - - function createOtherRun(): string { - return h.db.createRun({ - objective: 'Another Run', - coordinatorHandle: 'term_other', - coordinatorPaneKey: 'tab_other:cccccccc-cccc-4ccc-8ccc-cccccccccccc' - }).id - } - - it('resolves the bound Run from the coordinator handle and lists only its dispatches', async () => { - const boundDispatch = createDispatchInRun(h.activeRunId, 'term_bound') - const otherDispatch = createDispatchInRun(createOtherRun(), 'term_unbound') - - const current = (await h.call('orchestration.runCurrent', { from: 'term_coord' })) as { - run: { id: string } | null - } - expect(current.run?.id).toBe(h.activeRunId) - - const listed = (await h.call('orchestration.workerList', { - paginate: true, - run: current.run!.id - })) as WorkerListReceipt - expect(listed.workers.map((worker) => worker.dispatchId)).toEqual([boundDispatch]) - expect(listed.workers.map((worker) => worker.dispatchId)).not.toContain(otherDispatch) - }) - - it('refuses runCurrent for an unbound handle, and an unscoped list spans every Run', async () => { - const boundDispatch = createDispatchInRun(h.activeRunId, 'term_bound') - const otherDispatch = createDispatchInRun(createOtherRun(), 'term_unbound') - - // The CLI's catch turns this refusal into `scope.source = 'all'`. - await expect( - h.call('orchestration.runCurrent', { from: 'term_unbound_shell' }) - ).rejects.toThrow(/no stable pane identity/) - - const listed = (await h.call('orchestration.workerList', { - paginate: true - })) as WorkerListReceipt - expect(listed.workers.map((worker) => worker.dispatchId).sort()).toEqual( - [boundDispatch, otherDispatch].sort() - ) - }) -}) - -// A plain orchestration.dispatch attempt has no worker_dispatches row, so the retry precondition -// used to reject it and its abandoned Task had no documented route back. -describe('worker-start --retry-of a context-only Dispatch', () => { - const harness = createOrchestrationWorkerReleaseHarness() - beforeEach(() => harness.setup()) - afterEach(() => harness.cleanup()) - - async function dispatchContextOnly( - spec: string - ): Promise<{ taskId: string; dispatchId: string }> { - const task = harness.db.createTask({ spec, runId: harness.activeRunId }) - const result = (await harness.call('orchestration.dispatch', { - task: task.id, - from: 'term_coord', - to: 'term_worker' - })) as { dispatch: { id: string } } - expect(harness.db.getWorkerDispatch(result.dispatch.id)).toBeUndefined() - return { taskId: task.id, dispatchId: result.dispatch.id } - } - - it('restarts the Task after the attempt is abandoned', async () => { - const { taskId, dispatchId } = await dispatchContextOnly('unsupervised attempt') - - await expect( - harness.call('orchestration.workerAbandon', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'abandoned', alreadySettled: false }) - expect(harness.db.getTask(taskId)?.status).toBe('blocked') - - const retried = (await harness.call('orchestration.workerStart', { - task: taskId, - from: 'term_coord', - terminal: 'term_worker', - retryOf: dispatchId - })) as { dispatchId: string; state: string } - - expect(retried.state).toBe('ready') - expect(harness.db.getDispatchContextById(retried.dispatchId)?.retry_of_dispatch_id).toBe( - dispatchId - ) - expect(harness.db.getTask(taskId)?.status).toBe('dispatched') - }) - - it('still refuses to retry an attempt that has not settled', async () => { - const { taskId, dispatchId } = await dispatchContextOnly('live attempt') - - await expect( - harness.call('orchestration.workerStart', { - task: taskId, - from: 'term_coord', - terminal: 'term_worker', - retryOf: dispatchId - }) - ).rejects.toMatchObject({ code: 'task_not_startable' }) - }) -}) - -// A coordinator that records context against its own terminal delegated nothing, so the row it -// leaves behind must not read back as the coordinator's own parent Attempt. -describe('context-only self-dispatch and nesting depth', () => { - const harness = createOrchestrationWorkerReleaseHarness() - beforeEach(() => harness.setup()) - afterEach(() => harness.cleanup()) - - async function selfDispatch(): Promise { - const task = harness.db.createTask({ spec: 'self bookkeeping', runId: harness.activeRunId }) - const result = (await harness.call('orchestration.dispatch', { - task: task.id, - from: 'term_coord', - to: 'term_coord' - })) as { dispatch: { id: string } } - return result.dispatch.id - } - - it('leaves the coordinator able to start a worker', async () => { - const selfDispatchId = await selfDispatch() - expect(harness.db.getDispatchContextById(selfDispatchId)).toMatchObject({ - creator_handle: 'term_coord', - creator_pane_key: harness.coordinatorPaneKey - }) - - const started = await harness.startWorker({ terminal: 'term_worker' }) - - expect(harness.db.getDispatchContextById(started.dispatchId)).toMatchObject({ - depth: 1, - creator_dispatch_id: null - }) - }) - - it('still counts a real assignment to another pane as a nesting parent', async () => { - const task = harness.db.createTask({ spec: 'real delegation', runId: harness.activeRunId }) - const delegated = (await harness.call('orchestration.dispatch', { - task: task.id, - from: 'term_coord', - to: 'term_worker' - })) as { dispatch: { id: string } } - - expect( - harness.db.resolveCreatorDepth({ - kind: 'terminal', - handle: 'term_worker', - paneKey: harness.workerPaneKey - }) - ).toBe(1) - expect( - harness.db.resolveCreatorDispatchId({ - kind: 'terminal', - handle: 'term_worker', - paneKey: harness.workerPaneKey - }) - ).toBe(delegated.dispatch.id) - }) - - it('reports the self-dispatching coordinator as a root', async () => { - await selfDispatch() - - const creator = { - kind: 'terminal', - handle: 'term_coord', - paneKey: harness.coordinatorPaneKey - } as const - expect(harness.db.resolveCreatorDepth(creator)).toBe(0) - expect(harness.db.resolveCreatorDispatchId(creator)).toBeNull() - }) -}) - -type ReadWithProjection = { projection?: OrchestrationFleetWorker | null } - -describe('orchestration worker-read fleet projection', () => { - const h = createOrchestrationWorkerReleaseHarness() - - afterEach(() => h.cleanup()) - - it('publishes the fleet agent verdict beside the PTY verdict on a live read', async () => { - h.setup() - const { dispatchId } = await h.startWorker() - - const read = (await h.call('orchestration.workerRead', { - dispatch: dispatchId - })) as ReadWithProjection & { status: { liveness?: string } } - - expect(read.projection?.dispatchId).toBe(dispatchId) - // The agent verdict is not the PTY verdict; worker-read must carry both. - expect(read.status.liveness).toBe('live') - expect(read.projection?.liveness.verdict).toBe('unverifiable') - }) - - it('carries the projection on an archived read after release', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker() - await h.call('orchestration.workerRelease', { dispatch: dispatchId }) - - const read = (await h.call('orchestration.workerRead', { - dispatch: dispatchId - })) as ReadWithProjection - - expect(read.projection?.dispatchId).toBe(dispatchId) - expect(read.projection?.liveness.verdict).toBe('exited') - }) -}) - -type ListedWorker = { - dispatchId: string - workerState: string - dispatchStatus: string - projection: { - outcome: string - liveness: { verdict: string; reason?: string } - nextAction: { kind: string; argv: string[] } - attention: { categories: string[]; requiresAction: boolean } - } -} - -describe('pre-v3 dispatch rows in worker-list', () => { - const h = createOrchestrationWorkerReleaseHarness() - - afterEach(() => h.cleanup()) - - /** A pre-v3 dispatch: a real dispatch_contexts row settled through the real lifecycle with no - * worker_dispatches row, which is what every dispatch made before supervised workers looks like. */ - function createLegacyDispatch(status: 'completed' | 'failed' | 'dispatched'): string { - const task = h.db.createTask({ spec: `legacy ${status} task`, runId: h.activeRunId }) - const dispatch = createRootDispatch(h.db, task.id, `term_legacy_${status}`) - if (status === 'completed') { - h.db.completeDispatch(dispatch.id) - } - if (status === 'failed') { - h.db.failDispatch(dispatch.id, 'legacy failure') - } - return dispatch.id - } - - async function listWorkers(): Promise> { - const listed = (await h.call('orchestration.workerList', { - paginate: true, - run: h.activeRunId - })) as { workers: ListedWorker[] } - return new Map(listed.workers.map((worker) => [worker.dispatchId, worker])) - } - - it('projects a settled legacy dispatch as settled with nothing to act on', async () => { - h.setup() - const completed = createLegacyDispatch('completed') - - const worker = (await listWorkers()).get(completed)! - - expect(worker.workerState).toBe('unsupervised') - expect(worker.dispatchStatus).toBe('completed') - // `dispatch_contexts.status = 'completed'` is only written from an accepted `succeeded` - // report or a task completion, so the durable record is the whole settlement. - expect(worker.projection.outcome).toBe('succeeded') - // Absence is not a death certificate, so the verdict stays unverifiable — but a dispatch - // that never had a worker row has no process whose absence could require action. - expect(worker.projection.liveness).toEqual({ - verdict: 'unverifiable', - reason: 'unsupervised_settled' - }) - expect(worker.projection.attention.categories).not.toContain('unverifiable') - expect(worker.projection.attention.requiresAction).toBe(false) - expect(worker.projection.nextAction.kind).toBe('none') - }) - - it.each(['completed', 'failed'] as const)( - 'closes a pending question when a legacy dispatch settles as %s', - async (status) => { - h.setup() - const task = h.db.createTask({ spec: `legacy ${status} with question`, runId: h.activeRunId }) - const dispatch = createRootDispatch(h.db, task.id, `term_legacy_q_${status}`) - const asked = h.db.createQuestion({ - runId: h.activeRunId, - dispatchId: dispatch.id, - askerHandle: `term_legacy_q_${status}`, - question: 'Which branch?' - }) - // Both settlement paths a pre-v3 dispatch can take: the task-status path and failDispatch. - if (status === 'completed') { - h.db.updateTaskStatus(task.id, 'completed', 'done') - } else { - h.db.failDispatch(dispatch.id, 'legacy failure') - } - - const worker = (await listWorkers()).get(dispatch.id)! - - expect(h.db.getQuestion(asked.question.message_id)?.status).toBe('closed') - expect(worker.dispatchStatus).toBe(status) - expect(worker.projection.attention.categories).not.toContain('input') - // Nothing can answer a question on a settled Dispatch, so `input` must not outlive it. - expect(worker.projection.attention.requiresAction).toBe(status === 'failed') - } - ) - - it('keeps a legacy failed dispatch actionable on the failure, not on absence', async () => { - h.setup() - const failed = createLegacyDispatch('failed') - - const worker = (await listWorkers()).get(failed)! - - expect(worker.dispatchStatus).toBe('failed') - expect(worker.projection.outcome).toBe('failed') - expect(worker.projection.attention.categories).toEqual(['failure']) - expect(worker.projection.attention.requiresAction).toBe(true) - }) - - it('leaves an unsettled legacy dispatch genuinely unknown', async () => { - h.setup() - const dispatched = createLegacyDispatch('dispatched') - - const worker = (await listWorkers()).get(dispatched)! - - expect(worker.projection.outcome).toBe('in_progress') - expect(worker.projection.liveness).toEqual({ - verdict: 'unverifiable', - reason: 'missing_status' - }) - expect(worker.projection.attention.categories).toContain('unverifiable') - expect(worker.projection.attention.requiresAction).toBe(true) - expect(worker.projection.nextAction).toEqual({ kind: 'none', argv: [] }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-archive.spec.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-archive.spec.ts new file mode 100644 index 00000000000..9dd01bbe9c1 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-archive.spec.ts @@ -0,0 +1,249 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' + +export function registerWorkerReleaseArchiveTests(): void { + function codexMessage(id: string, text: string): string { + return JSON.stringify({ + timestamp: '2026-08-03T12:00:00.000Z', + type: 'event_msg', + payload: { id, type: 'agent_message', message: text } + }) + } + + describe('orchestration worker release archive', () => { + const h = createOrchestrationWorkerReleaseHarness() + + afterEach(() => h.cleanup()) + + it('records an explicitly empty archive for an already-exited worker process', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker() + vi.mocked(h.runtime.showTerminal).mockImplementation( + async (handle) => ({ handle, worktreeId: 'repo::worktree', connected: false }) as never + ) + vi.mocked(h.runtime.readTerminal).mockResolvedValue({ + handle: 'term_worker', + status: 'exited', + tail: [], + truncated: false, + nextCursor: null + }) + const receipt = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + archive: { status: string | null } | null + } + expect(receipt).toMatchObject({ + state: 'released', + processAction: 'closed_exited_terminal', + archive: { status: 'empty' } + }) + }) + + it('keeps a bounded tail when one terminal line exceeds the archive budget', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker() + const suffix = 'meaningful-tail' + vi.mocked(h.runtime.readTerminal).mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: [`${'x'.repeat(300_000)}${suffix}`], + truncated: false, + nextCursor: '1' + }) + + const release = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as { + archive: { status: string | null } | null + } + const read = (await h.call('orchestration.workerRead', { dispatch: dispatchId })) as { + terminal: { tail: string[]; truncated: boolean } + warnings: string[] + } + + expect(release.archive?.status).toBe('captured') + expect(read.terminal.tail).toHaveLength(1) + expect(read.terminal.tail[0]).toMatch(new RegExp(`${suffix}$`)) + expect(read.terminal.truncated).toBe(true) + expect(read.warnings).not.toContain( + 'The live terminal buffer was empty at release; structured transcript output was unavailable.' + ) + }) + + it('serves the frozen redacted archive through worker-read after release, with cursors', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker() + vi.mocked(h.runtime.readTerminal).mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: ['first line', `capability dcap_${'a'.repeat(24)} leaked`, 'last line'], + draft: `send --dispatch-capability dcap_${'b'.repeat(24)}`, + truncated: false, + nextCursor: '3' + }) + await h.call('orchestration.workerRelease', { dispatch: dispatchId }) + vi.mocked(h.runtime.readTerminal).mockClear() + + const page1 = (await h.call('orchestration.workerRead', { + dispatch: dispatchId, + limit: 2 + })) as { + archived?: boolean + terminal: { tail: string[]; draft?: string } + cursor: string | null + } + expect(page1.terminal.tail).toEqual([ + 'first line', + 'capability [dispatch capability redacted] leaked' + ]) + expect(page1.terminal.draft).toBe('send --dispatch-capability [dispatch capability redacted]') + expect(page1.cursor).not.toBeNull() + + const page2 = (await h.call('orchestration.workerRead', { + dispatch: dispatchId, + cursor: page1.cursor as string + })) as { terminal: { tail: string[]; draft?: string }; cursor: string | null } + expect(page2.terminal.tail).toEqual(['last line']) + expect(page2.terminal.draft).toBeUndefined() + expect(page2.cursor).toBeNull() + // The live terminal is never consulted after release. + expect(h.runtime.readTerminal).not.toHaveBeenCalled() + }) + + it('reads an immutable transcript snapshot after the provider file disappears', async () => { + h.setup() + const directory = await mkdtemp(join(tmpdir(), 'orca-worker-release-snapshot-')) + const transcriptPath = join(directory, 'rollout.jsonl') + try { + await writeFile( + transcriptPath, + `${codexMessage('snapshot-one', 'frozen first')}\n${codexMessage('snapshot-two', 'frozen second')}\n` + ) + vi.mocked(h.runtime.getExactWorkerProviderSession).mockReturnValue({ + agent: 'codex', + processIncarnation: 'runtime_test:term_worker:1', + providerSession: { + key: 'codex:snapshot-session', + id: 'snapshot-session', + transcriptPath + } + } as never) + const { dispatchId } = await h.startSettledWorker() + await h.call('orchestration.workerRelease', { dispatch: dispatchId }) + await rm(transcriptPath) + + const page = (await h.call('orchestration.workerRead', { + dispatch: dispatchId, + limit: 1 + })) as { cursor: string } + expect(page).toMatchObject({ + archived: true, + source: 'transcript', + transcript: { + messages: [{ id: 'snapshot-one', blocks: [{ type: 'text', text: 'frozen first' }] }] + } + }) + await expect( + h.call('orchestration.workerRead', { dispatch: dispatchId, cursor: page.cursor }) + ).resolves.toMatchObject({ + transcript: { + messages: [{ id: 'snapshot-two', blocks: [{ type: 'text', text: 'frozen second' }] }] + } + }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('preserves payload clipping metadata in the released transcript snapshot', async () => { + h.setup() + const directory = await mkdtemp(join(tmpdir(), 'orca-worker-release-clipped-snapshot-')) + const transcriptPath = join(directory, 'rollout.jsonl') + try { + await writeFile( + transcriptPath, + `${JSON.stringify({ + timestamp: '2026-08-03T12:00:00.000Z', + type: 'event_msg', + payload: { id: 'clipped-message', type: 'agent_message', message: 'x'.repeat(5_000) } + })}\n` + ) + vi.mocked(h.runtime.getExactWorkerProviderSession).mockReturnValue({ + agent: 'codex', + processIncarnation: 'runtime_test:term_worker:1', + providerSession: { + key: 'codex:clipped-session', + id: 'clipped-session', + transcriptPath + } + } as never) + const { dispatchId } = await h.startSettledWorker() + await h.call('orchestration.workerRelease', { dispatch: dispatchId }) + + const read = (await h.call('orchestration.workerRead', { dispatch: dispatchId })) as { + transcript: { limited: boolean } + cursor: string + contentComplete: boolean + clipping: string[] + warnings: string[] + } + + expect(read).toMatchObject({ + transcript: { limited: true }, + contentComplete: false, + clipping: ['transcript_payload'], + warnings: ['Oversized transcript text was clipped.'] + }) + expect(read.cursor).toMatch(/^owr1_/) + expect(read.warnings).not.toContain( + 'Older transcript messages were omitted from the bounded archive.' + ) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('rejects a legacy live-terminal cursor after output moves to the archive', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker() + await h.call('orchestration.workerRelease', { dispatch: dispatchId }) + + await expect( + h.call('orchestration.workerRead', { dispatch: dispatchId, cursor: 1 }) + ).rejects.toThrow(/source changed/i) + }) + + it('recovers archive metadata when a prior attempt committed only the archive row', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker() + const requested = h.db.requestWorkerTerminalRelease(dispatchId) + expect(requested.disposition).toBe('requested') + if (requested.disposition !== 'requested') { + throw new Error('release request was not recorded') + } + h.db.storeWorkerTerminalArchive({ + dispatchId, + resourceId: requested.resource.id, + kind: 'terminal_tail', + content: JSON.stringify({ + lines: ['archive survived the interrupted attempt'], + truncated: false, + terminalStatus: 'running', + warnings: [] + }) + }) + + const release = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as { + archive: { source: string | null; status: string | null } | null + } + + expect(release.archive).toEqual({ source: 'terminal', status: 'captured' }) + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + archive_source: 'terminal', + archive_status: 'captured' + }) + }) + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-archive.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-archive.test.ts deleted file mode 100644 index fb53014071d..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-archive.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -function codexMessage(id: string, text: string): string { - return JSON.stringify({ - timestamp: '2026-08-03T12:00:00.000Z', - type: 'event_msg', - payload: { id, type: 'agent_message', message: text } - }) -} - -describe('orchestration worker release archive', () => { - const h = createOrchestrationWorkerReleaseHarness() - - afterEach(() => h.cleanup()) - - it('records an explicitly empty archive for an already-exited worker process', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker() - vi.mocked(h.runtime.showTerminal).mockImplementation( - async (handle) => ({ handle, worktreeId: 'repo::worktree', connected: false }) as never - ) - vi.mocked(h.runtime.readTerminal).mockResolvedValue({ - handle: 'term_worker', - status: 'exited', - tail: [], - truncated: false, - nextCursor: null - }) - const receipt = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as { - state: string - processAction: string - archive: { status: string | null } | null - } - expect(receipt).toMatchObject({ - state: 'released', - processAction: 'closed_exited_terminal', - archive: { status: 'empty' } - }) - }) - - it('keeps a bounded tail when one terminal line exceeds the archive budget', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker() - const suffix = 'meaningful-tail' - vi.mocked(h.runtime.readTerminal).mockResolvedValue({ - handle: 'term_worker', - status: 'running', - tail: [`${'x'.repeat(300_000)}${suffix}`], - truncated: false, - nextCursor: '1' - }) - - const release = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as { - archive: { status: string | null } | null - } - const read = (await h.call('orchestration.workerRead', { dispatch: dispatchId })) as { - terminal: { tail: string[]; truncated: boolean } - warnings: string[] - } - - expect(release.archive?.status).toBe('captured') - expect(read.terminal.tail).toHaveLength(1) - expect(read.terminal.tail[0]).toMatch(new RegExp(`${suffix}$`)) - expect(read.terminal.truncated).toBe(true) - expect(read.warnings).not.toContain( - 'The live terminal buffer was empty at release; structured transcript output was unavailable.' - ) - }) - - it('serves the frozen redacted archive through worker-read after release, with cursors', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker() - vi.mocked(h.runtime.readTerminal).mockResolvedValue({ - handle: 'term_worker', - status: 'running', - tail: ['first line', `capability dcap_${'a'.repeat(24)} leaked`, 'last line'], - draft: `send --dispatch-capability dcap_${'b'.repeat(24)}`, - truncated: false, - nextCursor: '3' - }) - await h.call('orchestration.workerRelease', { dispatch: dispatchId }) - vi.mocked(h.runtime.readTerminal).mockClear() - - const page1 = (await h.call('orchestration.workerRead', { - dispatch: dispatchId, - limit: 2 - })) as { - archived?: boolean - terminal: { tail: string[]; draft?: string } - cursor: string | null - } - expect(page1.terminal.tail).toEqual([ - 'first line', - 'capability [dispatch capability redacted] leaked' - ]) - expect(page1.terminal.draft).toBe('send --dispatch-capability [dispatch capability redacted]') - expect(page1.cursor).not.toBeNull() - - const page2 = (await h.call('orchestration.workerRead', { - dispatch: dispatchId, - cursor: page1.cursor as string - })) as { terminal: { tail: string[]; draft?: string }; cursor: string | null } - expect(page2.terminal.tail).toEqual(['last line']) - expect(page2.terminal.draft).toBeUndefined() - expect(page2.cursor).toBeNull() - // The live terminal is never consulted after release. - expect(h.runtime.readTerminal).not.toHaveBeenCalled() - }) - - it('reads an immutable transcript snapshot after the provider file disappears', async () => { - h.setup() - const directory = await mkdtemp(join(tmpdir(), 'orca-worker-release-snapshot-')) - const transcriptPath = join(directory, 'rollout.jsonl') - try { - await writeFile( - transcriptPath, - `${codexMessage('snapshot-one', 'frozen first')}\n${codexMessage('snapshot-two', 'frozen second')}\n` - ) - vi.mocked(h.runtime.getExactWorkerProviderSession).mockReturnValue({ - agent: 'codex', - processIncarnation: 'runtime_test:term_worker:1', - providerSession: { - key: 'codex:snapshot-session', - id: 'snapshot-session', - transcriptPath - } - } as never) - const { dispatchId } = await h.startSettledWorker() - await h.call('orchestration.workerRelease', { dispatch: dispatchId }) - await rm(transcriptPath) - - const page = (await h.call('orchestration.workerRead', { - dispatch: dispatchId, - limit: 1 - })) as { cursor: string } - expect(page).toMatchObject({ - archived: true, - source: 'transcript', - transcript: { - messages: [{ id: 'snapshot-one', blocks: [{ type: 'text', text: 'frozen first' }] }] - } - }) - await expect( - h.call('orchestration.workerRead', { dispatch: dispatchId, cursor: page.cursor }) - ).resolves.toMatchObject({ - transcript: { - messages: [{ id: 'snapshot-two', blocks: [{ type: 'text', text: 'frozen second' }] }] - } - }) - } finally { - await rm(directory, { recursive: true, force: true }) - } - }) - - it('preserves payload clipping metadata in the released transcript snapshot', async () => { - h.setup() - const directory = await mkdtemp(join(tmpdir(), 'orca-worker-release-clipped-snapshot-')) - const transcriptPath = join(directory, 'rollout.jsonl') - try { - await writeFile( - transcriptPath, - `${JSON.stringify({ - timestamp: '2026-08-03T12:00:00.000Z', - type: 'event_msg', - payload: { id: 'clipped-message', type: 'agent_message', message: 'x'.repeat(5_000) } - })}\n` - ) - vi.mocked(h.runtime.getExactWorkerProviderSession).mockReturnValue({ - agent: 'codex', - processIncarnation: 'runtime_test:term_worker:1', - providerSession: { - key: 'codex:clipped-session', - id: 'clipped-session', - transcriptPath - } - } as never) - const { dispatchId } = await h.startSettledWorker() - await h.call('orchestration.workerRelease', { dispatch: dispatchId }) - - const read = (await h.call('orchestration.workerRead', { dispatch: dispatchId })) as { - transcript: { limited: boolean } - cursor: string - contentComplete: boolean - clipping: string[] - warnings: string[] - } - - expect(read).toMatchObject({ - transcript: { limited: true }, - contentComplete: false, - clipping: ['transcript_payload'], - warnings: ['Oversized transcript text was clipped.'] - }) - expect(read.cursor).toMatch(/^owr1_/) - expect(read.warnings).not.toContain( - 'Older transcript messages were omitted from the bounded archive.' - ) - } finally { - await rm(directory, { recursive: true, force: true }) - } - }) - - it('rejects a legacy live-terminal cursor after output moves to the archive', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker() - await h.call('orchestration.workerRelease', { dispatch: dispatchId }) - - await expect( - h.call('orchestration.workerRead', { dispatch: dispatchId, cursor: 1 }) - ).rejects.toThrow(/source changed/i) - }) - - it('recovers archive metadata when a prior attempt committed only the archive row', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker() - const requested = h.db.requestWorkerTerminalRelease(dispatchId) - expect(requested.disposition).toBe('requested') - if (requested.disposition !== 'requested') { - throw new Error('release request was not recorded') - } - h.db.storeWorkerTerminalArchive({ - dispatchId, - resourceId: requested.resource.id, - kind: 'terminal_tail', - content: JSON.stringify({ - lines: ['archive survived the interrupted attempt'], - truncated: false, - terminalStatus: 'running', - warnings: [] - }) - }) - - const release = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as { - archive: { source: string | null; status: string | null } | null - } - - expect(release.archive).toEqual({ source: 'terminal', status: 'captured' }) - expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ - archive_source: 'terminal', - archive_status: 'captured' - }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-authority-contracts.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-authority-contracts.test.ts new file mode 100644 index 00000000000..1efb9650d6d --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-authority-contracts.test.ts @@ -0,0 +1,5 @@ +import { registerWorkerReleaseOwnershipGuardTests } from './worker-release-ownership-guard.spec' +import { registerWorkerReleaseRuntimeIncarnationTests } from './worker-release-runtime-incarnation.spec' + +registerWorkerReleaseOwnershipGuardTests() +registerWorkerReleaseRuntimeIncarnationTests() diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-evidence-contracts.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-evidence-contracts.test.ts new file mode 100644 index 00000000000..a5f1d64f260 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-evidence-contracts.test.ts @@ -0,0 +1,5 @@ +import { registerWorkerReleaseArchiveTests } from './worker-release-archive.spec' +import { registerWorkerReleaseInventoryTests } from './worker-release-inventory.spec' + +registerWorkerReleaseArchiveTests() +registerWorkerReleaseInventoryTests() diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-inventory.spec.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-inventory.spec.ts new file mode 100644 index 00000000000..c9da4817831 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-inventory.spec.ts @@ -0,0 +1,194 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' + +export function registerWorkerReleaseInventoryTests(): void { + describe('orchestration worker release inventory', () => { + const h = createOrchestrationWorkerReleaseHarness() + + afterEach(() => h.cleanup()) + + it('transfers ownership on exact reuse and fences release through the old Dispatch', async () => { + h.setup() + const first = await h.startSettledWorker('succeeded') + const originalResource = h.db.getWorkerTerminalResourceByOwner(first.dispatchId) + expect(originalResource?.ownership_state).toBe('owned') + + const second = await h.startWorker({ terminal: 'term_reminted' }) + const transferred = h.db.getWorkerTerminalResourceByOwner(second.dispatchId) + expect(transferred?.id).toBe(originalResource?.id) + expect(transferred?.terminal_handle).toBe('term_reminted') + expect(h.db.getWorkerTerminalResourceByOwner(first.dispatchId)).toBeUndefined() + + h.inspectProcessLiveness.mockResolvedValueOnce('exited') + const oldRelease = (await h.call('orchestration.workerRelease', { + dispatch: first.dispatchId + })) as { state: string; reason?: string } + expect(oldRelease).toMatchObject({ state: 'retained', reason: 'ownership_transferred' }) + expect(h.runtime.closeTerminal).not.toHaveBeenCalled() + + h.settle(second.taskId, second.dispatchId, 'succeeded') + const newRelease = (await h.call('orchestration.workerRelease', { + dispatch: second.dispatchId + })) as { state: string } + expect(newRelease.state).toBe('released') + expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(1) + expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_reminted') + }) + + it('refuses to settle dead transferred ownership with no durable archive', async () => { + h.setup() + const first = await h.startSettledWorker('succeeded') + const second = await h.startWorker({ terminal: 'term_reminted' }) + h.settle(second.taskId, second.dispatchId, 'succeeded') + h.inspectProcessLiveness.mockResolvedValue('exited') + + await expect( + h.call('orchestration.workerRelease', { dispatch: first.dispatchId }) + ).resolves.toMatchObject({ + state: 'retained', + reason: 'ownership_transferred', + processAction: 'none' + }) + expect(h.inspectProcessLiveness).toHaveBeenCalledWith( + 'runtime_test:term_worker:1', + JSON.stringify({ kind: 'local', hostId: 'local' }) + ) + expect(h.runtime.closeTerminal).not.toHaveBeenCalled() + expect(h.db.getWorkerTerminalResourceByOwner(second.dispatchId)?.release_state).not.toBe( + 'released' + ) + }) + + it('rejects exact reuse after release intent instead of closing the new worker', async () => { + h.setup() + const first = await h.startSettledWorker('succeeded') + expect(h.db.requestWorkerTerminalRelease(first.dispatchId).disposition).toBe('requested') + const nextTask = h.db.createTask({ spec: 'racing reuse', runId: h.activeRunId }) + + const attempted = (await h.call('orchestration.workerStart', { + task: nextTask.id, + from: 'term_coord', + terminal: 'term_worker' + })) as { state: string; lastError?: string } + + expect(attempted).toMatchObject({ state: 'failed' }) + expect(attempted.lastError).toMatch(/release.*progress/i) + expect(h.runtime.closeTerminal).not.toHaveBeenCalled() + await expect( + h.call('orchestration.workerRelease', { dispatch: first.dispatchId }) + ).resolves.toMatchObject({ state: 'released' }) + expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(1) + }) + + it('retains when persisted state has another resource for the exact terminal identity', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker() + const raw = h.db.db + raw + .prepare( + `INSERT INTO worker_terminal_resources ( + id, origin_dispatch_id, owner_dispatch_id, terminal_handle, pane_key, + process_incarnation, host_scope, ownership_state, release_state, retained_reason + ) VALUES ( + 'wtr_conflict', 'ctx_conflict', 'ctx_conflict', 'term_reminted', ?, ?, ?, + 'external', 'retained', 'legacy_ambiguous' + )` + ) + .run( + h.workerPaneKey, + 'runtime_test:term_worker:1', + JSON.stringify({ kind: 'local', hostId: 'local' }) + ) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'retained', reason: 'identity_unproven' }) + expect(h.runtime.closeTerminal).not.toHaveBeenCalled() + }) + + it('worker-retain records a durable user exception that release can later replace', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker() + const retained = (await h.call('orchestration.workerRetain', { dispatch: dispatchId })) as { + state: string + reason?: string + } + expect(retained).toMatchObject({ state: 'retained', reason: 'user_requested' }) + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('retained') + + const release = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + } + expect(release.state).toBe('released') + }) + + it('worker-list separates terminal accounting from Task outcome', async () => { + h.setup() + const active = await h.startWorker() + const perWorkerLookup = vi.spyOn(h.db, 'getWorkerTerminalResourceByOwner') + perWorkerLookup.mockClear() + const result1 = (await h.call('orchestration.workerList', { run: h.activeRunId })) as { + workers: { dispatchId: string; terminalState: string | null; workerState: string }[] + counts: Record + } + expect(result1.workers).toHaveLength(1) + expect(result1.workers[0]).toMatchObject({ + dispatchId: active.dispatchId, + terminalState: 'active', + workerState: 'ready' + }) + expect(perWorkerLookup).not.toHaveBeenCalled() + + h.settle(active.taskId, active.dispatchId, 'succeeded') + const result2 = (await h.call('orchestration.workerList', { + run: h.activeRunId, + terminalState: 'reclaimable' + })) as { workers: { dispatchId: string }[]; counts: Record } + expect(result2.workers.map((worker) => worker.dispatchId)).toEqual([active.dispatchId]) + expect(result2.counts).toMatchObject({ reclaimable: 1 }) + + await h.call('orchestration.workerRelease', { dispatch: active.dispatchId }) + const result3 = (await h.call('orchestration.workerList', { run: h.activeRunId })) as { + workers: { terminalState: string | null; workerState: string }[] + } + expect(result3.workers[0]).toMatchObject({ + terminalState: 'released', + workerState: 'succeeded' + }) + }) + + it('reports abandoned workers as retained instead of reclaimable', async () => { + h.setup() + const { dispatchId } = await h.startWorker() + await h.call('orchestration.workerAbandon', { dispatch: dispatchId }) + + const listed = (await h.call('orchestration.workerList', { run: h.activeRunId })) as { + workers: { dispatchId: string; terminalState: string | null }[] + } + + expect(listed.workers).toContainEqual( + expect.objectContaining({ dispatchId, terminalState: 'retained' }) + ) + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ + state: 'retained', + reason: 'identity_unproven', + processAction: 'none' + }) + expect(h.runtime.closeTerminal).not.toHaveBeenCalled() + }) + + it('worker-show exposes the terminal resource', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker() + const shown = (await h.call('orchestration.workerShow', { dispatch: dispatchId })) as { + terminalResource: { ownershipState: string; releaseState: string } | null + } + expect(shown.terminalResource).toMatchObject({ + ownershipState: 'owned', + releaseState: 'not_requested' + }) + }) + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-inventory.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-inventory.test.ts deleted file mode 100644 index 7b5b1f50209..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-inventory.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -describe('orchestration worker release inventory', () => { - const h = createOrchestrationWorkerReleaseHarness() - - afterEach(() => h.cleanup()) - - it('transfers ownership on exact reuse and fences release through the old Dispatch', async () => { - h.setup() - const first = await h.startSettledWorker('succeeded') - const originalResource = h.db.getWorkerTerminalResourceByOwner(first.dispatchId) - expect(originalResource?.ownership_state).toBe('owned') - - const second = await h.startWorker({ terminal: 'term_reminted' }) - const transferred = h.db.getWorkerTerminalResourceByOwner(second.dispatchId) - expect(transferred?.id).toBe(originalResource?.id) - expect(transferred?.terminal_handle).toBe('term_reminted') - expect(h.db.getWorkerTerminalResourceByOwner(first.dispatchId)).toBeUndefined() - - h.inspectProcessLiveness.mockResolvedValueOnce('exited') - const oldRelease = (await h.call('orchestration.workerRelease', { - dispatch: first.dispatchId - })) as { state: string; reason?: string } - expect(oldRelease).toMatchObject({ state: 'retained', reason: 'ownership_transferred' }) - expect(h.runtime.closeTerminal).not.toHaveBeenCalled() - - h.settle(second.taskId, second.dispatchId, 'succeeded') - const newRelease = (await h.call('orchestration.workerRelease', { - dispatch: second.dispatchId - })) as { state: string } - expect(newRelease.state).toBe('released') - expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(1) - expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_reminted') - }) - - it('refuses to settle dead transferred ownership with no durable archive', async () => { - h.setup() - const first = await h.startSettledWorker('succeeded') - const second = await h.startWorker({ terminal: 'term_reminted' }) - h.settle(second.taskId, second.dispatchId, 'succeeded') - h.inspectProcessLiveness.mockResolvedValue('exited') - - await expect( - h.call('orchestration.workerRelease', { dispatch: first.dispatchId }) - ).resolves.toMatchObject({ - state: 'retained', - reason: 'ownership_transferred', - processAction: 'none' - }) - expect(h.inspectProcessLiveness).toHaveBeenCalledWith( - 'runtime_test:term_worker:1', - JSON.stringify({ kind: 'local', hostId: 'local' }) - ) - expect(h.runtime.closeTerminal).not.toHaveBeenCalled() - expect(h.db.getWorkerTerminalResourceByOwner(second.dispatchId)?.release_state).not.toBe( - 'released' - ) - }) - - it('rejects exact reuse after release intent instead of closing the new worker', async () => { - h.setup() - const first = await h.startSettledWorker('succeeded') - expect(h.db.requestWorkerTerminalRelease(first.dispatchId).disposition).toBe('requested') - const nextTask = h.db.createTask({ spec: 'racing reuse', runId: h.activeRunId }) - - const attempted = (await h.call('orchestration.workerStart', { - task: nextTask.id, - from: 'term_coord', - terminal: 'term_worker' - })) as { state: string; lastError?: string } - - expect(attempted).toMatchObject({ state: 'failed' }) - expect(attempted.lastError).toMatch(/release.*progress/i) - expect(h.runtime.closeTerminal).not.toHaveBeenCalled() - await expect( - h.call('orchestration.workerRelease', { dispatch: first.dispatchId }) - ).resolves.toMatchObject({ state: 'released' }) - expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(1) - }) - - it('retains when persisted state has another resource for the exact terminal identity', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker() - const raw = ( - h.db as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } } - ).db - raw - .prepare( - `INSERT INTO worker_terminal_resources ( - id, origin_dispatch_id, owner_dispatch_id, terminal_handle, pane_key, - process_incarnation, host_scope, ownership_state, release_state, retained_reason - ) VALUES ( - 'wtr_conflict', 'ctx_conflict', 'ctx_conflict', 'term_reminted', ?, ?, ?, - 'external', 'retained', 'legacy_ambiguous' - )` - ) - .run( - h.workerPaneKey, - 'runtime_test:term_worker:1', - JSON.stringify({ kind: 'local', hostId: 'local' }) - ) - - await expect( - h.call('orchestration.workerRelease', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'retained', reason: 'identity_unproven' }) - expect(h.runtime.closeTerminal).not.toHaveBeenCalled() - }) - - it('worker-retain records a durable user exception that release can later replace', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker() - const retained = (await h.call('orchestration.workerRetain', { dispatch: dispatchId })) as { - state: string - reason?: string - } - expect(retained).toMatchObject({ state: 'retained', reason: 'user_requested' }) - expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('retained') - - const release = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as { - state: string - } - expect(release.state).toBe('released') - }) - - it('worker-list separates terminal accounting from Task outcome', async () => { - h.setup() - const active = await h.startWorker() - const perWorkerLookup = vi.spyOn(h.db, 'getWorkerTerminalResourceByOwner') - perWorkerLookup.mockClear() - const result1 = (await h.call('orchestration.workerList', { run: h.activeRunId })) as { - workers: { dispatchId: string; terminalState: string | null; workerState: string }[] - counts: Record - } - expect(result1.workers).toHaveLength(1) - expect(result1.workers[0]).toMatchObject({ - dispatchId: active.dispatchId, - terminalState: 'active', - workerState: 'ready' - }) - expect(perWorkerLookup).not.toHaveBeenCalled() - - h.settle(active.taskId, active.dispatchId, 'succeeded') - const result2 = (await h.call('orchestration.workerList', { - run: h.activeRunId, - terminalState: 'reclaimable' - })) as { workers: { dispatchId: string }[]; counts: Record } - expect(result2.workers.map((worker) => worker.dispatchId)).toEqual([active.dispatchId]) - expect(result2.counts).toMatchObject({ reclaimable: 1 }) - - await h.call('orchestration.workerRelease', { dispatch: active.dispatchId }) - const result3 = (await h.call('orchestration.workerList', { run: h.activeRunId })) as { - workers: { terminalState: string | null; workerState: string }[] - } - expect(result3.workers[0]).toMatchObject({ - terminalState: 'released', - workerState: 'succeeded' - }) - }) - - it('reports abandoned workers as retained instead of reclaimable', async () => { - h.setup() - const { dispatchId } = await h.startWorker() - await h.call('orchestration.workerAbandon', { dispatch: dispatchId }) - - const listed = (await h.call('orchestration.workerList', { run: h.activeRunId })) as { - workers: { dispatchId: string; terminalState: string | null }[] - } - - expect(listed.workers).toContainEqual( - expect.objectContaining({ dispatchId, terminalState: 'retained' }) - ) - await expect( - h.call('orchestration.workerRelease', { dispatch: dispatchId }) - ).resolves.toMatchObject({ - state: 'retained', - reason: 'identity_unproven', - processAction: 'none' - }) - expect(h.runtime.closeTerminal).not.toHaveBeenCalled() - }) - - it('worker-show exposes the terminal resource', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker() - const shown = (await h.call('orchestration.workerShow', { dispatch: dispatchId })) as { - terminalResource: { ownershipState: string; releaseState: string } | null - } - expect(shown.terminalResource).toMatchObject({ - ownershipState: 'owned', - releaseState: 'not_requested' - }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.spec.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.spec.ts new file mode 100644 index 00000000000..87613b17fdd --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.spec.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-types' +import { reconcileRequestedWorkerTerminalReleases } from '../../../../orchestration/worker-terminal-release-reconciliation' +import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' + +export function registerWorkerReleaseOwnershipGuardTests(): void { + describe('workerRelease on a retained resource whose process exited', () => { + const harness = createOrchestrationWorkerReleaseHarness() + beforeEach(() => harness.setup()) + afterEach(() => harness.cleanup()) + + it('does not release a terminal the user took over', async () => { + const { dispatchId } = await harness.startSettledWorker('succeeded') + const takeover = (await harness.call('orchestration.workerTerminalUserInput', { + paneKey: harness.workerPaneKey + })) as { changed: number } + expect(takeover.changed).toBe(1) + expect(harness.db.getWorkerTerminalResourceByOwner(dispatchId)?.ownership_state).toBe( + 'user_owned' + ) + + // The agent process later exits on its own; the user's pane and scrollback remain. + harness.inspectProcessLiveness.mockResolvedValue('exited') + const receipt = (await harness.call('orchestration.workerRelease', { + dispatch: dispatchId + })) as { state: string; reason?: string; archive: unknown } + + expect(receipt.state).toBe('retained') + expect(receipt.reason).toBe('user_takeover') + const after = harness.db.getWorkerTerminalResourceByOwner(dispatchId) + expect(after?.ownership_state).toBe('user_owned') + expect(after?.release_state).not.toBe('released') + }) + + it.each(['transferred', 'external'] as const)( + 'does not release a %s resource on an exited process', + async (ownershipState) => { + const { dispatchId } = await harness.startSettledWorker('succeeded') + const resource = harness.db.getWorkerTerminalResourceByOwner(dispatchId)! + harness.db.db + .prepare('UPDATE worker_terminal_resources SET ownership_state = ? WHERE id = ?') + .run(ownershipState, resource.id) + + harness.inspectProcessLiveness.mockResolvedValue('exited') + const receipt = (await harness.call('orchestration.workerRelease', { + dispatch: dispatchId + })) as { state: string } + + expect(receipt.state).toBe('retained') + const after = harness.db.getWorkerTerminalResourceByOwner(dispatchId) + expect(after?.ownership_state).toBe(ownershipState) + expect(after?.release_state).not.toBe('released') + } + ) + + it('records the archive as unavailable rather than retaining the pane forever', async () => { + const { dispatchId } = await harness.startWorker() + // Abandoned workers never reach `requested`, the only state that writes an archive. + expect(harness.db.abandonWorkerDispatch(dispatchId, 'epoch_test').disposition).toBe( + 'abandoned' + ) + expect(harness.db.getWorkerTerminalArchive(dispatchId)).toBeFalsy() + + harness.inspectProcessLiveness.mockResolvedValue('exited') + const receipt = (await harness.call('orchestration.workerRelease', { + dispatch: dispatchId + })) as { state: string; archive: { status: string | null } | null } + + expect(receipt.state).toBe('released') + expect(receipt.archive?.status).toBe('unavailable') + }) + + it('exits retention after a recovery abandon even once the user retained it', async () => { + const { dispatchId } = await harness.startWorker() + harness.db.reconcileMissingWorkerTerminal(dispatchId, 'terminal gone') + expect(harness.db.getWorkerDispatch(dispatchId)?.state).toBe('abandoned') + harness.inspectProcessLiveness.mockResolvedValue('exited') + + // retain deletes the archive and parks the row in `retained`: still no route back to `requested`. + await harness.call('orchestration.workerRetain', { dispatch: dispatchId }) + const receipt = (await harness.call('orchestration.workerRelease', { + dispatch: dispatchId + })) as { state: string } + + expect(receipt.state).toBe('released') + }) + + it('still refuses when an archive names a different resource', async () => { + const { dispatchId } = await harness.startWorker() + const resource = harness.db.getWorkerTerminalResourceByOwner(dispatchId)! + expect(harness.db.abandonWorkerDispatch(dispatchId, 'epoch_test').disposition).toBe( + 'abandoned' + ) + harness.db.storeWorkerTerminalArchive({ + dispatchId, + resourceId: `${resource.id}-other`, + kind: 'terminal_tail', + content: 'tail' + }) + + harness.inspectProcessLiveness.mockResolvedValue('exited') + const receipt = (await harness.call('orchestration.workerRelease', { + dispatch: dispatchId + })) as { state: string } + + expect(receipt.state).toBe('retained') + expect(harness.db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).not.toBe( + 'released' + ) + }) + }) + + const READY_WAIT = { + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + } satisfies RuntimeTerminalWait + + describe('Antigravity orchestration worker lifecycle', () => { + const h = createOrchestrationWorkerReleaseHarness() + + afterEach(() => h.cleanup()) + + it('owns the terminal immediately and delays prompt delivery until AGY is ready', async () => { + h.setup() + const readiness = h.deferred() + vi.spyOn(h.runtime, 'waitForTerminal').mockReturnValue(readiness.promise) + + const pending = h.startWorker({ agent: 'antigravity' }) + await vi.waitFor(() => expect(h.runtime.waitForTerminal).toHaveBeenCalled()) + + expect(h.runtime.createTerminal).toHaveBeenCalledWith( + 'id:repo::worktree', + expect.objectContaining({ startupAgent: 'antigravity', surfaceOwner: false }) + ) + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + expect(h.db.listWorkerTerminalResources({})[0]?.resource).toMatchObject({ + ownership_state: 'owned', + terminal_handle: 'term_worker' + }) + + readiness.resolve(READY_WAIT) + await expect(pending).resolves.toEqual( + expect.objectContaining({ dispatchId: expect.any(String) }) + ) + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledTimes(1) + }) + + it('stops only the owned AGY terminal', async () => { + h.setup() + const { dispatchId } = await h.startWorker({ agent: 'antigravity' }) + + await expect( + h.call('orchestration.workerStop', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'stopped', processAction: 'closed_agent_terminal' }) + expect(h.runtime.closeTerminal).toHaveBeenCalledOnce() + expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') + }) + + it('releases an owned AGY terminal and recovers a transient stale endpoint', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker('succeeded', { + agent: 'antigravity' + }) + vi.mocked(h.runtime.closeTerminal).mockRejectedValueOnce(new Error('Multiplexer disposed')) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'release_pending', processAction: 'none' }) + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'owned', + release_state: 'releasing' + }) + + await expect(reconcileRequestedWorkerTerminalReleases(h.runtime)).resolves.toMatchObject({ + attempted: 1, + released: 1 + }) + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + release_state: 'released' + }) + expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(2) + expect(h.runtime.closeTerminal).toHaveBeenNthCalledWith(2, 'term_worker') + }) + + it('fails closed on a stale AGY handle and releases it on a fresh retry', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker('succeeded', { + agent: 'antigravity' + }) + vi.mocked(h.runtime.showTerminal).mockRejectedValueOnce(new Error('terminal_handle_stale')) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'release_unknown' }) + expect(h.runtime.closeTerminal).not.toHaveBeenCalled() + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'owned', + release_state: 'unknown' + }) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'released' }) + expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') + }) + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts deleted file mode 100644 index 7904c8e0472..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-ownership-guard.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-types' -import { reconcileRequestedWorkerTerminalReleases } from '../../../../orchestration/worker-terminal-release-reconciliation' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -describe('workerRelease on a retained resource whose process exited', () => { - const harness = createOrchestrationWorkerReleaseHarness() - beforeEach(() => harness.setup()) - afterEach(() => harness.cleanup()) - - it('does not release a terminal the user took over', async () => { - const { dispatchId } = await harness.startSettledWorker('succeeded') - const takeover = (await harness.call('orchestration.workerTerminalUserInput', { - paneKey: harness.workerPaneKey - })) as { changed: number } - expect(takeover.changed).toBe(1) - expect(harness.db.getWorkerTerminalResourceByOwner(dispatchId)?.ownership_state).toBe( - 'user_owned' - ) - - // The agent process later exits on its own; the user's pane and scrollback remain. - harness.inspectProcessLiveness.mockResolvedValue('exited') - const receipt = (await harness.call('orchestration.workerRelease', { - dispatch: dispatchId - })) as { state: string; reason?: string; archive: unknown } - - expect(receipt.state).toBe('retained') - expect(receipt.reason).toBe('user_takeover') - const after = harness.db.getWorkerTerminalResourceByOwner(dispatchId) - expect(after?.ownership_state).toBe('user_owned') - expect(after?.release_state).not.toBe('released') - }) - - it.each(['transferred', 'external'] as const)( - 'does not release a %s resource on an exited process', - async (ownershipState) => { - const { dispatchId } = await harness.startSettledWorker('succeeded') - const resource = harness.db.getWorkerTerminalResourceByOwner(dispatchId)! - harness.db.db - .prepare('UPDATE worker_terminal_resources SET ownership_state = ? WHERE id = ?') - .run(ownershipState, resource.id) - - harness.inspectProcessLiveness.mockResolvedValue('exited') - const receipt = (await harness.call('orchestration.workerRelease', { - dispatch: dispatchId - })) as { state: string } - - expect(receipt.state).toBe('retained') - const after = harness.db.getWorkerTerminalResourceByOwner(dispatchId) - expect(after?.ownership_state).toBe(ownershipState) - expect(after?.release_state).not.toBe('released') - } - ) - - it('records the archive as unavailable rather than retaining the pane forever', async () => { - const { dispatchId } = await harness.startWorker() - // Abandoned workers never reach `requested`, the only state that writes an archive. - expect(harness.db.abandonWorkerDispatch(dispatchId, 'epoch_test').disposition).toBe('abandoned') - expect(harness.db.getWorkerTerminalArchive(dispatchId)).toBeFalsy() - - harness.inspectProcessLiveness.mockResolvedValue('exited') - const receipt = (await harness.call('orchestration.workerRelease', { - dispatch: dispatchId - })) as { state: string; archive: { status: string | null } | null } - - expect(receipt.state).toBe('released') - expect(receipt.archive?.status).toBe('unavailable') - }) - - it('exits retention after a recovery abandon even once the user retained it', async () => { - const { dispatchId } = await harness.startWorker() - harness.db.reconcileMissingWorkerTerminal(dispatchId, 'terminal gone') - expect(harness.db.getWorkerDispatch(dispatchId)?.state).toBe('abandoned') - harness.inspectProcessLiveness.mockResolvedValue('exited') - - // retain deletes the archive and parks the row in `retained`: still no route back to `requested`. - await harness.call('orchestration.workerRetain', { dispatch: dispatchId }) - const receipt = (await harness.call('orchestration.workerRelease', { - dispatch: dispatchId - })) as { state: string } - - expect(receipt.state).toBe('released') - }) - - it('still refuses when an archive names a different resource', async () => { - const { dispatchId } = await harness.startWorker() - const resource = harness.db.getWorkerTerminalResourceByOwner(dispatchId)! - expect(harness.db.abandonWorkerDispatch(dispatchId, 'epoch_test').disposition).toBe('abandoned') - harness.db.storeWorkerTerminalArchive({ - dispatchId, - resourceId: `${resource.id}-other`, - kind: 'terminal_tail', - content: 'tail' - }) - - harness.inspectProcessLiveness.mockResolvedValue('exited') - const receipt = (await harness.call('orchestration.workerRelease', { - dispatch: dispatchId - })) as { state: string } - - expect(receipt.state).toBe('retained') - expect(harness.db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).not.toBe( - 'released' - ) - }) -}) - -const READY_WAIT = { - handle: 'term_worker', - condition: 'tui-idle', - satisfied: true, - status: 'running', - exitCode: null -} satisfies RuntimeTerminalWait - -describe('Antigravity orchestration worker lifecycle', () => { - const h = createOrchestrationWorkerReleaseHarness() - - afterEach(() => h.cleanup()) - - it('owns the terminal immediately and delays prompt delivery until AGY is ready', async () => { - h.setup() - const readiness = h.deferred() - vi.spyOn(h.runtime, 'waitForTerminal').mockReturnValue(readiness.promise) - - const pending = h.startWorker({ agent: 'antigravity' }) - await vi.waitFor(() => expect(h.runtime.waitForTerminal).toHaveBeenCalled()) - - expect(h.runtime.createTerminal).toHaveBeenCalledWith( - 'id:repo::worktree', - expect.objectContaining({ startupAgent: 'antigravity', surfaceOwner: false }) - ) - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - expect(h.db.listWorkerTerminalResources({})[0]?.resource).toMatchObject({ - ownership_state: 'owned', - terminal_handle: 'term_worker' - }) - - readiness.resolve(READY_WAIT) - await expect(pending).resolves.toEqual( - expect.objectContaining({ dispatchId: expect.any(String) }) - ) - expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledTimes(1) - }) - - it('stops only the owned AGY terminal', async () => { - h.setup() - const { dispatchId } = await h.startWorker({ agent: 'antigravity' }) - - await expect( - h.call('orchestration.workerStop', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'stopped', processAction: 'closed_agent_terminal' }) - expect(h.runtime.closeTerminal).toHaveBeenCalledOnce() - expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') - }) - - it('releases an owned AGY terminal and recovers a transient stale endpoint', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker('succeeded', { - agent: 'antigravity' - }) - vi.mocked(h.runtime.closeTerminal).mockRejectedValueOnce(new Error('Multiplexer disposed')) - - await expect( - h.call('orchestration.workerRelease', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'release_pending', processAction: 'none' }) - expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ - ownership_state: 'owned', - release_state: 'releasing' - }) - - await expect(reconcileRequestedWorkerTerminalReleases(h.runtime)).resolves.toMatchObject({ - attempted: 1, - released: 1 - }) - expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ - release_state: 'released' - }) - expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(2) - expect(h.runtime.closeTerminal).toHaveBeenNthCalledWith(2, 'term_worker') - }) - - it('fails closed on a stale AGY handle and releases it on a fresh retry', async () => { - h.setup() - const { dispatchId } = await h.startSettledWorker('succeeded', { - agent: 'antigravity' - }) - vi.mocked(h.runtime.showTerminal).mockRejectedValueOnce(new Error('terminal_handle_stale')) - - await expect( - h.call('orchestration.workerRelease', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'release_unknown' }) - expect(h.runtime.closeTerminal).not.toHaveBeenCalled() - expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ - ownership_state: 'owned', - release_state: 'unknown' - }) - - await expect( - h.call('orchestration.workerRelease', { dispatch: dispatchId }) - ).resolves.toMatchObject({ state: 'released' }) - expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-runtime-incarnation.spec.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-runtime-incarnation.spec.ts new file mode 100644 index 00000000000..3e9cb753b15 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-runtime-incarnation.spec.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { inspectWorkerTerminal } from './worker-observation' +import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' +import { makePaneKey } from '../../../../../../shared/stable-pane-id' + +export function registerWorkerReleaseRuntimeIncarnationTests(): void { + const PTY_ID = 'runtime_test:term_worker' + const LEAF_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + + describe('worker release through runtime incarnation recovery', () => { + const harness = createOrchestrationWorkerReleaseHarness() + afterEach(() => harness.cleanup()) + + it.each(['release', 'stop', 'read', 'replacement', 'disconnected'] as const)( + '%s uses runtime identity and close paths', + async (scenario) => { + harness.setup() + const { dispatchId } = + scenario === 'stop' ? await harness.startWorker() : await harness.startSettledWorker() + const runtime = harness.runtime + vi.mocked(runtime.showTerminal).mockRestore() + vi.mocked(runtime.getTerminalPaneKey).mockRestore() + vi.mocked(runtime.getTerminalProcessIncarnation).mockRestore() + vi.mocked(runtime.getOrchestrationDispatchAuthority).mockRestore() + vi.mocked(runtime.readTerminal).mockRestore() + vi.mocked(runtime.closeTerminal).mockRestore() + const kill = vi.fn(() => true) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null + }) + runtime.registerPty(PTY_ID, 'repo::worktree', null, { + tabId: 'tab_worker', + leafId: LEAF_ID, + incarnationId: scenario === 'replacement' ? '2' : '1' + }) + await expect(runtime.showTerminal('term_worker')).rejects.toThrow() + if (scenario === 'disconnected') { + vi.spyOn(runtime, 'getTerminalLivenessVerdict').mockReturnValue({ + status: 'unverifiable', + reason: 'transport unavailable' + }) + const observed = await inspectWorkerTerminal(runtime, harness.db, dispatchId) + expect(observed).toMatchObject({ exact: true, status: 'unverifiable' }) + expect(kill).not.toHaveBeenCalled() + return + } + if (scenario === 'stop') { + const receipt = await harness.call('orchestration.workerStop', { dispatch: dispatchId }) + expect(receipt).toMatchObject({ + state: 'stopped', + processAction: 'closed_agent_terminal' + }) + expect(kill).toHaveBeenCalledExactlyOnceWith(PTY_ID) + return + } + if (scenario === 'read') { + const readTerminal = vi.spyOn(runtime, 'readTerminal') + const output = await harness.call('orchestration.workerRead', { dispatch: dispatchId }) + // Pins the read to the reminted terminal: the handle it reached must resolve to the + // registered pane and incarnation, which the stale durable handle never does. + const [[readHandle]] = readTerminal.mock.calls + expect(runtime.getTerminalPaneKey(readHandle)).toBe(makePaneKey('tab_worker', LEAF_ID)) + expect(runtime.getTerminalProcessIncarnation(readHandle)).toBe(`${PTY_ID}:1`) + expect(output).toMatchObject({ + source: 'terminal', + fallbackReason: 'session_not_reported' + }) + expect(kill).not.toHaveBeenCalled() + return + } + const receipt = await harness.call('orchestration.workerRelease', { dispatch: dispatchId }) + if (scenario === 'replacement') { + expect(receipt).toMatchObject({ state: 'release_unknown', processAction: 'none' }) + expect(kill).not.toHaveBeenCalled() + return + } + expect(receipt).toMatchObject({ state: 'released', processAction: 'closed_agent_terminal' }) + expect(kill).toHaveBeenCalledExactlyOnceWith(PTY_ID) + expect(harness.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'released', + release_state: 'released' + }) + } + ) + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-runtime-incarnation.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-runtime-incarnation.test.ts deleted file mode 100644 index 4caac62f5da..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-runtime-incarnation.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { inspectWorkerTerminal } from './worker-observation' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' -import { makePaneKey } from '../../../../../../shared/stable-pane-id' - -const PTY_ID = 'runtime_test:term_worker' -const LEAF_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' - -describe('worker release through runtime incarnation recovery', () => { - const harness = createOrchestrationWorkerReleaseHarness() - afterEach(() => harness.cleanup()) - - it.each(['release', 'stop', 'read', 'replacement', 'disconnected'] as const)( - '%s uses runtime identity and close paths', - async (scenario) => { - harness.setup() - const { dispatchId } = - scenario === 'stop' ? await harness.startWorker() : await harness.startSettledWorker() - const runtime = harness.runtime - vi.mocked(runtime.showTerminal).mockRestore() - vi.mocked(runtime.getTerminalPaneKey).mockRestore() - vi.mocked(runtime.getTerminalProcessIncarnation).mockRestore() - vi.mocked(runtime.getOrchestrationDispatchAuthority).mockRestore() - vi.mocked(runtime.readTerminal).mockRestore() - vi.mocked(runtime.closeTerminal).mockRestore() - const kill = vi.fn(() => true) - runtime.setPtyController({ write: () => true, kill, getForegroundProcess: async () => null }) - runtime.registerPty(PTY_ID, 'repo::worktree', null, { - tabId: 'tab_worker', - leafId: LEAF_ID, - incarnationId: scenario === 'replacement' ? '2' : '1' - }) - await expect(runtime.showTerminal('term_worker')).rejects.toThrow() - if (scenario === 'disconnected') { - vi.spyOn(runtime, 'getTerminalLivenessVerdict').mockReturnValue({ - status: 'unverifiable', - reason: 'transport unavailable' - }) - const observed = await inspectWorkerTerminal(runtime, harness.db, dispatchId) - expect(observed).toMatchObject({ exact: true, status: 'unverifiable' }) - expect(kill).not.toHaveBeenCalled() - return - } - if (scenario === 'stop') { - const receipt = await harness.call('orchestration.workerStop', { dispatch: dispatchId }) - expect(receipt).toMatchObject({ state: 'stopped', processAction: 'closed_agent_terminal' }) - expect(kill).toHaveBeenCalledExactlyOnceWith(PTY_ID) - return - } - if (scenario === 'read') { - const readTerminal = vi.spyOn(runtime, 'readTerminal') - const output = await harness.call('orchestration.workerRead', { dispatch: dispatchId }) - // Pins the read to the reminted terminal: the handle it reached must resolve to the - // registered pane and incarnation, which the stale durable handle never does. - const [[readHandle]] = readTerminal.mock.calls - expect(runtime.getTerminalPaneKey(readHandle)).toBe(makePaneKey('tab_worker', LEAF_ID)) - expect(runtime.getTerminalProcessIncarnation(readHandle)).toBe(`${PTY_ID}:1`) - expect(output).toMatchObject({ - source: 'terminal', - fallbackReason: 'session_not_reported' - }) - expect(kill).not.toHaveBeenCalled() - return - } - const receipt = await harness.call('orchestration.workerRelease', { dispatch: dispatchId }) - if (scenario === 'replacement') { - expect(receipt).toMatchObject({ state: 'release_unknown', processAction: 'none' }) - expect(kill).not.toHaveBeenCalled() - return - } - expect(receipt).toMatchObject({ state: 'released', processAction: 'closed_agent_terminal' }) - expect(kill).toHaveBeenCalledExactlyOnceWith(PTY_ID) - expect(harness.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ - ownership_state: 'released', - release_state: 'released' - }) - } - ) -}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-run-dispatch-contracts.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-run-dispatch-contracts.test.ts new file mode 100644 index 00000000000..c2e03e681db --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-run-dispatch-contracts.test.ts @@ -0,0 +1,5 @@ +import { registerWorkerListRunScopeRpcTests } from './worker-list-run-scope-rpc.spec' +import { registerWorkerStartTerminalTargetTests } from './worker-start-terminal-target.spec' + +registerWorkerListRunScopeRpcTests() +registerWorkerStartTerminalTargetTests() diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.spec.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.spec.ts new file mode 100644 index 00000000000..c34aa7a8c94 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.spec.ts @@ -0,0 +1,267 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { LaunchedAgentForeground } from '../../../../launched-agent-foreground' +import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-terminal-contracts' +import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' + +export function registerWorkerStartTerminalTargetTests(): void { + describe('worker-start --terminal target', () => { + const harness = createOrchestrationWorkerReleaseHarness() + beforeEach(() => harness.setup()) + afterEach(() => harness.cleanup()) + + it('refuses the coordinator terminal by handle', async () => { + const task = harness.db.createTask({ spec: 'self adoption', runId: harness.activeRunId }) + + await expect( + harness.call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + terminal: 'term_coord' + }) + ).rejects.toMatchObject({ + code: 'terminal_is_coordinator', + message: expect.stringContaining("coordinator's own terminal") + }) + expect(harness.db.getDispatchContext(task.id)).toBeUndefined() + }) + + it('refuses a different handle that resolves to the coordinator pane', async () => { + const task = harness.db.createTask({ + spec: 'self adoption alias', + runId: harness.activeRunId + }) + vi.spyOn(harness.runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' || handle === 'term_coord_alias' ? harness.coordinatorPaneKey : null + ) + + await expect( + harness.call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + terminal: 'term_coord_alias' + }) + ).rejects.toMatchObject({ code: 'terminal_is_coordinator' }) + }) + + it('still accepts a separate agent terminal in the same worktree', async () => { + const started = await harness.startWorker({ terminal: 'term_worker' }) + expect(started.dispatchId).toEqual(expect.any(String)) + }) + }) + + // The other door into the same self-adoption: manual dispatch never compared `to` to the caller. + describe('orchestration.dispatch --to the caller', () => { + const harness = createOrchestrationWorkerReleaseHarness() + beforeEach(() => harness.setup()) + afterEach(() => harness.cleanup()) + + it('refuses an injected dispatch aimed at the coordinator handle', async () => { + vi.spyOn(harness.runtime, 'getOrchestrationDispatchAuthority').mockImplementation( + (handle) => + ({ + terminalHandle: handle, + paneKey: harness.coordinatorPaneKey, + processIncarnation: 'runtime_test:term_coord:1' + }) as never + ) + const task = harness.db.createTask({ spec: 'self dispatch', runId: harness.activeRunId }) + + await expect( + harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: 'term_coord', + inject: true + }) + ).rejects.toMatchObject({ code: 'terminal_is_coordinator' }) + expect(harness.db.getDispatchContext(task.id)).toBeUndefined() + expect(harness.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) + + it('refuses an injected dispatch to a different handle that resolves to the coordinator pane', async () => { + vi.spyOn(harness.runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' || handle === 'term_coord_alias' ? harness.coordinatorPaneKey : null + ) + vi.spyOn(harness.runtime, 'getOrchestrationDispatchAuthority').mockImplementation( + (handle) => + ({ + terminalHandle: handle, + paneKey: harness.coordinatorPaneKey, + processIncarnation: 'runtime_test:term_coord:1' + }) as never + ) + const task = harness.db.createTask({ + spec: 'self dispatch alias', + runId: harness.activeRunId + }) + + await expect( + harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: 'term_coord_alias', + inject: true + }) + ).rejects.toMatchObject({ code: 'terminal_is_coordinator' }) + }) + + // Low-level topologies (and the e2e specs that drive them from one pane) dispatch context + // to the caller's own terminal; nothing is written into the pane, so nothing self-adopts. + it('still records a context-only dispatch aimed at the coordinator handle', async () => { + const task = harness.db.createTask({ spec: 'self context', runId: harness.activeRunId }) + + const result = (await harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: 'term_coord' + })) as { dispatch: { id: string; status: string } } + + expect(result.dispatch.status).toBe('dispatched') + expect(harness.db.getDispatchContextById(result.dispatch.id)?.assignee_handle).toBe( + 'term_coord' + ) + expect(harness.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) + + // The rejection for a missing agent tells the caller to dispatch without --inject, which the + // coordinator guard forbids; the self-target answer must not depend on agent presence. + it.each([ + ['the coordinator handle', 'term_coord'], + ['an alias of the coordinator pane', 'term_coord_alias'] + ])('refuses %s even when no agent is detected', async (_label, target) => { + vi.spyOn(harness.runtime, 'isTerminalRunningAgent').mockResolvedValue(false) + vi.spyOn(harness.runtime, 'getOrchestrationDispatchAuthority').mockImplementation( + (handle) => + (handle === 'term_coord_alias' + ? { + terminalHandle: handle, + paneKey: harness.coordinatorPaneKey, + processIncarnation: 'runtime_test:term_coord:1' + } + : null) as never + ) + const task = harness.db.createTask({ + spec: `self inject ${target}`, + runId: harness.activeRunId + }) + + await expect( + harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: target, + inject: true + }) + ).rejects.toMatchObject({ code: 'terminal_is_coordinator' }) + expect(harness.db.getDispatchContext(task.id)).toBeUndefined() + }) + + it('still dispatches to a different pane', async () => { + const task = harness.db.createTask({ spec: 'peer dispatch', runId: harness.activeRunId }) + const result = (await harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: 'term_worker' + })) as { dispatch: { assignee_pane_key: string } } + expect(result.dispatch.assignee_pane_key).toBe(harness.workerPaneKey) + }) + }) + + const PTY_ID = 'pty_worker' + + // Why: a shell back at its prompt after the agent exits reads as ready too, so the brief needs the + // launched agent found in front, or the shell runs it. + describe('a worker start writes its brief only into the agent it launched', () => { + const h = createOrchestrationWorkerReleaseHarness() + afterEach(() => h.cleanup()) + + function launchedPane(foreground: LaunchedAgentForeground): string[] { + h.setup() + const writes: string[] = [] + vi.spyOn(h.runtime, 'readLaunchedAgentForeground').mockResolvedValue(foreground) + vi.spyOn(h.runtime, 'launchedAgentHostProvesAgent').mockReturnValue(true) + vi.spyOn(h.runtime, 'subscribeToTerminalData').mockReturnValue(() => {}) + vi.mocked(h.runtime.sendTerminalAgentPrompt).mockImplementation( + async (handle, text, options) => { + await options?.beforeWrite?.(PTY_ID) + writes.push(text) + return { handle, accepted: true, bytesWritten: text.length } + } + ) + return writes + } + + it('types nothing when the agent exited and its shell is in front', async () => { + const writes = launchedPane('shell') + + await expect(h.startWorker({ agent: 'claude' })).rejects.toThrow() + expect(writes).toEqual([]) + }) + + it('writes the brief once into the agent found in front', async () => { + const writes = launchedPane('agent') + + await h.startWorker({ agent: 'claude' }) + expect(writes).toHaveLength(1) + }) + + it('leaves a terminal the caller supplied to its own idle wait', async () => { + const writes = launchedPane('shell') + + await h.startWorker({ terminal: 'term_worker' }) + expect(h.runtime.readLaunchedAgentForeground).not.toHaveBeenCalled() + expect(writes).toHaveLength(1) + }) + }) + + describe('composer-marker first dispatch readiness', () => { + const h = createOrchestrationWorkerReleaseHarness() + afterEach(() => h.cleanup()) + + it.each(['zcode', 'opencode', 'opencode2'] as const)( + '%s waits for the new composer before dispatch', + async (agent) => { + h.setup() + const gate = h.deferred() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) + const pending = h.startWorker({ agent }) + await vi.waitFor(() => + expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( + 'term_worker', + agent, + 60_000 + ) + ) + expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + gate.resolve({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + await pending + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + } + ) + + it('keeps reused terminals on the normal idle wait', async () => { + h.setup() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer') + await h.startWorker({ terminal: 'term_worker' }) + expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() + expect(h.runtime.waitForTerminal).toHaveBeenCalledWith( + 'term_worker', + expect.objectContaining({ condition: 'tui-idle' }) + ) + }) + + it('never delivers a task after a startup timeout', async () => { + h.setup() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockRejectedValue(new Error('timeout')) + await expect(h.startWorker({ agent: 'zcode' })).rejects.toThrow('Expected worker-start') + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts deleted file mode 100644 index f6742cea93e..00000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { LaunchedAgentForeground } from '../../../../launched-agent-foreground' -import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-terminal-contracts' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -describe('worker-start --terminal target', () => { - const harness = createOrchestrationWorkerReleaseHarness() - beforeEach(() => harness.setup()) - afterEach(() => harness.cleanup()) - - it('refuses the coordinator terminal by handle', async () => { - const task = harness.db.createTask({ spec: 'self adoption', runId: harness.activeRunId }) - - await expect( - harness.call('orchestration.workerStart', { - task: task.id, - from: 'term_coord', - terminal: 'term_coord' - }) - ).rejects.toMatchObject({ - code: 'terminal_is_coordinator', - message: expect.stringContaining("coordinator's own terminal") - }) - expect(harness.db.getDispatchContext(task.id)).toBeUndefined() - }) - - it('refuses a different handle that resolves to the coordinator pane', async () => { - const task = harness.db.createTask({ spec: 'self adoption alias', runId: harness.activeRunId }) - vi.spyOn(harness.runtime, 'getTerminalPaneKey').mockImplementation((handle) => - handle === 'term_coord' || handle === 'term_coord_alias' ? harness.coordinatorPaneKey : null - ) - - await expect( - harness.call('orchestration.workerStart', { - task: task.id, - from: 'term_coord', - terminal: 'term_coord_alias' - }) - ).rejects.toMatchObject({ code: 'terminal_is_coordinator' }) - }) - - it('still accepts a separate agent terminal in the same worktree', async () => { - const started = await harness.startWorker({ terminal: 'term_worker' }) - expect(started.dispatchId).toEqual(expect.any(String)) - }) -}) - -// The other door into the same self-adoption: manual dispatch never compared `to` to the caller. -describe('orchestration.dispatch --to the caller', () => { - const harness = createOrchestrationWorkerReleaseHarness() - beforeEach(() => harness.setup()) - afterEach(() => harness.cleanup()) - - it('refuses an injected dispatch aimed at the coordinator handle', async () => { - vi.spyOn(harness.runtime, 'getOrchestrationDispatchAuthority').mockImplementation( - (handle) => - ({ - terminalHandle: handle, - paneKey: harness.coordinatorPaneKey, - processIncarnation: 'runtime_test:term_coord:1' - }) as never - ) - const task = harness.db.createTask({ spec: 'self dispatch', runId: harness.activeRunId }) - - await expect( - harness.call('orchestration.dispatch', { - task: task.id, - from: 'term_coord', - to: 'term_coord', - inject: true - }) - ).rejects.toMatchObject({ code: 'terminal_is_coordinator' }) - expect(harness.db.getDispatchContext(task.id)).toBeUndefined() - expect(harness.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - }) - - it('refuses an injected dispatch to a different handle that resolves to the coordinator pane', async () => { - vi.spyOn(harness.runtime, 'getTerminalPaneKey').mockImplementation((handle) => - handle === 'term_coord' || handle === 'term_coord_alias' ? harness.coordinatorPaneKey : null - ) - vi.spyOn(harness.runtime, 'getOrchestrationDispatchAuthority').mockImplementation( - (handle) => - ({ - terminalHandle: handle, - paneKey: harness.coordinatorPaneKey, - processIncarnation: 'runtime_test:term_coord:1' - }) as never - ) - const task = harness.db.createTask({ spec: 'self dispatch alias', runId: harness.activeRunId }) - - await expect( - harness.call('orchestration.dispatch', { - task: task.id, - from: 'term_coord', - to: 'term_coord_alias', - inject: true - }) - ).rejects.toMatchObject({ code: 'terminal_is_coordinator' }) - }) - - // Low-level topologies (and the e2e specs that drive them from one pane) dispatch context - // to the caller's own terminal; nothing is written into the pane, so nothing self-adopts. - it('still records a context-only dispatch aimed at the coordinator handle', async () => { - const task = harness.db.createTask({ spec: 'self context', runId: harness.activeRunId }) - - const result = (await harness.call('orchestration.dispatch', { - task: task.id, - from: 'term_coord', - to: 'term_coord' - })) as { dispatch: { id: string; status: string } } - - expect(result.dispatch.status).toBe('dispatched') - expect(harness.db.getDispatchContextById(result.dispatch.id)?.assignee_handle).toBe( - 'term_coord' - ) - expect(harness.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - }) - - // The rejection for a missing agent tells the caller to dispatch without --inject, which the - // coordinator guard forbids; the self-target answer must not depend on agent presence. - it.each([ - ['the coordinator handle', 'term_coord'], - ['an alias of the coordinator pane', 'term_coord_alias'] - ])('refuses %s even when no agent is detected', async (_label, target) => { - vi.spyOn(harness.runtime, 'isTerminalRunningAgent').mockResolvedValue(false) - vi.spyOn(harness.runtime, 'getOrchestrationDispatchAuthority').mockImplementation( - (handle) => - (handle === 'term_coord_alias' - ? { - terminalHandle: handle, - paneKey: harness.coordinatorPaneKey, - processIncarnation: 'runtime_test:term_coord:1' - } - : null) as never - ) - const task = harness.db.createTask({ - spec: `self inject ${target}`, - runId: harness.activeRunId - }) - - await expect( - harness.call('orchestration.dispatch', { - task: task.id, - from: 'term_coord', - to: target, - inject: true - }) - ).rejects.toMatchObject({ code: 'terminal_is_coordinator' }) - expect(harness.db.getDispatchContext(task.id)).toBeUndefined() - }) - - it('still dispatches to a different pane', async () => { - const task = harness.db.createTask({ spec: 'peer dispatch', runId: harness.activeRunId }) - const result = (await harness.call('orchestration.dispatch', { - task: task.id, - from: 'term_coord', - to: 'term_worker' - })) as { dispatch: { assignee_pane_key: string } } - expect(result.dispatch.assignee_pane_key).toBe(harness.workerPaneKey) - }) -}) - -const PTY_ID = 'pty_worker' - -// Why: a shell back at its prompt after the agent exits reads as ready too, so the brief needs the -// launched agent found in front, or the shell runs it. -describe('a worker start writes its brief only into the agent it launched', () => { - const h = createOrchestrationWorkerReleaseHarness() - afterEach(() => h.cleanup()) - - function launchedPane(foreground: LaunchedAgentForeground): string[] { - h.setup() - const writes: string[] = [] - vi.spyOn(h.runtime, 'readLaunchedAgentForeground').mockResolvedValue(foreground) - vi.spyOn(h.runtime, 'launchedAgentHostProvesAgent').mockReturnValue(true) - vi.spyOn(h.runtime, 'subscribeToTerminalData').mockReturnValue(() => {}) - vi.mocked(h.runtime.sendTerminalAgentPrompt).mockImplementation( - async (handle, text, options) => { - await options?.beforeWrite?.(PTY_ID) - writes.push(text) - return { handle, accepted: true, bytesWritten: text.length } - } - ) - return writes - } - - it('types nothing when the agent exited and its shell is in front', async () => { - const writes = launchedPane('shell') - - await expect(h.startWorker({ agent: 'claude' })).rejects.toThrow() - expect(writes).toEqual([]) - }) - - it('writes the brief once into the agent found in front', async () => { - const writes = launchedPane('agent') - - await h.startWorker({ agent: 'claude' }) - expect(writes).toHaveLength(1) - }) - - it('leaves a terminal the caller supplied to its own idle wait', async () => { - const writes = launchedPane('shell') - - await h.startWorker({ terminal: 'term_worker' }) - expect(h.runtime.readLaunchedAgentForeground).not.toHaveBeenCalled() - expect(writes).toHaveLength(1) - }) -}) - -describe('composer-marker first dispatch readiness', () => { - const h = createOrchestrationWorkerReleaseHarness() - afterEach(() => h.cleanup()) - - it.each(['zcode', 'opencode', 'opencode2'] as const)( - '%s waits for the new composer before dispatch', - async (agent) => { - h.setup() - const gate = h.deferred() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) - const pending = h.startWorker({ agent }) - await vi.waitFor(() => - expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( - 'term_worker', - agent, - 60_000 - ) - ) - expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - gate.resolve({ - handle: 'term_worker', - condition: 'tui-idle', - satisfied: true, - status: 'running', - exitCode: null - }) - await pending - expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() - } - ) - - it('keeps reused terminals on the normal idle wait', async () => { - h.setup() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer') - await h.startWorker({ terminal: 'term_worker' }) - expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() - expect(h.runtime.waitForTerminal).toHaveBeenCalledWith( - 'term_worker', - expect.objectContaining({ condition: 'tui-idle' }) - ) - }) - - it('never delivers a task after a startup timeout', async () => { - h.setup() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockRejectedValue(new Error('timeout')) - await expect(h.startWorker({ agent: 'zcode' })).rejects.toThrow('Expected worker-start') - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/runtime/rpc/orchestration-mutation-request-show-legacy.test.ts b/src/main/runtime/rpc/orchestration-mutation-request-show-legacy.test.ts index 07c5527584e..4f3cc99b635 100644 --- a/src/main/runtime/rpc/orchestration-mutation-request-show-legacy.test.ts +++ b/src/main/runtime/rpc/orchestration-mutation-request-show-legacy.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it } from 'vitest' import { cleanupLegacyCompatibilityDispatcherHarnesses, diff --git a/src/renderer/src/store/slices/detected-agents-environment-prune-leak.test.ts b/src/renderer/src/store/slices/detected-agents-environment-prune-leak.test.ts index 2acc6177b32..64001bdb4db 100644 --- a/src/renderer/src/store/slices/detected-agents-environment-prune-leak.test.ts +++ b/src/renderer/src/store/slices/detected-agents-environment-prune-leak.test.ts @@ -50,7 +50,7 @@ vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => { // @ts-expect-error -- minimal window.api stub for the store under test globalThis.window = { api: {} } -import { createTestStore } from './store-test-helpers' +import { createRuntimeDetectedAgentsPairingTestStore as createTestStore } from './runtime-detected-agents-pairing.test-fixture' function env(id: string): PublicKnownRuntimeEnvironment { return { id } as unknown as PublicKnownRuntimeEnvironment diff --git a/src/renderer/src/store/slices/runtime-detected-agents-pairing.test-fixture.ts b/src/renderer/src/store/slices/runtime-detected-agents-pairing.test-fixture.ts new file mode 100644 index 00000000000..4193a3821ef --- /dev/null +++ b/src/renderer/src/store/slices/runtime-detected-agents-pairing.test-fixture.ts @@ -0,0 +1,22 @@ +import { createStore } from 'zustand/vanilla' +import { + createRuntimeDetectedAgentsSlice, + type RuntimeDetectedAgentsStoreState +} from './runtime-detected-agents' +import { createRuntimeAgentOwnerTestState } from './runtime-detected-agents.test-fixture' +import { createRuntimeStatusSlice } from './runtime-status' +import type { RuntimeStatusStoreState } from './runtime-status-types' + +type RuntimeAgentPairingStoreState = RuntimeStatusStoreState & RuntimeDetectedAgentsStoreState + +export function createRuntimeDetectedAgentsPairingTestStore() { + return createStore()((...args) => ({ + ...createRuntimeStatusSlice(...args), + ...createRuntimeDetectedAgentsSlice(...args), + ...createRuntimeAgentOwnerTestState(), + clientHostedBrowserCloseIntentsByEnvironment: {}, + clearClientHostedBrowserCloseIntents: () => { + throw new Error('Agent-detection pairing must not replay browser close intents') + } + })) +} diff --git a/src/renderer/src/store/slices/runtime-detected-agents-pairing.test.ts b/src/renderer/src/store/slices/runtime-detected-agents-pairing.test.ts index 42069d66027..1f09b8e212b 100644 --- a/src/renderer/src/store/slices/runtime-detected-agents-pairing.test.ts +++ b/src/renderer/src/store/slices/runtime-detected-agents-pairing.test.ts @@ -21,7 +21,7 @@ vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ // @ts-expect-error -- minimal API stub for the store under test globalThis.window = { api: {} } -import { createTestStore } from './store-test-helpers' +import { createRuntimeDetectedAgentsPairingTestStore as createTestStore } from './runtime-detected-agents-pairing.test-fixture' import { getRuntimeAgentInventoryKey } from './runtime-agent-inventory-key' import { makeWorktree, TEST_REPO } from './worktrees-slice-test-fixtures' diff --git a/src/renderer/src/store/slices/runtime-detected-agents.test-fixture.ts b/src/renderer/src/store/slices/runtime-detected-agents.test-fixture.ts new file mode 100644 index 00000000000..e9eaade6f30 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-detected-agents.test-fixture.ts @@ -0,0 +1,17 @@ +import { createStore } from 'zustand/vanilla' +import type { getConnectionIdFromState } from '@/lib/connection-owner-resolution' +import { + createRuntimeDetectedAgentsSlice, + type RuntimeDetectedAgentsStoreState +} from './runtime-detected-agents' + +export function createRuntimeAgentOwnerTestState(): Parameters[0] { + return { repos: [], worktreesByRepo: {}, folderWorkspaces: [], projectGroups: [] } +} + +export function createRuntimeDetectedAgentsTestStore() { + return createStore()((...args) => ({ + ...createRuntimeDetectedAgentsSlice(...args), + ...createRuntimeAgentOwnerTestState() + })) +} diff --git a/src/renderer/src/store/slices/runtime-detected-agents.test.ts b/src/renderer/src/store/slices/runtime-detected-agents.test.ts index c29a3d97c36..6ec5073f239 100644 --- a/src/renderer/src/store/slices/runtime-detected-agents.test.ts +++ b/src/renderer/src/store/slices/runtime-detected-agents.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createTestStore } from './store-test-helpers' +import { createRuntimeDetectedAgentsTestStore as createTestStore } from './runtime-detected-agents.test-fixture' import { makeWorktree, TEST_REPO } from './worktrees-slice-test-fixtures' import { getRuntimeAgentInventoryKey } from './runtime-agent-inventory-key' import { diff --git a/src/renderer/src/store/slices/runtime-detected-agents.ts b/src/renderer/src/store/slices/runtime-detected-agents.ts index 386bed3e2e3..92867551df8 100644 --- a/src/renderer/src/store/slices/runtime-detected-agents.ts +++ b/src/renderer/src/store/slices/runtime-detected-agents.ts @@ -1,5 +1,3 @@ -import type { StateCreator } from 'zustand' -import type { AppState } from '../types' import type { TuiAgent } from '../../../../shared/tui-agent' import { callRuntimeRpc, @@ -129,12 +127,20 @@ function withoutKeys(record: Record, drop: (key: string) => boolea return changed ? next : record } -export const createRuntimeDetectedAgentsSlice: StateCreator< - AppState, - [], - [], - RuntimeDetectedAgentsSlice -> = (set, get) => { +export type RuntimeDetectedAgentsStoreState = RuntimeDetectedAgentsSlice & + Parameters[0] + +export type RuntimeDetectedAgentsStoreSetState = ( + partial: + | Partial + | ((state: RuntimeDetectedAgentsStoreState) => Partial) +) => void + +export const createRuntimeDetectedAgentsSlice: ( + set: RuntimeDetectedAgentsStoreSetState, + get: () => RuntimeDetectedAgentsStoreState, + store?: unknown +) => RuntimeDetectedAgentsSlice = (set, get) => { // Drops every workspace's inventory for the environments `drop` selects. const dropEnvironments = (drop: (environmentId: string) => boolean): void => { const dropKey = (key: string) => drop(getRuntimeAgentInventoryEnvironmentId(key)) diff --git a/src/renderer/src/store/slices/runtime-environment-disconnect-toast.ts b/src/renderer/src/store/slices/runtime-environment-disconnect-toast.ts index 00ef737ca59..60a8dceba72 100644 --- a/src/renderer/src/store/slices/runtime-environment-disconnect-toast.ts +++ b/src/renderer/src/store/slices/runtime-environment-disconnect-toast.ts @@ -1,6 +1,6 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -import type { AppState } from '../types' +import type { RuntimeStatusSlice } from './runtime-status-types' const activeRuntimeDisconnectedToasts = new Map() const RUNTIME_DISCONNECTED_TOAST_DURATION_MS = 4_000 @@ -11,7 +11,10 @@ function getRuntimeDisconnectedToastId(environmentId: string): string { export function showRuntimeDisconnectedToast( environmentId: string, - getState: () => AppState + getState: () => Pick< + RuntimeStatusSlice, + 'runtimeEnvironments' | 'refreshRuntimeEnvironmentStatus' + > ): void { const environment = getState().runtimeEnvironments.find((entry) => entry.id === environmentId) const toastId = getRuntimeDisconnectedToastId(environmentId) diff --git a/src/renderer/src/store/slices/runtime-status-catalog-identity.test.ts b/src/renderer/src/store/slices/runtime-status-catalog-identity.test.ts index 17615ba40bc..27077379bd5 100644 --- a/src/renderer/src/store/slices/runtime-status-catalog-identity.test.ts +++ b/src/renderer/src/store/slices/runtime-status-catalog-identity.test.ts @@ -8,7 +8,7 @@ import { clearRuntimeEnvironmentConnectionGenerationsForTests, getRuntimeEnvironmentConnectionGeneration } from './runtime-status' -import { createTestStore } from './store-test-helpers' +import { createRuntimeStatusTestStore as createTestStore } from './runtime-status.test-fixture' vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } diff --git a/src/renderer/src/store/slices/runtime-status-skill-cache-eviction.test.ts b/src/renderer/src/store/slices/runtime-status-skill-cache-eviction.test.ts index 6554ca73d01..d2a086fa66a 100644 --- a/src/renderer/src/store/slices/runtime-status-skill-cache-eviction.test.ts +++ b/src/renderer/src/store/slices/runtime-status-skill-cache-eviction.test.ts @@ -25,7 +25,7 @@ import { resetSkillDiscoveryCacheForTests } from '@/hooks/installed-agent-skill-discovery' import { getInstalledAgentSkillDiscoveryCacheSizeForTests } from '@/hooks/installed-agent-skill-discovery-cache' -import { createTestStore } from './store-test-helpers' +import { createRuntimeStatusTestStore as createTestStore } from './runtime-status.test-fixture' function environment(id: string, pairingRevision = 1): PublicKnownRuntimeEnvironment { return { diff --git a/src/renderer/src/store/slices/runtime-status-snapshot.ts b/src/renderer/src/store/slices/runtime-status-snapshot.ts index 1b3c18ff640..522d6c6cd9f 100644 --- a/src/renderer/src/store/slices/runtime-status-snapshot.ts +++ b/src/renderer/src/store/slices/runtime-status-snapshot.ts @@ -2,8 +2,11 @@ import { runtimeEnvironmentStatusFromSnapshot, type RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' -import type { AppState } from '../types' -import type { RuntimeEnvironmentStatus } from './runtime-status-types' +import type { + RuntimeEnvironmentStatus, + RuntimeStatusSlice, + RuntimeStatusStoreState +} from './runtime-status-types' import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' import { replayClientHostedBrowserCloseIntents } from '@/runtime/client-hosted-browser-close-intent-replay' @@ -13,7 +16,7 @@ import { replayClientHostedBrowserCloseIntents } from '@/runtime/client-hosted-b */ export function snapshotOutrunsCatalog( snapshot: RuntimeHostStatusSnapshot, - state: Pick + state: Pick ): boolean { const environment = state.runtimeEnvironments.find((entry) => entry.id === snapshot.environmentId) return ( @@ -24,7 +27,7 @@ export function snapshotOutrunsCatalog( export function applyRuntimeHostStatusSnapshot( snapshot: RuntimeHostStatusSnapshot, - state: AppState, + state: RuntimeStatusStoreState, publishEvidence: (entry: RuntimeEnvironmentStatus) => void ): void { const environment = state.runtimeEnvironments.find((entry) => entry.id === snapshot.environmentId) diff --git a/src/renderer/src/store/slices/runtime-status-types.ts b/src/renderer/src/store/slices/runtime-status-types.ts index b9ea9411d93..76f308d64d4 100644 --- a/src/renderer/src/store/slices/runtime-status-types.ts +++ b/src/renderer/src/store/slices/runtime-status-types.ts @@ -3,6 +3,7 @@ import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { AppState } from '../types' export type { RuntimeEnvironmentStatus } @@ -25,3 +26,28 @@ export type RuntimeStatusSlice = { refreshRuntimeEnvironmentStatus: (environmentId: string, timeoutMs?: number) => Promise hydrateRuntimeEnvironmentStatuses: () => Promise } + +export type RuntimeStatusStoreState = RuntimeStatusSlice & + Pick< + AppState, + 'clientHostedBrowserCloseIntentsByEnvironment' | 'clearClientHostedBrowserCloseIntents' + > & + Partial< + Pick< + AppState, + | 'settings' + | 'remoteBrowserPageHandlesByPageId' + | 'retainRuntimeDetectedAgents' + | 'retainRuntimeTerminalQuickCommands' + | 'retainEnvironmentSshState' + | 'clearRuntimeDetectedAgents' + | 'markEnvironmentSshStateStale' + | 'purgeStaleRuntimeHostState' + > + > + +export type RuntimeStatusStoreSetState = ( + partial: + | Partial + | ((state: RuntimeStatusStoreState) => Partial) +) => void diff --git a/src/renderer/src/store/slices/runtime-status.test-fixture.ts b/src/renderer/src/store/slices/runtime-status.test-fixture.ts new file mode 100644 index 00000000000..55a92517101 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status.test-fixture.ts @@ -0,0 +1,13 @@ +import { createStore } from 'zustand/vanilla' +import { createRuntimeStatusSlice } from './runtime-status' +import type { RuntimeStatusStoreState } from './runtime-status-types' + +export function createRuntimeStatusTestStore() { + return createStore()((...args) => ({ + ...createRuntimeStatusSlice(...args), + clientHostedBrowserCloseIntentsByEnvironment: {}, + clearClientHostedBrowserCloseIntents: () => { + throw new Error('Runtime-status catalog tests must not replay browser close intents') + } + })) +} diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 48563a8ab90..7879340ac10 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -1,6 +1,8 @@ -import type { StateCreator } from 'zustand' -import type { AppState } from '../types' -import type { RuntimeStatusSlice } from './runtime-status-types' +import type { + RuntimeStatusSlice, + RuntimeStatusStoreSetState, + RuntimeStatusStoreState +} from './runtime-status-types' export type { RuntimeEnvironmentStatus, RuntimeStatusSlice } from './runtime-status-types' import { lastVerifiedRuntimeStatus } from '../../../../shared/runtime-host-status' import { runtimeEnvironmentStatusesEqual } from './runtime-environment-status-equality' @@ -40,10 +42,11 @@ export { setRuntimeEnvironmentConnectionGenerationForTests } from './runtime-status-connection-generation' -export const createRuntimeStatusSlice: StateCreator = ( - set, - get -) => ({ +export const createRuntimeStatusSlice: ( + set: RuntimeStatusStoreSetState, + get: () => RuntimeStatusStoreState, + store?: unknown +) => RuntimeStatusSlice = (set, get) => ({ runtimeEnvironments: [], runtimeEnvironmentCatalogHydrated: false, runtimeEnvironmentCatalogSettled: false,