diff --git a/.github/workflows/bun-profile-tests.yml b/.github/workflows/bun-profile-tests.yml index e579782c651..5fae18f2f84 100644 --- a/.github/workflows/bun-profile-tests.yml +++ b/.github/workflows/bun-profile-tests.yml @@ -2,6 +2,7 @@ name: Bun profile persistence on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'src/**' - 'config/**' @@ -17,6 +18,8 @@ on: - '.github/actions/install-node-dependencies/**' - '.github/workflows/bun-profile-tests.yml' workflow_dispatch: + schedule: + - cron: '30 11 * * *' permissions: contents: read @@ -32,6 +35,8 @@ jobs: timeout-minutes: 5 outputs: should_run: ${{ steps.scope.outputs.should_run }} + qualification: ${{ steps.scope.outputs.qualification }} + runners: ${{ steps.scope.outputs.runners }} steps: - uses: actions/checkout@v6 with: @@ -56,7 +61,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-22.04, ubuntu-24.04-arm, macos-14, macos-15-intel, windows-2022, windows-11-arm] + os: ${{ fromJSON(needs.changes.outputs.runners || '["ubuntu-22.04","ubuntu-24.04-arm","macos-14","macos-15-intel","windows-2022","windows-11-arm"]') }} runs-on: ${{ matrix.os }} timeout-minutes: 20 env: @@ -82,9 +87,12 @@ jobs: node out/orcad/orcad.js --orcad-profile-state-preflight 00000000-0000-4000-8000-000000000018 linux_glibc_floor: - needs: changes - # Missing/failed detection runs the full matrix; manual runs remain unconditional. - if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }} + needs: [changes, persistence] + # A failed smoke already blocks qualification; missing scope still selects every platform. + if: >- + ${{ !cancelled() && needs.persistence.result == 'success' && + needs.changes.outputs.should_run != 'false' && + needs.changes.outputs.qualification != 'false' }} strategy: fail-fast: false matrix: @@ -107,9 +115,12 @@ jobs: - run: pnpm test:bun:profile --artifact linux_musl: - needs: changes - # Missing/failed detection runs the full matrix; manual runs remain unconditional. - if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }} + needs: [changes, persistence] + # A failed smoke already blocks qualification; missing scope still selects every platform. + if: >- + ${{ !cancelled() && needs.persistence.result == 'success' && + needs.changes.outputs.should_run != 'false' && + needs.changes.outputs.qualification != 'false' }} strategy: fail-fast: false matrix: diff --git a/.github/workflows/ci-runner-demand.yml b/.github/workflows/ci-runner-demand.yml new file mode 100644 index 00000000000..966813a65e4 --- /dev/null +++ b/.github/workflows/ci-runner-demand.yml @@ -0,0 +1,33 @@ +name: CI runner demand + +on: + schedule: + - cron: '23 4 * * *' + workflow_dispatch: + +permissions: + contents: read + actions: read + +concurrency: + group: ci-runner-demand + cancel-in-progress: false + +jobs: + report: + runs-on: ubuntu-slim + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: config/scripts + persist-credentials: false + - name: Measure the previous complete 24 hours + env: + GH_TOKEN: ${{ github.token }} + run: node config/scripts/ci-runner-demand.mjs + - uses: actions/upload-artifact@v7 + with: + name: ci-runner-demand-${{ github.run_id }} + path: ci-demand/ + retention-days: 30 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ad9c1ee5139..4177b141d8a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -32,9 +32,8 @@ on: required: false type: string schedule: - # Why: GitHub cron uses UTC; these slots map to 10am and 3pm - # America/Phoenix for the default-branch E2E run. - - cron: '0 17,22 * * *' + # One complete daily reference run; targeted PR coverage remains unchanged. + - cron: '0 17 * * *' jobs: build: @@ -201,7 +200,14 @@ jobs: node config/scripts/ci-e2e-shard-plan.mjs --verify ci-shards/assignment.json ci-shards/selected-discovery.json - name: Run E2E tests (${{ matrix.shard_name }}) - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --test-list=ci-shards/selected.txt + env: + PLAYWRIGHT_JSON_OUTPUT_FILE: ci-shards/results.json + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --test-list=ci-shards/selected.txt --reporter=list,json + + - name: Summarize E2E failures + if: always() + continue-on-error: true + run: node config/scripts/ci-e2e-failure-summary.mjs ci-shards/results.json - name: Upload E2E shard assignment if: always() diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4580a6e2385..5c9094ae26a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,5 +1,5 @@ name: PR Checks -run-name: 'PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }}' +run-name: "PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }} | unit ${{ github.event.pull_request.draft && vars.ORCA_UNIT_SELECTION_MODE == 'selected' && 'selected' || 'full' }}" on: pull_request: @@ -161,7 +161,17 @@ jobs: filter: blob:none persist-credentials: false + # Why two guarded installs: the mixed root+mobile store entry is 537 MB against + # 321 MB for root alone, and restoring it costs 8.6s against 4.6s. Most PRs skip the + # mobile install below, so they were paying 216 MB for packages they never link. The + # root-only key is also the one the hourly warmer reseeds. Only one of these runs. - uses: ./.github/actions/install-node-dependencies + if: needs.code_paths.outputs.mobile_dependencies != 'true' + with: + native-runtime: node + + - uses: ./.github/actions/install-node-dependencies + if: needs.code_paths.outputs.mobile_dependencies == 'true' with: native-runtime: node cache-dependency-path: | @@ -620,16 +630,19 @@ jobs: node-version: '24' test: - needs: [code_paths, test_native_cache] + needs: [code_paths, test_native_cache, static_analysis, typecheck] # Honor cancellation while allowing the optional native-cache primer to skip. if: >- !cancelled() && needs.code_paths.outputs.test == 'true' && + needs.static_analysis.result == 'success' && + needs.typecheck.result == 'success' && (needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped') uses: ./.github/workflows/unit-tests.yml with: node_versions: '["24"]' runner: ubuntu-24.04-arm + selection_mode: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }} # Why a separate job: the test needs a real Chrome, and the sharded `test` matrix # would pay for it on every shard to run one file in whichever shard it landed in. @@ -784,6 +797,7 @@ jobs: tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts + tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts managed_hook_node18: name: managed hooks on Node 18 @@ -812,7 +826,7 @@ jobs: package: name: package - needs: [code_paths] + needs: [code_paths, static_analysis, typecheck] if: needs.code_paths.outputs.package == 'true' runs-on: ubuntu-latest # Let the serial Docker gates reach their own deadlines and report cleanup failures. @@ -960,7 +974,7 @@ jobs: package_windows: name: package (windows) - needs: [code_paths] + needs: [code_paths, static_analysis, typecheck] if: needs.code_paths.outputs.package_windows == 'true' runs-on: windows-2022 timeout-minutes: 30 diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml index d5030252f88..2fc81c887bb 100644 --- a/.github/workflows/pullfrog.yml +++ b/.github/workflows/pullfrog.yml @@ -1,6 +1,6 @@ -# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED +# Explicit review identities share workflow concurrency; legacy names use ordered cancellation. name: Pullfrog -run-name: ${{ inputs.name || github.workflow }} +run-name: ${{ inputs.name || github.workflow }}${{ inputs.pull_request_number && format(' | PR {0}', inputs.pull_request_number) || '' }} on: workflow_dispatch: inputs: @@ -11,21 +11,71 @@ on: type: string description: Run name + pull_request_number: + type: string + description: Optional PR identity for cancelling superseded reviews + head_sha: + type: string + description: Optional expected PR head; stale reviews are skipped + permissions: contents: read + pull-requests: read + +concurrency: + group: ${{ inputs.pull_request_number && format('pullfrog-pr-{0}', inputs.pull_request_number) || format('pullfrog-run-{0}', github.run_id) }} + cancel-in-progress: true jobs: + review_scope: + runs-on: ubuntu-slim + permissions: + contents: read + pull-requests: read + actions: write + outputs: + current: ${{ steps.scope.outputs.current }} + number: ${{ steps.scope.outputs.number }} + head: ${{ steps.scope.outputs.head }} + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: config/scripts/pullfrog-review-scope.cjs + sparse-checkout-cone-mode: false + persist-credentials: false + - uses: actions/github-script@v8 + id: scope + with: + script: | + const { reviewScope } = require('./config/scripts/pullfrog-review-scope.cjs') + await reviewScope({ github, context, core }) + pullfrog: + needs: review_scope + if: needs.review_scope.outputs.current == 'true' runs-on: ubuntu-latest permissions: id-token: write contents: read + pull-requests: read steps: - name: Checkout code uses: actions/checkout@v6 with: fetch-depth: 1 + - name: Recheck review head before starting agent + id: freshness + if: needs.review_scope.outputs.number != '' + uses: actions/github-script@v8 + env: + REVIEW_NUMBER: ${{ needs.review_scope.outputs.number }} + REVIEW_HEAD: ${{ needs.review_scope.outputs.head }} + with: + script: | + const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: Number(process.env.REVIEW_NUMBER) }) + core.setOutput('current', pr.state === 'open' && pr.head.sha === process.env.REVIEW_HEAD) - name: Run agent + if: needs.review_scope.outputs.number == '' || steps.freshness.outputs.current == 'true' uses: pullfrog/pullfrog@v0 with: prompt: ${{ inputs.prompt }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index ccb44c0faa6..2691f4907bc 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -14,19 +14,48 @@ on: default: ubuntu-latest type: string + selection_mode: + description: Shadow validates selection; selected applies it only to draft PRs. + required: false + default: shadow + type: string + permissions: contents: read jobs: + plan: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + shards: ${{ steps.plan.outputs.shards }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Plan unit selection + id: plan + env: + ORCA_UNIT_SELECTION_MODE: ${{ inputs.selection_mode }} + run: node config/scripts/ci-unit-plan.mjs + - uses: actions/upload-artifact@v7 + continue-on-error: true + with: + name: unit-selection-attempt-${{ github.run_attempt }} + path: ci-shards/unit-selection.json + retention-days: 14 + test: - name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }} + needs: plan + name: tests node ${{ matrix.node }} ${{ matrix.shard.index }}/${{ matrix.shard.count }} runs-on: ${{ inputs.runner }} strategy: fail-fast: false matrix: node: ${{ fromJSON(inputs.node_versions) }} - shard: [1, 2, 3, 4, 5, 6, 7, 8] - shard_total: [8] + shard: ${{ fromJSON(needs.plan.outputs.shards) }} steps: - name: Checkout @@ -43,6 +72,12 @@ jobs: - name: Install Electron package binary for tests run: node config/scripts/install-electron-package-binary.mjs + - uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: unit-selection-attempt-${{ github.run_attempt }} + path: ci-shards/ + - name: Test shard env: ORCA_BALANCE_UNIT_SHARDS: '1' @@ -50,27 +85,7 @@ jobs: run: | export ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)" pnpm exec vitest run --config config/vitest.config.ts \ - --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \ - --exclude=src/main/daemon/shell-ready.test.ts \ - --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ - --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ - --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ - --exclude=src/main/pty/omp-shell-wrapper-alias-safety.test.ts \ - --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ - --exclude=src/main/shell-startup-feature-channel.test.ts \ - --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ - --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ - --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ - --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ - --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ - --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ - --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \ - --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ - --exclude=src/shared/posix-command-path-lookup.test.ts \ - --exclude=tests/e2e/relay-region-compatibility.unit.test.ts \ - --exclude=tests/e2e/relay-region-correction.unit.test.ts \ - --exclude=tests/e2e/cross-version-wire/** \ - --shard=${{ matrix.shard }}/${{ matrix.shard_total }} + --shard=${{ matrix.shard.index }}/${{ matrix.shard.count }} - name: Upload unit shard assignment if: always() @@ -78,11 +93,40 @@ jobs: continue-on-error: true uses: actions/upload-artifact@v7 with: - name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard }}-attempt-${{ github.run_attempt }} + name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard.index }}-attempt-${{ github.run_attempt }} path: ci-shards/ retention-days: 14 if-no-files-found: warn + selection_evidence: + needs: test + if: ${{ !cancelled() }} + continue-on-error: true + runs-on: ubuntu-slim + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: config/scripts/ci-unit-selection-review.mjs + sparse-checkout-cone-mode: false + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: '24' + - uses: actions/download-artifact@v8 + with: + pattern: unit-shard-node-*-attempt-${{ github.run_attempt }} + path: unit-evidence/ + - name: Compare selection with full results + continue-on-error: true + run: node config/scripts/ci-unit-selection-review.mjs unit-evidence + - uses: actions/upload-artifact@v7 + if: always() + continue-on-error: true + with: + name: unit-selection-review-attempt-${{ github.run_attempt }} + path: unit-evidence/selection-review.json + retention-days: 30 + relay_integration: name: relay integration node ${{ matrix.node }} strategy: diff --git a/README.md b/README.md index 98efc301c2f..708d3311bb2 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   Muse logo Muse   + DeepSeek Harness logo DeepSeek Harness   ZCode logo ZCode   OpenCode logo OpenCode   MiMo Code logo MiMo Code   diff --git a/config/e2e-failure-tracking.json b/config/e2e-failure-tracking.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/config/e2e-failure-tracking.json @@ -0,0 +1 @@ +[] diff --git a/config/scripts/anti-slop-shards.test.mjs b/config/scripts/anti-slop-shards.test.mjs new file mode 100644 index 00000000000..9f2aa9db161 --- /dev/null +++ b/config/scripts/anti-slop-shards.test.mjs @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { buildUnits, packShards, planShards } from './run-anti-slop-shards.mjs' + +// Why these properties: the sharded pass equals a single pass only if every file is linted by +// exactly one shard. Coverage and disjointness are what carry that, so they are asserted +// directly rather than by diffing two multi-minute lint runs. +function filesUnder(unit, files) { + return files.filter((file) => file === unit || file.startsWith(`${unit}/`)) +} + +function coveredBy(units, files) { + return files.filter((file) => units.some((unit) => file === unit || file.startsWith(`${unit}/`))) +} + +const SAMPLE = [ + 'src/renderer/src/components/a.tsx', + 'src/renderer/src/components/b.tsx', + 'src/renderer/src/hooks/c.ts', + 'src/renderer/index.ts', + 'src/main/agent/d.ts', + 'src/main/agent/e.ts', + 'src/main/f.ts', + 'src/shared/g.ts', + 'config/scripts/h.mjs', + 'tests/e2e/i.spec.ts', + 'mobile/src/j.tsx', + 'mobile/src/k.tsx' +] + +describe('anti-slop shard planning', () => { + it('covers every file exactly once across units', () => { + const units = buildUnits(SAMPLE, 3).map((entry) => entry.unit) + for (const file of SAMPLE) { + const owners = units.filter((unit) => file === unit || file.startsWith(`${unit}/`)) + expect(owners, `${file} owned by ${JSON.stringify(owners)}`).toHaveLength(1) + } + }) + + it('reports a unit count that matches the files it owns', () => { + for (const { unit, count } of buildUnits(SAMPLE, 3)) { + expect(count).toBe(filesUnder(unit, SAMPLE).length) + } + }) + + it('splits a directory larger than the target instead of leaving it whole', () => { + // src/renderer holds 4 of 12 sample files through a single child directory, so the + // splitter has to descend more than one level to get under a small target. + const units = buildUnits(SAMPLE, 2).map((entry) => entry.unit) + expect(units).not.toContain('src') + expect(units.some((unit) => unit.startsWith('src/renderer/'))).toBe(true) + }) + + it('assigns every unit to exactly one shard and keeps shards disjoint', () => { + const { units, bins } = planShards(SAMPLE, 3) + const assigned = bins.flatMap((bin) => bin.units) + expect(assigned.slice().sort()).toEqual(units.map((entry) => entry.unit).sort()) + expect(new Set(assigned).size).toBe(assigned.length) + expect(coveredBy(assigned, SAMPLE)).toHaveLength(SAMPLE.length) + }) + + it('keeps the heaviest shard near the mean so wall time is not bound by one shard', () => { + const files = Array.from({ length: 400 }, (_, index) => `src/pkg${index % 40}/file${index}.ts`) + const { bins } = planShards(files, 4) + const heaviest = Math.max(...bins.map((bin) => bin.count)) + expect(heaviest).toBeLessThanOrEqual(Math.ceil(files.length / 4) * 1.35) + }) + + it('never emits more shards than there are units', () => { + expect(packShards(buildUnits(['src/a.ts'], 1), 4)).toHaveLength(1) + }) +}) diff --git a/config/scripts/bun-profile-change-scope.mjs b/config/scripts/bun-profile-change-scope.mjs index c30c4a282ac..7b8b47b69aa 100644 --- a/config/scripts/bun-profile-change-scope.mjs +++ b/config/scripts/bun-profile-change-scope.mjs @@ -8,6 +8,7 @@ import { ORCAD_ENTRY_POINT } from './orcad-entry-build.mjs' import { bunProfileTestPaths } from './bun-profile-test-paths.mjs' +import { bunProfileQualification } from './bun-profile-qualification.mjs' const ROOT = resolve(import.meta.dirname, '../..') const BUILD_SCRIPTS = [ @@ -29,7 +30,9 @@ const ALWAYS_FILES = new Set([ 'tsconfig.json', '.github/workflows/bun-profile-tests.yml', 'config/scripts/bun-profile-change-scope.mjs', - 'config/scripts/bun-profile-change-scope.test.mjs' + 'config/scripts/bun-profile-change-scope.test.mjs', + 'config/scripts/bun-profile-qualification.mjs', + 'config/scripts/bun-profile-qualification.test.mjs' ]) const ALWAYS_PREFIXES = [ '.github/actions/install-node-dependencies/', @@ -110,7 +113,11 @@ export async function classifyBunProfileChanges(changedFiles, collect = collectB reason: matched ? `Runtime or test dependency changed: ${matched}` : 'No Bun inputs changed' } } catch (error) { - return { shouldRun: true, reason: `Dependency graph unavailable: ${String(error)}` } + return { + shouldRun: true, + graphUnavailable: true, + reason: `Dependency graph unavailable: ${String(error)}` + } } } @@ -118,7 +125,14 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const changedFiles = readFileSync(process.argv[2], 'utf8').split('\0').filter(Boolean) const result = await classifyBunProfileChanges(changedFiles) console.log(result.reason) - const output = `should_run=${String(result.shouldRun)}\n` + let event = {} + try { + event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')) + } catch { + // Missing event evidence retains full qualification. + } + const policy = bunProfileQualification(changedFiles, result, event) + const output = `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n` if (process.env.GITHUB_OUTPUT) { appendFileSync(process.env.GITHUB_OUTPUT, output) } else { diff --git a/config/scripts/bun-profile-change-scope.test.mjs b/config/scripts/bun-profile-change-scope.test.mjs index 4b6d1647ffc..e813be93621 100644 --- a/config/scripts/bun-profile-change-scope.test.mjs +++ b/config/scripts/bun-profile-change-scope.test.mjs @@ -78,6 +78,7 @@ it.each([ '.npmrc', 'tsconfig.json', 'config/tsconfig.node.json', + 'config/scripts/bun-profile-qualification.mjs', 'config/patches/node-pty@1.1.0.patch', 'native/windows-registry/src/addon.cc', '.github/actions/install-node-dependencies/action.yml', @@ -140,12 +141,15 @@ it('keeps all ten platform jobs and runs them when detection is skipped or fails expect(workflow.jobs.changes.steps[0].with['persist-credentials']).toBe(false) const detect = workflow.jobs.changes.steps.find((step) => step.id === 'scope') expect(detect.run).toContain('git diff --name-only --no-renames -z HEAD^1 HEAD') - let count = 0 - for (const jobName of ['persistence', 'linux_glibc_floor', 'linux_musl']) { + expect(workflow.on.pull_request.types).toContain('ready_for_review') + expect(workflow.on.schedule).toHaveLength(1) + expect(workflow.jobs.persistence.strategy.matrix.os).toContain('needs.changes.outputs.runners') + for (const jobName of ['linux_glibc_floor', 'linux_musl']) { const job = workflow.jobs[jobName] - expect(job.needs).toBe('changes') - expect(job.if).toBe("${{ !cancelled() && needs.changes.outputs.should_run != 'false' }}") - count += job.strategy.matrix.os.length + expect(job.needs).toEqual(['changes', 'persistence']) + expect(job.if).toContain("needs.persistence.result == 'success'") + expect(job.if).toContain("needs.changes.outputs.qualification != 'false'") + expect(job.if).toContain("needs.changes.outputs.should_run != 'false'") + expect(job.strategy.matrix.os).toEqual(['ubuntu-22.04', 'ubuntu-24.04-arm']) } - expect(count).toBe(10) }) diff --git a/config/scripts/bun-profile-qualification.mjs b/config/scripts/bun-profile-qualification.mjs new file mode 100644 index 00000000000..799f459ff85 --- /dev/null +++ b/config/scripts/bun-profile-qualification.mjs @@ -0,0 +1,42 @@ +export const BUN_PERSISTENCE_RUNNERS = [ + 'ubuntu-22.04', + 'ubuntu-24.04-arm', + 'macos-14', + 'macos-15-intel', + 'windows-2022', + 'windows-11-arm' +] + +const QUALIFICATION_PREFIXES = [ + 'config/', + 'native/', + 'resources/', + '.github/', + 'src/main/persistence/', + 'src/main/sqlite/', + 'src/main/orcad/', + 'src/main/providers/', + 'src/main/daemon/', + 'src/main/ssh/', + 'src/relay/', + 'src/shared/child-process/' +] + +export function bunProfileQualification(changedFiles, scope, event = {}) { + const sensitive = changedFiles.some( + (file) => + !file.includes('/') || + QUALIFICATION_PREFIXES.some((prefix) => file.startsWith(prefix)) || + /(?:^|[/.-])(?:windows|win32|wsl|macos|darwin|linux|posix|bun)(?:[/.-]|$)/i.test(file) + ) + // Only a proven unrelated platform change in a draft may defer qualification. + const full = + event.pull_request?.draft !== true || + changedFiles.length === 0 || + scope.graphUnavailable === true || + sensitive + return { + qualification: full, + runners: full ? BUN_PERSISTENCE_RUNNERS : ['ubuntu-22.04'] + } +} diff --git a/config/scripts/bun-profile-qualification.test.mjs b/config/scripts/bun-profile-qualification.test.mjs new file mode 100644 index 00000000000..9ebc5e437e1 --- /dev/null +++ b/config/scripts/bun-profile-qualification.test.mjs @@ -0,0 +1,47 @@ +import { expect, it } from 'vitest' +import { BUN_PERSISTENCE_RUNNERS, bunProfileQualification } from './bun-profile-qualification.mjs' + +const draft = { pull_request: { draft: true } } +const scope = { shouldRun: true } + +it('defers only platform qualification for ordinary draft runtime changes', () => { + expect( + bunProfileQualification(['src/main/runtime/rpc/methods/example.ts'], scope, draft) + ).toEqual({ + qualification: false, + runners: ['ubuntu-22.04'] + }) +}) + +it.each([ + 'package.json', + 'native/windows-registry/src/addon.cc', + 'config/vitest.config.ts', + 'src/main/ssh/ssh-provider.ts', + 'src/main/providers/local-pty-provider.ts', + 'src/shared/child-process/run-process.ts', + 'src/main/persistence/profile-state/store.ts', + 'src/main/sqlite/database.ts', + 'src/main/orcad/entry.ts', + 'src/main/runtime/windows-terminal.ts', + 'src/shared/linux-glibc.ts', + 'src/main/daemon/entry.ts', + 'src/relay/index.ts', + 'src/main/wsl/runner.ts' +])('retains all platforms for sensitive input %s', (file) => { + expect(bunProfileQualification([file], scope, draft)).toEqual({ + qualification: true, + runners: BUN_PERSISTENCE_RUNNERS + }) +}) + +it('qualifies every ready commit, scheduled/manual runs and incomplete evidence', () => { + const paths = ['src/main/runtime/rpc/methods/example.ts'] + for (const event of [{}, { pull_request: { draft: false } }]) { + expect(bunProfileQualification(paths, scope, event).qualification).toBe(true) + } + expect(bunProfileQualification([], scope, draft).qualification).toBe(true) + expect( + bunProfileQualification(paths, { ...scope, graphUnavailable: true }, draft).qualification + ).toBe(true) +}) diff --git a/config/scripts/ci-cache-warmup-workflow.test.mjs b/config/scripts/ci-cache-warmup-workflow.test.mjs index 803563f61c8..e913f780422 100644 --- a/config/scripts/ci-cache-warmup-workflow.test.mjs +++ b/config/scripts/ci-cache-warmup-workflow.test.mjs @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { expect, it } from 'vitest' import { parse } from 'yaml' +import { BUN_PERSISTENCE_RUNNERS } from './bun-profile-qualification.mjs' const readWorkflow = (name) => parse(readFileSync(new URL(`../../.github/workflows/${name}.yml`, import.meta.url), 'utf8')) @@ -50,9 +51,8 @@ it('bounds warming to the required platforms and validates changes without grant it('warms and probes both Windows images with the persistence job runtime', () => { const job = workflow.jobs['warm-windows'] - const persistence = readWorkflow('bun-profile-tests').jobs.persistence expect(job.strategy.matrix.os).toEqual( - persistence.strategy.matrix.os.filter((os) => os.startsWith('windows-')) + BUN_PERSISTENCE_RUNNERS.filter((os) => os.startsWith('windows-')) ) expect(job['runs-on']).toBe('${{ matrix.os }}') expect(job.strategy['fail-fast']).toBe(false) diff --git a/config/scripts/ci-e2e-failure-summary.mjs b/config/scripts/ci-e2e-failure-summary.mjs new file mode 100644 index 00000000000..f95fed20dac --- /dev/null +++ b/config/scripts/ci-e2e-failure-summary.mjs @@ -0,0 +1,92 @@ +import { appendFileSync, readFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' +import { trackE2eFailures } from './ci-e2e-failure-tracking.mjs' + +export function e2eFailureSummary(report) { + const failures = [] + let passed = 0 + let skipped = 0 + function visit(suite, parents = [], parentFile) { + const titles = [...parents, suite.title].filter(Boolean) + const file = suite.file ?? parentFile + for (const spec of suite.specs ?? []) { + for (const test of spec.tests ?? []) { + if (test.status === 'expected') { + passed++ + continue + } + if (test.status === 'skipped') { + skipped++ + continue + } + const errors = test.results.flatMap((result) => result.errors ?? []) + failures.push({ + file: spec.file ?? file, + title: [...titles, spec.title].join(' › '), + project: test.projectName, + status: test.status, + message: errors + .map((error) => error.message ?? error.value ?? '') + .join('\n') + .slice(0, 2000) + }) + } + } + for (const child of suite.suites ?? []) { + visit(child, titles, file) + } + } + for (const suite of report.suites ?? []) { + visit(suite) + } + return { passed, skipped, failures, errors: report.errors ?? [] } +} + +export function renderE2eFailures(summary, records = [], now = new Date()) { + const escape = (value) => + String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + const tracked = trackE2eFailures(summary.failures, records, now) + const details = (failure) => + `
${escape(failure.status)}: ${escape(failure.file)} — ${escape(failure.title)}
${escape(failure.message)}
` + return [ + '## E2E results', + '', + `${summary.passed} expected results; ${summary.skipped} skipped; ${summary.failures.length} unexpected/flaky results; ${summary.errors.length} run errors.`, + '', + `### Untracked failures (${tracked.untracked.length})`, + '', + ...tracked.untracked.map(details), + '', + `### Tracked failures (${tracked.known.length})`, + '', + ...tracked.known.flatMap((failure) => [ + details(failure), + `Owner: ${escape(failure.tracking.owner)}; ${escape(failure.tracking.issue)}; expires ${escape(failure.tracking.expires)}.` + ]), + ...(tracked.invalid.length + ? [`${tracked.invalid.length} invalid/expired tracking entries were not used.`] + : []), + ...summary.errors.map((error) => `
${escape(error.message ?? error.value)}
`), + '', + 'Failures retain their original verdict. Repeated failures need a tracked owner, reproduction and review date; do not treat a red baseline as passing.', + '' + ].join('\n') +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const summary = e2eFailureSummary(JSON.parse(readFileSync(process.argv[2], 'utf8'))) + const records = JSON.parse( + readFileSync(new URL('../e2e-failure-tracking.json', import.meta.url), 'utf8') + ) + appendFileSync(process.env.GITHUB_STEP_SUMMARY, renderE2eFailures(summary, records)) + } catch (error) { + appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `E2E report unavailable (${error.code ?? 'invalid report'}); inspect the failing step and traces.\n` + ) + } +} diff --git a/config/scripts/ci-e2e-failure-summary.test.mjs b/config/scripts/ci-e2e-failure-summary.test.mjs new file mode 100644 index 00000000000..5594e4faa56 --- /dev/null +++ b/config/scripts/ci-e2e-failure-summary.test.mjs @@ -0,0 +1,79 @@ +import { expect, it } from 'vitest' +import { e2eFailureSummary, renderE2eFailures } from './ci-e2e-failure-summary.mjs' +import { trackE2eFailures } from './ci-e2e-failure-tracking.mjs' + +it('requires exact failures with an owner, issue and unexpired review date', () => { + const failure = { + file: 'test.spec.ts', + title: 'case', + project: 'electron', + message: 'expected focus failed' + } + const record = { + ...failure, + message: 'expected focus', + owner: '@owner', + issue: 'https://github.com/stablyai/orca/issues/123', + expires: '2026-10-01' + } + const now = new Date('2026-09-28') + expect(trackE2eFailures([failure], [null, record], now)).toMatchObject({ + known: [{ ...failure, tracking: record }], + invalid: [null] + }) + expect(trackE2eFailures([failure], null, now).untracked).toEqual([failure]) + for (const change of [ + { expires: '2026-09-27' }, + { expires: '2026-09-31' }, + { owner: '' }, + { issue: '' }, + { title: 'different' }, + { message: 'different' }, + { project: 'other' } + ]) { + expect(trackE2eFailures([failure], [{ ...record, ...change }], now).untracked).toEqual([ + failure + ]) + } +}) + +it('keeps failure, flaky, skipped and startup-error evidence separate', () => { + const report = { + errors: [{ message: 'startup failed' }], + suites: [ + { + title: 'file', + file: 'tests/e2e/test.spec.ts', + suites: [ + { + title: 'feature', + specs: [ + { + title: 'case', + tests: [ + { status: 'expected' }, + { status: 'skipped' }, + { + status: 'unexpected', + projectName: 'electron-headless', + results: [{ errors: [{ message: '' }] }] + }, + { + status: 'flaky', + results: [{ errors: [{ message: 'first try' }] }, { errors: [] }] + } + ] + } + ] + } + ] + } + ] + } + const summary = e2eFailureSummary(report) + expect(summary).toMatchObject({ passed: 1, skipped: 1, errors: report.errors }) + expect(summary.failures).toHaveLength(2) + expect(summary.failures[0].title).toBe('file › feature › case') + expect(renderE2eFailures(summary)).toContain('<timeout>') + expect(renderE2eFailures(summary)).toContain('startup failed') +}) diff --git a/config/scripts/ci-e2e-failure-tracking.mjs b/config/scripts/ci-e2e-failure-tracking.mjs new file mode 100644 index 00000000000..3415c023ad5 --- /dev/null +++ b/config/scripts/ci-e2e-failure-tracking.mjs @@ -0,0 +1,42 @@ +export function trackE2eFailures(failures, records, now = new Date()) { + const known = [] + const untracked = [] + const invalid = [] + const active = (Array.isArray(records) ? records : [records]).filter((record) => { + if (!record || typeof record !== 'object' || Array.isArray(record)) { + invalid.push(record) + return false + } + const expiry = new Date(`${record.expires}T23:59:59Z`) + const valid = + typeof record.file === 'string' && + typeof record.title === 'string' && + typeof record.message === 'string' && + record.message.length > 0 && + /^@[\w-]+(?:\/[\w-]+)?$/.test(record.owner ?? '') && + /^https:\/\/github\.com\/stablyai\/orca\/issues\/\d+$/.test(record.issue ?? '') && + /^\d{4}-\d{2}-\d{2}$/.test(record.expires ?? '') && + Number.isFinite(expiry.getTime()) && + expiry.toISOString().slice(0, 10) === record.expires && + expiry.getTime() >= now.getTime() + if (!valid) { + invalid.push(record) + } + return valid + }) + for (const failure of failures) { + const record = active.find( + (entry) => + entry.file === failure.file && + entry.title === failure.title && + entry.project === failure.project && + failure.message.includes(entry.message) + ) + if (record) { + known.push({ ...failure, tracking: record }) + } else { + untracked.push(failure) + } + } + return { known, untracked, invalid } +} diff --git a/config/scripts/ci-runner-demand.mjs b/config/scripts/ci-runner-demand.mjs new file mode 100644 index 00000000000..7690ea358db --- /dev/null +++ b/config/scripts/ci-runner-demand.mjs @@ -0,0 +1,100 @@ +import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' +import { runnerDemand, sampleWorkflowRuns } from './ci-runner-metrics.mjs' + +async function api(path, env) { + const response = await fetch( + `${env.GITHUB_API_URL ?? 'https://api.github.com'}/repos/${env.GITHUB_REPOSITORY}/${path}`, + { + headers: { + Authorization: `Bearer ${env.GH_TOKEN}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' + }, + signal: AbortSignal.timeout(30_000) + } + ) + if (!response.ok) { + throw new Error(`Actions API: ${response.status}`) + } + return response.json() +} + +export async function collectRunnerDemand(env = process.env, request = (path) => api(path, env)) { + const end = new Date(env.CI_METRICS_END ?? Date.now()) + end.setUTCMinutes(0, 0, 0) + if (!Number.isFinite(end.getTime())) { + throw new Error('Invalid window end') + } + const runs = [] + for (let hour = 0; hour < 24; hour++) { + const until = new Date(end.getTime() - hour * 3_600_000 - 1000) + const since = new Date(end.getTime() - (hour + 1) * 3_600_000) + const range = `${since.toISOString()}..${until.toISOString()}` + const path = `actions/runs?per_page=100&created=${encodeURIComponent(range)}` + const first = await request(path) + if (first.total_count > 1000) { + throw new Error('Hourly run inventory exceeds API limit; split the interval') + } + runs.push(...first.workflow_runs) + for (let page = 2; page <= Math.ceil(first.total_count / 100); page++) { + runs.push(...(await request(`${path}&page=${page}`)).workflow_runs) + } + } + const samples = sampleWorkflowRuns(runs) + // Bound API concurrency and preserve every selected observation, including zero-job runs. + for (let index = 0; index < samples.length; index += 4) { + await Promise.all( + samples.slice(index, index + 4).map(async (sample) => { + const path = `actions/runs/${sample.run.id}/jobs?per_page=100` + const first = await request(path) + sample.jobs = first.jobs + for (let page = 2; page <= Math.ceil(first.total_count / 100); page++) { + sample.jobs.push(...(await request(`${path}&page=${page}`)).jobs) + } + }) + ) + } + const report = { + start: new Date(end.getTime() - 86_400_000).toISOString(), + end: end.toISOString(), + method: + 'Stratified by workflow/conclusion, six sampled runs per stratum; latest attempts only; incomplete jobs excluded', + ...runnerDemand(runs, samples) + } + return { report, runs, samples } +} + +export function demandMarkdown(report) { + return [ + `## CI demand: ${report.start} to ${report.end}`, + '', + `Estimated full job duration for runs created in this window (not window-clipped occupancy); ${report.sampledRuns}/${report.populationRuns} runs sampled. ${report.method}.`, + '', + '| Workflow | Runs | Runner hours | Cancelled-run hours | Minutes/completed PR run |', + '| --- | ---: | ---: | ---: | ---: |', + ...report.workflows.map( + (row) => + `| ${row.workflow} | ${row.runs} | ${(row.runnerMinutes / 60).toFixed(1)} | ${(row.cancelledRunnerMinutes / 60).toFixed(1)} | ${row.runnerMinutesPerCompletedPrRun?.toFixed(1) ?? '—'} |` + ), + '', + '| Runner labels | Hours | Queue/provisioning p95 minutes |', + '| --- | ---: | ---: |', + ...report.pools.map( + (pool) => + `| ${pool.label} | ${(pool.runnerMinutes / 60).toFixed(1)} | ${pool.queueP95Minutes?.toFixed(1) ?? '—'} |` + ), + '' + ].join('\n') +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const evidence = await collectRunnerDemand() + mkdirSync('ci-demand', { recursive: true }) + writeFileSync('ci-demand/evidence.json', JSON.stringify(evidence)) + const markdown = demandMarkdown(evidence.report) + writeFileSync('ci-demand/report.md', markdown) + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown) + } +} diff --git a/config/scripts/ci-runner-metrics.mjs b/config/scripts/ci-runner-metrics.mjs new file mode 100644 index 00000000000..d01ae3d0153 --- /dev/null +++ b/config/scripts/ci-runner-metrics.mjs @@ -0,0 +1,110 @@ +const minutesBetween = (start, end) => { + const value = (Date.parse(end) - Date.parse(start)) / 60_000 + return Number.isFinite(value) ? Math.max(0, value) : 0 +} + +const workflowPath = (run) => run.path.split('@')[0] +const workflowKey = (run) => run.workflow_id ?? workflowPath(run) + +export function weightedPercentile(values, percentile) { + const sorted = [...values].sort((a, b) => a.value - b.value) + const target = sorted.reduce((sum, item) => sum + item.weight, 0) * percentile + let seen = 0 + for (const item of sorted) { + seen += item.weight + if (seen >= target) { + return item.value + } + } + return null +} + +export function runnerDemand(runs, samples) { + const workflows = new Map() + const pools = new Map() + for (const run of runs) { + if (!workflows.has(workflowKey(run))) { + workflows.set(workflowKey(run), { + workflow: workflowPath(run), + runs: 0, + sampledRuns: 0, + runnerMinutes: 0, + cancelledRunnerMinutes: 0, + completedPrRuns: 0, + completedPrRunnerMinutes: 0 + }) + } + workflows.get(workflowKey(run)).runs++ + } + let incompleteJobs = 0 + for (const { run, jobs, weight } of samples) { + const row = workflows.get(workflowKey(run)) + row.sampledRuns++ + for (const job of jobs) { + if (!job.runner_name || !job.started_at) { + continue + } + if (!job.completed_at) { + incompleteJobs++ + continue + } + const duration = minutesBetween(job.started_at, job.completed_at) + const weighted = duration * weight + row.runnerMinutes += weighted + if (run.conclusion === 'cancelled') { + row.cancelledRunnerMinutes += weighted + } + if (run.event === 'pull_request' && ['success', 'failure'].includes(run.conclusion)) { + row.completedPrRunnerMinutes += weighted + } + const label = [...job.labels].sort().join(', ') || 'unknown' + if (!pools.has(label)) { + pools.set(label, { label, runnerMinutes: 0, waits: [] }) + } + const pool = pools.get(label) + pool.runnerMinutes += weighted + if (job.created_at) { + pool.waits.push({ value: minutesBetween(job.created_at, job.started_at), weight }) + } + } + if (run.event === 'pull_request' && ['success', 'failure'].includes(run.conclusion)) { + row.completedPrRuns += weight + } + } + return { + populationRuns: runs.length, + sampledRuns: samples.length, + incompleteJobs, + workflows: [...workflows.values()] + .map((row) => ({ + ...row, + runnerMinutesPerCompletedPrRun: row.completedPrRuns + ? row.completedPrRunnerMinutes / row.completedPrRuns + : null + })) + .sort((a, b) => b.runnerMinutes - a.runnerMinutes), + pools: [...pools.values()] + .map(({ waits, ...pool }) => ({ ...pool, queueP95Minutes: weightedPercentile(waits, 0.95) })) + .sort((a, b) => b.runnerMinutes - a.runnerMinutes) + } +} + +export function sampleWorkflowRuns(runs, perStratum = 6, random = Math.random) { + const groups = new Map() + for (const run of runs) { + const key = `${workflowKey(run)}:${run.conclusion ?? run.status}` + if (!groups.has(key)) { + groups.set(key, []) + } + groups.get(key).push(run) + } + return [...groups.values()].flatMap((group) => { + const shuffled = [...group] + for (let index = shuffled.length - 1; index > 0; index--) { + const target = Math.floor(random() * (index + 1)) + ;[shuffled[index], shuffled[target]] = [shuffled[target], shuffled[index]] + } + const selected = shuffled.slice(0, perStratum) + return selected.map((run) => ({ run, weight: group.length / selected.length })) + }) +} diff --git a/config/scripts/ci-runner-metrics.test.mjs b/config/scripts/ci-runner-metrics.test.mjs new file mode 100644 index 00000000000..ebf45cc7c35 --- /dev/null +++ b/config/scripts/ci-runner-metrics.test.mjs @@ -0,0 +1,95 @@ +import { expect, it } from 'vitest' +import { runnerDemand, sampleWorkflowRuns } from './ci-runner-metrics.mjs' +import { collectRunnerDemand } from './ci-runner-demand.mjs' + +const run = { + id: 1, + path: '.github/workflows/pr.yml', + event: 'pull_request', + conclusion: 'success' +} +const job = { + runner_name: 'hosted', + labels: ['ubuntu-latest'], + created_at: '2026-09-27T00:00:00Z', + started_at: '2026-09-27T00:03:00Z', + completed_at: '2026-09-27T00:08:00Z' +} + +it('separates occupancy, queueing, cancellations and zero-job observations', () => { + const cancelled = { ...run, id: 2, conclusion: 'cancelled' } + const report = runnerDemand( + [run, cancelled], + [ + { run, weight: 2, jobs: [job, { ...job, runner_name: null }] }, + { run: cancelled, weight: 3, jobs: [job, { ...job, completed_at: null }] } + ] + ) + expect(report.workflows[0]).toMatchObject({ + runnerMinutes: 25, + cancelledRunnerMinutes: 15, + runnerMinutesPerCompletedPrRun: 5 + }) + expect(report.pools[0].queueP95Minutes).toBe(3) + expect(report.incompleteJobs).toBe(1) + expect(runnerDemand([run], [{ run, weight: 1, jobs: [] }]).workflows[0].runnerMinutes).toBe(0) +}) + +it('weights each workflow/outcome stratum back to the full inventory', () => { + const runs = Array.from({ length: 20 }, (_, index) => ({ + ...run, + id: index, + conclusion: index < 10 ? 'success' : 'failure' + })) + const sample = sampleWorkflowRuns(runs, 2, () => 0.5) + expect(sample).toHaveLength(4) + expect(sample.reduce((sum, row) => sum + row.weight, 0)).toBe(20) + expect(new Set(sample.map((row) => row.run.id)).size).toBe(4) +}) + +it('paginates jobs and bounds run discovery to 24 complete hours', async () => { + const requests = [] + const result = await collectRunnerDemand( + { CI_METRICS_END: '2026-09-28T04:59:00Z' }, + async (path) => { + requests.push(path) + if (path.includes('/jobs')) { + return { + total_count: 101, + jobs: path.includes('page=2') ? [job] : Array.from({ length: 100 }, () => job) + } + } + return { + total_count: requests.length === 1 ? 1 : 0, + workflow_runs: requests.length === 1 ? [run] : [] + } + } + ) + expect(result.report.start).toBe('2026-09-27T04:00:00.000Z') + expect(result.report.end).toBe('2026-09-28T04:00:00.000Z') + expect(requests.filter((path) => path.startsWith('actions/runs?'))).toHaveLength(24) + expect(result.samples[0].jobs).toHaveLength(101) + expect(result.report.workflows[0].runnerMinutes).toBe(505) +}) + +it('refuses to publish a silently truncated inventory', async () => { + await expect( + collectRunnerDemand({}, async () => ({ total_count: 1001, workflow_runs: [] })) + ).rejects.toThrow('API limit') +}) + +it('groups ref-qualified paths under the stable workflow ID', () => { + const runs = [ + { ...run, id: 1, workflow_id: 42, path: '.github/workflows/pr.yml@main' }, + { ...run, id: 2, workflow_id: 42, path: '.github/workflows/pr.yml@feature' } + ] + const sample = sampleWorkflowRuns(runs, 1, () => 0.5) + expect(sample).toHaveLength(1) + expect(sample[0].weight).toBe(2) + expect( + runnerDemand( + runs, + sample.map((row) => ({ ...row, jobs: [job] })) + ).workflows + ).toMatchObject([{ workflow: '.github/workflows/pr.yml', runs: 2, runnerMinutes: 10 }]) +}) diff --git a/config/scripts/ci-unit-dependency-graph.mjs b/config/scripts/ci-unit-dependency-graph.mjs new file mode 100644 index 00000000000..f491c1749de --- /dev/null +++ b/config/scripts/ci-unit-dependency-graph.mjs @@ -0,0 +1,84 @@ +import { globSync, readFileSync } from 'node:fs' +import { posix, join } from 'node:path' +import ts from 'typescript-api' + +const EXTENSIONS = [ + '', + '.ts', + '.tsx', + '.mjs', + '.js', + '.cjs', + '.json', + '/index.ts', + '/index.tsx', + '/index.js' +] +const INDIRECT_INPUT = + /\b(?:readFile\w*|readdir\w*|glob\w*|spawn\w*|execFile\w*|execSync|runProcess\w*|fork|Worker)\b|\bimport\s*\(\s*[^'"\s]|\brequire\s*\(\s*[^'"\s]|\bnew\s+URL\s*\(/ + +function localPath(file, specifier) { + if (specifier.startsWith('.')) { + return posix.join(posix.dirname(file), specifier) + } + if (specifier.startsWith('@renderer/')) { + return `src/renderer/src/${specifier.slice(10)}` + } + if (specifier.startsWith('@/')) { + return `src/renderer/src/${specifier.slice(2)}` + } + return null +} + +export function buildUnitDependencyGraph(sources) { + const reverse = new Map() + const opaque = new Set() + for (const [file, source] of sources) { + if (INDIRECT_INPUT.test(source) || file.startsWith('config/') || file.startsWith('tests/')) { + opaque.add(file) + } + for (const imported of ts.preProcessFile(source, true, true).importedFiles) { + const path = localPath(file, imported.fileName) + if (path === null) { + continue + } + const resolved = EXTENSIONS.map((extension) => path + extension).find((candidate) => + sources.has(candidate) + ) + if (!resolved) { + opaque.add(file) + continue + } + if (!reverse.has(resolved)) { + reverse.set(resolved, new Set()) + } + reverse.get(resolved).add(file) + } + } + return { reverse, opaque } +} + +export function collectUnitDependencyGraph(root = process.cwd()) { + const files = globSync( + [ + 'src/**/*.{ts,tsx,js,mjs,cjs,json}', + 'config/**/*.{ts,tsx,js,mjs,cjs,json}', + 'tests/**/*.{ts,tsx,js,mjs,cjs,json}' + ], + { cwd: root } + ) + const sources = new Map( + files.map((file) => [file.replaceAll('\\', '/'), readFileSync(join(root, file), 'utf8')]) + ) + return { ...buildUnitDependencyGraph(sources), files: new Set(sources.keys()) } +} + +export function unitConsumers(seeds, reverse) { + const result = new Set(seeds) + for (const file of result) { + for (const consumer of reverse.get(file) ?? []) { + result.add(consumer) + } + } + return result +} diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs new file mode 100644 index 00000000000..ec7101b6ee3 --- /dev/null +++ b/config/scripts/ci-unit-files.mjs @@ -0,0 +1,41 @@ +import { globSync } from 'node:fs' +import { defaultExclude } from 'vitest/config' + +export const UNIT_INCLUDE = [ + 'src/**/*.test.ts', + 'src/**/*.test.tsx', + 'config/scripts/**/*.test.ts', + 'config/scripts/**/*.test.mjs', + 'tests/tools/**/*.test.mjs', + 'tests/e2e/**/*.unit.test.ts' +] + +export const UNIT_EXCLUDE = [ + ...defaultExclude, + 'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts', + 'src/main/daemon/shell-ready.test.ts', + 'src/main/daemon/node-pty-fd-leak.test.ts', + 'src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts', + 'src/main/providers/__tests__/shell-ready-framework-example.test.ts', + 'src/main/pty/omp-shell-wrapper-alias-safety.test.ts', + 'src/main/pty/omp-shell-wrapper.node-pty.test.ts', + 'src/main/shell-startup-feature-channel.test.ts', + 'src/main/terminal-history-fish-session.node-pty.test.ts', + 'src/main/zsh-scoped-histfile.live-shell.test.ts', + 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', + 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', + 'src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts', + 'src/shared/fish-query-reply-child-stdin.node-pty.test.ts', + 'src/shared/pty-reply-echo-shapes.node-pty.test.ts', + 'src/shared/startup-shell-portability.live-shell.test.ts', + 'src/shared/posix-command-path-lookup.test.ts', + 'tests/e2e/relay-region-compatibility.unit.test.ts', + 'tests/e2e/relay-region-correction.unit.test.ts', + 'tests/e2e/cross-version-wire/**' +] + +export function discoverUnitFiles(root = process.cwd()) { + return globSync(UNIT_INCLUDE, { cwd: root, exclude: UNIT_EXCLUDE }) + .map((file) => file.replaceAll('\\', '/')) + .sort() +} diff --git a/config/scripts/ci-unit-plan.mjs b/config/scripts/ci-unit-plan.mjs new file mode 100644 index 00000000000..28eed60f8e8 --- /dev/null +++ b/config/scripts/ci-unit-plan.mjs @@ -0,0 +1,60 @@ +import { appendFileSync, readFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' +import { runProcessSync } from './script-child-process.mjs' +import { collectUnitDependencyGraph } from './ci-unit-dependency-graph.mjs' +import { discoverUnitFiles } from './ci-unit-files.mjs' +import { planUnitSelection } from './ci-unit-selection.mjs' +import { readTimingBaseline, writeAssignment } from './ci-shard-assignment.mjs' + +export function prepareUnitPlan(env = process.env) { + const files = discoverUnitFiles() + let plan + try { + const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8')) + if (env.GITHUB_EVENT_NAME !== 'pull_request') { + throw new Error('Full reference run') + } + const diff = runProcessSync({ + program: 'git', + args: ['diff', '--name-only', '--no-renames', '-z', 'HEAD^1', 'HEAD'], + maxOutputBytes: 16 * 1024 * 1024 + }) + if (diff.code !== 0 || diff.timedOut) { + throw new Error('Changed paths unavailable') + } + plan = planUnitSelection({ + files, + changed: diff.stdout.split('\0').filter(Boolean), + graph: collectUnitDependencyGraph(), + timings: readTimingBaseline('unit').timings, + event, + mode: env.ORCA_UNIT_SELECTION_MODE + }) + } catch (error) { + plan = { + version: 1, + mode: 'shadow', + selectionAvailable: false, + reason: String(error), + files, + candidateFiles: files, + executionFiles: files, + shards: Array.from({ length: 8 }, (_, index) => ({ index: index + 1, count: 8 })) + } + } + writeAssignment('ci-shards/unit-selection.json', plan) + if (env.GITHUB_OUTPUT) { + appendFileSync(env.GITHUB_OUTPUT, `shards=${JSON.stringify(plan.shards)}\n`) + } + if (env.GITHUB_STEP_SUMMARY) { + appendFileSync( + env.GITHUB_STEP_SUMMARY, + `Unit selection: **${plan.mode}**; ${plan.candidateFiles.length}/${files.length} candidate files; ${plan.shards.length} execution shards. ${plan.reason}\n` + ) + } + return plan +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + prepareUnitPlan() +} diff --git a/config/scripts/ci-unit-plan.test.mjs b/config/scripts/ci-unit-plan.test.mjs new file mode 100644 index 00000000000..1711582b64f --- /dev/null +++ b/config/scripts/ci-unit-plan.test.mjs @@ -0,0 +1,78 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' +import { runProcessSync } from './script-child-process.mjs' + +it.each([true, false])( + 'plans from a real Git diff, with parent evidence available: %s', + (withParent) => { + const root = mkdtempSync(join(tmpdir(), 'orca-unit-plan-')) + const git = (args) => { + const result = runProcessSync({ program: 'git', args, cwd: root }) + expect(result.code, result.stderr).toBe(0) + return result.stdout.trim() + } + try { + mkdirSync(join(root, 'src')) + writeFileSync(join(root, 'src/value.ts'), 'export const value = 1') + writeFileSync(join(root, 'src/consumer.test.ts'), "import './value'") + writeFileSync(join(root, 'src/unrelated.test.ts'), 'export const unrelated = true') + git(['init', '--quiet']) + git(['add', 'src']) + const commit = [ + '-c', + 'user.name=CI Test', + '-c', + 'user.email=ci-test@example.invalid', + '-c', + 'commit.gpgsign=false', + 'commit', + '--quiet', + '-m', + 'fixture' + ] + git(commit) + if (withParent) { + writeFileSync(join(root, 'src/value.ts'), 'export const value = 2') + git(['add', 'src']) + git(commit) + } + const sourceSha = git(['rev-parse', 'HEAD']) + const eventPath = join(root, 'event.json') + writeFileSync(eventPath, JSON.stringify({ pull_request: { draft: true } })) + const result = runProcessSync({ + program: process.execPath, + args: [fileURLToPath(new URL('./ci-unit-plan.mjs', import.meta.url))], + cwd: root, + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_UNIT_SELECTION_MODE: 'selected', + GITHUB_EVENT_NAME: 'pull_request', + GITHUB_EVENT_PATH: eventPath, + GITHUB_SHA: sourceSha, + ORCA_SHARD_SOURCE_SHA: sourceSha, + GITHUB_OUTPUT: join(root, 'outputs'), + GITHUB_STEP_SUMMARY: join(root, 'summary') + } + }) + expect(result.code, result.stderr).toBe(0) + const plan = JSON.parse(readFileSync(join(root, 'ci-shards/unit-selection.json'), 'utf8')) + expect(plan.sourceSha).toBe(sourceSha) + expect(plan.mode).toBe(withParent ? 'selected' : 'shadow') + expect(plan.executionFiles).toEqual( + withParent ? ['src/consumer.test.ts'] : ['src/consumer.test.ts', 'src/unrelated.test.ts'] + ) + expect(plan.reason).toBe( + withParent + ? 'Transitive imports plus indirect-input consumers' + : 'Error: Changed paths unavailable' + ) + expect(plan.shards).toHaveLength(withParent ? 1 : 8) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } +) diff --git a/config/scripts/ci-unit-selection-review.mjs b/config/scripts/ci-unit-selection-review.mjs new file mode 100644 index 00000000000..ae976a06428 --- /dev/null +++ b/config/scripts/ci-unit-selection-review.mjs @@ -0,0 +1,113 @@ +import { appendFileSync, globSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +export function reviewUnitSelection(records) { + const groups = new Map() + for (const { plan, timing } of records) { + const key = JSON.stringify([ + timing.sourceSha, + timing.runId, + timing.runAttempt, + timing.nodeVersion + ]) + if (!groups.has(key)) { + groups.set(key, []) + } + groups.get(key).push({ plan, timing }) + } + return [...groups.values()].map((group) => { + const { plan, timing: first } = group[0] + const selected = new Set(plan.candidateFiles) + const files = new Set() + const shards = new Set() + const missedFailures = [] + let invalid = false + let omittedMs = 0 + let totalMs = 0 + for (const { plan: other, timing } of group) { + invalid ||= + JSON.stringify(other) !== JSON.stringify(plan) || + !first.sourceSha || + plan.sourceSha !== first.sourceSha || + timing.unhandledErrors !== 0 || + !['passed', 'failed'].includes(timing.status) || + timing.shard.count !== first.shard.count || + shards.has(timing.shard.index) || + timing.shard.index < 1 || + timing.shard.index > first.shard.count + shards.add(timing.shard.index) + for (const [file, duration] of Object.entries(timing.timings)) { + invalid ||= + files.has(file) || + !Number.isFinite(duration) || + duration <= 0 || + !['passed', 'failed', 'skipped'].includes(timing.results?.[file]) + files.add(file) + totalMs += duration + if (!selected.has(file)) { + omittedMs += duration + if (timing.results?.[file] === 'failed') { + missedFailures.push(file) + } + } + } + } + const complete = + !invalid && + shards.size === first.shard.count && + JSON.stringify([...files].sort()) === JSON.stringify([...plan.files].sort()) + return { + sourceSha: first.sourceSha, + runId: first.runId, + runAttempt: first.runAttempt, + nodeVersion: first.nodeVersion, + mode: plan.mode, + completeFullRun: complete && plan.mode === 'shadow', + selectionEvaluated: complete && plan.mode === 'shadow' && plan.selectionAvailable === true, + reason: plan.reason, + missedFailures, + files: files.size, + candidateFiles: selected.size, + measuredWorkerMs: totalMs, + potentiallyOmittedWorkerMs: omittedMs + } + }) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const directory = process.argv[2] + if (!directory) { + throw new Error('Usage: ci-unit-selection-review.mjs ARTIFACT_DIRECTORY') + } + const records = globSync('**/unit-timings.json', { cwd: directory }).map((file) => { + const timing = JSON.parse(readFileSync(join(directory, file), 'utf8')) + const plan = JSON.parse( + readFileSync( + join(directory, file.replace('unit-timings.json', 'unit-selection.json')), + 'utf8' + ) + ) + return { timing, plan } + }) + if (!records.length) { + throw new Error('No unit selection evidence') + } + const review = reviewUnitSelection(records) + writeFileSync(join(directory, 'selection-review.json'), `${JSON.stringify(review, null, 2)}\n`) + const summary = [ + '## Unit selection evidence', + '', + ...review.map( + (row) => + `- Node ${row.nodeVersion}: ${row.completeFullRun ? 'complete full reference' : 'incomplete or selected evidence'}; ${row.selectionEvaluated ? 'selection evaluated' : 'not selection-validation evidence'}; ${row.candidateFiles}/${row.files} candidate files; ${row.missedFailures.length} failures outside selection; ${(row.potentiallyOmittedWorkerMs / 60_000).toFixed(1)} potentially omitted worker-minutes. ${row.reason ?? ''}` + ), + '', + 'Worker time overlaps across processes; it is not runner time. Promote only after representative complete references show no missed failures.', + '' + ].join('\n') + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary) + } + console.log(summary) +} diff --git a/config/scripts/ci-unit-selection-review.test.mjs b/config/scripts/ci-unit-selection-review.test.mjs new file mode 100644 index 00000000000..ff8f4c38568 --- /dev/null +++ b/config/scripts/ci-unit-selection-review.test.mjs @@ -0,0 +1,55 @@ +import { expect, it } from 'vitest' +import { reviewUnitSelection } from './ci-unit-selection-review.mjs' + +const plan = { + sourceSha: 'sha', + mode: 'shadow', + selectionAvailable: true, + files: ['a', 'b'], + candidateFiles: ['a'] +} + +it('does not present full fallback runs as selection-validation evidence', () => { + const records = [record(1, 'a', 'passed'), record(2, 'b', 'passed')].map((row) => ({ + ...row, + plan: { ...plan, selectionAvailable: false } + })) + expect(reviewUnitSelection(records)[0]).toMatchObject({ + completeFullRun: true, + selectionEvaluated: false + }) +}) +const record = (index, file, state) => ({ + plan, + timing: { + sourceSha: 'sha', + runId: '1', + runAttempt: '1', + nodeVersion: '24', + status: state, + unhandledErrors: 0, + shard: { index, count: 2 }, + timings: { [file]: 100 }, + results: { [file]: state } + } +}) + +it('finds omitted failures in a complete failing reference run', () => { + expect( + reviewUnitSelection([record(1, 'a', 'passed'), record(2, 'b', 'failed')])[0] + ).toMatchObject({ completeFullRun: true, missedFailures: ['b'], potentiallyOmittedWorkerMs: 100 }) +}) + +it('does not call incomplete, duplicate, interrupted or selected evidence a full reference', () => { + const a = record(1, 'a', 'passed'), + b = record(2, 'b', 'passed') + for (const rows of [ + [a], + [a, a], + [a, { ...b, timing: { ...b.timing, unhandledErrors: 1 } }], + [a, { ...b, timing: { ...b.timing, status: 'interrupted' } }], + [a, b].map((row) => ({ ...row, plan: { ...plan, mode: 'selected' } })) + ]) { + expect(reviewUnitSelection(rows).every((row) => !row.completeFullRun)).toBe(true) + } +}) diff --git a/config/scripts/ci-unit-selection.mjs b/config/scripts/ci-unit-selection.mjs new file mode 100644 index 00000000000..70894dde0a1 --- /dev/null +++ b/config/scripts/ci-unit-selection.mjs @@ -0,0 +1,62 @@ +import { unitConsumers } from './ci-unit-dependency-graph.mjs' +import { balanceFiles } from './ci-shard-assignment.mjs' + +export function selectUnitFiles(files, changed, graph) { + const full = (reason) => ({ files, reason, full: true }) + if (!changed.length) { + return full('Missing changed-path evidence') + } + if (changed.some((file) => !file.startsWith('src/') || !graph.files.has(file))) { + return full('Global, deleted, renamed or unknown input') + } + for (const file of changed) { + const consumers = unitConsumers([file], graph.reverse) + if (!files.some((test) => consumers.has(test))) { + return full('No proven test coverage for changed inputs') + } + } + const affected = unitConsumers([...changed, ...graph.opaque], graph.reverse) + const selected = files.filter((file) => affected.has(file)) + if (!selected.length) { + return full('No proven test coverage for changed inputs') + } + return { + files: selected, + reason: 'Transitive imports plus indirect-input consumers', + full: false + } +} + +export function planUnitSelection({ files, changed, graph, timings, event, mode = 'shadow' }) { + const candidate = selectUnitFiles(files, changed, graph) + const selected = mode === 'selected' && event?.pull_request?.draft === true && !candidate.full + const executionFiles = selected ? candidate.files : files + const totalMs = balanceFiles(executionFiles, 1, timings).shards[0].durationMs + const count = selected + ? Math.max(1, Math.min(8, Math.ceil(totalMs / 900_000), executionFiles.length)) + : 8 + return { + version: 1, + mode: selected ? 'selected' : 'shadow', + selectionAvailable: !candidate.full, + reason: candidate.reason, + files, + candidateFiles: candidate.files, + executionFiles, + shards: Array.from({ length: count }, (_, index) => ({ index: index + 1, count })) + } +} + +export function auditUnitSelection(plan, results) { + const candidates = new Set(plan.candidateFiles) + const omittedFailures = Object.entries(results) + .filter(([file, state]) => state === 'failed' && !candidates.has(file)) + .map(([file]) => file) + return { + mode: plan.mode, + discovered: plan.files.length, + candidate: candidates.size, + executed: Object.keys(results).length, + omittedFailures + } +} diff --git a/config/scripts/ci-unit-selection.test.mjs b/config/scripts/ci-unit-selection.test.mjs new file mode 100644 index 00000000000..37338cbfc6d --- /dev/null +++ b/config/scripts/ci-unit-selection.test.mjs @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { buildUnitDependencyGraph } from './ci-unit-dependency-graph.mjs' +import { auditUnitSelection, planUnitSelection, selectUnitFiles } from './ci-unit-selection.mjs' + +const sources = new Map( + Object.entries({ + 'src/leaf.ts': 'export const value = 1', + 'src/forward.ts': `export * from './leaf'`, + 'src/consumer.test.ts': `import './forward'`, + 'src/dynamic.test.ts': `import('./leaf')`, + 'src/require.test.ts': `require('./leaf')`, + 'src/scan.test.ts': `import { readFileSync } from 'node:fs'; readFileSync('src/leaf.ts')`, + 'src/indirect.ts': `import(pathFromSettings)`, + 'src/indirect.test.ts': `import './indirect'`, + 'src/unrelated.test.ts': 'export const test = 1', + 'src/renderer/src/view.tsx': 'export const value = 1', + 'src/view.test.ts': `import '@renderer/view'; import '@/view'` + }) +) +const graph = { ...buildUnitDependencyGraph(sources), files: new Set(sources.keys()) } +const files = [...sources.keys()].filter((file) => file.endsWith('.test.ts')).sort() + +describe('conservative unit selection', () => { + it('follows re-exports, literal dynamic imports and requires, retaining indirect readers', () => { + expect(selectUnitFiles(files, ['src/leaf.ts'], graph).files).toEqual([ + 'src/consumer.test.ts', + 'src/dynamic.test.ts', + 'src/indirect.test.ts', + 'src/require.test.ts', + 'src/scan.test.ts' + ]) + }) + + it('resolves renderer aliases and changed tests without executing their source', () => { + expect(selectUnitFiles(files, ['src/renderer/src/view.tsx'], graph).files).toContain( + 'src/view.test.ts' + ) + expect(selectUnitFiles(files, ['src/unrelated.test.ts'], graph).files).toContain( + 'src/unrelated.test.ts' + ) + }) + + it('does not mistake unrelated opaque readers for coverage of a new entry point', () => { + const uncovered = { ...graph, files: new Set([...graph.files, 'src/entry.ts']) } + expect(selectUnitFiles(files, ['src/entry.ts'], uncovered)).toMatchObject({ full: true, files }) + }) + + it.each( + [ + [], + ['src/deleted.ts'], + ['src/deleted.ts', 'src/leaf.ts'], + ['pnpm-lock.yaml'], + ['config/vitest.config.ts'] + ].map((changed) => ({ changed })) + )('runs everything for incomplete/global evidence: $changed', ({ changed }) => { + expect(selectUnitFiles(files, changed, graph)).toMatchObject({ files, full: true }) + }) + + it('keeps full coverage by default and on every non-draft commit', () => { + const base = { files, changed: ['src/leaf.ts'], graph, timings: {} } + for (const event of [ + {}, + { pull_request: { draft: false } }, + { pull_request: { draft: true } } + ]) { + expect(planUnitSelection({ ...base, event }).executionFiles).toEqual(files) + } + expect( + planUnitSelection({ ...base, mode: 'selected', event: { pull_request: { draft: false } } }) + .executionFiles + ).toEqual(files) + const selected = planUnitSelection({ + ...base, + mode: 'selected', + event: { pull_request: { draft: true } } + }) + expect(selected.executionFiles).not.toContain('src/unrelated.test.ts') + expect(selected.shards).toEqual([{ index: 1, count: 1 }]) + }) + + it('records failures that would have been missed while shadow runs remain full', () => { + const plan = planUnitSelection({ files, changed: ['src/leaf.ts'], graph, timings: {} }) + expect( + auditUnitSelection(plan, { + 'src/consumer.test.ts': 'failed', + 'src/unrelated.test.ts': 'failed', + 'src/view.test.ts': 'passed' + }).omittedFailures + ).toEqual(['src/unrelated.test.ts']) + }) +}) diff --git a/config/scripts/ci-unit-sequencer.mjs b/config/scripts/ci-unit-sequencer.mjs index cd55b3343b2..6b580bdb002 100644 --- a/config/scripts/ci-unit-sequencer.mjs +++ b/config/scripts/ci-unit-sequencer.mjs @@ -1,4 +1,5 @@ import { relative } from 'node:path' +import { readFileSync } from 'node:fs' import { BaseSequencer } from 'vitest/node' import { balanceFiles, readTimingBaseline, writeAssignment } from './ci-shard-assignment.mjs' @@ -6,12 +7,46 @@ export default class TimingSequencer extends BaseSequencer { async shard(specs) { const { index, count } = this.ctx.config.shard const key = (spec) => relative(this.ctx.config.root, spec.moduleId).replaceAll('\\', '/') + let execution = specs + let selectionReason = 'Full suite: no verified selection plan' + try { + const plan = JSON.parse( + readFileSync( + process.env.ORCA_UNIT_SELECTION_PLAN ?? 'ci-shards/unit-selection.json', + 'utf8' + ) + ) + if ( + plan.version !== 1 || + !plan.sourceSha || + plan.sourceSha !== process.env.ORCA_SHARD_SOURCE_SHA || + JSON.stringify([...plan.files].sort()) !== JSON.stringify(specs.map(key).sort()) || + !Array.isArray(plan.executionFiles) || + plan.executionFiles.some((file) => !plan.files.includes(file)) + ) { + throw new Error('Selection provenance or discovery differs') + } + const allowed = new Set(plan.executionFiles) + if (allowed.size === 0) { + throw new Error('Empty execution selection') + } + execution = specs.filter((spec) => allowed.has(key(spec))) + selectionReason = plan.reason + } catch (error) { + console.log(`Running every discovered unit test: ${error.message}`) + } const baseline = readTimingBaseline('unit') - const assignment = balanceFiles(specs.map(key), count, baseline.timings, baseline.overheadMs) + const assignment = balanceFiles( + execution.map(key), + count, + baseline.timings, + baseline.overheadMs + ) writeAssignment(process.env.ORCA_SHARD_MANIFEST ?? 'ci-shards/unit-assignment.json', { ...assignment, baselineSha256: baseline.baselineSha256, - selectedShard: index + selectedShard: index, + selectionReason }) const selected = new Set(assignment.shards[index - 1].files) return specs.filter((spec) => selected.has(key(spec))) diff --git a/config/scripts/ci-unit-sequencer.test.mjs b/config/scripts/ci-unit-sequencer.test.mjs new file mode 100644 index 00000000000..931a3ccd158 --- /dev/null +++ b/config/scripts/ci-unit-sequencer.test.mjs @@ -0,0 +1,44 @@ +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import TimingSequencer from './ci-unit-sequencer.mjs' + +let root +afterEach(() => { + vi.unstubAllEnvs() + if (root) { + rmSync(root, { recursive: true, force: true }) + } +}) + +it.each(['valid', 'stale', 'missing-file', 'missing-artifact'])( + 'preserves complete shard coverage with %s planning evidence', + async (kind) => { + root = mkdtempSync(join(tmpdir(), 'unit-sequencer-')) + const files = ['src/a.test.ts', 'src/b.test.ts', 'src/c.test.ts', 'src/d.test.ts'] + const plan = { + version: 1, + sourceSha: kind === 'stale' ? 'old' : 'current', + files: kind === 'missing-file' ? files.slice(1) : files, + executionFiles: files.slice(0, 2) + } + const planPath = join(root, 'selection.json') + if (kind !== 'missing-artifact') { + writeFileSync(planPath, JSON.stringify(plan)) + } + vi.stubEnv('ORCA_UNIT_SELECTION_PLAN', planPath) + vi.stubEnv('ORCA_SHARD_SOURCE_SHA', 'current') + vi.stubEnv('ORCA_SHARD_MANIFEST', join(root, 'assignment.json')) + const assigned = [] + for (const index of [1, 2]) { + const sequencer = new TimingSequencer({ config: { root, shard: { index, count: 2 } } }) + const specs = files.map((file) => ({ moduleId: join(root, file) })) + assigned.push(...(await sequencer.shard(specs)).map((spec) => spec.moduleId)) + } + expect(assigned.sort()).toEqual( + (kind === 'valid' ? files.slice(0, 2) : files).map((file) => join(root, file)).sort() + ) + expect(new Set(assigned).size).toBe(assigned.length) + } +) diff --git a/config/scripts/ci-unit-timing-reporter.mjs b/config/scripts/ci-unit-timing-reporter.mjs index 58ccc8de768..fa54aa1072a 100644 --- a/config/scripts/ci-unit-timing-reporter.mjs +++ b/config/scripts/ci-unit-timing-reporter.mjs @@ -1,5 +1,7 @@ import { relative } from 'node:path' +import { appendFileSync, readFileSync } from 'node:fs' import { writeAssignment } from './ci-shard-assignment.mjs' +import { auditUnitSelection } from './ci-unit-selection.mjs' export function moduleDuration(diagnostic) { return Math.max( @@ -20,12 +22,19 @@ export default class UnitTimingReporter { } onTestRunEnd(modules, errors, reason) { + const results = Object.fromEntries( + modules.map((module) => [ + relative(this.ctx.config.root, module.moduleId).replaceAll('\\', '/'), + module.state?.() ?? 'unknown' + ]) + ) writeAssignment(process.env.ORCA_UNIT_TIMING_REPORT ?? 'ci-shards/unit-timings.json', { metric: 'module-duration-v1', nodeVersion: process.versions.node, shard: this.ctx.config.shard ?? { index: 1, count: 1 }, status: reason, unhandledErrors: errors.length, + results, timings: Object.fromEntries( modules.map((module) => [ relative(this.ctx.config.root, module.moduleId).replaceAll('\\', '/'), @@ -33,5 +42,23 @@ export default class UnitTimingReporter { ]) ) }) + try { + const plan = JSON.parse( + readFileSync( + process.env.ORCA_UNIT_SELECTION_PLAN ?? 'ci-shards/unit-selection.json', + 'utf8' + ) + ) + const audit = auditUnitSelection(plan, results) + writeAssignment('ci-shards/unit-selection-audit.json', audit) + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `Unit selection (${audit.mode}): ${audit.candidate}/${audit.discovered} candidate files; ${audit.omittedFailures.length} failures outside selection.\n` + ) + } + } catch { + // Timing evidence remains useful when selection planning was unavailable. + } } } diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index ceb75a66bfc..ab23c41d75a 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -159,6 +159,9 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/main/runtime/rpc/methods/session-tabs.ts', 'src/main/runtime/rpc/methods/structured-agent-session', 'src/main/runtime/rpc/methods/terminal', + 'src/main/runtime/runtime-worktree-agent-', + 'src/main/runtime/runtime-worktree-pty-agent-sources', + 'src/shared/runtime-worktree-contracts', 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer' ] diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 06ef8e3879e..5f2823b6e44 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -350,6 +350,9 @@ describe('per-job path classification', () => { 'src/main/runtime/rpc/methods/structured-agent-session-hold.ts', 'src/main/runtime/rpc/methods/structured-agent-session-schemas.ts', 'src/main/runtime/rpc/methods/terminal.ts', + 'src/main/runtime/runtime-worktree-agent-rows.ts', + 'src/main/runtime/runtime-worktree-pty-agent-sources.ts', + 'src/shared/runtime-worktree-contracts.ts', 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts' ]) { expectClassification([file], { @@ -579,12 +582,21 @@ describe('PR Checks skip wiring', () => { it('gates each expensive job on its classifier and cache prerequisite', () => { for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) { - expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths']) + expect(prWorkflow.jobs[jobName].needs, jobName).toEqual( + ['package', 'package_windows'].includes(jobName) + ? ['code_paths', 'static_analysis', 'typecheck'] + : ['code_paths'] + ) expect(prWorkflow.jobs[jobName].if, jobName).toBe( `needs.code_paths.outputs.${jobName} == 'true'` ) } - expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'test_native_cache']) + expect(prWorkflow.jobs.test.needs).toEqual([ + 'code_paths', + 'test_native_cache', + 'static_analysis', + 'typecheck' + ]) expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'") expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'success'") expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'skipped'") diff --git a/config/scripts/pr-ready-check-reuse.mjs b/config/scripts/pr-ready-check-reuse.mjs index 7c319f5554c..57e00a740c2 100644 --- a/config/scripts/pr-ready-check-reuse.mjs +++ b/config/scripts/pr-ready-check-reuse.mjs @@ -1,8 +1,8 @@ import { appendFileSync, readFileSync } from 'node:fs' import { pathToFileURL } from 'node:url' -export function prCheckRunTitle({ number, sourceSha, workflowSha }) { - return `PR ${number} | source ${sourceSha} | workflow ${workflowSha}` +export function prCheckRunTitle({ number, sourceSha, workflowSha, unitMode = 'full' }) { + return `PR ${number} | source ${sourceSha} | workflow ${workflowSha} | unit ${unitMode}` } export function reusablePrCheckRun(runs, identity) { diff --git a/config/scripts/pr-ready-check-reuse.test.mjs b/config/scripts/pr-ready-check-reuse.test.mjs index b841aa651fa..1d64542a614 100644 --- a/config/scripts/pr-ready-check-reuse.test.mjs +++ b/config/scripts/pr-ready-check-reuse.test.mjs @@ -45,6 +45,12 @@ describe('ready-for-review required check reuse', () => { it('reuses a completed success only for the identical PR, merge source and workflow', () => { expect(reusablePrCheckRun([passed], identity)).toBe(passed) expect(reusablePrCheckRun([], identity)).toBeUndefined() + expect( + reusablePrCheckRun( + [{ ...passed, display_title: prCheckRunTitle({ ...identity, unitMode: 'selected' }) }], + identity + ) + ).toBeUndefined() for (const key of ['number', 'sourceSha', 'workflowSha', 'headSha', 'runId']) { const changed = key === 'number' ? 43 : key === 'runId' ? '123' : 'd'.repeat(40) expect(reusablePrCheckRun([passed], { ...identity, [key]: changed }), key).toBeUndefined() @@ -121,7 +127,7 @@ describe('ready-for-review required check reuse', () => { it('keeps required skips conditional on proof and leaves advisory routing eligible', () => { expect(workflow['run-name']).toBe( - 'PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }}' + "PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }} | unit ${{ github.event.pull_request.draft && vars.ORCA_UNIT_SELECTION_MODE == 'selected' && 'selected' || 'full' }}" ) expect(workflow.on.pull_request.types).toContain('ready_for_review') const detector = workflow.jobs.code_paths diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 54d00b876fe..4e23d5580c3 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -1,6 +1,7 @@ import { existsSync, globSync, readFileSync } from 'node:fs' import { parse } from 'yaml' import { describe, expect, it } from 'vitest' +import { UNIT_EXCLUDE } from './ci-unit-files.mjs' import { mobileWebCheckArgs } from './run-mobile-web-app-checks.mjs' import { MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV } from './mobile-web-app-bundle-dependencies.mjs' @@ -105,15 +106,13 @@ describe('PR workflow parallelism', () => { expect(nodeNextWorkflow.on.schedule).toHaveLength(1) expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull() expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}') - expect(sharedTest.strategy.matrix.shard).toEqual( - Array.from({ length: 8 }, (_, index) => index + 1) - ) - expect(sharedTest.strategy.matrix.shard_total).toEqual([8]) + expect(sharedTest.strategy.matrix.shard).toBe('${{ fromJSON(needs.plan.outputs.shards) }}') + expect(sharedTest.needs).toBe('plan') expect(installStep.with['node-version']).toBe('${{ matrix.node }}') expect(installStep.with['cache-electron-package']).toBe('true') - expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}') + expect(testStep.run).toContain('--shard=${{ matrix.shard.index }}/${{ matrix.shard.count }}') for (const testFile of nativeShellContractFiles) { - expect(testStep.run).toContain(`--exclude=${testFile}`) + expect(UNIT_EXCLUDE).toContain(testFile) } expect(primerInstall.with['native-runtime']).toBe('node') expect(primerInstall.with['node-version']).toBe('24') diff --git a/config/scripts/pullfrog-review-scope.cjs b/config/scripts/pullfrog-review-scope.cjs new file mode 100644 index 00000000000..5168d5bd7ef --- /dev/null +++ b/config/scripts/pullfrog-review-scope.cjs @@ -0,0 +1,64 @@ +function reviewNumber(inputs) { + if (/^[1-9]\d*$/.test(inputs.pull_request_number ?? '')) { + return Number(inputs.pull_request_number) + } + const match = + / \| PR ([1-9]\d*)$/.exec(inputs.name ?? '') ?? + /^Review (?:new commits on )?#([1-9]\d*) \[[\w-]+\]$/.exec(inputs.name ?? '') + return match ? Number(match[1]) : null +} + +function reviewRunOrder(runs, number, runId) { + const related = runs.filter((run) => reviewNumber({ name: run.display_title }) === number) + return { + superseded: related.some((run) => run.id > runId), + older: related + .filter( + (run) => + run.id < runId && + ['queued', 'in_progress', 'waiting', 'pending', 'requested'].includes(run.status) + ) + .map((run) => run.id) + } +} + +async function reviewScope({ github, context, core }) { + const inputs = context.payload.inputs ?? {} + const number = reviewNumber(inputs) + core.setOutput('current', 'true') + if (!number || !Number.isSafeInteger(number)) { + return + } + try { + const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: number }) + if (pr.state !== 'open' || (inputs.head_sha && inputs.head_sha !== pr.head.sha)) { + core.setOutput('current', 'false') + return + } + core.setOutput('head', pr.head.sha) + core.setOutput('number', String(number)) + const { data } = await github.rest.actions.listWorkflowRuns({ + ...context.repo, + workflow_id: 'pullfrog.yml', + event: 'workflow_dispatch', + per_page: 100 + }) + const order = reviewRunOrder(data.workflow_runs, number, context.runId) + if (order.superseded) { + core.setOutput('current', 'false') + return + } + // Run IDs, unlike scope-job completion order, cannot let an older review cancel a newer one. + for (const runId of order.older) { + try { + await github.rest.actions.cancelWorkflowRun({ ...context.repo, run_id: runId }) + } catch (error) { + core.warning(`Could not cancel older review ${runId}: ${error.message}`) + } + } + } catch (error) { + core.warning(`Review identity unavailable; leaving this task independent: ${error.message}`) + } +} + +module.exports = { reviewNumber, reviewRunOrder, reviewScope } diff --git a/config/scripts/pullfrog-review-scope.test.mjs b/config/scripts/pullfrog-review-scope.test.mjs new file mode 100644 index 00000000000..2a3f902da87 --- /dev/null +++ b/config/scripts/pullfrog-review-scope.test.mjs @@ -0,0 +1,109 @@ +import { expect, it, vi } from 'vitest' +import scope from './pullfrog-review-scope.cjs' + +it('groups a current review by PR while leaving other agent tasks independent', async () => { + const get = vi.fn(async () => ({ data: { state: 'open', head: { sha: 'current' } } })) + const core = { setOutput: vi.fn(), warning: vi.fn() } + const context = { + repo: { owner: 'stablyai', repo: 'orca' }, + runId: 7, + payload: { inputs: { name: 'Review #42 [abc]' } } + } + const github = { + rest: { + pulls: { get }, + actions: { listWorkflowRuns: async () => ({ data: { workflow_runs: [] } }) } + } + } + await scope.reviewScope({ core, context, github }) + expect(get).toHaveBeenCalledWith({ owner: 'stablyai', repo: 'orca', pull_number: 42 }) + expect(core.setOutput).toHaveBeenCalledWith('head', 'current') + get.mockClear() + await scope.reviewScope({ + core, + github, + context: { ...context, payload: { inputs: { name: 'Investigate #42' } } } + }) + expect(get).not.toHaveBeenCalled() + expect(core.setOutput).toHaveBeenLastCalledWith('current', 'true') +}) + +it('coalesces only explicit or recognized PR review identities', () => { + expect(scope.reviewNumber({ name: 'Review #23532 [85jpk]' })).toBe(23532) + expect(scope.reviewNumber({ name: 'Review new commits on #22727 [24lpq]' })).toBe(22727) + expect(scope.reviewNumber({ pull_request_number: '123' })).toBe(123) + for (const name of ['Fix #123', 'Review #12; echo test', 'Review #123', '', 'Review #0 [abc]']) { + expect(scope.reviewNumber({ name })).toBeNull() + } +}) + +it('skips closed or explicitly stale reviews and tolerates lookup failure', async () => { + for (const data of [ + { state: 'closed', head: { sha: 'a' } }, + { state: 'open', head: { sha: 'b' } } + ]) { + const core = { setOutput: vi.fn(), warning: vi.fn() } + await scope.reviewScope({ + core, + context: { + repo: {}, + runId: 1, + payload: { inputs: { pull_request_number: '1', head_sha: 'a' } } + }, + github: { rest: { pulls: { get: async () => ({ data }) } } } + }) + expect(core.setOutput).toHaveBeenCalledWith('current', 'false') + } + const core = { setOutput: vi.fn(), warning: vi.fn() } + await scope.reviewScope({ + core, + context: { repo: {}, runId: 2, payload: { inputs: { pull_request_number: '1' } } }, + github: { + rest: { + pulls: { + get: async () => { + throw new Error('offline') + } + } + } + } + }) + expect(core.setOutput).toHaveBeenCalledWith('current', 'true') + expect(core.warning).toHaveBeenCalled() +}) + +it('a delayed older scope cannot cancel or replace a newer review', async () => { + const runs = [ + { id: 6, display_title: 'Review #42 [a]', status: 'in_progress' }, + { id: 8, display_title: 'Custom review | PR 42', status: 'queued' }, + { id: 5, display_title: 'Investigate #42', status: 'in_progress' }, + { id: 4, display_title: 'Review #43 [other]', status: 'in_progress' } + ] + for (const runId of [7, 9]) { + const cancelWorkflowRun = vi.fn(async () => ({})) + const core = { setOutput: vi.fn(), warning: vi.fn() } + await scope.reviewScope({ + context: { + repo: { owner: 'stablyai', repo: 'orca' }, + runId, + payload: { inputs: { name: 'Review #42 [current]' } } + }, + core, + github: { + rest: { + pulls: { get: async () => ({ data: { state: 'open', head: { sha: 'head' } } }) }, + actions: { + listWorkflowRuns: async () => ({ data: { workflow_runs: runs } }), + cancelWorkflowRun + } + } + } + }) + if (runId === 7) { + expect(cancelWorkflowRun).not.toHaveBeenCalled() + expect(core.setOutput).toHaveBeenCalledWith('current', 'false') + } else { + expect(cancelWorkflowRun.mock.calls.map(([args]) => args.run_id)).toEqual([6, 8]) + } + } +}) diff --git a/config/scripts/run-anti-slop-shards.mjs b/config/scripts/run-anti-slop-shards.mjs new file mode 100644 index 00000000000..07709baaff6 --- /dev/null +++ b/config/scripts/run-anti-slop-shards.mjs @@ -0,0 +1,205 @@ +// Why sharded: config/oxlint-anti-slop.json turns every native category off and runs its +// rules through `jsPlugins`, so oxlint's threaded Rust engine does no work and the pass is +// one JS runtime per process. Measured, it does not scale with `--threads` (11.68s at 4 vs +// 12.38s at 16). Parallelism has to come from more processes, so this splits the file set +// across them. Sharding is sound because every anti-slop rule is a single-file analysis: the +// only mutable module state is a WeakMap keyed on each file's own Program node. +// +// Why directory units and not file paths: a shard holds ~7k files, and passing those as argv +// overruns the command-line limit (hard-fails on Windows via CommandLineToArgvW). Whole +// directories keep argv to a few dozen entries, so the splitter recurses only until each unit +// fits the per-shard target. +import { spawn, spawnSync } from 'node:child_process' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' +import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs' + +export const CONFIG = 'config/oxlint-anti-slop.json' +export const ROOTS = ['src', 'config', 'tests', 'mobile'] +// Bounds argv growth. Each unit is a path of ~40 chars, so even at this cap a shard stays far +// under the ~32k Windows command-line limit, while leaving room to split a lopsided tree. +const MAX_UNITS = 4096 + +function shardCount() { + const requested = Number(process.env.ORCA_ANTI_SLOP_SHARDS) + if (Number.isInteger(requested) && requested > 0) { + return requested + } + // CPU-seconds grow with shard count, so on a 4-core runner more shards than cores is a + // measured regression (N=8 was slower than N=4 there). Cap keeps a 64-core dev box sane. + return Math.max(1, Math.min(os.availableParallelism?.() ?? os.cpus().length, 8)) +} + +export function listFiles(root = process.cwd()) { + const { command, prefixArgs } = resolveOxlintInvocation(root) + const result = spawnSync( + command, + [...prefixArgs, '--config', CONFIG, ...ROOTS, '--debug=files'], + { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024 + } + ) + if (result.status !== 0) { + process.stderr.write(result.stderr ?? '') + throw new Error(`oxlint --debug=files exited with ${result.status}`) + } + return result.stdout + .split('\n') + .map((line) => line.trim().replace(/^\.\//, '')) + .filter(Boolean) +} + +// Splits the largest unit into its children until every unit fits `target`. A directory's own +// direct files become individual units so the parent stays fully covered after a split. +export function buildUnits(files, target) { + const units = new Map() + for (const file of files) { + const top = file.split('/')[0] + if (!units.has(top)) { + units.set(top, []) + } + units.get(top).push(file) + } + + while (units.size < MAX_UNITS) { + let biggest + for (const [unit, unitFiles] of units) { + // A unit that is already a single file cannot be split further. + if (unitFiles.length <= target || unitFiles.length <= 1) { + continue + } + if (!biggest || unitFiles.length > units.get(biggest).length) { + biggest = unit + } + } + if (!biggest) { + break + } + + const depth = biggest.split('/').length + const children = new Map() + for (const file of units.get(biggest)) { + const segments = file.split('/') + // Files sitting directly in the split directory have no deeper segment to group by. + const key = segments.length > depth ? segments.slice(0, depth + 1).join('/') : file + if (!children.has(key)) { + children.set(key, []) + } + children.get(key).push(file) + } + // A single child means every file shares the next segment (src/renderer -> src/renderer/src). + // Replacing the unit with it still deepens the path, so the next pass can split further. + units.delete(biggest) + for (const [key, childFiles] of children) { + units.set(key, childFiles) + } + } + + return [...units.entries()].map(([unit, unitFiles]) => ({ unit, count: unitFiles.length })) +} + +// Largest-first into the least-loaded shard: keeps the slowest shard close to the mean, which +// is what the wall time is bound by. +export function packShards(units, shards) { + const bins = Array.from({ length: shards }, () => ({ units: [], count: 0 })) + for (const unit of [...units].sort((a, b) => b.count - a.count)) { + const lightest = bins.reduce((best, bin) => (bin.count < best.count ? bin : best), bins[0]) + lightest.units.push(unit.unit) + lightest.count += unit.count + } + return bins.filter((bin) => bin.units.length > 0) +} + +function runShard(units, root) { + const { command, prefixArgs } = resolveOxlintInvocation(root) + return new Promise((resolve) => { + const child = spawn( + command, + [ + ...prefixArgs, + '--config', + CONFIG, + '--deny-warnings', + ...units.map((unit) => path.normalize(unit)) + ], + { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] } + ) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.on('error', (error) => + resolve({ status: 1, stdout, stderr: `${stderr}${error.message}\n` }) + ) + child.on('close', (status) => resolve({ status: status ?? 1, stdout, stderr })) + }) +} + +// Keeps a shard's paths well inside the ~32k Windows command-line limit. Splitting finer buys +// balance but costs argv, so this is the ceiling the planner degrades against. +const ARGV_BUDGET = 16_000 + +// Plans the shards for a file list: exported so a test can assert the units stay disjoint and +// complete, which is what makes the union of shard findings equal to a single pass. +export function planShards(files, shards) { + let divisor = shards + let plan + // A deeper split means more units and a longer argv. If the tree is lopsided enough that the + // fine split would overrun the budget, back off to coarser units and accept the imbalance + // rather than handing the OS a command line it will reject. + for (let attempt = 0; attempt < 8; attempt += 1) { + const units = buildUnits(files, Math.ceil(files.length / Math.max(divisor, 1))) + const bins = packShards(units, shards) + plan = { units, bins } + const widest = bins.reduce((max, bin) => Math.max(max, bin.units.join(' ').length), 0) + if (widest <= ARGV_BUDGET || divisor <= 1) { + break + } + divisor = Math.floor(divisor / 2) + } + return plan +} + +async function main() { + const root = process.cwd() + const files = listFiles(root) + const shards = Math.min(shardCount(), files.length || 1) + const { units, bins } = planShards(files, shards) + + console.log( + `anti-slop: ${files.length} files across ${bins.length} shard(s) (${units.length} units): ${bins + .map((bin) => bin.count) + .join(', ')}` + ) + + // Printed in shard order rather than completion order so the log is reproducible. + const results = await Promise.all(bins.map((bin) => runShard(bin.units, root))) + let failed = false + for (const result of results) { + if (result.stdout) { + process.stdout.write(result.stdout) + } + if (result.stderr) { + process.stderr.write(result.stderr) + } + if (result.status !== 0) { + failed = true + } + } + + if (failed) { + process.exitCode = 1 + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main() +} diff --git a/config/scripts/runtime-serve-terminal-smoke.mjs b/config/scripts/runtime-serve-terminal-smoke.mjs index a03c85983d8..c1d998717ff 100644 --- a/config/scripts/runtime-serve-terminal-smoke.mjs +++ b/config/scripts/runtime-serve-terminal-smoke.mjs @@ -388,7 +388,9 @@ async function main() { child.kill('SIGTERM') const exited = await Promise.race([ new Promise((r) => child.on('exit', () => r(true))), - new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS)) + // unref'd: the loser of this race must not hold the event loop open after the + // winner already decided. The timer still bounds the wait. + new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS).unref()) ]) if (!exited) { child.kill('SIGKILL') diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index fe892b20734..653f10e3fc6 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -18,6 +18,7 @@ "../src/main/agent-hooks/managed-agent-hook-controls.ts", "../src/main/agent-hooks/managed-agent-hook-registry.ts", "../src/main/agent-hooks/managed-hook-script-refresh.ts", + "../src/main/agent-hooks/managed-hooks-json-events.ts", "../src/main/agent-hooks/posix-hook-command.ts", "../src/main/agent-hooks/runtime-home-hook-command.ts", "../src/main/orca-profiles/profile-storage-paths.ts", @@ -207,6 +208,9 @@ "../src/main/in-flight-run-dedupe.ts", "../src/main/kimi/hook-service.ts", "../src/main/kimi/kimi-hook-config-toml.ts", + "../src/main/dsh/dsh-home-patch.ts", + "../src/main/dsh/hook-service.ts", + "../src/main/dsh/hook-settings.ts", "../src/main/muse/hook-config-json.ts", "../src/main/muse/hook-service.ts", "../src/main/muse/hook-settings.ts", diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 5c85c3e6980..b81bbc2b0f3 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -1,5 +1,6 @@ import { resolve } from 'node:path' import { defineConfig } from 'vitest/config' +import { UNIT_INCLUDE, UNIT_EXCLUDE } from './scripts/ci-unit-files.mjs' import TimingSequencer from './scripts/ci-unit-sequencer.mjs' const windowsTestWorkerOptions = process.platform === 'win32' ? { maxWorkers: 4 } : {} @@ -33,14 +34,8 @@ export default defineConfig({ resolve('config/scripts/happy-dom-mutation-observer-retention.ts'), resolve('config/scripts/vitest-host-ports-setup.ts') ], - include: [ - 'src/**/*.test.ts', - 'src/**/*.test.tsx', - 'config/scripts/**/*.test.ts', - 'config/scripts/**/*.test.mjs', - 'tests/tools/**/*.test.mjs', - 'tests/e2e/**/*.unit.test.ts' - ], + include: UNIT_INCLUDE, + ...(process.env.ORCA_BALANCE_UNIT_SHARDS === '1' ? { exclude: UNIT_EXCLUDE } : {}), // Why: the full suite runs heavy TS transforms plus real git/http fixtures; // the Vitest 5s defaults are too tight for the slowest integration cases. hookTimeout: 60_000, diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index fdd067ef763..6cc98d698c5 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -179,6 +179,7 @@ Funciona con **cualquier agente CLI** — si corre en una terminal, corre en Orc Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   Muse logo Muse   + DeepSeek Harness logo DeepSeek Harness   ZCode logo ZCode   OpenCode logo OpenCode   Amp logo Amp   diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index 56cfc334fd7..69e97b98490 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -183,6 +183,7 @@ Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, i Logo Cursor Cursor   Logo GitHub Copilot GitHub Copilot   Logo Muse Muse   + DeepSeek Harness logo DeepSeek Harness   Logo ZCode ZCode   Logo OpenCode OpenCode   Logo MiMo Code MiMo Code   diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index e5eee5781b0..975cb65d485 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -179,6 +179,7 @@ PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意の Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   Muse logo Muse   + DeepSeek Harness logo DeepSeek Harness   ZCode logo ZCode   OpenCode logo OpenCode   Amp logo Amp   diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 17528c25ee0..8e4ded59bff 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -179,6 +179,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   Muse logo Muse   + DeepSeek Harness logo DeepSeek Harness   ZCode logo ZCode   OpenCode logo OpenCode   MiMo Code logo MiMo Code   diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 568a6d0de0d..8e3a13d898d 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -179,6 +179,7 @@ Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca. Logotipo do Cursor Cursor   Logotipo do GitHub Copilot GitHub Copilot   Logotipo do Muse Muse   + DeepSeek Harness logo DeepSeek Harness   Logotipo do ZCode ZCode   Logotipo do OpenCode OpenCode   Logotipo do MiMo Code MiMo Code   diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 7882935dfa4..f7828f9e06d 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -179,6 +179,7 @@ VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智 Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   Muse logo Muse   + DeepSeek Harness logo DeepSeek Harness   ZCode logo ZCode   OpenCode logo OpenCode   Amp logo Amp   diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 4dff79d6666..b7ce4fa16dd 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -105,8 +105,15 @@ ingests the summary into the hook server as a status row: | `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it | | prompt, tool, last message, model, provider session | the summary's fields | -Sessions with no persisted turn (`status === null`) produce no row, matching -what the chat shows. When the host revokes live ownership the row is re-set +Sessions with no request (`status === null`) produce no row. A request is a +turn record, an assistant message, a user message the provider journaled itself +(history, an older host), an accepted or unanswered send, or a send the agent or +its start refused; a send that was withdrawn, or left undelivered by a +restart or a close, fails nobody and makes nothing listable. +`summary.turnOutcome` is the latest request's verdict: its turn's outcome, or +`failure` for a send the agent or its start refused (a send that joined a running +turn is answered by that turn). The row also publishes `interrupted` from +`mainAgent.outcome`, exactly as the hook lanes do. When the host revokes live ownership the row is re-set without the flag; when the host closes or evicts the session the row is dropped. Both already exist as feed events (`revokeLive` and the roster filter in `liveSessionSummaries`); PR 1 turns them into store writes. @@ -218,6 +225,32 @@ sends no hook at all on a cancel and no `is_interrupt` on Stop; that flag on a turn boundary remains a secondary source for builds that send it, and `StopFailure` maps to `failure`. +Readers decode the verdict through one accessor, `agentMainAgentVerdict`, which +reads the main agent's own state, not the combined row's: `mainAgent.outcome` +while `mainAgent.state` is `done`, then the legacy `interrupted` flag as a +cancellation, which alone needs the combined `done`. So a main agent that +failed while its subagents still run has a verdict on a `working` row. Every +copy of a row (state-history entries, sleep records, `worktree ps` rows) takes +the verdict through `agentVerdictFields`, which carries `interrupted` and the +whole `mainAgent` (state, outcome and its own clock) together, so a copy agrees +with the row and can date a failure by `mainAgent.stateStartedAt`. + +Display reads the verdict through `agentVerdictDisplayMark`: a failure marks the +agent failed whatever the combined state, because it is news the user must see +even while subagents run; a stop marks it interrupted only on a `done` row, so +a stopped or finished main agent with live child work still reads working. +Each subagent keeps its own row and state. Container rollups (worktree card, +terminal tab, Cmd+J) rank a pending question first, then a failure, then live +work, then a stop, then done. On the worktree card, a failure retained after its +agent's pane went away has no expiry, so it ranks below live work and above a +stop. Lifecycle waiters keep reading the combined `state`. + +Policy splits the verdict two ways. Clean-finish policy (hibernation, pane +ownership, the star-nag value moment) treats a failure like a cancellation +(`agentTurnEndedUncleanly`). Attention (completion time, Smart Sort, sticky +retention, Cmd+J Recent) demotes only a turn the user stopped +(`agentTurnStoppedByUser`); a failure ranks like a completion. + Admission is one function, `normalizeAgentStatusPayload`, on the relay wire, IPC and disk. A malformed `mainAgent` drops the field and keeps the row. Old hosts send none and readers fall back to `state`. Hook rows persist it inside the diff --git a/docs/reference/ci-demand-rollout.md b/docs/reference/ci-demand-rollout.md new file mode 100644 index 00000000000..8219a3401cb --- /dev/null +++ b/docs/reference/ci-demand-rollout.md @@ -0,0 +1,145 @@ +# CI demand rollout + +This implements the September 28 runner-demand analysis. The baseline inventory +covered September 27 04:00–September 28 04:00 UTC: 4,028 workflow runs, with +463 stratified job samples. Estimated occupancy was 1,081 runner-hours, dominated +by PR unit shards (414 hours) and Bun qualification (262 hours). These are +sampled sums of job durations across different runner pools, not billing totals +or a guaranteed forecast of savings. + +## What runs now + +| Work | Ordinary draft update | Ready PR / final checks | Main reference | +| ---------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------- | --------------------------------------- | +| Static analysis and types | Immediately | Immediately | Existing workflows | +| Unit suite | Full, with shadow selection evidence | Full | Existing daily Node 24/26 x86 suite | +| Packages | After successful static analysis and types | Same | Existing release workflows | +| Bun persistence | Linux x64 for ordinary runtime changes; all six platforms for sensitive inputs | All six platforms when Bun inputs changed | Full nightly qualification at 11:30 UTC | +| Bun glibc/musl qualification | Sensitive inputs only, after persistence succeeds | Both architectures after persistence succeeds | Both architectures | +| E2E | Existing targeted routing | Existing targeted routing | One complete run at 17:00 UTC | + +Bun-sensitive inputs include root/toolchain files, configuration, native code, +resources, platform-specific paths, persistence, SQLite, orcad, providers, +daemon, SSH, relay, and child-process code. Dependency discovery failure, a +missing event or an incomplete diff retains full qualification. An unrelated +change still skips Bun through the existing dependency classifier. No native +artifact is shared across platforms or ABIs. The routine draft reduction is an +explicit coverage-placement change; it does not assert identical per-update +coverage. Every non-draft synchronize event and the ready-for-review event +restores full qualification. + +Expensive PR jobs wait for static/type success. This reduces fan-out for failed +or rapidly superseded commits without sleeping on a runner. Successful isolated +PRs pay the extra stage latency. Existing per-PR cancellation remains in place. +Package assertions, native boundaries, SSH/folder coverage, cache warming and +slow-test assertions are retained. + +## Unit selection rollout + +`ci-unit-plan.mjs` discovers the same include/exclude set as Vitest and follows +static imports, re-exports, literal dynamic imports, CommonJS requires and the +renderer aliases. Consumers of indirect filesystem/process inputs remain in the +candidate set, as do script/tool tests. Global configuration changes, deletions, +renames involving removed paths, unknown inputs and graph failures run the full +suite. A shard verifies the plan's source SHA and complete discovery list before +using it. Missing/stale artifacts fall back to full coverage, even if that means +running the suite on fewer shards. + +The initial policy is **shadow**, with all eight shards retained. The first +local inventory matched Vitest exactly (9,950 files at validation); representative +source changes retained roughly 88% of files because of indirect input readers. +That is evidence for conservative coverage, not evidence of the analysis's +hypothetical 50% unit-work reduction. Improvements to indirect dependency +modeling should be demonstrated against full results before expanding selection. + +Every shard uploads `unit-selection.json`, `unit-timings.json` (including module +outcomes), and its assignment. The evidence job combines these into +`unit-selection-review-attempt-N/selection-review.json`, reporting: + +- Whether every discovered file appeared once across a complete reference run. +- Failures outside the candidate set, including failures in otherwise red runs. +- Measured worker time that selection would omit; worker times overlap and are + not runner occupancy or a prediction of wall-clock savings. + +Missing/duplicate shards, stale plans, interrupted runs and unhandled errors do +not count as complete references. Diagnostic upload/report failures do not make +tests pass and do not independently fail successful tests. + +After representative complete shadow runs show no missed failures, set repository +variable `ORCA_UNIT_SELECTION_MODE=selected` to enable selection **only for draft +PRs**. Keep full ready-PR checks and the daily compatibility suite. Inspect at +least a week's evidence across renderer, main, shared, SSH and fixture changes +before promotion, including red runs rather than only successful examples. +Unknown variable values retain shadow mode. Unset the variable or set it to +`shadow` to roll back immediately. Selected runs use one to eight timing-balanced +shards based on retained work. + +Ready-for-review result reuse includes `unit full` in its source/workflow +identity. A green selected draft cannot satisfy the final full check, even if +the repository variable changes between runs. An already successful _full_ +identical-source check can still be reused. + +To inspect downloaded shard artifacts locally: + +```sh +node config/scripts/ci-unit-selection-review.mjs ARTIFACT_DIRECTORY +``` + +## Review automation + +Pullfrog recognizes the existing `Review #N [id]` and +`Review new commits on #N [id]` dispatch names. Explicit dispatchers may provide +`pull_request_number` and `head_sha`. Explicit PR identities share concurrency at the workflow boundary. Legacy review +names use a bounded lookup of the latest 100 dispatches and cancel only lower +run IDs for the same PR; a delayed older scope cannot cancel a newer review. +Unrecognized tasks are never grouped. The scope job alone has Actions write +permission for ordered cancellation. Closed PRs and explicitly stale heads +are skipped. A second head check prevents starting an agent after its queued +head has changed. Unrecognized agent tasks remain independent; lookup failures +also retain an independent task rather than cancelling unrelated work. + +This does not introduce a fixed debounce interval or remove final reviews. +Dispatchers should supply `head_sha` for reliable stale-at-dispatch detection; +legacy names identify a PR but do not prove which head the prompt describes. + +## E2E signal + +The daily reference still executes all shards and keeps original verdicts. Each +shard uploads Playwright JSON and publishes expected, skipped, unexpected, flaky +and startup-error counts with the failing test names/messages. Targeted PR and +manual coverage remain available. This change does not fix the historically +red tests or pretend they pass. + +`config/e2e-failure-tracking.json` can separate an evidenced repeated failure +from new failures in the summary. Each entry must have exact `file`, full +`title`, `project`, a nonempty stable `message` substring, an `@owner`, a linked +repository `issue`, and an ISO `expires` review date. Expired/malformed entries +are ignored and reported; changed error signatures appear as untracked. Entries +never skip a test or change its exit status. The initial list is empty because +the analysis established red workflows but did not establish owners and +reproductions for individual failures. Do not blanket-baseline an entire red run. + +## Capacity measurements and acceptance + +`CI runner demand` runs daily at 04:23 UTC and can be dispatched manually. It +reads the previous 24 complete hours in hourly pages, samples up to six runs per +workflow/outcome stratum, and fetches job pages with bounded concurrency. An +hour exceeding the API's 1,000-result search cap fails visibly. The report and +raw evidence are retained for 30 days. No extra runner pool is provisioned. + +The report measures the full job durations of runs **created** in the window, +not occupancy clipped to the window: earlier runs that overlap it are excluded, +and completed sampled jobs may finish after it. This matches the baseline +cohort method. Workflow IDs keep ref-qualified paths in one sampling stratum. + +The report shows weighted runner-hours and cancelled-run hours per workflow, +runner-minutes per completed PR _run_, and weighted queue/provisioning p95 per +runner label. It counts latest attempts only, excludes incomplete jobs, and +retains zero-job observations. It does not measure other repositories competing +for organization capacity. Compare equivalent traffic windows, not raw totals +alone. The collector needs only `contents: read` and `actions: read`. + +After a week, compare runner-minutes per PR run, cancellation occupancy and +queue p95 in each affected pool. Count newly added planning/reference overhead. +A 25–35% overall reduction remains an experiment target, not an achieved result; +selection, coalescing and matrix reductions overlap and cannot simply be added. diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 3718a65e35e..5a59c75db5e 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -1,5 +1,8 @@ # CI efficiency and runner capacity +The [September 28 demand rollout](ci-demand-rollout.md) documents staged checks, +unit-selection evidence, Bun qualification, review cancellation and daily occupancy reports. + ## September 27 follow-up ### Shared E2E CLI output diff --git a/docs/site/content/docs/agents/supported.mdx b/docs/site/content/docs/agents/supported.mdx index 6532e04fb58..6b6a0d143a0 100644 --- a/docs/site/content/docs/agents/supported.mdx +++ b/docs/site/content/docs/agents/supported.mdx @@ -48,6 +48,7 @@ To restore prompts for one agent only, edit that agent's default arguments or en | Codebuff | Auto-setup | [Codebuff](https://www.codebuff.com/docs/help/quick-start) | | Command Code | Auto-setup, status | [Command Code](https://commandcode.ai/docs/quickstart) | | Muse | macOS/Linux; trusts the workspace at launch | [Meta](https://dev.meta.ai/docs/muse-code) | +| DeepSeek Harness | Launched through its `dsh-tui` profile; hooks, status, questions, resume | [DeepSeek](https://deepseek-harness.github.io/deepseek-harness/) | | ZCode | Deep integration; needs a `zcode` CLI that ships the TUI (see note below) | [Z.ai](https://zcode.z.ai/en/docs) | | Continue | Auto-setup | [Continue](https://docs.continue.dev/guides/cli) | | Cursor CLI | Deep integration | [Cursor](https://cursor.com/cli) | diff --git a/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json b/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json index b09486000a0..dd11f006e61 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json @@ -6,7 +6,7 @@ "baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "c583058382c949e977b7a1287895ed9ccb8cb408b684aef186b629976ff1da4d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json index ae27cfa3a62..8538f6073d8 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json @@ -6,7 +6,7 @@ "baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "651cb070a4b4ba7deddeb37c08886094b83f006e7cbe2bef65d5433ce0430dff", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json index 7074ed354b0..1f156afff30 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json @@ -6,7 +6,7 @@ "baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "e0ce643010302d304ba3611bd43932c6cdd0fa9290a3ecd4fffb4fc92c758b77", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json index 57ad2b28c87..ca4c9946826 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json @@ -6,7 +6,7 @@ "baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "c6b3b4be6b55daf53ee1f5ff755e73a032c4da5379f90f4973736c032eb79414", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json b/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json index 2743e4b6257..760d0348f78 100644 --- a/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json @@ -6,7 +6,7 @@ "baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "a71815661dee6129a1b133c398b37ea89a9e4939b45626dada16889acabb2afe", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/review-create-terminal-refused.json b/mobile/rpc-foundation/goldens/review-create-terminal-refused.json index 1fd7905d056..576b9f39644 100644 --- a/mobile/rpc-foundation/goldens/review-create-terminal-refused.json +++ b/mobile/rpc-foundation/goldens/review-create-terminal-refused.json @@ -6,7 +6,7 @@ "baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "af837de2e273c5f03130b51d6e1a9bff9ff99a4e8d1c9554a53134acb5924c72", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/review-git-mutations-run.json b/mobile/rpc-foundation/goldens/review-git-mutations-run.json index 74f672310ab..6d52b5e906e 100644 --- a/mobile/rpc-foundation/goldens/review-git-mutations-run.json +++ b/mobile/rpc-foundation/goldens/review-git-mutations-run.json @@ -6,7 +6,7 @@ "baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "7981ac87c4397b9703330835327c70828cb5a76633f1c4803ded1b66520d184d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json b/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json index 3a65d8f56fc..4af90af0ccc 100644 --- a/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json +++ b/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json @@ -6,7 +6,7 @@ "baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "9f1c39e91a97266b0a550d2b2d061592ae050078401ef23177dd2e00db67bf04", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json b/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json index 83b60dd04e9..c443998f880 100644 --- a/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json +++ b/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json @@ -6,7 +6,7 @@ "baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "c858cfca59148548146b4c0a775e9518d3ab826ef268ccc9af60b04e52cc9e3d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/review-open-in-session.json b/mobile/rpc-foundation/goldens/review-open-in-session.json index 8627963febc..2638b852c7a 100644 --- a/mobile/rpc-foundation/goldens/review-open-in-session.json +++ b/mobile/rpc-foundation/goldens/review-open-in-session.json @@ -6,7 +6,7 @@ "baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "2f0a6aa8ce100edbce9ccf64c81efe64356fccbdcd2e16f561360e2b11b5700f", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json b/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json index 4fa1ac7bed4..e1248bc3e09 100644 --- a/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json +++ b/mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json @@ -6,7 +6,7 @@ "baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "7099d7311b2046dde21a2750201718995c0869bc4f17fb7f3ce2b997ce0e6506", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json b/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json index c944001fca7..0c1d3d3c040 100644 --- a/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json +++ b/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json @@ -6,7 +6,7 @@ "baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "8b4a06c1b2918d5ed5f40102c1c7cb411e02aba5e7601e98658d673634784bb0", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/review-stage-file.json b/mobile/rpc-foundation/goldens/review-stage-file.json index 99ac5155e70..64f9a706149 100644 --- a/mobile/rpc-foundation/goldens/review-stage-file.json +++ b/mobile/rpc-foundation/goldens/review-stage-file.json @@ -6,7 +6,7 @@ "baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "793e954960d371c6477d4cd3d70a5215c0bd90764684eafdb1e1a2e09790cc86", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/review-stage-refused.json b/mobile/rpc-foundation/goldens/review-stage-refused.json index 6fc8a8e2dd2..d94298eb023 100644 --- a/mobile/rpc-foundation/goldens/review-stage-refused.json +++ b/mobile/rpc-foundation/goldens/review-stage-refused.json @@ -6,7 +6,7 @@ "baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "46b720fed417d29f79dbde4cc7941579f2d6a2f920bae24234537480079e7de5", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index 1637296d900..80ef9d9d239 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -5,7 +5,7 @@ import type { AgentDotState } from '../worktree/agent-row-display' // Per-agent state indicator, 1:1 with desktop AgentStateDot // (src/renderer/src/components/AgentStateDot.tsx): yellow spinner for 'working', -// emerald for 'done', red for blocked/waiting/interrupted (attention), neutral +// emerald for 'done', red for blocked/waiting/interrupted/failed (attention), neutral // for idle. Distinct from the worktree-level AgentSpinner, which collapses the // agent vocabulary into the 5-state rollup the sidebar dot uses. const DOT_COLORS: Record, string> = { @@ -13,6 +13,7 @@ const DOT_COLORS: Record, strin blocked: '#ef4444', waiting: '#ef4444', interrupted: '#ef4444', + failed: '#ef4444', idle: 'rgba(115,115,115,0.4)' } const WORKING_COLOR = '#eab308' diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index 9d27c2c0c4e..4b381511d76 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -1,6 +1,5 @@ -import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' -import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state' -import { MountedBottomDrawer } from './mounted-bottom-drawer' +import type { ReactNode } from 'react' +import { KeyedBottomDrawer } from './keyed-bottom-drawer' type Props = { visible: boolean @@ -19,75 +18,21 @@ type Props = { zIndex?: number } -export function BottomDrawer({ - visible, - onClose, - onAfterClose, - children, - dragContentToDismiss = true, - contentScrollable = true, - fillAvailable = false, - interactive = true, - zIndex -}: Props) { - const [mounted, setMounted] = useState(visible) - const onAfterCloseRef = useRef(onAfterClose) - const hiddenHandledRef = useRef(false) - const afterClosePendingRef = useRef(false) - - useEffect(() => { - onAfterCloseRef.current = onAfterClose - }, [onAfterClose]) - - useEffect(() => { - if (visible) { - hiddenHandledRef.current = false - afterClosePendingRef.current = false - } - }, [visible]) - - useEffect(() => { - if (mounted || !afterClosePendingRef.current) { - return - } - afterClosePendingRef.current = false - onAfterCloseRef.current?.() - }, [mounted]) - - const handleHidden = useCallback(() => { - if (hiddenHandledRef.current) { - return - } - hiddenHandledRef.current = true - afterClosePendingRef.current = true - setMounted(false) - }, []) - const resolvedMounted = resolveBottomDrawerMounted(visible, mounted) - - // Why: opening drawers should mount before commit; waiting for a passive - // Effect adds a null render before every drawer can animate in. - if (resolvedMounted !== mounted) { - setMounted(resolvedMounted) - } - - // Why: hidden drawers are rendered by parent screens even while closed; keep - // their Reanimated/Gesture setup out of hot paths like commit-message typing. - if (!resolvedMounted) { - return null - } +const SHOWN = 'shown' +const sheetKey = () => SHOWN +export function BottomDrawer({ visible, onClose, onAfterClose, children, ...drawerProps }: Props) { + // Why: hidden drawers are rendered by parent screens even while closed; the keyed drawer + // renders nothing until shown, which keeps Reanimated/Gesture setup out of hot paths. return ( - - {children} - + {() => children} + ) } diff --git a/mobile/src/components/ConfirmModal.tsx b/mobile/src/components/ConfirmModal.tsx index a393593b5cf..5d0f70e6081 100644 --- a/mobile/src/components/ConfirmModal.tsx +++ b/mobile/src/components/ConfirmModal.tsx @@ -2,8 +2,7 @@ import { View, Text, Pressable, StyleSheet } from 'react-native' import { colors, spacing, radii, typography } from '../theme/mobile-theme' import { BottomDrawer } from './BottomDrawer' -type Props = { - visible: boolean +type ContentProps = { title: string message?: string confirmLabel?: string @@ -13,8 +12,17 @@ type Props = { onCancel: () => void } -export function ConfirmModal({ - visible, +type Props = ContentProps & { visible: boolean } + +export function ConfirmModal({ visible, ...content }: Props) { + return ( + + + + ) +} + +export function ConfirmContent({ title, message, confirmLabel = 'Confirm', @@ -22,9 +30,9 @@ export function ConfirmModal({ destructive = false, onConfirm, onCancel -}: Props) { +}: ContentProps) { return ( - + <> {title} {message ? {message} : null} @@ -52,7 +60,7 @@ export function ConfirmModal({ - + ) } diff --git a/mobile/src/components/MobileDiffReviewDrawers.tsx b/mobile/src/components/MobileDiffReviewDrawers.tsx index 9f979761fe8..b129da81616 100644 --- a/mobile/src/components/MobileDiffReviewDrawers.tsx +++ b/mobile/src/components/MobileDiffReviewDrawers.tsx @@ -5,10 +5,15 @@ import type { DiffComment } from '../../../src/shared/diff-comment-types' import { useKeyboardAvoidingPadding } from '../platform/keyboard-occlusion' import { colors } from '../theme/mobile-theme' import type { ActionSheetAction } from './ActionSheetModal' -import { ActionSheetModal } from './ActionSheetModal' -import { BottomDrawer } from './BottomDrawer' -import { ConfirmModal } from './ConfirmModal' -import { mobileReviewCountLabel } from '../session/mobile-diff-review-screen-model' +import { ActionSheetContent } from './ActionSheetModal' +import { ConfirmContent } from './ConfirmModal' +import { KeyedBottomDrawer } from './keyed-bottom-drawer' +import { + mobileReviewCountLabel, + type ComposerState, + type SendSheetState +} from '../session/mobile-diff-review-screen-model' +import { reviewSheetKey, type ReviewSheet } from '../session/mobile-diff-review-sheets' import type { useMobileDiffReviewController } from '../session/use-mobile-diff-review-controller' import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles' @@ -17,59 +22,82 @@ type Props = { } export function MobileDiffReviewDrawers({ controller }: Props) { - const sendActions = useSendActions(controller) - const overflowActions = useOverflowActions(controller) + // One drawer for every review sheet: iOS cannot present a sheet while another is still closing. return ( - <> - 0 - ? `${controller.reviewedUnstagedCount} reviewed unstaged files can be staged` - : undefined - } - actions={overflowActions} - onClose={() => controller.setShowOverflow(false)} - /> - controller.setSendSheet(null)} - /> - { - const target = controller.discardTarget - controller.setDiscardTarget(null) - if (target) { - void controller.runGitMutation('git.discard', target) - } - }} - onCancel={() => controller.setDiscardTarget(null)} - /> - - - + controller.closeSheet(presented.kind)} + > + {(presented) => } + ) } -function useSendActions(controller: ReturnType) { +function ReviewSheetContent({ controller, sheet }: Props & { sheet: ReviewSheet }) { + switch (sheet.kind) { + case 'actions': + return + case 'send': + return + case 'discard': + return ( + { + controller.closeSheet('discard') + void controller.runGitMutation('git.discard', sheet.target) + }} + onCancel={() => controller.closeSheet('discard')} + /> + ) + case 'composer': + return + case 'completion': + return + } +} + +function ReviewActionsContent({ controller }: Props) { + const overflowActions = useOverflowActions(controller) + return ( + 0 + ? `${controller.reviewedUnstagedCount} reviewed unstaged files can be staged` + : undefined + } + actions={overflowActions} + onClose={() => controller.closeSheet('actions')} + /> + ) +} + +function SendNotesContent({ controller, load }: Props & { load: SendSheetState }) { + const sendActions = useSendActions(controller, load) + return ( + controller.closeSheet('send')} + /> + ) +} + +function useSendActions( + controller: ReturnType, + load: SendSheetState +) { return useMemo(() => { const comments = controller.unsentComments const terminalActions = - controller.sendSheet?.kind === 'ready' || controller.sendSheet?.kind === 'error' - ? controller.sendSheet.terminals.map((terminal) => ({ + load.kind === 'ready' || load.kind === 'error' + ? load.terminals.map((terminal) => ({ label: `${terminal.title || 'Terminal'} (${terminal.terminal.slice(0, 6)})`, icon: Send, disabled: comments.length === 0, @@ -94,7 +122,7 @@ function useSendActions(controller: ReturnType void controller.copyNotes() } ] - }, [controller]) + }, [controller, load]) } function useOverflowActions(controller: ReturnType) { @@ -111,7 +139,6 @@ function useOverflowActions(controller: ReturnType void controller.openSendSheet() }, { @@ -152,65 +179,61 @@ function useOverflowActions(controller: ReturnType + controller: ReturnType, + load: SendSheetState ): string | undefined { - return controller.sendSheet?.kind === 'loading' + return load.kind === 'loading' ? 'Loading agent sessions...' - : controller.sendSheet?.kind === 'error' - ? controller.sendSheet.message + : load.kind === 'error' + ? load.message : `${controller.unsentComments.length} unsent notes` } -function NoteComposerDrawer({ controller }: Props) { - const composer = controller.composer +function NoteComposerContent({ controller, composer }: Props & { composer: ComposerState }) { // Zero on a phone, where `KeyboardAvoidingView` above already moved this; the page's own // keyboard measurement where it cannot, because that view is driven by events RN Web never // sends. Padding rather than a second avoiding view: the drawer owns the position. const keyboardPadding = useKeyboardAvoidingPadding() return ( - - 0 ? { paddingBottom: keyboardPadding } : undefined} - > - - - - {composer?.mode === 'edit' ? 'Edit Note' : 'Add Note'} - - - {composer?.mode === 'create' && composer.lineNumber > 0 - ? `Line ${composer.lineNumber}` - : 'File note'} - - - [styles.iconButton, pressed && styles.iconButtonPressed]} - onPress={controller.closeComposer} - accessibilityRole="button" - accessibilityLabel="Cancel note" - > - - + 0 ? { paddingBottom: keyboardPadding } : undefined} + > + + + + {composer.mode === 'edit' ? 'Edit Note' : 'Add Note'} + + + {composer.mode === 'create' && composer.lineNumber > 0 + ? `Line ${composer.lineNumber}` + : 'File note'} + - - - {composer?.mode === 'edit' ? ( - - ) : null} - - - - + [styles.iconButton, pressed && styles.iconButtonPressed]} + onPress={controller.closeComposer} + accessibilityRole="button" + accessibilityLabel="Cancel note" + > + + + + + + {composer.mode === 'edit' ? : null} + + + ) } @@ -262,14 +285,11 @@ function SaveNoteButton({ ) } -function CompletionDrawer({ controller }: Props) { +function CompletionContent({ controller }: Props) { const noteCount = controller.screenState.kind === 'ready' ? controller.screenState.comments.length : 0 return ( - controller.setShowCompletion(false)} - > + <> Review Complete {mobileReviewCountLabel(controller.queue.length, 'file', 'files')} reviewed,{' '} @@ -297,6 +317,6 @@ function CompletionDrawer({ controller }: Props) { Send Notes - + ) } diff --git a/mobile/src/components/MobileDiffReviewScreenView.tsx b/mobile/src/components/MobileDiffReviewScreenView.tsx index 3e68dd7c8f7..6fda33f76c5 100644 --- a/mobile/src/components/MobileDiffReviewScreenView.tsx +++ b/mobile/src/components/MobileDiffReviewScreenView.tsx @@ -61,7 +61,7 @@ export function MobileDiffReviewScreenView({ controller, onBack }: Props) { unsentCount={controller.unsentComments.length} worktreeLabel={controller.worktreeLabel} onBack={onBack} - onOpenActions={() => controller.setShowOverflow(true)} + onOpenActions={() => controller.openSheet({ kind: 'actions' })} onOpenPRSidebar={controller.openPRSidebar} onSelectFilter={controller.selectFilter} /> @@ -104,7 +104,7 @@ export function MobileDiffReviewScreenView({ controller, onBack }: Props) { busyAction={controller.busyAction} item={controller.currentItem} onAddFileNote={() => controller.openComposer(0)} - onDiscard={controller.setDiscardTarget} + onDiscard={(target) => controller.openSheet({ kind: 'discard', target })} onGitMutation={(method, item) => void controller.runGitMutation(method, item)} onMarkReviewed={() => void controller.markReviewed()} onMoveFile={controller.moveFile} diff --git a/mobile/src/components/WorktreeAgentRow.tsx b/mobile/src/components/WorktreeAgentRow.tsx index 8740d12eaed..cd8e10b3454 100644 --- a/mobile/src/components/WorktreeAgentRow.tsx +++ b/mobile/src/components/WorktreeAgentRow.tsx @@ -2,7 +2,12 @@ import { memo } from 'react' import { StyleSheet, Text, View } from 'react-native' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' import { colors, spacing } from '../theme/mobile-theme' -import { agentDisplayLabel, agentDotState, formatTimeAgo } from '../worktree/agent-row-display' +import { + agentDisplayLabel, + agentDotState, + agentRowTimeAt, + formatTimeAgo +} from '../worktree/agent-row-display' import { AgentStateDot } from './AgentStateDot' import { MobileAgentIcon } from './MobileAgentIcon' @@ -22,7 +27,7 @@ type Props = { function WorktreeAgentRowComponent({ agent, depth, now, unvisited }: Props) { const dotState = agentDotState(agent, now) const label = agentDisplayLabel(agent, now) - const ts = formatTimeAgo(agent.stateStartedAt, now) + const ts = formatTimeAgo(agentRowTimeAt(agent), now) return ( diff --git a/mobile/src/components/bottom-drawer-back-while-hiding.test.tsx b/mobile/src/components/bottom-drawer-back-while-hiding.test.tsx new file mode 100644 index 00000000000..a038560b44f --- /dev/null +++ b/mobile/src/components/bottom-drawer-back-while-hiding.test.tsx @@ -0,0 +1,151 @@ +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' + +type Timing = { to: number; callback?: (finished: boolean) => void } + +// Models Reanimated's contract: assigning a new animation cancels the running one, +// whose callback then gets finished=false. +const animations = vi.hoisted((): { running: Timing[] } => ({ running: [] })) + +vi.mock('../navigation/use-back-claim', () => ({ useBackClaim: () => {} })) +vi.mock('../platform/keyboard-occlusion', () => ({ + currentSoftKeyboardHeight: () => 0, + subscribeSoftKeyboard: () => () => {} +})) +vi.mock('react-native', () => ({ + Keyboard: { dismiss: () => {} }, + Modal: 'Modal', + Platform: { OS: 'android', select: (options: { android?: unknown }) => options.android }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: T) => styles, absoluteFillObject: {} }, + View: 'View', + useWindowDimensions: () => ({ width: 412, height: 900 }) +})) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 24, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', () => { + const chain: Record = {} + for (const method of [ + 'activeOffsetY', + 'simultaneousWithExternalGesture', + 'onBegin', + 'onUpdate', + 'onEnd' + ]) { + chain[method] = () => chain + } + return { + Gesture: { Pan: () => chain, Native: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +}) +vi.mock('react-native-reanimated', () => { + function isTiming(value: unknown): value is Timing { + return typeof value === 'object' && value !== null && 'to' in value + } + return { + default: { View: 'AnimatedView', ScrollView: 'AnimatedScrollView' }, + useSharedValue: (initial: number) => { + let value = initial + let current: Timing | null = null + return { + get value() { + return value + }, + set value(next: unknown) { + if (current) { + const cancelled = current + current = null + animations.running = animations.running.filter((entry) => entry !== cancelled) + cancelled.callback?.(false) + } + if (isTiming(next)) { + current = next + animations.running.push(next) + value = next.to + } else if (typeof next === 'number') { + value = next + } + } + } + }, + useAnimatedStyle: () => ({}), + useAnimatedScrollHandler: () => () => {}, + withSpring: (to: number) => to, + withTiming: (to: number, _config?: unknown, callback?: (finished: boolean) => void) => ({ + to, + callback + }), + runOnJS: (fn: () => void) => fn, + interpolate: () => 0, + Extrapolation: { CLAMP: 'clamp' } + } +}) + +import { MountedBottomDrawer } from './mounted-bottom-drawer' + +function finishAnimations(): void { + const finishing = animations.running + animations.running = [] + act(() => { + for (const animation of finishing) { + animation.callback?.(true) + } + }) +} + +function drawer(visible: boolean, onClose: () => void, onHidden: () => void) { + return ( + + {null} + + ) +} + +function pressAndroidBack(renderer: ReactTestRenderer): void { + act(() => renderer.root.find((node) => String(node.type) === 'Modal').props.onRequestClose()) +} + +describe('bottom drawer close request while hiding', () => { + afterEach(() => { + animations.running = [] + }) + + it('still closes an open drawer on Android Back', () => { + const onClose = vi.fn() + let renderer!: ReactTestRenderer + act(() => { + renderer = create(drawer(true, onClose, vi.fn())) + }) + finishAnimations() + + pressAndroidBack(renderer) + finishAnimations() + + expect(onClose).toHaveBeenCalledTimes(1) + act(() => renderer.unmount()) + }) + + // A Back press inside the hide animation used to restart it, so onHidden never fired + // and the drawer's invisible Modal stayed up, swallowing every tap on the screen. + it('lets the hide finish when Back is pressed mid-close', () => { + const onClose = vi.fn() + const onHidden = vi.fn() + let renderer!: ReactTestRenderer + act(() => { + renderer = create(drawer(true, onClose, onHidden)) + }) + finishAnimations() + act(() => renderer.update(drawer(false, onClose, onHidden))) + + pressAndroidBack(renderer) + finishAnimations() + + expect(onHidden).toHaveBeenCalledTimes(1) + expect(onClose).not.toHaveBeenCalled() + act(() => renderer.unmount()) + }) +}) diff --git a/mobile/src/components/bottom-drawer-close-lifecycle.test.ts b/mobile/src/components/bottom-drawer-close-lifecycle.test.ts index 94eb8a8495c..2e993af225c 100644 --- a/mobile/src/components/bottom-drawer-close-lifecycle.test.ts +++ b/mobile/src/components/bottom-drawer-close-lifecycle.test.ts @@ -98,4 +98,23 @@ describe('BottomDrawer close lifecycle', () => { expect(latestAfterClose).toHaveBeenCalledTimes(1) expect(renderer.toJSON()).toBeNull() }) + + // The hide finished and the drawer reopened before the scheduled JS callback ran; that late + // callback used to latch, so the next close never unmounted and its invisible Modal ate taps. + it('a hide that lands after a reopen does not swallow the next close', () => { + const onAfterClose = vi.fn() + const renderer = renderDrawer(true, vi.fn(), onAfterClose) + const lateOnHidden = mountedDrawer(renderer).props.onHidden + updateDrawer(renderer, false, vi.fn(), onAfterClose) + updateDrawer(renderer, true, vi.fn(), onAfterClose) + + act(() => lateOnHidden()) + expect(mountedDrawer(renderer).props.visible).toBe(true) + expect(onAfterClose).not.toHaveBeenCalled() + + updateDrawer(renderer, false, vi.fn(), onAfterClose) + act(() => mountedDrawer(renderer).props.onHidden()) + expect(renderer.toJSON()).toBeNull() + expect(onAfterClose).toHaveBeenCalledTimes(1) + }) }) diff --git a/mobile/src/components/keyed-bottom-drawer.test.tsx b/mobile/src/components/keyed-bottom-drawer.test.tsx new file mode 100644 index 00000000000..26d95521ce4 --- /dev/null +++ b/mobile/src/components/keyed-bottom-drawer.test.tsx @@ -0,0 +1,294 @@ +import { createElement, Profiler, useLayoutEffect, useState, type ReactElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' + +// iOS cannot present a native Modal while another is still presented, even mid-close. Each mounted +// MountedBottomDrawer is one native Modal; this mock records when each mounts and unmounts, in which +// commit, and which sheets it ever showed, so the tests can check the drawer's lifecycle directly. + +type Drawer = { + id: number + keys: Set + visible: boolean + onHidden: () => void + onClose: () => void +} +type Event = { type: 'mount' | 'unmount'; id: number; commit: number } + +type Modals = { + commit: number + nextId: number + all: Drawer[] + live: Map + events: Event[] +} + +const modals = vi.hoisted((): Modals => ({ + commit: 0, + nextId: 0, + all: [], + live: new Map(), + events: [] +})) + +vi.mock('./mounted-bottom-drawer', () => ({ + MountedBottomDrawer: function MockMountedBottomDrawer(props: { + visible: boolean + onHidden: () => void + onClose: () => void + children: ReactElement<{ name: string }> + }) { + const [drawer] = useState((): Drawer => ({ id: modals.nextId++, keys: new Set(), ...props })) + useLayoutEffect(() => { + drawer.visible = props.visible + drawer.onHidden = props.onHidden + drawer.onClose = props.onClose + drawer.keys.add(props.children.props.name) + }) + useLayoutEffect(() => { + modals.all.push(drawer) + modals.live.set(drawer.id, drawer) + modals.events.push({ type: 'mount', id: drawer.id, commit: modals.commit }) + return () => { + modals.live.delete(drawer.id) + modals.events.push({ type: 'unmount', id: drawer.id, commit: modals.commit }) + } + }, []) + return props.children + } +})) + +const { KeyedBottomDrawer } = await import('./keyed-bottom-drawer') + +type Sheet = { name: string; version: number } + +let renderer: ReactTestRenderer | null = null +let onAfterClose: Mock<(closed: Sheet) => void> + +function tree(sheet: Sheet | null, onClose: (presented: Sheet) => void = () => {}): ReactElement { + return ( + { + modals.commit++ + }} + > + + sheet={sheet} + sheetKey={(s) => s.name} + onClose={onClose} + onAfterClose={onAfterClose} + > + {(presented) => + createElement('SheetContent', { name: presented.name, version: presented.version }) + } + + + ) +} + +function request(sheet: Sheet | null): void { + act(() => { + if (renderer) { + renderer.update(tree(sheet)) + } else { + renderer = create(tree(sheet)) + } + }) +} + +function only(): Drawer | null { + expect(modals.live.size).toBeLessThanOrEqual(1) + return [...modals.live.values()][0] ?? null +} + +function presented(): { name: string; version: number; visible: boolean } | null { + const drawer = only() + if (!drawer) { + return null + } + const content = renderer!.root.find((node) => String(node.type) === 'SheetContent') + return { name: content.props.name, version: content.props.version, visible: drawer.visible } +} + +/** The native hide animation of the mounted drawer finished. */ +function finishHide(drawer = only()): void { + act(() => drawer?.onHidden()) +} + +function mounts(): Event[] { + return modals.events.filter((event) => event.type === 'mount') +} + +const A = { name: 'a', version: 1 } +const B = { name: 'b', version: 1 } +const C = { name: 'c', version: 1 } + +beforeEach(() => { + modals.commit = 0 + modals.nextId = 0 + modals.all = [] + modals.live.clear() + modals.events = [] + onAfterClose = vi.fn() +}) + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null +}) + +describe('KeyedBottomDrawer', () => { + it('presents a request at once when nothing is presented', () => { + request(null) + expect(presented()).toBeNull() + request(A) + expect(presented()).toEqual({ name: 'a', version: 1, visible: true }) + }) + + it('hides the presented sheet and shows the next only after its Modal has unmounted', () => { + request(A) + request(B) + // A keeps its content through the close; B is not mounted yet. + expect(presented()).toEqual({ name: 'a', version: 1, visible: false }) + + finishHide() + expect(presented()).toEqual({ name: 'b', version: 1, visible: true }) + expect(onAfterClose).toHaveBeenCalledExactlyOnceWith(A) + + const unmountA = modals.events.find((event) => event.type === 'unmount') + const mountB = mounts()[1]! + // A commit with no Modal at all lands between A leaving and B arriving. + expect(mountB.commit).toBeGreaterThan(unmountA!.commit) + }) + + it('presents the latest request after a close, never one replaced before it was shown', () => { + request(A) + request(B) + request(C) + finishHide() + expect(presented()?.name).toBe('c') + expect(modals.all.map((drawer) => [...drawer.keys])).toEqual([['a'], ['c']]) + }) + + it('a request cleared before the close finishes presents nothing afterwards', () => { + request(A) + request(B) + request(null) + finishHide() + expect(presented()).toBeNull() + expect(mounts()).toHaveLength(1) + }) + + it('reopening the same sheet mid-close re-shows the same Modal with the new content', () => { + request(A) + const drawer = only() + request(null) + expect(presented()?.visible).toBe(false) + request({ name: 'a', version: 2 }) + expect(presented()).toEqual({ name: 'a', version: 2, visible: true }) + expect(only()).toBe(drawer) + expect(mounts()).toHaveLength(1) + }) + + it('switching away and back to the presented sheet mid-close re-shows it', () => { + request(A) + request(B) + request(A) + expect(presented()).toEqual({ name: 'a', version: 1, visible: true }) + finishHide() + expect(presented()?.visible).toBe(true) + expect(mounts()).toHaveLength(1) + }) + + // The hide animation finishes, the sheet reopens before the scheduled JS callback runs, and then + // the callback lands: it must not unmount the shown sheet or swallow the next close. + it('ignores a hide that lands after a reopen and still honours the next close', () => { + request(A) + const drawer = only()! + request(null) + request(A) + finishHide(drawer) + expect(presented()).toEqual({ name: 'a', version: 1, visible: true }) + expect(onAfterClose).not.toHaveBeenCalled() + + request(B) + finishHide() + expect(presented()).toEqual({ name: 'b', version: 1, visible: true }) + expect(onAfterClose).toHaveBeenCalledExactlyOnceWith(A) + }) + + it('a hide from an earlier Modal cannot close a later one', () => { + request(A) + const first = only()! + request(null) + finishHide() + request(B) + request(null) + act(() => first.onHidden()) + expect(presented()).toEqual({ name: 'b', version: 1, visible: false }) + finishHide() + expect(presented()).toBeNull() + }) + + it('reports the close with the sheet that was presented', () => { + const onClose = vi.fn() + act(() => { + renderer = create(tree({ name: 'a', version: 3 }, onClose)) + }) + act(() => only()?.onClose()) + expect(onClose).toHaveBeenCalledExactlyOnceWith({ name: 'a', version: 3 }) + }) + + it('keeps one Modal, never swaps its sheet, and always settles on the latest request', () => { + const names = ['a', 'b', 'c'] + let seed = 11 + const random = (n: number) => { + seed = (seed * 48271) % 2147483647 + return seed % n + } + let latestName: string | null = null + let version = 0 + for (let step = 0; step < 600; step++) { + const move = random(5) + if (move === 2) { + finishHide() + } else if (move === 3) { + // A stale or early hide from whichever Modal is up. + act(() => only()?.onHidden()) + } else { + // A new request, or the same sheet again with fresh content. + const pick = random(names.length + 1) + latestName = move === 4 ? latestName : (names[pick] ?? null) + request(latestName === null ? null : { name: latestName, version: ++version }) + } + const shown = presented() + if (shown?.visible) { + expect(shown.name).toBe(latestName) + } + } + // Settle: finishing every pending hide lands on the latest request. + for (let i = 0; i < 3; i++) { + if (presented()?.visible === false) { + finishHide() + } + } + expect(presented()?.name ?? null).toBe(latestName) + + expect(modals.all.length).toBeGreaterThan(20) + for (const drawer of modals.all) { + expect(drawer.keys.size).toBe(1) + } + // Each Modal mounted in a later commit than the one before it left. + const ordered = modals.events + for (let i = 1; i < ordered.length; i++) { + const event = ordered[i]! + if (event.type === 'mount') { + const previous = ordered[i - 1]! + expect(previous.type).toBe('unmount') + expect(event.commit).toBeGreaterThan(previous.commit) + } + } + }) +}) diff --git a/mobile/src/components/keyed-bottom-drawer.tsx b/mobile/src/components/keyed-bottom-drawer.tsx new file mode 100644 index 00000000000..83265cc079b --- /dev/null +++ b/mobile/src/components/keyed-bottom-drawer.tsx @@ -0,0 +1,106 @@ +import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' +import { MountedBottomDrawer, type MountedBottomDrawerProps } from './mounted-bottom-drawer' + +// iOS cannot present a native Modal while another is still presented, even mid-close, so one +// drawer owns what is on screen and swaps sheets only after the previous Modal has unmounted. + +type Props = Omit & { + /** The sheet the screen wants shown, or null for none. */ + sheet: T | null + /** Sheets with the same key are one presentation: new content refreshes it in place. */ + sheetKey: (sheet: T) => string + onClose: (presented: T) => void + /** Runs once a sheet's Modal has unmounted, in the commit after it left the tree. */ + onAfterClose?: (closed: T) => void + children: (presented: T) => ReactNode +} + +type Presentation = { + presented: T | null + /** The presented sheet is animating closed. */ + closing: boolean + /** Just unmounted; nothing may present until a commit without its Modal has landed. */ + released: T | null + /** Identifies one mounted Modal so a hide from an earlier one cannot close a later one. */ + epoch: number +} + +export function KeyedBottomDrawer({ + sheet, + sheetKey, + onClose, + onAfterClose, + children, + ...drawerProps +}: Props) { + const [state, setState] = useState>(() => ({ + presented: sheet, + closing: false, + released: null, + epoch: 0 + })) + const onAfterCloseRef = useRef(onAfterClose) + useEffect(() => { + onAfterCloseRef.current = onAfterClose + }, [onAfterClose]) + + const next = followRequest(state, sheet, sheetKey) + // Why: present in the same render as the request so opening does not add a blank commit. + if (next !== state) { + setState(next) + } + + const { epoch, released } = next + const handleHidden = useCallback(() => { + // Why: a hide that finished before a reopen re-showed this sheet must not unmount it. + setState((current) => + current.epoch === epoch && current.closing + ? { presented: null, closing: false, released: current.presented, epoch: epoch + 1 } + : current + ) + }, [epoch]) + + useEffect(() => { + if (released === null) { + return + } + onAfterCloseRef.current?.(released) + setState((current) => + current.released === released ? { ...current, released: null } : current + ) + }, [released]) + + const presented = next.presented + if (presented === null) { + return null + } + return ( + onClose(presented)} + onHidden={handleHidden} + > + {children(presented)} + + ) +} + +function followRequest( + state: Presentation, + sheet: T | null, + sheetKey: (sheet: T) => string +): Presentation { + const { presented } = state + if (presented === null) { + return sheet !== null && state.released === null + ? { ...state, presented: sheet, closing: false } + : state + } + if (sheet !== null && sheetKey(sheet) === sheetKey(presented)) { + return sheet !== presented || state.closing + ? { ...state, presented: sheet, closing: false } + : state + } + return state.closing ? state : { ...state, closing: true } +} diff --git a/mobile/src/components/mobile-agent-icon-assets.ts b/mobile/src/components/mobile-agent-icon-assets.ts index 473b557096e..687c9d5824e 100644 --- a/mobile/src/components/mobile-agent-icon-assets.ts +++ b/mobile/src/components/mobile-agent-icon-assets.ts @@ -40,6 +40,7 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial ({ + ActivityIndicator: 'ActivityIndicator', + KeyboardAvoidingView: 'KeyboardAvoidingView', + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View' +})) +vi.mock('lucide-react-native', () => ({ + Check: 'Check', + Copy: 'Copy', + Edit3: 'Edit3', + FileText: 'FileText', + Plus: 'Plus', + Send: 'Send', + Trash2: 'Trash2', + X: 'X' +})) +vi.mock('expo-haptics', () => ({ + impactAsync: vi.fn(async () => {}), + notificationAsync: vi.fn(async () => {}), + selectionAsync: vi.fn(async () => {}), + performAndroidHapticsAsync: vi.fn(async () => {}), + AndroidHaptics: {}, + ImpactFeedbackStyle: {}, + NotificationFeedbackType: {} +})) +vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) +vi.mock('../platform/keyboard-occlusion', () => ({ useKeyboardAvoidingPadding: () => 0 })) +vi.mock('./mobile-diff-review-screen-styles', () => ({ + mobileDiffReviewStyles: new Proxy({}, { get: () => ({}) }) +})) +vi.mock('./mounted-bottom-drawer', () => ({ MountedBottomDrawer: 'MountedBottomDrawer' })) +const loadSnapshot = vi.hoisted(() => vi.fn()) +vi.mock('../session/mobile-diff-review-loaders', () => ({ + loadMobileDiffReviewSnapshot: loadSnapshot, + loadMobileDiffReviewDiff: vi.fn().mockResolvedValue({ kind: 'idle' }) +})) +vi.mock('../session/use-mobile-pr-sidebar-controller', () => ({ + useMobilePrSidebarController: () => ({}) +})) + +const { MobileDiffReviewDrawers } = await import('./MobileDiffReviewDrawers') +const { useMobileDiffReviewController } = + await import('../session/use-mobile-diff-review-controller') + +type Controller = ReturnType +type Deferred = { promise: Promise; resolve: (value: unknown) => void } + +function deferred(): Deferred { + let resolve: (value: unknown) => void = () => {} + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +const NOTE: DiffComment = { + id: 'note-1', + worktreeId: 'wt-1', + filePath: 'src/a.ts', + lineNumber: 3, + body: 'rename this', + createdAt: 1, + side: 'modified' +} + +const SNAPSHOT: ReviewScreenState = { + kind: 'ready', + status: { + entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }], + conflictOperation: undefined, + upstreamStatus: undefined, + branch: 'feature', + head: 'abc123' + }, + branchCompare: null, + comments: [NOTE], + reviewState: { version: 1, files: {} } +} + +const TABS_REPLY = { + id: 'tabs', + ok: true, + result: { tabs: [{ type: 'terminal', id: 'tab-1', terminal: 'terminal-1', title: 'codex' }] }, + _meta: { runtimeId: 'runtime' } +} +const SAVE_REPLY = { id: 'save', ok: true, result: {}, _meta: { runtimeId: 'runtime' } } + +let renderer: ReactTestRenderer | null = null +let controller: Controller +let replies: Map + +function Screen({ client }: { client: RpcClient }) { + controller = useMobileDiffReviewController({ + client, + connState: 'connected', + hostId: 'host-1', + worktreeId: 'wt-1', + name: 'review', + initialFilter: 'all', + initialTarget: null, + onOpenSession: () => {}, + onReconnect: null + }) + return createElement(MobileDiffReviewDrawers, { controller }) +} + +/** Every RPC waits until the test answers it, so each case controls when a load or save lands. */ +async function mountScreen(): Promise { + replies = new Map() + const sendRequest = vi.fn((method: string) => { + const reply = deferred() + replies.set(method, reply) + return reply.promise + }) + loadSnapshot.mockResolvedValue(SNAPSHOT) + await act(async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the operations under test only call `sendRequest`. + renderer = create(createElement(Screen, { client: { sendRequest } as unknown as RpcClient })) + await Promise.resolve() + }) + expect(controller.currentItem).not.toBeNull() +} + +async function answer(method: string, reply: unknown): Promise { + const pending = replies.get(method) + expect(pending, `${method} was never requested`).toBeDefined() + await act(async () => { + pending?.resolve(reply) + await new Promise((settle) => setTimeout(settle, 0)) + }) +} + +function textOf(node: ReactTestInstance): string[] { + return node + .findAll((child) => String(child.type) === 'Text') + .flatMap((text) => text.children.filter((child) => typeof child === 'string')) +} + +/** Every mounted native drawer: each is its own Modal, so there must never be two. */ +function mountedDrawers(): ReactTestInstance[] { + return renderer!.root.findAll((node) => String(node.type) === 'MountedBottomDrawer') +} + +/** The mounted sheet, which must carry this title (the first text it renders). */ +function drawer(title: string): ReactTestInstance { + const [mounted, ...others] = mountedDrawers() + expect(others).toEqual([]) + expect(mounted && textOf(mounted)[0]).toBe(title) + return mounted! +} + +function shownSheets(): string[] { + const mounted = mountedDrawers() + expect(mounted.length).toBeLessThanOrEqual(1) + return mounted.filter((node) => node.props.visible === true).map((node) => textOf(node)[0]!) +} + +function press(within: ReactTestInstance, label: string): void { + const target = within.find( + (node) => + String(node.type) === 'Pressable' && + (node.props.accessibilityLabel === label || textOf(node).includes(label)) + ) + act(() => target.props.onPress()) +} + +/** The drawer's native hide animation finished. */ +function finishClosing(title: string): void { + const closing = drawer(title) + expect(closing.props.visible).toBe(false) + act(() => closing.props.onHidden()) +} + +/** Marks the only file reviewed; the save stays in flight until `answer('worktree.set')`. */ +function startMarkReviewed(): Promise { + let marking: Promise = Promise.resolve() + act(() => { + marking = controller.markReviewed() + }) + return marking +} + +async function completeReview(): Promise { + const marking = startMarkReviewed() + await answer('worktree.set', SAVE_REPLY) + await act(() => marking) +} + +beforeEach(() => { + loadSnapshot.mockReset() +}) + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null +}) + +describe('review screen sheets never stack', () => { + it('Send Unsent Notes shows Send Notes only after Review Actions has closed', async () => { + await mountScreen() + act(() => controller.openSheet({ kind: 'actions' })) + expect(shownSheets()).toEqual(['Review Actions']) + + press(drawer('Review Actions'), 'Send Unsent Notes') + expect(shownSheets()).toEqual([]) + + await answer('session.tabs.list', TABS_REPLY) + expect(shownSheets()).toEqual([]) + + finishClosing('Review Actions') + expect(shownSheets()).toEqual(['Send Notes']) + expect(textOf(drawer('Send Notes'))).toContain('codex (termin)') + }) + + it('Review Complete → Send shows Send Notes only after Review Complete has closed', async () => { + await mountScreen() + await completeReview() + expect(shownSheets()).toEqual(['Review Complete']) + + press(drawer('Review Complete'), 'Send notes to agent') + expect(shownSheets()).toEqual([]) + + finishClosing('Review Complete') + expect(shownSheets()).toEqual(['Send Notes']) + }) + + it('does not open Send Notes when Review Complete closes for another reason', async () => { + await mountScreen() + await completeReview() + + act(() => drawer('Review Complete').props.onClose()) + finishClosing('Review Complete') + + expect(shownSheets()).toEqual([]) + }) + + it('Review Complete arriving while another sheet is open waits for that sheet to close', async () => { + await mountScreen() + const marking = startMarkReviewed() + // The user opens Review Actions while the save is still in flight. + act(() => controller.openSheet({ kind: 'actions' })) + await answer('worktree.set', SAVE_REPLY) + await act(() => marking) + expect(shownSheets()).toEqual(['Review Actions']) + + act(() => drawer('Review Actions').props.onClose()) + expect(shownSheets()).toEqual([]) + + finishClosing('Review Actions') + expect(shownSheets()).toEqual(['Review Complete']) + }) + + it('a sheet replaced before it was ever shown is never mounted', async () => { + await mountScreen() + act(() => { + controller.openSheet({ kind: 'completion' }) + controller.openSheet({ kind: 'actions' }) + }) + expect(shownSheets()).toEqual(['Review Actions']) + + act(() => { + controller.openSheet({ kind: 'completion' }) + controller.closeSheet('completion') + }) + expect(shownSheets()).toEqual([]) + finishClosing('Review Actions') + expect(mountedDrawers()).toEqual([]) + }) + + it('a send list that lands after Send Notes was dismissed does not bring it back', async () => { + await mountScreen() + act(() => void controller.openSendSheet()) + expect(shownSheets()).toEqual(['Send Notes']) + + act(() => drawer('Send Notes').props.onClose()) + await answer('session.tabs.list', TABS_REPLY) + finishClosing('Send Notes') + expect(mountedDrawers()).toEqual([]) + + act(() => controller.openSheet({ kind: 'actions' })) + expect(shownSheets()).toEqual(['Review Actions']) + }) + + it('Discard keeps its file through the close and discards the file it showed', async () => { + await mountScreen() + const target = controller.currentItem! + act(() => controller.openSheet({ kind: 'discard', target })) + press(drawer('Discard File'), 'Discard') + expect(shownSheets()).toEqual([]) + expect(textOf(drawer('Discard File')).join(' ')).toContain(target.filePath) + expect(replies.has('git.discard')).toBe(true) + }) +}) diff --git a/mobile/src/components/mounted-bottom-drawer.tsx b/mobile/src/components/mounted-bottom-drawer.tsx index ea85c320861..d42fce7912a 100644 --- a/mobile/src/components/mounted-bottom-drawer.tsx +++ b/mobile/src/components/mounted-bottom-drawer.tsx @@ -185,13 +185,18 @@ export function MountedBottomDrawer({ }, [visible, interactive, insets.bottom, fillAvailable]) const dismiss = useCallback(() => { + // Why: restarting the hide animation cancels it, so onHidden never fires and the + // invisible Modal stays up swallowing taps (Android Back lands here mid-close). + if (!visible) { + return + } Keyboard.dismiss() progress.value = withTiming(0, { duration: BOTTOM_DRAWER_HIDE_DURATION_MS }, (finished) => { if (finished) { runOnJS(onClose)() } }) - }, [onClose, progress]) + }, [onClose, progress, visible]) // One seam, both platforms: natively this is the hardware key, and inside the shell's page it is // a claim the shell hands one press over on. Every session sheet renders through this component, diff --git a/mobile/src/session/mobile-diff-review-sheets.test.ts b/mobile/src/session/mobile-diff-review-sheets.test.ts new file mode 100644 index 00000000000..2a0f70c28df --- /dev/null +++ b/mobile/src/session/mobile-diff-review-sheets.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest' +import type { MobileDiffReviewQueueItem } from './mobile-diff-review-queue' +import type { SendSheetState } from './mobile-diff-review-screen-model' +import { + NO_REVIEW_SHEETS, + reduceReviewSheets, + reviewComposer, + type ReviewSheet, + type ReviewSheetsAction, + type ReviewSheetsState +} from './mobile-diff-review-sheets' + +const ACTIONS: ReviewSheet = { kind: 'actions' } +const COMPLETION: ReviewSheet = { kind: 'completion' } +const SEND_LOADING: ReviewSheet = { kind: 'send', load: { kind: 'loading' } } +const COMPOSER: ReviewSheet = { kind: 'composer', composer: { mode: 'create', lineNumber: 4 } } +const READY: SendSheetState = { kind: 'ready', terminals: [] } +const DISCARD_TARGET: MobileDiffReviewQueueItem = { + key: 'unstaged:src/a.ts', + scope: 'unstaged', + area: 'unstaged', + filePath: 'src/a.ts', + status: 'modified', + title: 'a.ts', + subtitle: 'src', + canStage: true, + canUnstage: false, + canDiscard: true, + isGeneratedOrLockFile: false, + diffIdentity: 'identity-1', + noteCount: 0, + unsentNoteCount: 0, + staleNoteCount: 0, + isReviewed: false, + changedSinceReview: false +} + +function run(...actions: ReviewSheetsAction[]): ReviewSheetsState { + return actions.reduce(reduceReviewSheets, NO_REVIEW_SHEETS) +} + +const open = (sheet: ReviewSheet): ReviewSheetsAction => ({ type: 'open', sheet }) +const openWhenIdle = (sheet: ReviewSheet): ReviewSheetsAction => ({ type: 'openWhenIdle', sheet }) +const close = (kind: ReviewSheet['kind']): ReviewSheetsAction => ({ type: 'close', kind }) +const updateSend = (load: SendSheetState): ReviewSheetsAction => ({ type: 'updateSend', load }) +const DISCARD: ReviewSheet = { kind: 'discard', target: DISCARD_TARGET } + +describe('review screen sheet requests', () => { + it('the newest request wins', () => { + expect(run(open(ACTIONS))).toEqual({ requested: ACTIONS, deferred: null }) + expect(run(open(ACTIONS), open(SEND_LOADING), open(COMPOSER))).toEqual({ + requested: COMPOSER, + deferred: null + }) + }) + + it('closing a sheet clears the request only for that sheet', () => { + expect(run(open(DISCARD), close('discard'))).toEqual(NO_REVIEW_SHEETS) + const state = run(open(ACTIONS)) + expect(reduceReviewSheets(state, close('send'))).toBe(state) + }) + + it('exposes the requested composer only', () => { + expect(reviewComposer(run(open(COMPOSER)))).toEqual({ mode: 'create', lineNumber: 4 }) + expect(reviewComposer(run(open(COMPOSER), close('composer')))).toBeNull() + }) + + it('openWhenIdle opens at once when nothing is requested', () => { + expect(run(openWhenIdle(COMPLETION))).toEqual({ requested: COMPLETION, deferred: null }) + }) + + it('openWhenIdle waits behind the user sheet and opens when it closes', () => { + const waiting = run(open(ACTIONS), openWhenIdle(COMPLETION)) + expect(waiting).toEqual({ requested: ACTIONS, deferred: COMPLETION }) + expect(reduceReviewSheets(waiting, close('actions'))).toEqual({ + requested: COMPLETION, + deferred: null + }) + }) + + // Review Complete → Send Notes, then a second Mark Reviewed save lands while Review Complete is + // still closing: Send Notes must stay the request. + it('a late Review Complete never displaces a sheet the user asked for', () => { + const state = run(open(COMPLETION), open(SEND_LOADING), openWhenIdle(COMPLETION)) + expect(state.requested).toEqual(SEND_LOADING) + expect(run(open(ACTIONS), open(SEND_LOADING), openWhenIdle(COMPLETION)).requested).toEqual( + SEND_LOADING + ) + }) + + it('openWhenIdle does not repeat a sheet that is already requested or waiting', () => { + const shown = run(openWhenIdle(COMPLETION)) + expect(reduceReviewSheets(shown, openWhenIdle(COMPLETION))).toBe(shown) + const waiting = run(open(ACTIONS), openWhenIdle(COMPLETION)) + expect(reduceReviewSheets(waiting, openWhenIdle(COMPLETION))).toBe(waiting) + }) + + it('moving to another sheet drops a waiting background sheet', () => { + expect(run(open(ACTIONS), openWhenIdle(COMPLETION), open(SEND_LOADING))).toEqual({ + requested: SEND_LOADING, + deferred: null + }) + }) + + it('refreshing the requested sheet keeps a waiting background sheet', () => { + const edit: ReviewSheet = { kind: 'composer', composer: { mode: 'create', lineNumber: 9 } } + expect(run(open(COMPOSER), openWhenIdle(COMPLETION), open(edit))).toEqual({ + requested: edit, + deferred: COMPLETION + }) + }) + + it('closing a waiting background sheet leaves the user sheet alone', () => { + expect(run(open(ACTIONS), openWhenIdle(COMPLETION), close('completion'))).toEqual({ + requested: ACTIONS, + deferred: null + }) + }) + + it('updateSend fills a requested Send Notes', () => { + expect(run(open(SEND_LOADING), updateSend(READY)).requested).toEqual({ + kind: 'send', + load: READY + }) + }) + + it('a send list that lands after Send Notes was dismissed does not bring it back', () => { + const dismissed = run(open(SEND_LOADING), close('send')) + expect(reduceReviewSheets(dismissed, updateSend(READY))).toBe(dismissed) + const moved = run(open(SEND_LOADING), open(ACTIONS)) + expect(reduceReviewSheets(moved, updateSend(READY))).toBe(moved) + }) + + it('never waits a sheet behind nothing or behind its own kind', () => { + const sheets = [ACTIONS, COMPLETION, SEND_LOADING, COMPOSER, DISCARD] + const actions: ReviewSheetsAction[] = [updateSend(READY)] + for (const sheet of sheets) { + actions.push(open(sheet), openWhenIdle(sheet), close(sheet.kind)) + } + let seed = 7 + let state = NO_REVIEW_SHEETS + for (let step = 0; step < 2000; step++) { + seed = (seed * 48271) % 2147483647 + state = reduceReviewSheets(state, actions[seed % actions.length]!) + if (state.deferred !== null) { + expect(state.requested).not.toBeNull() + expect(state.deferred.kind).not.toBe(state.requested?.kind) + } + } + }) +}) diff --git a/mobile/src/session/mobile-diff-review-sheets.ts b/mobile/src/session/mobile-diff-review-sheets.ts new file mode 100644 index 00000000000..29a87f478c7 --- /dev/null +++ b/mobile/src/session/mobile-diff-review-sheets.ts @@ -0,0 +1,86 @@ +import type { MobileDiffReviewQueueItem } from './mobile-diff-review-queue' +import type { ComposerState, SendSheetState } from './mobile-diff-review-screen-model' + +// Which review sheet the user wants; the screen's one keyed drawer decides when it can be shown. + +export type ReviewSheet = + | { kind: 'actions' } + | { kind: 'send'; load: SendSheetState } + | { kind: 'discard'; target: MobileDiffReviewQueueItem } + | { kind: 'composer'; composer: ComposerState } + | { kind: 'completion' } + +export type ReviewSheetKind = ReviewSheet['kind'] + +export type ReviewSheetsState = { + requested: ReviewSheet | null + /** A background sheet waiting for `requested` to close; never of the same kind. */ + deferred: ReviewSheet | null +} + +export type ReviewSheetsAction = + | { type: 'open'; sheet: ReviewSheet } + | { type: 'openWhenIdle'; sheet: ReviewSheet } + | { type: 'close'; kind: ReviewSheetKind } + | { type: 'updateSend'; load: SendSheetState } + +export const NO_REVIEW_SHEETS: ReviewSheetsState = { requested: null, deferred: null } + +export function reduceReviewSheets( + state: ReviewSheetsState, + action: ReviewSheetsAction +): ReviewSheetsState { + const { requested, deferred } = state + switch (action.type) { + case 'open': + // Why: the newest request wins; a background sheet only survives a refresh of the same sheet. + return { + requested: action.sheet, + deferred: requested?.kind === action.sheet.kind ? deferred : null + } + case 'openWhenIdle': + if (!requested) { + return { requested: action.sheet, deferred: null } + } + // Why: a background opener waits behind the user's sheet and never displaces it. + if (deferred || requested.kind === action.sheet.kind) { + return state + } + return { requested, deferred: action.sheet } + case 'close': + if (requested?.kind === action.kind) { + return { requested: deferred, deferred: null } + } + if (deferred?.kind === action.kind) { + return { requested, deferred: null } + } + return state + case 'updateSend': + // Why: a list that resolves after Send Notes was dismissed must not bring it back. + if (requested?.kind !== 'send') { + return state + } + return { requested: { kind: 'send', load: action.load }, deferred } + } +} + +export function reviewComposer(state: ReviewSheetsState): ComposerState | null { + return state.requested?.kind === 'composer' ? state.requested.composer : null +} + +/** The only ways callers change the review screen's sheets. */ +export function reviewSheetIntents(dispatch: (action: ReviewSheetsAction) => void) { + return { + openSheet: (sheet: ReviewSheet) => dispatch({ type: 'open', sheet }), + /** For async openers: waits for the user's sheet to close instead of closing it. */ + openSheetWhenIdle: (sheet: ReviewSheet) => dispatch({ type: 'openWhenIdle', sheet }), + closeSheet: (kind: ReviewSheetKind) => dispatch({ type: 'close', kind }), + updateSendSheet: (load: SendSheetState) => dispatch({ type: 'updateSend', load }) + } +} + +export type ReviewSheetIntents = ReturnType + +export function reviewSheetKey(sheet: ReviewSheet): ReviewSheetKind { + return sheet.kind +} diff --git a/mobile/src/session/mobile-worker-takeover-send-sites.test.ts b/mobile/src/session/mobile-worker-takeover-send-sites.test.ts index 1c13100e1c8..d018495fdfa 100644 --- a/mobile/src/session/mobile-worker-takeover-send-sites.test.ts +++ b/mobile/src/session/mobile-worker-takeover-send-sites.test.ts @@ -110,13 +110,14 @@ function mountSendSites(client: ReturnType, handle = 'term onSuccess: vi.fn(), refreshCanPaste: vi.fn() } as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: untyped vi.fn() stubs stand in for callbacks whose returns the send path never reads. diff = useMobileDiffReviewSendActions({ client: client as unknown as RpcClient, connState: 'connected', worktreeId: 'workspace', screenState: { kind: 'loading' }, setActionError: vi.fn(), - setSendSheet: vi.fn(), + sheets: { openSheet: vi.fn(), closeSheet: vi.fn(), updateSendSheet: vi.fn() }, saveCommentsAndReviewState: vi.fn() } as never) return null diff --git a/mobile/src/session/use-mobile-diff-review-comment-actions.ts b/mobile/src/session/use-mobile-diff-review-comment-actions.ts index d49350f1519..b5e2df91e16 100644 --- a/mobile/src/session/use-mobile-diff-review-comment-actions.ts +++ b/mobile/src/session/use-mobile-diff-review-comment-actions.ts @@ -21,6 +21,7 @@ import { nextReviewIndexAfterMarkReviewed, reviewDescriptorFromItem } from './mobile-diff-review-screen-model' +import type { ReviewSheetIntents } from './mobile-diff-review-sheets' type CommentActionsInput = { client: RpcClient | null @@ -36,10 +37,9 @@ type CommentActionsInput = { composerBody: string setScreenState: Dispatch> setCurrentIndex: Dispatch> - setComposer: Dispatch> setComposerBody: Dispatch> setActionError: Dispatch> - setShowCompletion: Dispatch> + sheets: Pick } export function useMobileDiffReviewCommentActions(input: CommentActionsInput) { @@ -57,11 +57,11 @@ export function useMobileDiffReviewCommentActions(input: CommentActionsInput) { composerBody, setScreenState, setCurrentIndex, - setComposer, setComposerBody, setActionError, - setShowCompletion + sheets } = input + const { openSheet, openSheetWhenIdle, closeSheet } = sheets const persistMetadata = useCallback( async (comments: readonly DiffComment[], reviewState: MobileDiffReviewState) => { @@ -109,24 +109,24 @@ export function useMobileDiffReviewCommentActions(input: CommentActionsInput) { const openComposer = useCallback( (lineNumber: number) => { - setComposer({ mode: 'create', lineNumber }) + openSheet({ kind: 'composer', composer: { mode: 'create', lineNumber } }) setComposerBody('') }, - [setComposer, setComposerBody] + [openSheet, setComposerBody] ) const openEditComposer = useCallback( (comment: DiffComment) => { - setComposer({ mode: 'edit', comment }) + openSheet({ kind: 'composer', composer: { mode: 'edit', comment } }) setComposerBody(comment.body) }, - [setComposer, setComposerBody] + [openSheet, setComposerBody] ) const closeComposer = useCallback(() => { - setComposer(null) + closeSheet('composer') setComposerBody('') - }, [setComposer, setComposerBody]) + }, [closeSheet, setComposerBody]) const saveComposer = useCallback(async () => { if (!composer || !currentItem || screenState.kind !== 'ready') { @@ -201,18 +201,19 @@ export function useMobileDiffReviewCommentActions(input: CommentActionsInput) { if (nextIndex !== null) { setCurrentIndex(nextIndex) } else { - setShowCompletion(true) + // Why: the save can outlast a sheet the user opened meanwhile; never stack on or close it. + openSheetWhenIdle({ kind: 'completion' }) } }, [ currentIndex, currentItem, filter, filteredQueue, + openSheetWhenIdle, queue, saveCommentsAndReviewState, screenState, - setCurrentIndex, - setShowCompletion + setCurrentIndex ]) const markUnreviewed = useCallback(async () => { diff --git a/mobile/src/session/use-mobile-diff-review-controller.ts b/mobile/src/session/use-mobile-diff-review-controller.ts index 1efab1fe4ee..3ce6481dc2d 100644 --- a/mobile/src/session/use-mobile-diff-review-controller.ts +++ b/mobile/src/session/use-mobile-diff-review-controller.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import type { FlatList } from 'react-native' import type { DiffComment } from '../../../src/shared/diff-comment-types' import type { ConnectionState } from '../transport/types' @@ -10,8 +10,7 @@ import { filterMobileDiffReviewQueue, mobileDiffReviewCommentMatchesItem, summarizeMobileDiffReviewQueue, - type MobileDiffReviewQueueFilter, - type MobileDiffReviewQueueItem + type MobileDiffReviewQueueFilter } from './mobile-diff-review-queue' import { findMobileDiffReviewInitialIndex, @@ -20,12 +19,13 @@ import { import { loadMobileDiffReviewSnapshot } from './mobile-diff-review-loaders' import { useMobileDiffReviewDiffLoading } from './use-mobile-diff-review-diff-loading' import { canOpenMobileBranchCompareDiff } from '../source-control/mobile-branch-compare' -import type { - ComposerState, - ReviewDiffLine, - ReviewScreenState, - SendSheetState -} from './mobile-diff-review-screen-model' +import type { ReviewDiffLine, ReviewScreenState } from './mobile-diff-review-screen-model' +import { + NO_REVIEW_SHEETS, + reduceReviewSheets, + reviewComposer, + reviewSheetIntents +} from './mobile-diff-review-sheets' import { useMobileDiffReviewInteractions } from './use-mobile-diff-review-interactions' import { useMobilePrSidebarController } from './use-mobile-pr-sidebar-controller' @@ -62,14 +62,12 @@ export function useMobileDiffReviewController(input: ControllerInput) { const [filter, setFilter] = useState(initialFilter) const [currentIndex, setCurrentIndex] = useState(0) const [activeHunkIndex, setActiveHunkIndex] = useState(null) - const [composer, setComposer] = useState(null) + const [sheets, dispatchSheets] = useReducer(reduceReviewSheets, NO_REVIEW_SHEETS) + const sheetIntents = useMemo(() => reviewSheetIntents(dispatchSheets), []) + const composer = reviewComposer(sheets) const [composerBody, setComposerBody] = useState('') const [actionError, setActionError] = useState(null) const [busyAction, setBusyAction] = useState(null) - const [discardTarget, setDiscardTarget] = useState(null) - const [showOverflow, setShowOverflow] = useState(false) - const [sendSheet, setSendSheet] = useState(null) - const [showCompletion, setShowCompletion] = useState(false) const worktreeLabel = getWorktreeLabel(name, worktreeId) const loadReviewData = useCallback(async () => { @@ -244,12 +242,10 @@ export function useMobileDiffReviewController(input: ControllerInput) { setFilter, setCurrentIndex, setActiveHunkIndex, - setComposer, setComposerBody, setActionError, setBusyAction, - setSendSheet, - setShowCompletion, + sheets: sheetIntents, loadReviewData, onOpenSession, onReconnect @@ -258,6 +254,7 @@ export function useMobileDiffReviewController(input: ControllerInput) { return { ...interactions, ...prSidebar, + ...sheetIntents, // Exposed so the screen can thread the RPC client + worktree into the PR // sidebar's lazy check-detail fetches (U5) and mutation actions (U6). client, @@ -274,7 +271,6 @@ export function useMobileDiffReviewController(input: ControllerInput) { currentIndex, currentItem, diffState, - discardTarget, fileNotes: commentsByLine.get(0) ?? [], filter, filteredQueue, @@ -283,14 +279,8 @@ export function useMobileDiffReviewController(input: ControllerInput) { reviewedCount, reviewedUnstagedCount, screenState, - sendSheet, setComposerBody, - setDiscardTarget, - setSendSheet, - setShowCompletion, - setShowOverflow, - showCompletion, - showOverflow, + sheet: sheets.requested, staleCommentIds, unsentComments, worktreeLabel diff --git a/mobile/src/session/use-mobile-diff-review-interactions.ts b/mobile/src/session/use-mobile-diff-review-interactions.ts index 53dc69fadad..1f8c78e1f19 100644 --- a/mobile/src/session/use-mobile-diff-review-interactions.ts +++ b/mobile/src/session/use-mobile-diff-review-interactions.ts @@ -12,9 +12,9 @@ import type { ComposerState, ReviewDiffLine, ReviewDiffState, - ReviewScreenState, - SendSheetState + ReviewScreenState } from './mobile-diff-review-screen-model' +import type { ReviewSheetIntents } from './mobile-diff-review-sheets' import { sourceFileDiffOpenRun } from '../source-control/mobile-source-file-open-operations' import { refusedRpcMessageOrFallback } from '../transport/rpc-refusal-message' import { useMobileDiffReviewCommentActions } from './use-mobile-diff-review-comment-actions' @@ -42,12 +42,10 @@ type InteractionInput = { setFilter: Dispatch> setCurrentIndex: Dispatch> setActiveHunkIndex: Dispatch> - setComposer: Dispatch> setComposerBody: Dispatch> setActionError: Dispatch> setBusyAction: Dispatch> - setSendSheet: Dispatch> - setShowCompletion: Dispatch> + sheets: ReviewSheetIntents loadReviewData: () => Promise onOpenSession: () => void onReconnect: ((hostId: string) => void | Promise) | null @@ -74,12 +72,10 @@ export function useMobileDiffReviewInteractions(input: InteractionInput) { setFilter, setCurrentIndex, setActiveHunkIndex, - setComposer, setComposerBody, setActionError, setBusyAction, - setSendSheet, - setShowCompletion, + sheets, loadReviewData, onOpenSession, onReconnect @@ -108,10 +104,9 @@ export function useMobileDiffReviewInteractions(input: InteractionInput) { composerBody, setScreenState, setCurrentIndex, - setComposer, setComposerBody, setActionError, - setShowCompletion + sheets }) const { runGitMutation, stageReviewedFiles } = useMobileDiffReviewGitActions({ @@ -131,7 +126,7 @@ export function useMobileDiffReviewInteractions(input: InteractionInput) { worktreeId, screenState, setActionError, - setSendSheet, + sheets, saveCommentsAndReviewState }) diff --git a/mobile/src/session/use-mobile-diff-review-send-actions.test.ts b/mobile/src/session/use-mobile-diff-review-send-actions.test.ts index f763a8f3078..ab41abb6b4b 100644 --- a/mobile/src/session/use-mobile-diff-review-send-actions.test.ts +++ b/mobile/src/session/use-mobile-diff-review-send-actions.test.ts @@ -1,9 +1,10 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import type { DiffComment } from '../../../src/shared/diff-comment-types' import type { RpcClient } from '../transport/rpc-client' import type { ReviewScreenState } from './mobile-diff-review-screen-model' +import type { ReviewSheetIntents } from './mobile-diff-review-sheets' import { isMobileNativeChatInputStale, markMobileNativeChatInputStale, @@ -51,14 +52,18 @@ describe('useMobileDiffReviewSendActions', () => { let actions: SendActions | null = null let mountedClient: RpcClient | null = null let setActionError: ReturnType - let setSendSheet: ReturnType + let sheets: { + openSheet: Mock + closeSheet: Mock + updateSendSheet: Mock + } let saveCommentsAndReviewState: ReturnType beforeEach(() => { clipboardMock.setStringAsync.mockReset().mockResolvedValue(true) resetMobileNativeChatStaleInputForTests() setActionError = vi.fn() - setSendSheet = vi.fn() + sheets = { openSheet: vi.fn(), closeSheet: vi.fn(), updateSendSheet: vi.fn() } saveCommentsAndReviewState = vi.fn().mockResolvedValue(undefined) }) @@ -76,7 +81,7 @@ describe('useMobileDiffReviewSendActions', () => { worktreeId: 'wt-1', screenState: READY, setActionError, - setSendSheet, + sheets, saveCommentsAndReviewState }) return null @@ -158,7 +163,7 @@ describe('useMobileDiffReviewSendActions', () => { expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '\x15', enter: false }) expect(saveCommentsAndReviewState).not.toHaveBeenCalled() expect(setActionError).not.toHaveBeenCalled() - expect(setSendSheet).not.toHaveBeenCalled() + expect(sheets.closeSheet).not.toHaveBeenCalled() // Marker survives for the next attempt. expect(isMobileNativeChatInputStale('terminal-1')).toBe(true) }) @@ -192,7 +197,7 @@ describe('useMobileDiffReviewSendActions', () => { expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ terminal: 'terminal-1', enter: true }) expect(saveCommentsAndReviewState).toHaveBeenCalledTimes(1) expect(setActionError).toHaveBeenCalledWith('Review notes sent') - expect(setSendSheet).toHaveBeenCalledWith(null) + expect(sheets.closeSheet).toHaveBeenCalledWith('send') }) it('only heals the terminal that was marked', async () => { diff --git a/mobile/src/session/use-mobile-diff-review-send-actions.ts b/mobile/src/session/use-mobile-diff-review-send-actions.ts index 8413cf61c36..90ddd591300 100644 --- a/mobile/src/session/use-mobile-diff-review-send-actions.ts +++ b/mobile/src/session/use-mobile-diff-review-send-actions.ts @@ -13,7 +13,8 @@ import { } from './mobile-review-terminal-operations' import { interpretOrThrowRefusalMessage } from '../transport/rpc-refusal-message' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' -import type { ReviewScreenState, SendSheetState } from './mobile-diff-review-screen-model' +import type { ReviewScreenState } from './mobile-diff-review-screen-model' +import type { ReviewSheetIntents } from './mobile-diff-review-sheets' type SendActionsInput = { client: RpcClient | null @@ -21,7 +22,7 @@ type SendActionsInput = { worktreeId: string screenState: ReviewScreenState setActionError: Dispatch> - setSendSheet: Dispatch> + sheets: Pick saveCommentsAndReviewState: ( comments: DiffComment[], reviewState: MobileDiffReviewState @@ -38,9 +39,10 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) { worktreeId, screenState, setActionError, - setSendSheet, + sheets, saveCommentsAndReviewState } = input + const { openSheet, closeSheet, updateSendSheet } = sheets const copyNotes = useCallback(async () => { if (screenState.kind !== 'ready' || screenState.comments.length === 0) { @@ -107,9 +109,9 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) { await markNotesSent(comments) triggerSuccess() setActionError('Review notes sent') - setSendSheet(null) + closeSheet('send') }, - [client, connState, markNotesSent, setActionError, setSendSheet] + [client, connState, closeSheet, markNotesSent, setActionError] ) const createTerminalAndSend = useCallback( @@ -138,7 +140,7 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) { setActionError('Waiting for desktop...') return } - setSendSheet({ kind: 'loading' }) + openSheet({ kind: 'send', load: { kind: 'loading' } }) try { const response = await reviewTerminalListRead.request(client, { worktree: `id:${worktreeId}` @@ -148,15 +150,15 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) { () => reviewTerminalListRead.interpret(response), 'Unable to load agent sessions' ) - setSendSheet({ kind: 'ready', terminals }) + updateSendSheet({ kind: 'ready', terminals }) } catch (err) { - setSendSheet({ + updateSendSheet({ kind: 'error', message: err instanceof Error ? err.message : 'Unable to load agent sessions', terminals: [] }) } - }, [client, connState, setActionError, setSendSheet, worktreeId]) + }, [client, connState, openSheet, setActionError, updateSendSheet, worktreeId]) return { clearSentNotes, diff --git a/mobile/src/tasks/mobile-tui-agents.ts b/mobile/src/tasks/mobile-tui-agents.ts index 46d8dd00886..d6cb8349e03 100644 --- a/mobile/src/tasks/mobile-tui-agents.ts +++ b/mobile/src/tasks/mobile-tui-agents.ts @@ -22,6 +22,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> ante: 'antigma.ai', trae: 'www.trae.cn', muse: 'dev.meta.ai', + dsh: 'deepseek.com', zcode: 'zcode.z.ai', omp: 'omp.sh', 'prime-agent': 'primeintellect.ai', diff --git a/mobile/src/test-support/rpc-recording/adapters/diff-review-action-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/diff-review-action-mount-adapters.ts index cc8c69061d2..8dd7d816fc3 100644 --- a/mobile/src/test-support/rpc-recording/adapters/diff-review-action-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/diff-review-action-mount-adapters.ts @@ -31,6 +31,9 @@ export function diffReviewActionMountAdapters( const useInteractions = modules.load< typeof import('../../../session/use-mobile-diff-review-interactions') >('mobile/src/session/use-mobile-diff-review-interactions.ts').useMobileDiffReviewInteractions + const reviewSheets = modules.load< + typeof import('../../../session/mobile-diff-review-sheets') + >('mobile/src/session/mobile-diff-review-sheets.ts') const staleInput = modules.load< typeof import('../../../session/mobile-native-chat-stale-input') >('mobile/src/session/mobile-native-chat-stale-input.ts') @@ -75,7 +78,12 @@ export function diffReviewActionMountAdapters( } let actionError: string | null = null let busyAction: string | null = null - let sendSheet: SendSheetState | null = null + let sheets = reviewSheets.NO_REVIEW_SHEETS + const sheetIntents = reviewSheets.reviewSheetIntents((action) => { + sheets = reviewSheets.reduceReviewSheets(sheets, action) + }) + const sendSheet = (): SendSheetState | null => + sheets.requested?.kind === 'send' ? sheets.requested.load : null let interactions: ReturnType const hook = hookMount(() => { interactions = useInteractions( @@ -101,7 +109,6 @@ export function diffReviewActionMountAdapters( setFilter: () => {}, setCurrentIndex: () => {}, setActiveHunkIndex: () => {}, - setComposer: () => {}, setComposerBody: () => {}, setActionError: (update) => { actionError = typeof update === 'function' ? update(actionError) : update @@ -109,10 +116,7 @@ export function diffReviewActionMountAdapters( setBusyAction: (update) => { busyAction = typeof update === 'function' ? update(busyAction) : update }, - setSendSheet: (update) => { - sendSheet = typeof update === 'function' ? update(sendSheet) : update - }, - setShowCompletion: () => {}, + sheets: sheetIntents, loadReviewData: () => { effect('load-review-data', {}) return Promise.resolve() @@ -163,7 +167,7 @@ export function diffReviewActionMountAdapters( throw new Error(`Unknown review action: ${name}${String(args.unused ?? '')}`) }) }, - state: () => ({ screenState, actionError, busyAction, sendSheet }), + state: () => ({ screenState, actionError, busyAction, sendSheet: sendSheet() }), dispose: () => { staleInput.resetMobileNativeChatStaleInputForTests() hook.unmount() diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index ee872e40f26..47898017ff8 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -1,13 +1,26 @@ import { describe, expect, it } from 'vitest' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' +import { + agentMainAgentVerdict, + agentVerdictDisplayMark +} from '../../../src/shared/agent-main-agent-verdict' +import { AGENT_JOURNAL_TURN_OUTCOMES } from '../../../src/shared/agent-turn-outcome' import { AGENT_STATUS_STALE_AFTER_MS, agentDisplayLabel, agentDotState, agentIdentityLabel, + agentRowTimeAt, + agentRowVerdict, + agentRowVerdictMark, formatTimeAgo } from './agent-row-display' +type Outcome = (typeof AGENT_JOURNAL_TURN_OUTCOMES)[number] +const mainAgentDone = (outcome: Outcome, stateStartedAt = 0) => ({ + mainAgent: { state: 'done' as const, outcome, stateStartedAt } +}) + function row(overrides: Partial = {}): RuntimeWorktreeAgentRow { return { paneKey: 'p', @@ -37,8 +50,54 @@ describe('agentDotState', () => { expect(agentDotState(row({ state: 'unknown-state' as never }), 0)).toBe('idle') }) - it('reports interrupted regardless of state', () => { + it('reports the verdict of a done row: failed, interrupted, or an old host legacy flag', () => { expect(agentDotState(row({ state: 'done', interrupted: true }), 0)).toBe('interrupted') + expect(agentDotState(row({ state: 'done', ...mainAgentDone('failure') }), 0)).toBe('failed') + expect( + agentDotState(row({ state: 'done', ...mainAgentDone('cancellation'), interrupted: true }), 0) + ).toBe('interrupted') + expect(agentDotState(row({ state: 'done', ...mainAgentDone('success') }), 0)).toBe('done') + }) + + it('shows a main agent that failed while its subagents still run as failed', () => { + expect(agentDotState(row({ state: 'working', ...mainAgentDone('failure') }), 0)).toBe('failed') + expect(agentDotState(row({ state: 'waiting', ...mainAgentDone('failure') }), 0)).toBe('failed') + // Only a failure outranks live work; a success or a stop with live subagents reads working. + expect(agentDotState(row({ state: 'working', ...mainAgentDone('success') }), 0)).toBe('working') + expect( + agentDotState( + row({ state: 'working', ...mainAgentDone('cancellation'), interrupted: true }), + 0 + ) + ).toBe('working') + }) + + // The shared accessor cannot be imported by app code here, so this mirror must not drift from it. + it('agrees with the desktop verdict accessor on every row', () => { + const states = ['working', 'blocked', 'waiting', 'done'] as const + const mainAgents = [ + undefined, + ...states.flatMap((state) => + [undefined, ...AGENT_JOURNAL_TURN_OUTCOMES].map((outcome) => ({ + state, + ...(outcome ? { outcome } : {}), + stateStartedAt: 0 + })) + ) + ] + for (const state of states) { + for (const mainAgent of mainAgents) { + for (const interrupted of [false, true]) { + const agentRow = { state, interrupted, ...(mainAgent ? { mainAgent } : {}) } + expect(agentRowVerdict(agentRow), JSON.stringify(agentRow)).toBe( + agentMainAgentVerdict(agentRow) + ) + expect(agentRowVerdictMark(agentRow), JSON.stringify(agentRow)).toBe( + agentVerdictDisplayMark(agentRow) + ) + } + } + } }) it('decays a stale active state to idle, matching desktop', () => { @@ -51,14 +110,36 @@ describe('agentDotState', () => { expect( agentDotState(row({ state: 'working', updatedAt: 0 }), AGENT_STATUS_STALE_AFTER_MS) ).toBe('working') - // 'done' never decays; interrupted still wins. + // 'done' never decays, and neither does its verdict. expect(agentDotState(row({ state: 'done', updatedAt: 0 }), stale)).toBe('done') - expect(agentDotState(row({ state: 'working', updatedAt: 0, interrupted: true }), stale)).toBe( + expect(agentDotState(row({ state: 'done', updatedAt: 0, interrupted: true }), stale)).toBe( 'interrupted' ) }) }) +describe('agentRowTimeAt', () => { + it('dates a main agent that failed while its subagents run by its own failure', () => { + expect( + agentRowTimeAt( + row({ state: 'working', stateStartedAt: 100, ...mainAgentDone('failure', 900) }) + ) + ).toBe(900) + }) + + it('dates every other row by when its state began', () => { + expect( + agentRowTimeAt( + row({ state: 'working', stateStartedAt: 100, ...mainAgentDone('success', 900) }) + ) + ).toBe(100) + expect( + agentRowTimeAt(row({ state: 'done', stateStartedAt: 100, ...mainAgentDone('failure', 900) })) + ).toBe(100) + expect(agentRowTimeAt(row({ state: 'working', stateStartedAt: 100 }))).toBe(100) + }) +}) + describe('agentDisplayLabel', () => { it('prefers last message, then prompt, then state label', () => { expect(agentDisplayLabel(row({ lastAssistantMessage: 'hello there' }), 0)).toBe('hello there') diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index c678b522a15..da93aa39220 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -1,4 +1,5 @@ import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' +import type { AgentJournalTurnOutcome } from '../../../src/shared/agent-turn-outcome' // Mirrors the desktop AGENT_STATUS_STALE_AFTER_MS (src/shared/agent-status-types.ts: // 30 min). Defined locally rather than imported because a runtime-value import @@ -17,13 +18,39 @@ export type AgentDotState = | 'done' | 'idle' | 'interrupted' + | 'failed' + +type AgentRowVerdictSource = Pick + +// Mirrors desktop agentMainAgentVerdict and agentVerdictDisplayMark +// (src/shared/agent-main-agent-verdict.ts); a parity test runs both over one table. `mainAgent` is +// the main agent's own status, sent also while subagents hold the row working; an old host sends none. +export function agentRowVerdict(row: AgentRowVerdictSource): AgentJournalTurnOutcome | null { + if (row.mainAgent && row.mainAgent.state !== 'done') { + return null + } + return row.mainAgent?.outcome ?? (row.state === 'done' && row.interrupted ? 'cancellation' : null) +} + +// A failure outranks every state; a stop marks only a row that is itself done. +export function agentRowVerdictMark(row: AgentRowVerdictSource): 'failed' | 'interrupted' | null { + const verdict = agentRowVerdict(row) + if (verdict === 'failure') { + return 'failed' + } + return verdict === 'cancellation' && row.state === 'done' ? 'interrupted' : null +} export function agentDotState( - row: Pick, + row: Pick< + RuntimeWorktreeAgentRow, + 'state' | 'workingMode' | 'interrupted' | 'mainAgent' | 'updatedAt' + >, now: number ): AgentDotState { - if (row.interrupted) { - return 'interrupted' + const mark = agentRowVerdictMark(row) + if (mark) { + return mark } switch (row.state) { case 'blocked': @@ -56,6 +83,8 @@ export function agentStateLabel(state: AgentDotState): string { return 'Waiting for input' case 'interrupted': return 'Interrupted' + case 'failed': + return 'Failed' case 'done': return 'Done' case 'idle': @@ -99,6 +128,17 @@ export function agentIdentityLabel(agentType: string | null): string { return known[normalized] ?? normalized.slice(0, 2).toUpperCase() } +// When the row's state began, except that a main agent that failed while its subagents run is +// dated by its own failure. Mirrors desktop lastEnteredDoneAt (agent-finished-timestamp.ts). +export function agentRowTimeAt( + row: Pick +): number { + if (row.state !== 'done' && row.mainAgent && agentRowVerdictMark(row) === 'failed') { + return row.mainAgent.stateStartedAt + } + return row.stateStartedAt +} + // Relative time, matching desktop formatTimeAgo thresholds (just now / Xm / Xh / Xd). export function formatTimeAgo(ts: number, now: number): string { const delta = now - ts diff --git a/mobile/src/worktree/worktree-list-snapshot.test.ts b/mobile/src/worktree/worktree-list-snapshot.test.ts index c7c5756160f..25c9ecbe852 100644 --- a/mobile/src/worktree/worktree-list-snapshot.test.ts +++ b/mobile/src/worktree/worktree-list-snapshot.test.ts @@ -21,6 +21,13 @@ function agent(overrides: Partial = {}): RuntimeWorktre } } +function done( + outcome: 'success' | 'failure', + stateStartedAt = 1 +): NonNullable { + return { state: 'done', outcome, stateStartedAt } +} + function worktree(overrides: Partial = {}): Worktree { const worktreePath = join('/tmp', 'orca', 'worktrees', 'manta') return { @@ -160,6 +167,31 @@ describe('areWorktreeListsEqual', () => { expect(areWorktreeListsEqual(first, second)).toBe(false) }) + it('detects a verdict change that leaves the interrupted flag as it was', () => { + const first = [worktree({ agents: [agent({ state: 'done', mainAgent: done('success') })] })] + const second = [worktree({ agents: [agent({ state: 'done', mainAgent: done('failure') })] })] + + expect(areWorktreeListsEqual(first, second)).toBe(false) + }) + + it('detects a main agent failing while its subagents keep the row working', () => { + const first = [worktree({ agents: [agent({ state: 'working' })] })] + const second = [worktree({ agents: [agent({ state: 'working', mainAgent: done('failure') })] })] + + expect(areWorktreeListsEqual(first, second)).toBe(false) + }) + + it('detects the main agent clock moving, which dates a failure', () => { + const at = (stateStartedAt: number) => [ + worktree({ + agents: [agent({ state: 'working', mainAgent: done('failure', stateStartedAt) })] + }) + ] + + expect(areWorktreeListsEqual(at(1), at(2))).toBe(false) + expect(areWorktreeListsEqual(at(1), at(1))).toBe(true) + }) + it('detects monitoring mode changes within working', () => { const first = [worktree({ agents: [agent({ state: 'working' })] })] const second = [worktree({ agents: [agent({ state: 'working', workingMode: 'monitoring' })] })] diff --git a/mobile/src/worktree/worktree-list-snapshot.ts b/mobile/src/worktree/worktree-list-snapshot.ts index 8fa27c2a197..250c871493f 100644 --- a/mobile/src/worktree/worktree-list-snapshot.ts +++ b/mobile/src/worktree/worktree-list-snapshot.ts @@ -110,6 +110,7 @@ function areAgentRowsEqual( a.toolName !== b.toolName || a.toolInput !== b.toolInput || a.interrupted !== b.interrupted || + !areMainAgentsEqual(a.mainAgent, b.mainAgent) || a.stateStartedAt !== b.stateStartedAt || a.updatedAt !== b.updatedAt ) { @@ -118,3 +119,20 @@ function areAgentRowsEqual( } return true } + +function areMainAgentsEqual( + left: RuntimeWorktreeAgentRow['mainAgent'], + right: RuntimeWorktreeAgentRow['mainAgent'] +): boolean { + if (left === right) { + return true + } + if (!left || !right) { + return false + } + return ( + left.state === right.state && + left.outcome === right.outcome && + left.stateStartedAt === right.stateStartedAt + ) +} diff --git a/package.json b/package.json index 1f1573f9577..ca861d887e4 100644 --- a/package.json +++ b/package.json @@ -170,7 +170,7 @@ "test:e2e:ssh-docker-bulk-open-freeze": "node config/scripts/run-ssh-docker-bulk-open-freeze-e2e.mjs", "repro:live-remote-bulk-open-freeze": "node config/scripts/live-remote-bulk-open-freeze-repro.mjs", "repro:live-remote-realistic-freeze": "node config/scripts/live-remote-realistic-freeze-repro.mjs", - "audit:anti-slop": "node config/scripts/sync-anti-slop-plugin.mjs && oxlint --config config/oxlint-anti-slop.json src config tests mobile --deny-warnings", + "audit:anti-slop": "node config/scripts/sync-anti-slop-plugin.mjs && node config/scripts/run-anti-slop-shards.mjs", "sync:anti-slop-plugin": "node config/scripts/sync-anti-slop-plugin.mjs" }, "dependencies": { diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index 560233e8363..61dc9596eff 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -8,6 +8,7 @@ import { commandCodeHookService } from '../command-code/hook-service' import { copilotHookService } from '../copilot/hook-service' import { cursorHookService } from '../cursor/hook-service' import { devinHookService } from '../devin/hook-service' +import { dshHookService } from '../dsh/hook-service' import { droidHookService } from '../droid/hook-service' import { geminiHookService } from '../gemini/hook-service' import { grokHookService } from '../grok/hook-service' @@ -54,7 +55,8 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['devin', () => devinHookService.install()], ['kimi', () => kimiHookService.install()], ['muse', () => museHookService.install()], - ['zcode', () => zcodeHookService.install()] + ['zcode', () => zcodeHookService.install()], + ['dsh', () => dshHookService.install()] ] // Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a @@ -77,7 +79,8 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri ['devin', () => devinHookService.refreshManagedScripts()], ['kimi', () => kimiHookService.refreshManagedScripts()], ['muse', () => museHookService.refreshManagedScripts()], - ['zcode', () => zcodeHookService.refreshManagedScripts()] + ['zcode', () => zcodeHookService.refreshManagedScripts()], + ['dsh', () => dshHookService.refreshManagedScripts()] ] export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ @@ -96,7 +99,8 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ ['devin', () => devinHookService.remove()], ['kimi', () => kimiHookService.remove()], ['muse', () => museHookService.remove()], - ['zcode', () => zcodeHookService.remove()] + ['zcode', () => zcodeHookService.remove()], + ['dsh', () => dshHookService.remove()] ] export const MANAGED_AGENT_HOOK_ASYNC_REMOVERS: readonly ManagedAgentHookAsyncRemover[] = [ @@ -119,5 +123,6 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR ['devin', () => devinHookService.getStatus()], ['kimi', () => kimiHookService.getStatus()], ['muse', () => museHookService.getStatus()], - ['zcode', () => zcodeHookService.getStatus()] + ['zcode', () => zcodeHookService.getStatus()], + ['dsh', () => dshHookService.getStatus()] ] diff --git a/src/main/agent-hooks/managed-hook-command-contract.test.ts b/src/main/agent-hooks/managed-hook-command-contract.test.ts index 774e9396c25..540d9deb6d2 100644 --- a/src/main/agent-hooks/managed-hook-command-contract.test.ts +++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts @@ -22,6 +22,7 @@ import { import { getDevinManagedCommand, getDevinRemoteManagedCommand } from '../devin/hook-settings' import { getGrokManagedCommand } from '../grok/grok-hook-script' import { getMuseManagedCommand, getMuseRemoteManagedCommand } from '../muse/hook-settings' +import { getDshManagedCommand, getDshRemoteManagedCommand } from '../dsh/hook-settings' import { getZCodeManagedCommand, getZCodeRemoteManagedCommand } from '../zcode/hook-settings' import { wrapPosixHookCommand, @@ -151,6 +152,13 @@ const buildersByAgent = new Map([ remote: (path) => [getMuseRemoteManagedCommand(path)] } ], + [ + 'dsh', + { + local: (path) => [getDshManagedCommand(path)], + remote: (path) => [getDshRemoteManagedCommand(path)] + } + ], [ 'zcode', { diff --git a/src/main/agent-hooks/managed-hooks-json-events.ts b/src/main/agent-hooks/managed-hooks-json-events.ts new file mode 100644 index 00000000000..bff2bbf6942 --- /dev/null +++ b/src/main/agent-hooks/managed-hooks-json-events.ts @@ -0,0 +1,38 @@ +import { isPlainObject } from './installer-utils' + +/** + * Which of `events` an Orca-owned `{ hooks: { : [{ hooks: [{ command }] }] } }` file + * still registers under a managed command. + * + * Shared by every agent whose managed hooks file Orca generates wholesale (Muse, DSH), so + * "installed", "partial" and "not_installed" cannot drift between them. + * + * Why every lookup is guarded: the file is on disk and hand-editable, so any node can be + * null, a scalar, or the wrong container. A malformed node reads as "event absent" — status + * calculation must report a broken install, never throw on it. + */ +export function readManagedHookEventsFromJson( + parsed: unknown, + events: readonly string[], + isManagedCommand: (command: string | undefined) => boolean +): Set { + const hooks = isPlainObject(parsed) && isPlainObject(parsed.hooks) ? parsed.hooks : {} + return new Set( + events.filter((event) => + asArray(hooks[event]).some((definition) => + asArray(isPlainObject(definition) ? definition.hooks : null).some((hook) => + isManagedCommand(readCommand(hook)) + ) + ) + ) + ) +} + +function asArray(value: unknown): readonly unknown[] { + return Array.isArray(value) ? value : [] +} + +function readCommand(hook: unknown): string | undefined { + const command = isPlainObject(hook) ? hook.command : undefined + return typeof command === 'string' ? command : undefined +} diff --git a/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts b/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts index f145ebc9be8..376e53be125 100644 --- a/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts +++ b/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts @@ -19,6 +19,7 @@ import { geminiHookService } from '../gemini/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' import { kimiHookService } from '../kimi/hook-service' +import { dshHookService } from '../dsh/hook-service' import { museHookService } from '../muse/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' import { zcodeHookService } from '../zcode/hook-service' @@ -50,7 +51,8 @@ describe('remote hook service registry coverage', () => { ['devin', devinHookService], ['kimi', kimiHookService], ['muse', museHookService], - ['zcode', zcodeHookService] + ['zcode', zcodeHookService], + ['dsh', dshHookService] ]) // Guard against a service silently missing from the map above as new agents land. diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 1f42bdd68a6..f0346971068 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -13,6 +13,7 @@ import { droidHookService } from '../droid/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' import { kimiHookService } from '../kimi/hook-service' +import { dshHookService } from '../dsh/hook-service' import { museHookService } from '../muse/hook-service' import { zcodeHookService } from '../zcode/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' @@ -76,7 +77,8 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)], ['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)], ['muse', (sftp, remoteHome) => museHookService.installRemote(sftp, remoteHome)], - ['zcode', (sftp, remoteHome) => zcodeHookService.installRemote(sftp, remoteHome)] + ['zcode', (sftp, remoteHome) => zcodeHookService.installRemote(sftp, remoteHome)], + ['dsh', (sftp, remoteHome) => dshHookService.installRemote(sftp, remoteHome)] ] /** Agents wired into the remote (SSH) hook installer. Exported so an invariant diff --git a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts index d38f6c836c7..9c0d030b04d 100644 --- a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts +++ b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts @@ -42,7 +42,8 @@ const NEW_TURN_EVENT: Record = { 'mimo-code': null, 'command-code': null, muse: 'UserPromptSubmit', - zcode: 'SessionStart' + zcode: 'SessionStart', + dsh: 'SessionStart' } function reviveRetiredPane(source: unknown, hookEventName: string): boolean { diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index 5a5e2ce5ecf..19ee70e88af 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -11,7 +11,8 @@ import { } from '../../../shared/structured-agent-session-projection' import { continueMainAgentStatus, - isAgentStatusHeldOpenByChildWork + isAgentStatusHeldOpenByChildWork, + mainAgentTurnInterrupted } from '../../../shared/agent-lead-status-fold' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import { @@ -73,6 +74,8 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng state, ...(workingMode ? { workingMode } : {}), mainAgent, + // Readers that predate `mainAgent` read a cancellation off this flag, as the hook lanes publish it. + interrupted: mainAgentTurnInterrupted(mainAgent), prompt: summary.latestPrompt, agentType: summary.agent, ...(summary.model ? { model: summary.model } : {}), diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 60aab5ed1db..36ab39e3bab 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -180,6 +180,16 @@ describe('codex structured launch resolution', () => { }) }) + // A thread opened on the configured default and then given a turn on the saved model reads to + // Codex as a model switch, and it injects the saved model's whole prompt a second time. + it('opens the thread on the model the record saved', async () => { + const launch = await resolverFor( + record({ options: { model: 'gpt-chosen', effort: 'high', fastMode: 'false' } }) + )({ identity: IDENTITY }) + + expect(launch.model).toBe('gpt-chosen') + }) + // The configured CLI arguments are a terminal concern: a durable record written before they // stopped being read must not smuggle one back into app-server's argv. it("ignores the record's durable launch arguments", async () => { diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 8a3fe002cb1..ebfdb27200a 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -94,6 +94,8 @@ export function createCodexStructuredLaunchResolver( const permissionPolicy = deps.resolvePermissionPolicy?.() const head = agentSessionProviderHandleChainHead(record.providerHandleChain) const resumeThreadId = head?.handle.provider === 'codex' ? head.handle.threadId : null + // The same saved options every turn sends, so the thread and its turns name one model. + const model = record.options?.model return { command, args: ['app-server'], @@ -107,6 +109,7 @@ export function createCodexStructuredLaunchResolver( // forked or adopted head names a conversation Codex held. ...(resumeThreadId && head?.origin === 'created' ? { supersedeIfUnsaved: true } : {}), ...(permissionPolicy ? { permissionPolicy } : {}), + ...(model ? { model } : {}), ...(resumeThreadId ? { resumePath: await (deps.resolveRollout ?? resolvePinnedCodexRolloutProof)( diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 2dd4b8d78af..169772d4992 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -58,6 +58,20 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(acquisition.acquisitionGeneration).toBe('generation-1') }) + // A thread opened on Codex's configured default and then given a turn on the chosen model + // reads to Codex as a model switch, and it injects the chosen model's whole prompt again. + it('opens the thread on the model the session chose, not on the configured default', async () => { + const codex = fakeCodex() + const adapter = adapterFor(codex, { model: 'gpt-chosen' }) + + await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + + expect(codex.connections[0].calls[0]).toEqual({ + method: 'thread/start', + params: { cwd: '/work/repo', model: 'gpt-chosen' } + }) + }) + it('resumes the thread the durable handle chain names, not the client one', async () => { const codex = fakeCodex() const adapter = adapterFor(codex, { diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index b5e4796d21d..f4828b7df68 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -34,6 +34,8 @@ export type CodexStructuredLaunch = { * rollout for it, start a new thread in its place. Never set for a thread a resume proved. */ supersedeIfUnsaved?: boolean permissionPolicy?: CodexStructuredPermissionPolicy + /** The model the session chose; the thread opens on it so its first turn is not a switch. */ + model?: string env?: Record } diff --git a/src/main/codex/codex-structured-thread-open.test.ts b/src/main/codex/codex-structured-thread-open.test.ts index b819a0dc6b2..fede869a83c 100644 --- a/src/main/codex/codex-structured-thread-open.test.ts +++ b/src/main/codex/codex-structured-thread-open.test.ts @@ -261,6 +261,29 @@ describe('openCodexThread', () => { ) }) + it('opens the replacement thread on the chosen model', async () => { + const request = codexWithoutRollout() + + await openCodexThread( + connectionFor(request), + { + cwd: '/workspace', + resumeThreadId: 'thread-unsaved', + supersedeIfUnsaved: true, + model: 'gpt-chosen' + }, + 2_000 + ) + + // A resume keeps the thread's own saved model, provider and effort, which naming one skips. + expect(request.mock.calls[0]?.[1]).not.toHaveProperty('model') + expect(request).toHaveBeenLastCalledWith( + 'thread/start', + { cwd: '/workspace', model: 'gpt-chosen' }, + { timeoutMs: 2_000 } + ) + }) + it('keeps the resume failure for a thread a resume already proved', async () => { const request = codexWithoutRollout() diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index 83cd710e136..0f8b530d624 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -90,14 +90,18 @@ export async function openCodexThread( resumePath?: string | null supersedeIfUnsaved?: boolean permissionPolicy?: CodexStructuredPermissionPolicy + /** Why: Codex renders a thread's base instructions for its opening model; a first turn on + * another model reads as a mid-conversation switch and injects a second full prompt. */ + model?: string }, timeoutMs: number | undefined ): Promise { const resumeThreadId = launch.resumeThreadId + const threadSettings = { cwd: launch.cwd, ...launch.permissionPolicy } const startThread = (): Promise => connection.request( 'thread/start', - { cwd: launch.cwd, ...launch.permissionPolicy }, + { ...threadSettings, ...(launch.model ? { model: launch.model } : {}) }, { timeoutMs } ) let supersededThreadId: string | undefined @@ -105,10 +109,10 @@ export async function openCodexThread( if (!resumeThreadId) { opened = await startThread() } else { + // No model: naming one makes Codex skip the thread's saved model, provider and effort. const resumeParams = { threadId: resumeThreadId, - cwd: launch.cwd, - ...launch.permissionPolicy, + ...threadSettings, ...(launch.resumePath ? { path: launch.resumePath } : {}) } try { diff --git a/src/main/daemon/node-pty-error-hints.test.ts b/src/main/daemon/node-pty-error-hints.test.ts index f43951f05a6..361d8b73ba1 100644 --- a/src/main/daemon/node-pty-error-hints.test.ts +++ b/src/main/daemon/node-pty-error-hints.test.ts @@ -1,22 +1,12 @@ import { describe, expect, it } from 'vitest' +import { + LEGACY_PTY_ALLOCATION_HINT, + LEGACY_TERMINAL_PROCESS_LIMIT_HINT, + PTY_ALLOCATION_HINT, + TERMINAL_PROCESS_LIMIT_HINT +} from '../../shared/terminal-spawn-error-copy' import { addNodePtyRecoveryHint, parseNodePtyDiagnostic } from './node-pty-error-hints' -const PTY_ALLOCATION_HINT = [ - 'Your system cannot allocate any more pty devices.', - '', - 'Orca requires a pty device to launch a new terminal. This error is usually due to having too many terminal windows or terminal sessions open, either in Orca or another program.', - '', - 'Free up some pty devices and try again.' -].join('\n') - -const TERMINAL_PROCESS_LIMIT_HINT = [ - 'Your system cannot start another terminal process.', - '', - 'This is usually due to having too many terminal sessions or other processes running.', - '', - 'Close unused terminals or quit unused processes and try again.' -].join('\n') - describe('node-pty diagnostic error hints', () => { it('parses the native step and errno without dropping the original message', () => { const message = @@ -32,46 +22,71 @@ describe('node-pty diagnostic error hints', () => { const message = "node-pty: open_slave failed: EMFILE (errno 24, Too many open files) - slave='/dev/ttys003'" - expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT}\n${message}`) }) it('hints when the system cannot allocate a pty master', () => { const message = 'node-pty: posix_openpt failed: ENFILE (errno 23, Too many open files in system)' - expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT}\n${message}`) }) it('hints when macOS cannot configure a pty master device', () => { const message = 'node-pty: posix_openpt failed: errno (errno 6, Device not configured)' - expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT}\n${message}`) }) it('hints local wrapped spawn errors from pty allocation failures', () => { const message = 'Failed to spawn shell "/bin/zsh": node-pty: open_slave failed: EMFILE (errno 24, Too many open files) - slave=\'/dev/ttys003\' (shell: /bin/zsh, cwd: /tmp, arch: arm64, platform: darwin 25.0.0, orca: 1.4.178). If this persists, please file an issue.' - expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT}\n${message}`) + }) + + it('keeps the whole recovery action on the first line', () => { + const message = + 'Failed to spawn shell "/bin/zsh": node-pty: posix_openpt failed: errno (errno 6, Device not configured) (shell: /bin/zsh). If this persists, please file an issue.' + + expect(addNodePtyRecoveryHint(message).split('\n')[0]).toBe(PTY_ALLOCATION_HINT) }) it('hints unstructured openpty allocation failures', () => { const message = 'Failed to spawn shell "/bin/bash": openpty(3) failed.' - expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT}\n${message}`) }) it('hints when posix_spawn reports the per-user process limit', () => { const message = "node-pty: posix_spawn failed: EAGAIN (errno 35, Resource temporarily unavailable) - helper='/tmp/node-pty/spawn-helper'" - expect(addNodePtyRecoveryHint(message)).toBe(`${TERMINAL_PROCESS_LIMIT_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${TERMINAL_PROCESS_LIMIT_HINT}\n${message}`) }) it('does not duplicate an existing recovery hint', () => { const message = "node-pty: open_slave failed: EMFILE (errno 24, Too many open files) - slave='/dev/ttys003'" - const hinted = `${PTY_ALLOCATION_HINT} ${message}` + const hinted = `${PTY_ALLOCATION_HINT}\n${message}` + + expect(addNodePtyRecoveryHint(hinted)).toBe(hinted) + }) + + it('does not duplicate hints from an older remote host', () => { + const ptyError = 'node-pty: open_slave failed: EMFILE (errno 24, Too many open files)' + const processError = 'node-pty: posix_spawn failed: EAGAIN (errno 35, Resource unavailable)' + + expect(addNodePtyRecoveryHint(`${LEGACY_PTY_ALLOCATION_HINT} ${ptyError}`)).toBe( + `${LEGACY_PTY_ALLOCATION_HINT} ${ptyError}` + ) + expect(addNodePtyRecoveryHint(`${LEGACY_TERMINAL_PROCESS_LIMIT_HINT} ${processError}`)).toBe( + `${LEGACY_TERMINAL_PROCESS_LIMIT_HINT} ${processError}` + ) + }) + + it('does not duplicate a legacy hint on unstructured openpty failures', () => { + const hinted = `${LEGACY_PTY_ALLOCATION_HINT} Failed to spawn shell "/bin/bash": openpty(3) failed.` expect(addNodePtyRecoveryHint(hinted)).toBe(hinted) }) diff --git a/src/main/daemon/node-pty-error-hints.ts b/src/main/daemon/node-pty-error-hints.ts index 022fb98c5be..182beb41a79 100644 --- a/src/main/daemon/node-pty-error-hints.ts +++ b/src/main/daemon/node-pty-error-hints.ts @@ -1,3 +1,10 @@ +import { + LEGACY_PTY_ALLOCATION_HINT, + LEGACY_TERMINAL_PROCESS_LIMIT_HINT, + PTY_ALLOCATION_HINT, + TERMINAL_PROCESS_LIMIT_HINT +} from '../../shared/terminal-spawn-error-copy' + export type NodePtyDiagnostic = { step: string errno: number @@ -25,22 +32,6 @@ const RESOURCE_EXHAUSTION_ERRNOS = new Set([ 35 // EAGAIN on macOS ]) -const PTY_ALLOCATION_HINT = [ - 'Your system cannot allocate any more pty devices.', - '', - 'Orca requires a pty device to launch a new terminal. This error is usually due to having too many terminal windows or terminal sessions open, either in Orca or another program.', - '', - 'Free up some pty devices and try again.' -].join('\n') - -const TERMINAL_PROCESS_LIMIT_HINT = [ - 'Your system cannot start another terminal process.', - '', - 'This is usually due to having too many terminal sessions or other processes running.', - '', - 'Close unused terminals or quit unused processes and try again.' -].join('\n') - export function parseNodePtyDiagnostic(message: string): NodePtyDiagnostic | null { const match = NODE_PTY_DIAGNOSTIC_RE.exec(message) ?? NODE_PTY_DIAGNOSTIC_ANYWHERE_RE.exec(message) @@ -70,18 +61,30 @@ export function getNodePtyRecoveryHint(diagnostic: NodePtyDiagnostic): string | return null } +function hasPtyAllocationHint(message: string): boolean { + return message.startsWith(PTY_ALLOCATION_HINT) || message.startsWith(LEGACY_PTY_ALLOCATION_HINT) +} + export function addNodePtyRecoveryHint(message: string): string { const diagnostic = parseNodePtyDiagnostic(message) if (!diagnostic) { - if (GENERIC_PTY_ALLOCATION_RE.test(message) && !message.startsWith(PTY_ALLOCATION_HINT)) { - return `${PTY_ALLOCATION_HINT} ${message}` + if (GENERIC_PTY_ALLOCATION_RE.test(message) && !hasPtyAllocationHint(message)) { + return `${PTY_ALLOCATION_HINT}\n${message}` } return message } const hint = getNodePtyRecoveryHint(diagnostic) - if (hint && message.startsWith(hint)) { + if ( + !hint || + message.startsWith(hint) || + (hint === PTY_ALLOCATION_HINT && hasPtyAllocationHint(message)) || + (hint === TERMINAL_PROCESS_LIMIT_HINT && message.startsWith(LEGACY_TERMINAL_PROCESS_LIMIT_HINT)) + ) { return message } - return hint ? `${hint} ${message}` : message + // Older clients need both stale-daemon markers before their first-line IPC truncation. + const separator = + hint === PTY_ALLOCATION_HINT || hint === TERMINAL_PROCESS_LIMIT_HINT ? '\n' : ' ' + return `${hint}${separator}${message}` } diff --git a/src/main/daemon/process-boundary-ground.test.ts b/src/main/daemon/process-boundary-ground.test.ts index dfdf5262edd..5cefaa0694b 100644 --- a/src/main/daemon/process-boundary-ground.test.ts +++ b/src/main/daemon/process-boundary-ground.test.ts @@ -142,7 +142,7 @@ describe('process boundary ground at a proven crash', () => { barrier.accept({ data, rawStartSeq: 0, rawEndSeq: data.length, transformed: false }) await vi.waitFor(() => expect(released).toHaveLength(3)) - expect(released[1]).toBe(PROCESS_BOUNDARY_GROUND) + expect(released[1]).toBe(`\x1b]133;D;137\x07${PROCESS_BOUNDARY_GROUND}`) expect(released[2]).toBe('\x1b[?2004h$ ') expect(barrier.getOwner()).toBe('shell') const snapshot = live.getSnapshot() @@ -155,4 +155,19 @@ describe('process boundary ground at a proven crash', () => { } expect(live.getBufferTailLines(24).slice(0, 2)).toEqual(['$ tui', '$ ']) }) + + it('pauses on an escape boundary so a mid-proof snapshot has no open OSC', () => { + const live = emulator(DAEMON_SESSION_SCROLLBACK_ROWS) + const barrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: () => new Promise(() => {}), + release: (emission) => write(live, emission.data), + isAlive: () => true + }) + + const data = `\x1b[?1049h${DEAD_PROCESS_ARMS}TUI\x1b]133;D;137\x07$ ` + barrier.accept({ data, rawStartSeq: 0, rawEndSeq: data.length, transformed: false }) + + expect(live.getSnapshot().pendingEscapeTailAnsi).toBeUndefined() + barrier.dispose() + }) }) diff --git a/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts b/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts index b4f6aa502f8..978da1ac67f 100644 --- a/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts +++ b/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts @@ -8,7 +8,7 @@ import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-mem * never cached state. */ export async function confirmPtyShellForeground(args: { process: pty.IPty - shellPath: string + shellPath: string | undefined isDead: () => boolean }): Promise { if (args.isDead() || !args.process.pid) { diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index 6fdf7b70da6..c8316bb383a 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -22,6 +22,7 @@ import { expandWindowsEnvironmentVariables, expandWindowsPathEnvironmentVariables } from '../../../shared/windows-environment-expansion' +import { applyScrubSafeAgentEnvAliases } from '../../../shared/agent-hook-scrub-safe-env' import type { TuiAgent } from '../../../shared/tui-agent' import type { PtySubprocessOptions } from '../pty-subprocess' @@ -188,6 +189,9 @@ export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record = { 'codex-0157-effort-override-embedded-warning': 4, 'codex-0157-no-daemon-effort-override': 16, 'codex-0157-plain-ready': 18, - 'claude-dialog-trust-workspace-answered': 13 + 'claude-dialog-trust-workspace-answered': 13, + // DSH-TUI's whale intro paints whole rows of 24-bit background, and every one of this + // transcript's divergences is the same shape: `visible-grid row=0`, a true-colour + // background that the round trip does not restore to default. Verified as upstream, not a + // regression, by replaying it against the previous build + // (`build-serialize-addon-at-ref.mjs --ref origin/main`): I1 and I3 both hold. + 'dsh-tui-ready-no-key': 10 } type Transcript = { name: string; data: string; cols: number; rows: number } diff --git a/src/main/daemon/terminal-shell-lifecycle-scanner.test.ts b/src/main/daemon/terminal-shell-lifecycle-scanner.test.ts index 08d2d8c9325..48e1658bef1 100644 --- a/src/main/daemon/terminal-shell-lifecycle-scanner.test.ts +++ b/src/main/daemon/terminal-shell-lifecycle-scanner.test.ts @@ -14,6 +14,7 @@ describe('TerminalShellLifecycleScanner', () => { const events = scanner.scan(chunk) expect(events.uncleanDeathTriggerEnd).toBe(chunk.indexOf('shell-marker')) + expect(events.uncleanDeathTriggerStart).toBe(chunk.indexOf('\x1b]133;D')) expect(chunk.slice(events.uncleanDeathTriggerEnd)).toBe('shell-marker') expect(scanner.isAlternateScreenActive).toBe(true) expect(scanner.owner).toBeUndefined() @@ -45,6 +46,7 @@ describe('TerminalShellLifecycleScanner', () => { const events = scanner.scan('37\x07tail') expect(events.uncleanDeathTriggerEnd).toBe('37\x07'.length) + expect(events.uncleanDeathTriggerStart).toBe(0) expect('37\x07tail'.slice(events.uncleanDeathTriggerEnd)).toBe('tail') expect(scanner.owner).toBeUndefined() }) diff --git a/src/main/daemon/terminal-shell-lifecycle-scanner.ts b/src/main/daemon/terminal-shell-lifecycle-scanner.ts index 1e655cd4896..f93f9c4df66 100644 --- a/src/main/daemon/terminal-shell-lifecycle-scanner.ts +++ b/src/main/daemon/terminal-shell-lifecycle-scanner.ts @@ -34,6 +34,8 @@ export type ShellLifecycleScanEvents = { * and after this index were NOT consumed; the caller re-feeds them. */ uncleanDeathTriggerEnd?: number + /** Where that OSC 133;D begins in the chunk; 0 when it began in an earlier one. */ + uncleanDeathTriggerStart?: number /** An OSC 133;D closed a command that had entered the alternate screen and left it cleanly. */ cleanExitCandidate?: { generation: number } } @@ -150,6 +152,7 @@ export class TerminalShellLifecycleScanner { 0, match.index + match[0].length - previousTailLength ) + events.uncleanDeathTriggerStart = Math.max(0, match.index - previousTailLength) return events } if (cleanExit) { diff --git a/src/main/daemon/terminal-shell-recovery-barrier.test.ts b/src/main/daemon/terminal-shell-recovery-barrier.test.ts index 872d33fe766..dead64c9bec 100644 --- a/src/main/daemon/terminal-shell-recovery-barrier.test.ts +++ b/src/main/daemon/terminal-shell-recovery-barrier.test.ts @@ -4,6 +4,10 @@ import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' const TRIGGER = '\x1b[?1049hTUI\x1b]133;D;137\x07' +// The barrier holds the whole D mark; the ground rides on it. +const MARK = '\x1b]133;D;137\x07' +const HEAD = TRIGGER.slice(0, -MARK.length) +const GROUNDED = `${MARK}${PROCESS_BOUNDARY_GROUND}` function passthrough(data: string, rawStartSeq = 0): PtyIngressEmission { return { data, rawStartSeq, rawEndSeq: rawStartSeq + data.length, transformed: false } @@ -47,14 +51,14 @@ describe('TerminalShellRecoveryBarrier', () => { barrier.accept(passthrough(`${TRIGGER}SHELL-PROMPT`, 100)) expect(confirm).toHaveBeenCalledTimes(1) expect(released).toEqual([ - { data: TRIGGER, rawStartSeq: 100, rawEndSeq: 100 + TRIGGER.length, transformed: false } + { data: HEAD, rawStartSeq: 100, rawEndSeq: 100 + HEAD.length, transformed: false } ]) resolveConfirm?.(true) await vi.waitFor(() => expect(released).toHaveLength(3)) expect(released[1]).toEqual({ - data: PROCESS_BOUNDARY_GROUND, - rawStartSeq: 100 + TRIGGER.length, + data: GROUNDED, + rawStartSeq: 100 + HEAD.length, rawEndSeq: 100 + TRIGGER.length, transformed: true }) @@ -80,12 +84,7 @@ describe('TerminalShellRecoveryBarrier', () => { resolveConfirm?.(true) await vi.waitFor(() => expect(released).toHaveLength(4)) - expect(released.map((emission) => emission.data)).toEqual([ - TRIGGER, - PROCESS_BOUNDARY_GROUND, - 'late-1', - 'late-2' - ]) + expect(released.map((emission) => emission.data)).toEqual([HEAD, GROUNDED, 'late-1', 'late-2']) }) it('flushes unmodified with no injection when the proof is refuted', async () => { @@ -97,8 +96,8 @@ describe('TerminalShellRecoveryBarrier', () => { barrier.accept(passthrough(`${TRIGGER}nested-shell`)) resolveConfirm?.(false) - await vi.waitFor(() => expect(released).toHaveLength(2)) - expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'nested-shell']) + await vi.waitFor(() => expect(released).toHaveLength(3)) + expect(released.map((emission) => emission.data)).toEqual([HEAD, MARK, 'nested-shell']) expect(barrier.getOwner()).toBeUndefined() }) @@ -110,12 +109,12 @@ describe('TerminalShellRecoveryBarrier', () => { }) barrier.accept(passthrough(`${TRIGGER}prompt`)) - await vi.waitFor(() => expect(released).toHaveLength(2)) - expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'prompt']) + await vi.waitFor(() => expect(released).toHaveLength(3)) + expect(released.map((emission) => emission.data)).toEqual([HEAD, MARK, 'prompt']) resolveConfirm?.(true) await new Promise((resolve) => setTimeout(resolve, 5)) - expect(released).toHaveLength(2) + expect(released).toHaveLength(3) expect(barrier.getOwner()).toBeUndefined() }) @@ -128,7 +127,7 @@ describe('TerminalShellRecoveryBarrier', () => { barrier.accept(passthrough(TRIGGER)) barrier.accept(passthrough('0123456789')) - expect(released.map((emission) => emission.data)).toEqual([TRIGGER, '0123456789']) + expect(released.map((emission) => emission.data)).toEqual([HEAD, MARK, '0123456789']) expect(barrier.getOwner()).toBeUndefined() }) @@ -144,8 +143,8 @@ describe('TerminalShellRecoveryBarrier', () => { alive = false resolveConfirm?.(true) - await vi.waitFor(() => expect(released).toHaveLength(2)) - expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'prompt']) + await vi.waitFor(() => expect(released).toHaveLength(3)) + expect(released.map((emission) => emission.data)).toEqual([HEAD, MARK, 'prompt']) expect(barrier.getOwner()).toBeUndefined() }) @@ -165,11 +164,11 @@ describe('TerminalShellRecoveryBarrier', () => { await vi.waitFor(() => expect(released.map((emission) => emission.data)).toEqual([ - TRIGGER, - PROCESS_BOUNDARY_GROUND, + HEAD, + GROUNDED, 'first-prompt', - '\x1b[?1049hAGAIN\x1b]133;D;9\x07', - PROCESS_BOUNDARY_GROUND, + '\x1b[?1049hAGAIN', + `\x1b]133;D;9\x07${PROCESS_BOUNDARY_GROUND}`, 'second-prompt' ]) ) @@ -274,13 +273,12 @@ describe('TerminalShellRecoveryBarrier', () => { await vi.waitFor(() => expect(released.map((emission) => emission.data)).toEqual([ head, - '37\x07', - PROCESS_BOUNDARY_GROUND, + `37\x07${PROCESS_BOUNDARY_GROUND}`, 'PROMPT' ]) ) expect(released[1]).toMatchObject({ rawStartSeq: head.length, rawEndSeq: head.length + 3 }) - expect(released[3]).toMatchObject({ + expect(released[2]).toMatchObject({ rawStartSeq: head.length + 3, rawEndSeq: head.length + tail.length }) @@ -308,11 +306,7 @@ describe('TerminalShellRecoveryBarrier', () => { await settled await vi.waitFor(() => - expect(released.map((emission) => emission.data)).toEqual([ - TRIGGER, - PROCESS_BOUNDARY_GROUND, - 'after-poison' - ]) + expect(released.map((emission) => emission.data)).toEqual([HEAD, GROUNDED, 'after-poison']) ) await expect(barrier.idle()).resolves.toBeUndefined() }) @@ -350,11 +344,7 @@ describe('TerminalShellRecoveryBarrier', () => { barrier.accept(passthrough('\x1b]133;D;137\x07prompt')) await vi.waitFor(() => expect(barrier.getOwner()).toBe('shell')) - expect(released.slice(-3).map((emission) => emission.data)).toEqual([ - '\x1b]133;D;137\x07', - PROCESS_BOUNDARY_GROUND, - 'prompt' - ]) + expect(released.slice(-2).map((emission) => emission.data)).toEqual([GROUNDED, 'prompt']) // Grounded once: the next prompt opens no episode. barrier.accept(passthrough('\x1b]133;C\x07ls\x1b]133;D;0\x07')) expect(confirm).toHaveBeenCalledTimes(6) @@ -375,9 +365,10 @@ describe('TerminalShellRecoveryBarrier', () => { await vi.waitFor(() => expect(barrier.getOwner()).toBe('shell')) expect(released.map((emission) => emission.data)).toEqual([ - leak, - 'frame\x1b]133;D;137\x07', - PROCESS_BOUNDARY_GROUND, + leak.slice(0, -'\x1b]133;D;0\x07'.length), + '\x1b]133;D;0\x07', + 'frame', + GROUNDED, 'prompt' ]) }) @@ -403,7 +394,7 @@ describe('TerminalShellRecoveryBarrier', () => { const barrier = new TerminalShellRecoveryBarrier({ confirmShellForeground: confirm, release: (emission) => { - if (!headThrown && emission.data === TRIGGER) { + if (!headThrown && emission.data === HEAD) { headThrown = true throw new Error('client transport died mid-broadcast') } @@ -417,10 +408,7 @@ describe('TerminalShellRecoveryBarrier', () => { resolveConfirm?.(true) await vi.waitFor(() => - expect(released.map((emission) => emission.data)).toEqual([ - PROCESS_BOUNDARY_GROUND, - 'SHELL-PROMPT' - ]) + expect(released.map((emission) => emission.data)).toEqual([GROUNDED, 'SHELL-PROMPT']) ) expect(barrier.getOwner()).toBe('shell') }) @@ -429,11 +417,11 @@ describe('TerminalShellRecoveryBarrier', () => { const { barrier, released } = createBarrier({ confirm: () => new Promise(() => {}) }) barrier.accept(passthrough(`${TRIGGER}prompt`)) - expect(released.map((emission) => emission.data)).toEqual([TRIGGER]) + expect(released.map((emission) => emission.data)).toEqual([HEAD]) barrier.flushPending() - expect(released.map((emission) => emission.data)).toEqual([TRIGGER, 'prompt']) + expect(released.map((emission) => emission.data)).toEqual([HEAD, MARK, 'prompt']) expect(barrier.getOwner()).toBeUndefined() }) @@ -442,8 +430,50 @@ describe('TerminalShellRecoveryBarrier', () => { barrier.accept(passthrough(`${TRIGGER}prompt`)) barrier.dispose() - expect(released.map((emission) => emission.data)).toEqual([TRIGGER]) + expect(released.map((emission) => emission.data)).toEqual([HEAD]) barrier.accept(passthrough('after-dispose')) expect(released).toHaveLength(1) }) + + it('carries the ground on an ESC-backslash terminator split from its ESC', async () => { + const { barrier, released } = createBarrier() + const head = '\x1b[?1049hTUI\x1b]133;D;137\x1b' + barrier.accept(passthrough(head, 0)) + barrier.accept(passthrough('\\PROMPT', head.length)) + + await vi.waitFor(() => expect(barrier.getOwner()).toBe('shell')) + expect(released).toEqual([ + passthrough(head, 0), + { + data: `\\${PROCESS_BOUNDARY_GROUND}`, + rawStartSeq: head.length, + rawEndSeq: head.length + 1, + transformed: true + }, + passthrough('PROMPT', head.length + 1) + ]) + }) + + it.each([ + ['confirmed', async () => true], + ['refuted', async () => false], + ['timed out', () => new Promise(() => {})] + ])('covers at least one raw unit with every emission when %s', async (_, confirm) => { + const { barrier, released } = createBarrier({ confirm, maxPendingMs: 20 }) + const stream = [`${TRIGGER}p1`, '\x1b[?1049h\x1b]133;D;1\x1b', '\\p2', `${TRIGGER}`, 'p3'] + let seq = 0 + for (const data of stream) { + barrier.accept(passthrough(data, seq)) + seq += data.length + } + await vi.waitFor(() => expect(released.at(-1)?.rawEndSeq).toBe(seq)) + + expect(released.every((emission) => emission.rawEndSeq > emission.rawStartSeq)).toBe(true) + const raw = released.map((emission) => + emission.transformed + ? emission.data.slice(0, emission.rawEndSeq - emission.rawStartSeq) + : emission.data + ) + expect(raw.join('')).toBe(stream.join('')) + }) }) diff --git a/src/main/daemon/terminal-shell-recovery-barrier.ts b/src/main/daemon/terminal-shell-recovery-barrier.ts index 69149835216..6fe5832dcf1 100644 --- a/src/main/daemon/terminal-shell-recovery-barrier.ts +++ b/src/main/daemon/terminal-shell-recovery-barrier.ts @@ -7,6 +7,9 @@ import type { TerminalOwner } from '../../shared/terminal-owner' // flood or hang means the trigger misfired, so bail out and flush unmodified. const MAX_QUEUED_BYTES = 262_144 const MAX_PENDING_MS = 750 +// Why bounded: the grounded mark is one indivisible span, and the relay never +// sends a span wider than its 16K source frame. +const MAX_HELD_MARK_CHARS = 4096 export type TerminalShellRecoveryBarrierOptions = { /** Fresh execution-host proof that the spawned shell owns the PTY foreground. */ @@ -22,13 +25,17 @@ export type TerminalShellRecoveryBarrierOptions = { * Ordered output barrier for dead-TUI mode recovery. Sits between startup * ingress and the output plane. When a shell-integration command-done marker * (OSC 133;D) arrives while the alternate screen is still active, the stream - * pauses at that exact byte boundary, the execution host proves the shell owns - * the PTY foreground, and on proof a mode reset is injected as in-stream output - * so every downstream consumer (host emulator, mirrors, attached renderers, + * holds that marker, the execution host proves the shell owns the PTY + * foreground, and on proof a mode reset is injected as in-stream output so + * every downstream consumer (host emulator, mirrors, attached renderers, * history) converges — and the queued shell prompt then paints onto the normal - * buffer instead of the discarded alternate screen. Any failure (refuted proof, - * timeout, overflow, death, disposal) flushes the queue unmodified, preserving - * incumbent behavior. Clean alternate-screen exits prove ownership without + * buffer instead of the discarded alternate screen. Holding the whole marker + * keeps a mid-proof snapshot on an escape boundary. The reset rides on it so + * every emission covers at least one raw unit: a zero-raw span is never sent by + * credit-windowed delivery (SSH relay), and its seq would not rise above a + * mid-proof snapshot, so snapshot-seq dedup would drop it. Any failure (refuted + * proof, timeout, overflow, death, disposal) flushes the queue unmodified, + * preserving incumbent behavior. Clean alternate-screen exits prove ownership without * pausing: the model needs no correction, only snapshot metadata. */ export class TerminalShellRecoveryBarrier { @@ -43,7 +50,6 @@ export class TerminalShellRecoveryBarrier { private queuedBytes = 0 private pending = false private pendingGeneration = 0 - private pendingRawSeq = 0 private pendingEpisode = 0 private bailTimer: ReturnType | null = null private idleWaiters: (() => void)[] = [] @@ -187,12 +193,16 @@ export class TerminalShellRecoveryBarrier { this.releaseDownstream(emission) return } + // end >= 1 keeps the held mark inside this emission's raw span. const splittable = - !emission.transformed && emission.rawEndSeq - emission.rawStartSeq === emission.data.length + end >= 1 && + !emission.transformed && + emission.rawEndSeq - emission.rawStartSeq === emission.data.length if (!splittable) { // Why skip the episode: the raw-seq boundary inside a transformed emission - // cannot be reconstructed, so release everything and keep the scanner - // honest about the remainder — incumbent behavior for this rare corner. + // (or a mark ending outside this one) cannot be reconstructed, so release + // everything and keep the scanner honest about the remainder — incumbent + // behavior for this rare corner. try { this.releaseDownstream(emission) } catch { @@ -202,20 +212,29 @@ export class TerminalShellRecoveryBarrier { this.consumeForStateOnly(emission.data.slice(end)) return } + const start = Math.max(events.uncleanDeathTriggerStart ?? 0, end - MAX_HELD_MARK_CHARS) + const markSeq = emission.rawStartSeq + start const splitSeq = emission.rawStartSeq + end - try { - this.releaseDownstream({ - data: emission.data.slice(0, end), - rawStartSeq: emission.rawStartSeq, - rawEndSeq: splitSeq, - transformed: false - }) - } catch { - // Why swallowed: the emulator and records already took the head inside - // emit before a client's broadcast threw; aborting here would cost the - // post-boundary prompt its entire recovery episode. + if (start > 0) { + try { + this.releaseDownstream({ + data: emission.data.slice(0, start), + rawStartSeq: emission.rawStartSeq, + rawEndSeq: markSeq, + transformed: false + }) + } catch { + // Why swallowed: the emulator and records already took the head inside + // emit before a client's broadcast threw; aborting here would cost the + // post-boundary prompt its entire recovery episode. + } } - this.enterPending(splitSeq) + this.enterPending({ + data: emission.data.slice(start, end), + rawStartSeq: markSeq, + rawEndSeq: splitSeq, + transformed: false + }) if (end < emission.data.length) { this.enqueue({ data: emission.data.slice(end), @@ -240,14 +259,15 @@ export class TerminalShellRecoveryBarrier { } } - private enterPending(rawSeq: number): void { + /** Opens an episode holding `mark` as the queue head, already scanned. */ + private enterPending(mark: PtyIngressEmission): void { this.pending = true this.pendingEpisode += 1 this.pendingGeneration = this.scanner.generation - this.pendingRawSeq = rawSeq const episode = this.pendingEpisode this.bailTimer = setTimeout(() => this.finishPending(episode, false), this.maxPendingMs) this.bailTimer.unref?.() + this.enqueue(mark) // Why the guard: the callback is injected; a synchronous throw must not // escape after pending flipped true and strand the episode until the bail. let proof: Promise @@ -279,21 +299,9 @@ export class TerminalShellRecoveryBarrier { this.queue = [] this.queuedBytes = 0 try { - if (confirmed && this.isAlive()) { - // Scanned before release so alt-state stays honest. - const ground = this.scanner.groundProcessBoundary() - try { - this.releaseDownstream({ - data: ground, - rawStartSeq: this.pendingRawSeq, - rawEndSeq: this.pendingRawSeq, - transformed: true - }) - } catch { - // Why swallowed: a throwing downstream client must not strand the - // queued prompt bytes below. - } - this.scanner.trySetOwner(this.pendingGeneration) + const mark = queued.shift() + if (mark) { + this.releaseMark(mark, confirmed && this.isAlive()) } for (let index = 0; index < queued.length; index += 1) { if (this.disposed) { @@ -318,6 +326,27 @@ export class TerminalShellRecoveryBarrier { } } + private releaseMark(mark: PtyIngressEmission, grounded: boolean): void { + try { + this.releaseDownstream( + grounded + ? { + ...mark, + // Scanned before release so alt-state stays honest. + data: mark.data + this.scanner.groundProcessBoundary(), + transformed: true + } + : mark + ) + } catch { + // Why swallowed: a throwing downstream client must not strand the + // queued prompt bytes behind it. + } + if (grounded) { + this.scanner.trySetOwner(this.pendingGeneration) + } + } + private enqueue(emission: PtyIngressEmission): void { this.queue.push(emission) this.queuedBytes += emission.data.length diff --git a/src/main/dsh/dsh-home-patch.test.ts b/src/main/dsh/dsh-home-patch.test.ts new file mode 100644 index 00000000000..53ab8986715 --- /dev/null +++ b/src/main/dsh/dsh-home-patch.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { + applyManagedDshPatch, + findManagedDshPatchRegion, + readManagedDshHooksConfigPath, + removeManagedDshPatch +} from './dsh-home-patch' + +const HOOKS_PATH = '/home/dev/.orca/agent-hooks/dsh-hooks.json' + +/** applyManagedDshPatch returns null only for files it refuses to edit; these cases expect an edit. */ +function applyOrFail(text: string, hooksPath = HOOKS_PATH): string { + const next = applyManagedDshPatch(text, hooksPath) + if (next === null) { + throw new Error('expected the patch file to be editable') + } + return next +} + +// The body DSH writes into a freshly initialized patch file. +const PRISTINE = [ + '# Your patch layer for this dsh profile, applied after every bundle layer:', + '# a top-level YAML array of loader patch entries (id-targeted config', + '# overrides, disables, and insert lists; `!!js` expressions allowed).', + '[]', + '' +].join('\n') + +const USER_ROWS = ['- id: llm-deepseek', ' config:', " apiKeyEnv: 'MY_KEY'", ''].join('\n') + +describe('applyManagedDshPatch', () => { + it('creates the managed block in an empty file', () => { + const text = applyOrFail('') + expect(readManagedDshHooksConfigPath(text)).toBe(HOOKS_PATH) + expect(text).toContain("name: '@deepseek-ai/dsh-hooks-claude-code'") + expect(text.endsWith('\n')).toBe(true) + }) + + it('replaces the empty flow sequence DSH ships, keeping its comments', () => { + const text = applyOrFail(PRISTINE) + // `- item` after `[]` is a YAML parse error, so the `[]` token has to go. + expect(text).not.toMatch(/^\[]$/m) + expect(text).toContain('# Your patch layer for this dsh profile') + expect(readManagedDshHooksConfigPath(text)).toBe(HOOKS_PATH) + }) + + it('appends after user rows without touching them', () => { + const text = applyOrFail(USER_ROWS) + expect(text.startsWith(USER_ROWS.trimEnd())).toBe(true) + expect(readManagedDshHooksConfigPath(text)).toBe(HOOKS_PATH) + }) + + it('rewrites its own block in place rather than stacking copies', () => { + const once = applyOrFail(USER_ROWS, '/old/path.json') + const twice = applyOrFail(once) + expect(twice.match(/orca-managed-dsh-hooks \(managed by Orca/g)).toHaveLength(1) + expect(readManagedDshHooksConfigPath(twice)).toBe(HOOKS_PATH) + expect(twice).not.toContain('/old/path.json') + }) + + it('is idempotent', () => { + const once = applyOrFail(PRISTINE) + expect(applyOrFail(once)).toBe(once) + }) + + it('treats `[] # comment` as the empty document it is, keeping the comment', () => { + // The exact-match check missed this and appended `- insert:` after the flow sequence. + const text = '[] # keep empty\n' + const next = applyOrFail(text) + expect(next).not.toMatch(/^\s*\[]/m) + expect(next).toContain('# keep empty') + expect(readManagedDshHooksConfigPath(next)).toBe(HOOKS_PATH) + }) + + it.each([ + '- id: llm-deepseek\n config: {}\n', // block sequence — editable + '\n', + '# only a comment\n' + ])('still edits %j', (text) => { + expect(applyManagedDshPatch(text, HOOKS_PATH)).not.toBeNull() + }) + + it.each(['[{ id: llm-deepseek }]\n', '[\n { id: a },\n { id: b }\n]\n'])( + 'refuses to append after the non-empty flow sequence %j', + (text) => { + // YAML forbids a block entry after a flow sequence: appending would leave DSH unable + // to parse the user's own layer either, so there is no safe in-place edit. + expect(applyManagedDshPatch(text, HOOKS_PATH)).toBeNull() + } + ) + + it('quotes a path containing a single quote', () => { + const awkward = "/home/o'brien/.orca/agent-hooks/dsh-hooks.json" + expect(readManagedDshHooksConfigPath(applyOrFail('', awkward))).toBe(awkward) + }) +}) + +describe('removeManagedDshPatch', () => { + it('restores the empty flow sequence when nothing else remains', () => { + const installed = applyOrFail(PRISTINE) + const { text, changed } = removeManagedDshPatch(installed) + expect(changed).toBe(true) + // Without this the file would come back as an unparseable empty document. + expect(text.trimEnd().endsWith('[]')).toBe(true) + expect(text).toContain('# Your patch layer for this dsh profile') + }) + + it('leaves user rows alone and adds no [] when they remain', () => { + const installed = applyOrFail(USER_ROWS) + const { text } = removeManagedDshPatch(installed) + expect(text.trimEnd()).toBe(USER_ROWS.trimEnd()) + }) + + it('reports no change when the file carries no managed block', () => { + expect(removeManagedDshPatch(USER_ROWS)).toEqual({ text: USER_ROWS, changed: false }) + }) +}) + +describe('findManagedDshPatchRegion', () => { + const ORPHAN_START = '# >>> orca-managed-dsh-hooks (managed by Orca; do not edit) >>>' + + it('fails closed on a truncated region rather than guessing its extent', () => { + // Splicing a guessed end marker would delete the user rows that follow. + const truncated = `${ORPHAN_START}\n${USER_ROWS}` + expect(findManagedDshPatchRegion(truncated)).toBeNull() + expect(removeManagedDshPatch(truncated).changed).toBe(false) + }) + + it('never pairs an orphan start with a later block\u2019s end', () => { + // The data-loss shape: an interrupted write leaves an orphan start above the user's + // rows, and the next install appends a complete block below them. Pairing the orphan + // with the new end marker would make the region cover the user's rows, so install + // (rewrite) and remove (strip) would both delete them. + const truncated = `${ORPHAN_START}\n${USER_ROWS}` + const installed = applyOrFail(truncated) + expect(installed).toContain('apiKeyEnv') + + const region = findManagedDshPatchRegion(installed) + expect(region).not.toBeNull() + const covered = installed.split('\n').slice(region?.startLine ?? 0, (region?.endLine ?? 0) + 1) + expect(covered.join('\n')).not.toContain('apiKeyEnv') + + // Both mutating paths must leave the user's rows intact, twice over. + expect(applyOrFail(installed)).toContain('apiKeyEnv') + expect(removeManagedDshPatch(installed).text).toContain('apiKeyEnv') + }) +}) diff --git a/src/main/dsh/dsh-home-patch.ts b/src/main/dsh/dsh-home-patch.ts new file mode 100644 index 00000000000..723d030e214 --- /dev/null +++ b/src/main/dsh/dsh-home-patch.ts @@ -0,0 +1,195 @@ +/** + * Orca's managed block inside `$DSH_HOME/cordis.patch.yml`. + * + * That file is a hand-editable top-level YAML sequence of loader patch entries, and no + * YAML library is vendored in the main process, so Orca manages only its own + * marker-delimited region: install rewrites the region, remove strips it, and everything + * outside the markers is copied through byte for byte. Appending sequence entries to a + * block sequence is always valid YAML, so the region can live at the end of any file. + * + * The one shape that is not append-safe is an empty *flow* sequence (`[]`), which is what + * DSH writes into a freshly initialized patch file. `- item` after `[]` is a parse error, + * so that token is dropped when the managed block is added and restored when it is the + * last thing removed — otherwise the file would come back as an unparseable empty + * document. + */ + +const START_MARKER = '# >>> orca-managed-dsh-hooks (managed by Orca; do not edit) >>>' +const END_MARKER = '# <<< orca-managed-dsh-hooks <<<' + +/** The loader row id Orca owns. A patch row is addressed by id, so this must be stable. */ +const MANAGED_ROW_ID = 'orca-agent-hooks' + +const EMPTY_FLOW_SEQUENCE = '[]' + +export type ManagedDshPatchRegion = { startLine: number; endLine: number } + +function splitLines(text: string): string[] { + return text.split('\n') +} + +/** Locate the managed region, or null when the file carries none. */ +export function findManagedDshPatchRegion(text: string): ManagedDshPatchRegion | null { + // Why the NEAREST preceding start, not the first one: an interrupted write can leave an + // orphan start marker with no end. Pairing that orphan with a LATER block's end marker + // makes the region swallow every row in between — so the next install (which rewrites the + // region) or remove (which strips it) would delete the user's own rows. Walking forward + // and resetting the candidate on each start keeps an orphan un-paired, which leaves it as + // an inert comment line rather than a deletion range. + let startLine = -1 + for (const [index, line] of splitLines(text).entries()) { + const trimmed = line.trim() + if (trimmed === START_MARKER) { + startLine = index + } else if (trimmed === END_MARKER && startLine !== -1) { + return { startLine, endLine: index } + } + } + return null +} + +function buildManagedBlock(managedHooksPath: string): string[] { + return [ + START_MARKER, + '- insert:', + ` - id: ${MANAGED_ROW_ID}`, + " name: '@deepseek-ai/dsh-hooks-claude-code'", + ' config:', + ` configPath: ${quoteYamlScalar(managedHooksPath)}`, + END_MARKER + ] +} + +/** Single-quoted YAML scalar: the only escape inside one is a doubled quote. */ +function quoteYamlScalar(value: string): string { + return `'${value.replaceAll("'", "''")}'` +} + +function unquoteYamlScalar(value: string): string { + const trimmed = value.trim() + if (trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2) { + return trimmed.slice(1, -1).replaceAll("''", "'") + } + if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) { + return trimmed.slice(1, -1) + } + return trimmed +} + +/** The `configPath` Orca's managed region currently points at, if any. */ +export function readManagedDshHooksConfigPath(text: string): string | undefined { + const region = findManagedDshPatchRegion(text) + if (!region) { + return undefined + } + for (const line of splitLines(text).slice(region.startLine + 1, region.endLine)) { + const match = /^\s*configPath:\s*(.+?)\s*$/.exec(line) + if (match) { + return unquoteYamlScalar(match[1]) + } + } + return undefined +} + +function stripRegion(lines: string[], region: ManagedDshPatchRegion): string[] { + return [...lines.slice(0, region.startLine), ...lines.slice(region.endLine + 1)] +} + +function isBlank(line: string): boolean { + return line.trim().length === 0 +} + +function isComment(line: string): boolean { + return line.trim().startsWith('#') +} + +function documentBody(lines: readonly string[]): readonly string[] { + return lines.filter((line) => !isBlank(line) && !isComment(line)) +} + +/** Strips a trailing `# …` so `[] # keep empty` reads as the empty sequence it is. */ +function withoutTrailingComment(line: string): string { + const hash = line.indexOf('#') + return (hash === -1 ? line : line.slice(0, hash)).trim() +} + +/** True when the body is the empty flow sequence, with or without a trailing comment. */ +function isEmptyFlowDocument(lines: readonly string[]): boolean { + const body = documentBody(lines) + return body.length === 1 && withoutTrailingComment(body[0]) === EMPTY_FLOW_SEQUENCE +} + +/** + * True when Orca cannot append its block to this file: the body is a NON-EMPTY flow + * sequence (`[a, b]`, or one spread over lines). + * + * Why it matters: YAML forbids a block entry after a flow sequence, so appending Orca's + * `- insert:` would produce a file DSH cannot parse — losing the user's own patch layer as + * well as Orca's hooks. There is no safe in-place edit, so install refuses instead. + * + * Exported because status has to report the same refusal on every read, not just on the + * install that first hit it. + */ +export function isDshPatchFileUnappendable(text: string): boolean { + const lines = splitLines(text) + const body = documentBody(lines) + return body.length > 0 && body[0].trimStart().startsWith('[') && !isEmptyFlowDocument(lines) +} + +function withoutTrailingBlanks(lines: readonly string[]): readonly string[] { + const end = lines.findLastIndex((line) => !isBlank(line)) + return lines.slice(0, end + 1) +} + +function joinPreservingTrailingNewline(lines: readonly string[]): string { + const text = lines.join('\n') + return text.endsWith('\n') || text.length === 0 ? text : `${text}\n` +} + +/** + * Install (or refresh) Orca's managed region so the DSH hook bridge reads + * `managedHooksPath`. Everything outside the markers is preserved. + * + * Returns null when the file cannot be edited safely — see isNonEmptyFlowDocument. The + * caller reports that; it must never write a file DSH would then fail to parse. + */ +export function applyManagedDshPatch(text: string, managedHooksPath: string): string | null { + const lines = splitLines(text) + const block = buildManagedBlock(managedHooksPath) + const region = findManagedDshPatchRegion(text) + if (region) { + return joinPreservingTrailingNewline([ + ...lines.slice(0, region.startLine), + ...block, + ...lines.slice(region.endLine + 1) + ]) + } + if (isDshPatchFileUnappendable(text)) { + return null + } + // Why dropped: `- item` after `[]` is a parse error, and an empty sequence has nothing to + // preserve. Only the token goes — a trailing comment on that line stays. + const kept = isEmptyFlowDocument(lines) + ? lines.map((line) => + withoutTrailingComment(line) === EMPTY_FLOW_SEQUENCE + ? line.slice(line.indexOf(EMPTY_FLOW_SEQUENCE) + EMPTY_FLOW_SEQUENCE.length) + : line + ) + : lines + return joinPreservingTrailingNewline([...withoutTrailingBlanks(kept), ...block]) +} + +/** Strip Orca's managed region, restoring `[]` when nothing else is left. */ +export function removeManagedDshPatch(text: string): { text: string; changed: boolean } { + const region = findManagedDshPatchRegion(text) + if (!region) { + return { text, changed: false } + } + const kept = withoutTrailingBlanks(stripRegion(splitLines(text), region)) + // Why restore `[]`: a document of comments alone is not a valid entry list, so removing + // Orca's block must not leave DSH a file it cannot parse. + const body = kept.every((line) => isBlank(line) || isComment(line)) + ? [...kept, EMPTY_FLOW_SEQUENCE] + : kept + return { text: joinPreservingTrailingNewline(body), changed: true } +} diff --git a/src/main/dsh/hook-service.test.ts b/src/main/dsh/hook-service.test.ts new file mode 100644 index 00000000000..331ecd97888 --- /dev/null +++ b/src/main/dsh/hook-service.test.ts @@ -0,0 +1,200 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { DshHookService } from './hook-service' +import { DSH_HOOK_EVENTS } from './hook-settings' + +// Why: getSharedManagedScriptPath() writes under homedir()/.orca and the patch layer +// resolves via DSH_HOME ?? ~/.dsh. Point the home env at a temp dir and clear DSH_HOME so +// install/remove never touches the real ~/.orca or a developer's own DSH home. +// Why both names: os.homedir() reads $HOME on POSIX and %USERPROFILE% on Windows, and this +// file asserts the Windows script name too — setting only HOME would let a Windows run edit +// the developer's real home. +let home: string +let originalHome: string | undefined +let originalUserProfile: string | undefined +let originalDshHome: string | undefined + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-dsh-hook-')) + originalHome = process.env.HOME + originalUserProfile = process.env.USERPROFILE + originalDshHome = process.env.DSH_HOME + process.env.HOME = home + process.env.USERPROFILE = home + delete process.env.DSH_HOME +}) + +afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE + } else { + process.env.USERPROFILE = originalUserProfile + } + if (originalDshHome === undefined) { + delete process.env.DSH_HOME + } else { + process.env.DSH_HOME = originalDshHome + } + rmSync(home, { recursive: true, force: true }) +}) + +const configPath = (): string => join(home, '.dsh', 'cordis.patch.yml') +const managedHooksPath = (): string => join(home, '.orca', 'agent-hooks', 'dsh-hooks.json') +const scriptPath = (): string => + join(home, '.orca', 'agent-hooks', process.platform === 'win32' ? 'dsh-hook.cmd' : 'dsh-hook.sh') + +function readManagedHooks(): { hooks: Record } { + const parsed: { hooks: Record } = JSON.parse( + readFileSync(managedHooksPath(), 'utf-8') + ) + return parsed +} + +describe('DshHookService', () => { + it('reports not_installed before install, with no DSH home on disk', () => { + expect(new DshHookService().getStatus().state).toBe('not_installed') + }) + + it('installs the patch block, the managed hooks file, and the script', () => { + const status = new DshHookService().install() + expect(status.state).toBe('installed') + expect(status.managedHooksPresent).toBe(true) + expect(status.configPath).toBe(configPath()) + + const patch = readFileSync(configPath(), 'utf-8') + expect(patch).toContain("name: '@deepseek-ai/dsh-hooks-claude-code'") + expect(patch).toContain(`configPath: '${managedHooksPath()}'`) + + // Exactly the events DSH's bridge can fire — registering more would register nothing + // for them and leave the status permanently `partial`. + expect(Object.keys(readManagedHooks().hooks).sort()).toEqual([...DSH_HOOK_EVENTS].sort()) + expect(readFileSync(scriptPath(), 'utf-8').length).toBeGreaterThan(0) + }) + + it('posts to the dsh hook route', () => { + new DshHookService().install() + expect(readFileSync(scriptPath(), 'utf-8')).toContain('/hook/dsh') + }) + + it('restores the pane identity DSH scrubs before anything reads it', () => { + // DSH drops env names containing KEY/TOKEN, so the script has to recover ORCA_PANE_KEY + // and ORCA_AGENT_LAUNCH_TOKEN from their aliases before the guard or the spool run. + new DshHookService().install() + const script = readFileSync(scriptPath(), 'utf-8') + const restoreAt = script.indexOf('ORCA_AGENT_PANE') + const guardAt = script.indexOf('ORCA_AGENT_HOOK_PORT') + expect(restoreAt).toBeGreaterThan(-1) + expect(script).toContain('ORCA_AGENT_LAUNCH') + expect(restoreAt).toBeLessThan(guardAt) + }) + + it('is idempotent', () => { + const service = new DshHookService() + service.install() + const first = readFileSync(configPath(), 'utf-8') + expect(service.install().state).toBe('installed') + expect(readFileSync(configPath(), 'utf-8')).toBe(first) + }) + + it('preserves an existing user patch layer through install and remove', () => { + const userRows = ['- id: llm-deepseek', ' config:', ' thinking: disabled', ''].join('\n') + mkdirSync(join(home, '.dsh'), { recursive: true }) + writeFileSync(configPath(), userRows, 'utf-8') + + const service = new DshHookService() + expect(service.install().state).toBe('installed') + expect(readFileSync(configPath(), 'utf-8')).toContain('thinking: disabled') + + expect(service.remove().state).toBe('not_installed') + expect(readFileSync(configPath(), 'utf-8').trimEnd()).toBe(userRows.trimEnd()) + }) + + it('removes the managed hooks file along with the block', () => { + const service = new DshHookService() + service.install() + service.remove() + expect(() => readFileSync(managedHooksPath(), 'utf-8')).toThrow() + expect(service.getStatus().state).toBe('not_installed') + }) + + it('reports partial when an event loses its managed hook', () => { + const service = new DshHookService() + service.install() + const managed = readManagedHooks() + delete managed.hooks.Stop + writeFileSync(managedHooksPath(), JSON.stringify(managed, null, 2), 'utf-8') + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.detail).toContain('Stop') + }) + + it('reports not_installed when the block points somewhere else', () => { + const service = new DshHookService() + service.install() + writeFileSync( + configPath(), + readFileSync(configPath(), 'utf-8').replace(managedHooksPath(), '/somewhere/else.json'), + 'utf-8' + ) + const status = service.getStatus() + expect(status.state).toBe('not_installed') + expect(status.detail).toContain('/somewhere/else.json') + }) + + // Why POSIX-only: on Windows chmod toggles the read-only attribute, so `mode & 0o777` + // reads 0o666 for any writable file and the assertion cannot mean what it says. + it.skipIf(process.platform === 'win32')('keeps an owner-only patch file owner-only', () => { + // CWE-732: the temp+rename replacement must not widen the file to the umask default. + const userRows = '- id: llm-deepseek\n config: {}\n' + mkdirSync(join(home, '.dsh'), { recursive: true }) + writeFileSync(configPath(), userRows, 'utf-8') + chmodSync(configPath(), 0o600) + + expect(new DshHookService().install().state).toBe('installed') + expect(statSync(configPath()).mode & 0o777).toBe(0o600) + }) + + it('refuses a flow-style patch file instead of corrupting it', () => { + // Appending a block entry after `[…]` is invalid YAML — DSH would then fail to load the + // user's own layer as well as Orca's hooks, so install must change nothing. + const flow = '[{ id: llm-deepseek }]\n' + mkdirSync(join(home, '.dsh'), { recursive: true }) + writeFileSync(configPath(), flow, 'utf-8') + + const service = new DshHookService() + const installed = service.install() + expect(installed.state).toBe('error') + expect(installed.detail).toContain('flow-style sequence') + expect(readFileSync(configPath(), 'utf-8')).toBe(flow) + + // Why re-read: a later status poll must keep saying why, not decay to a bare + // `not_installed` that gives the user nothing to act on. + const polled = service.getStatus() + expect(polled.state).toBe('error') + expect(polled.detail).toContain('flow-style sequence') + }) + + it('honours DSH_HOME', () => { + const dshHome = join(home, 'custom-dsh-home') + process.env.DSH_HOME = dshHome + const status = new DshHookService().install() + expect(status.configPath).toBe(join(dshHome, 'cordis.patch.yml')) + expect(status.state).toBe('installed') + }) +}) diff --git a/src/main/dsh/hook-service.ts b/src/main/dsh/hook-service.ts new file mode 100644 index 00000000000..0767f686f24 --- /dev/null +++ b/src/main/dsh/hook-service.ts @@ -0,0 +1,253 @@ +import { mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname } from 'node:path' +import type { SFTPWrapper } from 'ssh2' + +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { + buildWindowsAgentHookCurlPostCommand, + writeHooksJson, + writeManagedScript +} from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' +import { + readTextFileRemote, + writeManagedScriptRemote, + writeTextFileRemoteAtomic +} from '../agent-hooks/installer-utils-remote' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' +import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' +import { + applyManagedDshPatch, + isDshPatchFileUnappendable, + readManagedDshHooksConfigPath, + removeManagedDshPatch +} from './dsh-home-patch' +import { + buildDshManagedHooksFile, + DSH_HOOK_EVENTS, + getDshConfigPath, + getDshManagedCommand, + getDshManagedCommandMatcher, + getDshManagedHooksPath, + getDshManagedScriptPath, + getDshRemoteConfigPath, + getDshRemoteManagedCommand, + getDshRemoteManagedHooksPath, + readManagedDshHookEvents +} from './hook-settings' + +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + // Why: same scrub as POSIX — restore the canonical names from their aliases first. + 'if not defined ORCA_PANE_KEY if defined ORCA_AGENT_PANE set "ORCA_PANE_KEY=%ORCA_AGENT_PANE%"', + 'if not defined ORCA_AGENT_LAUNCH_TOKEN if defined ORCA_AGENT_LAUNCH set "ORCA_AGENT_LAUNCH_TOKEN=%ORCA_AGENT_LAUNCH%"', + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + ...buildWindowsHookEnvironmentGuardLines(), + buildWindowsAgentHookCurlPostCommand('dsh'), + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + // Why first: DSH's shell executor drops every env var whose NAME contains KEY, TOKEN, + // SECRET or PASSWORD before the hook starts, which takes ORCA_PANE_KEY and + // ORCA_AGENT_LAUNCH_TOKEN with it. Orca mirrors both onto scrub-safe aliases at spawn + // (see agent-hook-scrub-safe-env.ts); restore the canonical names from them so every + // line below — including the shared spool and post builders — is unchanged. + ': "${ORCA_PANE_KEY:=${ORCA_AGENT_PANE:-}}"', + ': "${ORCA_AGENT_LAUNCH_TOKEN:=${ORCA_AGENT_LAUNCH:-}}"', + 'export ORCA_PANE_KEY ORCA_AGENT_LAUNCH_TOKEN', + ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('dsh'), + // Why: the endpoint file holds the live port/token; a PTY that outlived an Orca + // restart carries stale env, so source it to reach the new server. + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', + ' exit 0', + 'fi', + ...buildPosixAgentHookPostCommand('dsh').map((line, index, lines) => + index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line + ), + 'exit 0', + '' + ].join('\n') +} + +/** '' when the file is absent (both are created lazily), null when it exists but cannot be read. */ +function readTextOrAbsent(path: string): string | null { + try { + return readFileSync(path, 'utf-8') + } catch (error) { + return isDefinitiveAbsence(error) ? '' : null + } +} + +/** null for anything that is not parseable JSON; the caller reads that as "no events". */ +function parseJsonOrNull(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return null + } +} + +function writePatchText(configPath: string, text: string): void { + mkdirSync(dirname(configPath), { recursive: true }) + // Why writeHooksJson: it owns the temp+rename and the rolling .bak this file needs too. + // Why preserveMode: an owner-only patch file must not widen to the umask default on rewrite. + writeHooksJson(configPath, {}, { serialized: text, preserveMode: true }) +} + +/** Why one constant: status has to say exactly what install said, on every later read. */ +const FLOW_STYLE_DETAIL = + 'The DSH home patch is a flow-style sequence ([…]); rewrite it as a block sequence (one `- ` entry per line) so Orca can add its hooks without breaking it' + +function status( + configPath: string, + state: AgentHookInstallState, + detail: string | null, + managedHooksPresent = false +): AgentHookInstallStatus { + return { agent: 'dsh', state, configPath, managedHooksPresent, detail } +} + +function buildStatus( + patchText: string, + managedHooksPath: string, + managedText: string | null, + configPath: string +): AgentHookInstallStatus { + if (managedText === null) { + return status(configPath, 'error', 'Could not read Orca managed hooks file') + } + // Why before the pointer check: a refused file carries no managed region, so the pointer + // path would report a bare `not_installed` and drop the one detail that says why. + if (isDshPatchFileUnappendable(patchText)) { + return status(configPath, 'error', FLOW_STYLE_DETAIL) + } + const pointer = readManagedDshHooksConfigPath(patchText) + if (pointer !== managedHooksPath) { + return status( + configPath, + 'not_installed', + pointer === undefined + ? null + : `The Orca patch block points at ${pointer}, not the Orca managed hooks file` + ) + } + const present = readManagedDshHookEvents( + parseJsonOrNull(managedText), + getDshManagedCommandMatcher() + ) + const missing = DSH_HOOK_EVENTS.filter((event) => !present.has(event)) + if (missing.length === 0) { + return status(configPath, 'installed', null, true) + } + // Why the split: nothing present is an uninstalled agent; some present is a broken install, + // and naming the gap is the only way a user can tell those apart. + return present.size === 0 + ? status(configPath, 'not_installed', null) + : status(configPath, 'partial', `Managed hook missing for events: ${missing.join(', ')}`, true) +} + +/** Installs Orca's status hooks into DSH. See `docs/reference/dsh-harness-integration.md` + * for the profile/patch-layer model and the env-scrub finding the aliases work around. */ +export class DshHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getDshManagedScriptPath(), getManagedScript()) + } + + getStatus(): AgentHookInstallStatus { + const configPath = getDshConfigPath() + const patchText = readTextOrAbsent(configPath) + if (patchText === null) { + return status(configPath, 'error', 'Could not read the DSH home patch file') + } + const managedHooksPath = getDshManagedHooksPath() + return buildStatus(patchText, managedHooksPath, readTextOrAbsent(managedHooksPath), configPath) + } + + install(): AgentHookInstallStatus { + const configPath = getDshConfigPath() + const patchText = readTextOrAbsent(configPath) + if (patchText === null) { + return status(configPath, 'error', 'Could not read the DSH home patch file') + } + const scriptPath = getDshManagedScriptPath() + const managedHooksPath = getDshManagedHooksPath() + // Write the script and the managed hooks file first so the patch layer never points at + // files that do not exist yet — a bridge that cannot read its config runs no hooks. + writeManagedScript(scriptPath, getManagedScript()) + writeHooksJson( + managedHooksPath, + { hooks: {} }, + { serialized: buildDshManagedHooksFile(getDshManagedCommand(scriptPath)) } + ) + const nextText = applyManagedDshPatch(patchText, managedHooksPath) + if (nextText === null) { + // Why refuse rather than edit: YAML forbids a block entry after a flow sequence, so + // appending here would leave DSH unable to parse the user's own patch layer either. + return status(configPath, 'error', FLOW_STYLE_DETAIL) + } + if (nextText !== patchText) { + writePatchText(configPath, nextText) + } + return this.getStatus() + } + + /** Install on an SSH execution host, where DSH's shell contract is always POSIX. */ + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const remoteConfigPath = getDshRemoteConfigPath(remoteHome) + const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/dsh-hook.sh` + const remoteManagedHooksPath = getDshRemoteManagedHooksPath(remoteHome) + try { + const body = (await readTextFileRemote(sftp, remoteConfigPath)) ?? '' + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeTextFileRemoteAtomic( + sftp, + remoteManagedHooksPath, + buildDshManagedHooksFile(getDshRemoteManagedCommand(remoteScriptPath)) + ) + const nextText = applyManagedDshPatch(body, remoteManagedHooksPath) + if (nextText === null) { + return status(remoteConfigPath, 'error', FLOW_STYLE_DETAIL) + } + await writeTextFileRemoteAtomic(sftp, remoteConfigPath, nextText) + return status(remoteConfigPath, 'installed', null, true) + } catch (err) { + return status(remoteConfigPath, 'error', err instanceof Error ? err.message : String(err)) + } + } + + remove(): AgentHookInstallStatus { + const configPath = getDshConfigPath() + const patchText = readTextOrAbsent(configPath) + if (patchText === null) { + return status(configPath, 'error', 'Could not read the DSH home patch file') + } + const { text: nextText, changed } = removeManagedDshPatch(patchText) + if (changed) { + writePatchText(configPath, nextText) + } + // Why force: the file is Orca's own and may already be gone; its absence is the goal. + rmSync(getDshManagedHooksPath(), { force: true }) + return this.getStatus() + } +} + +export const dshHookService = new DshHookService() diff --git a/src/main/dsh/hook-settings.ts b/src/main/dsh/hook-settings.ts new file mode 100644 index 00000000000..8ccbb8a9283 --- /dev/null +++ b/src/main/dsh/hook-settings.ts @@ -0,0 +1,110 @@ +import { homedir } from 'node:os' +import { join, posix as pathPosix } from 'node:path' +import { + buildManagedCommandHook, + createManagedCommandMatcher, + getSharedManagedScriptPath, + wrapPosixHookCommand, + wrapWindowsHookCommand, + type HookDefinition +} from '../agent-hooks/installer-utils' +import { readManagedHookEventsFromJson } from '../agent-hooks/managed-hooks-json-events' + +const DSH_SCRIPT_BASE = 'dsh-hook' + +/** + * The events DeepSeek Harness's own Claude-Code hook bridge + * (`@deepseek-ai/dsh-hooks-claude-code`) can fire. This is a strict subset of Claude's: + * the bridge documents no `Notification`, no `PermissionRequest` and no `SessionEnd`, + * and registering an unsupported event name makes it register nothing for that event. + * `normalizeDshEvent` is written against exactly this list. + */ +export const DSH_HOOK_EVENTS = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'Stop' +] as const + +export const DSH_MANAGED_HOOKS_FILE_NAME = 'dsh-hooks.json' + +/** `$DSH_HOME`, matching the launcher's own `DSH_HOME ?? ~/.dsh` resolution. */ +export function getDshHome(): string { + return process.env.DSH_HOME?.trim() || join(homedir(), '.dsh') +} + +/** + * The home-level patch layer. + * + * Why here and not in a profile: DSH composes every profile as bundle patches, then the + * profile's own `cordis.patch.yml`, then this file. Installing one layer above every + * profile means a pane the user started themselves — any profile, including one Orca + * never launched — still reports status, and Orca never edits a profile the user owns. + */ +export function getDshConfigPath(): string { + return join(getDshHome(), 'cordis.patch.yml') +} + +export function getDshRemoteConfigPath(remoteHome: string): string { + // Why: a remote $DSH_HOME is unknown over SFTP; default matches the launcher's own resolution. + return pathPosix.join(remoteHome.replace(/\/$/, ''), '.dsh', 'cordis.patch.yml') +} + +export function getDshManagedScriptFileName(): string { + return process.platform === 'win32' ? `${DSH_SCRIPT_BASE}.cmd` : `${DSH_SCRIPT_BASE}.sh` +} + +export function getDshManagedScriptPath(): string { + return getSharedManagedScriptPath(getDshManagedScriptFileName()) +} + +export function getDshManagedHooksPath(): string { + return getSharedManagedScriptPath(DSH_MANAGED_HOOKS_FILE_NAME) +} + +export function getDshRemoteManagedHooksPath(remoteHome: string): string { + return pathPosix.join( + remoteHome.replace(/\/$/, ''), + '.orca', + 'agent-hooks', + DSH_MANAGED_HOOKS_FILE_NAME + ) +} + +export function getDshManagedCommand(scriptPath: string): string { + // Why: DSH runs hooks through `ctx.shell`, which the base profile binds to bash + // everywhere except Windows, where it binds to PowerShell — the same split these two + // wrappers already encode. + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) +} + +export function getDshRemoteManagedCommand(scriptPath: string): string { + return wrapPosixHookCommand(scriptPath) +} + +export function getDshManagedCommandMatcher(): (command: string | undefined) => boolean { + return createManagedCommandMatcher(getDshManagedScriptFileName()) +} + +/** + * The managed hooks file is Orca's outright: DSH has no user-owned `hooks.json` + * convention of its own, and the bridge reads whatever single path it is pointed at. So + * generate it wholesale rather than merging into someone's file. + */ +export function buildDshManagedHooksFile(command: string): string { + const hooks: Record = {} + for (const event of DSH_HOOK_EVENTS) { + hooks[event] = [{ hooks: [buildManagedCommandHook(command)] }] + } + return `${JSON.stringify({ hooks }, null, 2)}\n` +} + +export function readManagedDshHookEvents( + parsed: unknown, + isManagedCommand: (command: string | undefined) => boolean +): Set { + return readManagedHookEventsFromJson(parsed, DSH_HOOK_EVENTS, isManagedCommand) +} diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts index a19f6044a46..24d43545e13 100644 --- a/src/main/ipc/notification-options.ts +++ b/src/main/ipc/notification-options.ts @@ -76,7 +76,10 @@ function formatAgentNotificationStatusText(args: NotificationDispatchRequest): s if (args.agentState === 'working') { return translateMain('notifications.agentStatus.working', 'working') } - return args.agentState === 'done' && args.agentInterrupted + if (args.agentState === 'done' && args.agentTurnOutcome === 'failure') { + return translateMain('notifications.agentStatus.failed', 'failed') + } + return args.agentState === 'done' && args.agentTurnOutcome === 'cancellation' ? translateMain('notifications.agentStatus.stopped', 'stopped') : translateMain('notifications.agentStatus.finished', 'finished') } @@ -104,7 +107,7 @@ function hasAgentNotificationSnapshot(args: NotificationDispatchRequest): boolea args.agentToolName || args.agentToolInput || args.agentLastAssistantMessage || - args.agentInterrupted + args.agentTurnOutcome !== undefined ) } diff --git a/src/main/ipc/notifications-message-formatting.test.ts b/src/main/ipc/notifications-message-formatting.test.ts index abf41bfae3c..3d519293f3c 100644 --- a/src/main/ipc/notifications-message-formatting.test.ts +++ b/src/main/ipc/notifications-message-formatting.test.ts @@ -217,7 +217,7 @@ describe('registerNotificationHandlers', () => { worktreeLabel: 'feat/notis', agentType: 'claude', agentState: 'done', - agentInterrupted: true, + agentTurnOutcome: 'cancellation', agentLastAssistantMessage: 'Stopped by user.' } ) @@ -316,7 +316,12 @@ describe('registerNotificationHandlers', () => { ) }) - it('reports an interrupted finish as stopped', async () => { + it.each([ + { agentTurnOutcome: 'cancellation', word: 'stopped' }, + { agentTurnOutcome: 'failure', word: 'failed' }, + { agentTurnOutcome: 'success', word: 'finished' }, + { agentTurnOutcome: undefined, word: 'finished' } + ] as const)('words a $agentTurnOutcome finish as $word', async ({ agentTurnOutcome, word }) => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -336,14 +341,40 @@ describe('registerNotificationHandlers', () => { worktreeLabel: 'feat/notis', agentType: 'claude', agentState: 'done', - agentInterrupted: true + ...(agentTurnOutcome ? { agentTurnOutcome } : {}) } ) expect(notificationCtorMock).toHaveBeenCalledWith( expectedNativeNotificationOptions({ - title: 'feat/notis - Claude stopped', - body: 'Claude stopped.' + title: `feat/notis - Claude ${word}`, + body: `Claude ${word}.` + }) + ) + }) + + it('counts a success verdict alone as an agent snapshot', async () => { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: false, + suppressWhenFocused: true + } + }) + } as never) + + const handler = getDispatchHandler() + await handler( + {}, + { source: 'agent-task-complete', worktreeLabel: 'feat/notis', agentTurnOutcome: 'success' } + ) + + expect(notificationCtorMock).toHaveBeenCalledWith( + expectedNativeNotificationOptions({ + title: 'feat/notis - Agent finished', + body: 'Agent finished.' }) ) }) diff --git a/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts b/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts index 128f163c5b3..5ae2adb49d7 100644 --- a/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts +++ b/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { openCodeClearPtyMock, piClearPtyMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' import { SSH_PTY_IDENTITY_MISMATCH_ERROR, SSH_SESSION_EXPIRED_ERROR @@ -359,7 +360,8 @@ describe('registerPtyHandlers', () => { const exitListeners = new Set<(payload: { id: string; code: number }) => void>() const runtime = { setPtyController: vi.fn(), - onPtyExit: vi.fn() + onPtyExit: vi.fn(), + intentionalPtyStops: new TerminalIntentionalStops() } setLocalPtyProvider({ spawn: vi.fn(), @@ -393,15 +395,14 @@ describe('registerPtyHandlers', () => { handlers.clear() registerPtyHandlers(mainWindow as never, runtime as never) const controller = runtime.setPtyController.mock.calls[0]?.[0] as { - markReversibleStops: (ptyIds: readonly string[]) => () => void stopAndWait: (ptyId: string) => Promise } - const release = controller.markReversibleStops(['local-pty']) + const settleStop = runtime.intentionalPtyStops.mark('local-pty', 'reversible', null) const stopPromise = controller.stopAndWait('local-pty') await vi.advanceTimersByTimeAsync(1_200) await expect(stopPromise).resolves.toBe(true) - release() + settleStop(true) expect( mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') diff --git a/src/main/ipc/pty-pane-restart-replace.test.ts b/src/main/ipc/pty-pane-restart-replace.test.ts index a1ced7904f8..50558a0f870 100644 --- a/src/main/ipc/pty-pane-restart-replace.test.ts +++ b/src/main/ipc/pty-pane-restart-replace.test.ts @@ -3,6 +3,7 @@ import { setupPtyIpcSuite, type PtyIpcSuiteFixtures } from './pty-ipc-test-harne import { SessionNotFoundError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' import { registerPtyHandlers, setLocalPtyProvider } from './pty' +import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -161,7 +162,8 @@ function installRestartHarness( seedHeadlessTerminal: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), - onPtyData: vi.fn() + onPtyData: vi.fn(), + intentionalPtyStops: new TerminalIntentionalStops() } return { providerSpawn, shutdown, store, runtime, control } } diff --git a/src/main/ipc/pty-ssh-undelivered-kill.test.ts b/src/main/ipc/pty-ssh-undelivered-kill.test.ts index bf231fdc235..32f4da8356f 100644 --- a/src/main/ipc/pty-ssh-undelivered-kill.test.ts +++ b/src/main/ipc/pty-ssh-undelivered-kill.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' import { SSH_SESSION_EXPIRED_ERROR } from '../providers/ssh-pty-errors' import { registerPtyHandlers, @@ -79,7 +80,8 @@ function installController(handlers: Map) { setPtyController: vi.fn(), markPtyStopRequested: vi.fn(), markPtyLivenessUnverifiable: vi.fn(), - onPtyExit: vi.fn() + onPtyExit: vi.fn(), + intentionalPtyStops: new TerminalIntentionalStops() } handlers.clear() return { runtime } @@ -91,7 +93,6 @@ describe('undelivered SSH stops', () => { function install(store: ReturnType): { kill: (ptyId: string) => boolean stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise - markReversibleStops: (ptyIds: readonly string[]) => () => void recordUnconfirmedStop: (ptyId: string) => boolean runtime: ReturnType['runtime'] } { @@ -108,13 +109,11 @@ describe('undelivered SSH stops', () => { const controller = runtime.setPtyController.mock.calls[0]?.[0] as { kill: (ptyId: string) => boolean stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise - markReversibleStops: (ptyIds: readonly string[]) => () => void recordUnconfirmedStop: (ptyId: string) => boolean } return { kill: controller.kill, stopAndWait: controller.stopAndWait, - markReversibleStops: controller.markReversibleStops, recordUnconfirmedStop: controller.recordUnconfirmedStop, runtime } @@ -302,15 +301,41 @@ describe('undelivered SSH stops', () => { ) setPtyOwnership(SCOPED_PTY_ID, 'ssh-1') restorePtyIncarnation(SCOPED_PTY_ID, 'inc-f') - const { kill, markReversibleStops } = install(store) - const release = markReversibleStops([SCOPED_PTY_ID]) + const { kill, runtime } = install(store) + const settleStop = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null) try { kill(SCOPED_PTY_ID) await new Promise((resolve) => setTimeout(resolve, 0)) expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() } finally { - release() + settleStop(false) + unregisterSshPtyProvider('ssh-1') + deletePtyOwnership(SCOPED_PTY_ID) + } + }) + + it('records nothing while a reversible stop owns the PTY and a restart stop joins it', async () => { + const store = createKillStore() + registerSshPtyProvider( + 'ssh-1', + sshProviderStub(async () => { + throw new Error('socket closed') + }) + ) + setPtyOwnership(SCOPED_PTY_ID, 'ssh-1') + restorePtyIncarnation(SCOPED_PTY_ID, 'inc-f') + const { kill, runtime } = install(store) + const settleSleep = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null) + const settleRestart = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'replaced', null) + + try { + kill(SCOPED_PTY_ID) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() + } finally { + settleRestart(false) + settleSleep(false) unregisterSshPtyProvider('ssh-1') deletePtyOwnership(SCOPED_PTY_ID) } @@ -379,15 +404,15 @@ describe('undelivered SSH stops', () => { restorePtyIncarnation('local-pty', 'inc-h') setPtyOwnership(SCOPED_PTY_ID, 'ssh-1') restorePtyIncarnation(SCOPED_PTY_ID, 'inc-i') - const { recordUnconfirmedStop, markReversibleStops } = install(store) - const release = markReversibleStops([SCOPED_PTY_ID]) + const { recordUnconfirmedStop, runtime } = install(store) + const settleStop = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null) try { expect(recordUnconfirmedStop('local-pty')).toBe(false) expect(recordUnconfirmedStop(SCOPED_PTY_ID)).toBe(false) expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() } finally { - release() + settleStop(false) deletePtyOwnership('local-pty') deletePtyOwnership(SCOPED_PTY_ID) } diff --git a/src/main/ipc/pty/delivery/exit.ts b/src/main/ipc/pty/delivery/exit.ts index a7575f7c2e6..ad144b4d587 100644 --- a/src/main/ipc/pty/delivery/exit.ts +++ b/src/main/ipc/pty/delivery/exit.ts @@ -7,57 +7,8 @@ import { allocatePtyLifecycleSequence } from '../host-env/types' import { makePtyDataPayload, sendPtyDataToRenderer } from './payload' import { getRendererInFlightCharsForPty } from './accounting' import { clearFlushTimerIfIdle } from './flush' -import { ptyIncarnationById } from '../provider/ownership-state' import type { PtyIpcSession } from '../session' -export type ReplacedPtyStop = { - incarnationId: string | undefined - expiryTimer?: NodeJS.Timeout -} - -/** Labels the exit of a PTY that main stops so a new process can take its pane. Settle with - * whether the stop succeeded; a failed stop leaves no label behind. */ -export function markReplacedPtyStop( - session: PtyIpcSession, - id: string -): (stopped: boolean) => void { - clearTimeout(session.replacedPtyStopsById.get(id)?.expiryTimer) - const mark: ReplacedPtyStop = { incarnationId: ptyIncarnationById.get(id) } - session.replacedPtyStopsById.set(id, mark) - return (stopped) => { - if (session.replacedPtyStopsById.get(id) !== mark) { - return - } - if (!stopped) { - session.replacedPtyStopsById.delete(id) - return - } - // Why a window: an SSH exit can reach the renderer after the stop settles; bound it like a synthetic kill. - mark.expiryTimer = setTimeout(() => { - if (session.replacedPtyStopsById.get(id) === mark) { - session.replacedPtyStopsById.delete(id) - } - }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) - mark.expiryTimer.unref?.() - } -} - -function consumeReplacedPtyStop( - session: PtyIpcSession, - payload: { id: string; incarnationId?: string } -): boolean { - const mark = session.replacedPtyStopsById.get(payload.id) - if ( - !mark || - (mark.incarnationId && payload.incarnationId && mark.incarnationId !== payload.incarnationId) - ) { - return false - } - clearTimeout(mark.expiryTimer) - session.replacedPtyStopsById.delete(payload.id) - return true -} - export function rememberSyntheticKillExit( session: PtyIpcSession, id: string, @@ -202,12 +153,12 @@ export function finalizePtyExitForRenderer( session.schedulePendingDataAfterCreditReport(true) } } + const intentionalStops = + session.runtime?.intentionalPtyStops?.claimExit(payload.id, payload.incarnationId) ?? [] session.mainWindow.webContents.send('pty:exit', { ...payload, - ...(session.reversibleStopOwnersByPtyId.has(payload.id) - ? { preserveRendererBinding: true } - : {}), - ...(consumeReplacedPtyStop(session, payload) ? { replacedByRestart: true } : {}) + ...(intentionalStops.includes('reversible') ? { preserveRendererBinding: true } : {}), + ...(intentionalStops.includes('replaced') ? { replacedByRestart: true } : {}) }) } diff --git a/src/main/ipc/pty/ipc/renderer-kill.ts b/src/main/ipc/pty/ipc/renderer-kill.ts index 8f73e72aa59..10a0ffa476e 100644 --- a/src/main/ipc/pty/ipc/renderer-kill.ts +++ b/src/main/ipc/pty/ipc/renderer-kill.ts @@ -1,10 +1,11 @@ import { getPtyIpc } from '../../pty-host-bindings' import type { Store } from '../../../persistence' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { TerminalIntentionalStopKind } from '../../../runtime/terminal-intentional-stops' import type { IPtyProvider } from '../../../providers/types' import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict' -import { ptyOwnership } from '../provider/ownership-state' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { getProviderForPty, sshProviders, tryGetProviderForPty } from '../provider/registry' import { finishPtyShutdown, isPtyAlreadyGoneError } from '../provider/liveness' import { recordUndeliveredSshPtyKill } from '../runtime/undelivered-ssh-kill' @@ -31,27 +32,49 @@ export function installPtyKillIpcHandler(deps: PtyKillIpcDeps): void { ) } -/** Stops a pane's PTY for the spawn replacing it. `markReplaced` labels the exit so the renderer - * reads it as a handoff, not the pane dying; a failed stop removes the label with nothing sent. */ -export async function stopReplacedPanePty( - deps: PtyKillIpcDeps, - id: string, - markReplaced: (id: string) => (stopped: boolean) => void -): Promise { - const settle = markReplaced(id) - try { - await stopRendererOwnedPty(deps, { id }) - } catch (err) { - settle(false) - throw err - } - settle(true) +/** Stops a pane's PTY for the spawn replacing it. The stop register labels the exit so the + * renderer reads it as a handoff, not the pane dying; a failed stop leaves no label. */ +export async function stopReplacedPanePty(deps: PtyKillIpcDeps, id: string): Promise { + await stopRendererOwnedPtyAs(deps, { id }, 'replaced') } /** Stops a renderer-owned PTY and settles only once its shutdown has been observed or synthesized. */ export async function stopRendererOwnedPty( deps: PtyKillIpcDeps, args: { id: string; keepHistory?: boolean } +): Promise { + // Why: only hibernation passes keepHistory, and its exit must keep the pane's wake binding. + await stopRendererOwnedPtyAs(deps, args, args?.keepHistory === true ? 'reversible' : null) +} + +async function stopRendererOwnedPtyAs( + deps: PtyKillIpcDeps, + args: { id: string; keepHistory?: boolean }, + intentionalStop: TerminalIntentionalStopKind | null +): Promise { + if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { + // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. + throw new Error('Invalid PTY provider id') + } + const settleStop = intentionalStop + ? deps.runtime?.intentionalPtyStops?.mark( + args.id, + intentionalStop, + ptyIncarnationById.get(args.id) ?? null + ) + : undefined + let stopped = false + try { + await stopRendererOwnedPtyProcess(deps, args) + stopped = true + } finally { + settleStop?.(stopped) + } +} + +async function stopRendererOwnedPtyProcess( + deps: PtyKillIpcDeps, + args: { id: string; keepHistory?: boolean } ): Promise { const { store, @@ -61,10 +84,6 @@ export async function stopRendererOwnedPty( rememberSyntheticKillExit, sendPtyExitToRenderer } = deps - if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { - // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. - throw new Error('Invalid PTY provider id') - } runtime?.markPtyStopRequested?.(args.id) const ownedConnectionId = ptyOwnership.get(args.id) const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 5d9ad086920..3740e1489a0 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -83,6 +83,8 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise

) { + const facts = new TerminalRunFactsRegister() + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + const runtime = { + terminalRunFacts: facts, + intentionalPtyStops: stops, + // Why the real method: the case under test is what the runtime does with each commit. + noteTerminalSpawnCommit: OrcaRuntimeWithRuntimeId.prototype.noteTerminalSpawnCommit, + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + reflowHeadlessTerminalToPtyGrid: vi.fn(), + seedHeadlessTerminal: vi.fn(), + noteTerminalSpawnCommand: vi.fn() + } + const ports = { + runtime, + store: { persistPtyBinding }, + options: {}, + sendPtySpawnedToRenderer: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit reads only the ports above, and the store only for its binding save. + const deps = ports as unknown as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'leaf-1', + cols: 120, + rows: 40 + }) + ctx.validatedLeafId = 'leaf-1' + ctx.provider = { ...ctx.provider, shutdown: vi.fn().mockResolvedValue(undefined) } + ctx.result = { id: PTY_ID, incarnationId: INCARNATION_ID } + const outcome = await commitPtyIpcSpawn(ctx).then( + () => 'committed', + () => 'discarded' + ) + return { outcome, facts, stops } +} + +describe('renderer spawn commit: run facts', () => { + afterEach(() => { + ptySizes.delete(PTY_ID) + ptyOwnership.delete(PTY_ID) + ptyIncarnationById.delete(PTY_ID) + }) + + it('records a committed spawn and lets it supersede a landed stop no exit pinned', async () => { + const { outcome, facts, stops } = await commit(vi.fn().mockResolvedValue(true)) + + expect(outcome).toBe('committed') + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(true) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([]) + }) + + it('records nothing for a spawn discarded because its binding save failed', async () => { + const persistPtyBinding = vi.fn().mockRejectedValue(new Error('disk full')) + const { outcome, facts, stops } = await commit(persistPtyBinding) + + expect(outcome).toBe('discarded') + expect(persistPtyBinding).toHaveBeenCalledOnce() + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible']) + }) +}) diff --git a/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts b/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts index ceeb5127b7d..4afd2b008f1 100644 --- a/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts +++ b/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts @@ -68,7 +68,7 @@ describe('chunked pty write yield', () => { }) as typeof setImmediate) const outcome = await Promise.race([ - createWriteInput()({ id: PTY_ID, data: THREE_CHUNK_INPUT }), + createWriteInput()({ inputKind: 'driving', id: PTY_ID, data: THREE_CHUNK_INPUT }), afterImmediateTurns(50) ]) @@ -88,6 +88,7 @@ describe('chunked pty write yield', () => { const immediate = vi.spyOn(globalThis, 'setImmediate') const outcome = createWriteInput()({ + inputKind: 'driving', id: PTY_ID, data: 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES) }) diff --git a/src/main/ipc/pty/ipc/write-input-user-input.test.ts b/src/main/ipc/pty/ipc/write-input-user-input.test.ts new file mode 100644 index 00000000000..d4f20f00a68 --- /dev/null +++ b/src/main/ipc/pty/ipc/write-input-user-input.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TerminalRunFactsRegister } from '../../../runtime/terminal-run-facts' +import { ptyOwnership } from '../provider/ownership-state' +import { createPtyWriteInput } from './write-input' + +const PTY_ID = 'pty-user-input' + +const { provider } = vi.hoisted(() => ({ + provider: { write: vi.fn(), hasPty: vi.fn(() => true) } +})) + +vi.mock('../provider/registry', () => ({ + tryGetProviderForPty: (id: string) => (id === PTY_ID ? provider : undefined) +})) + +function createWriteInput(facts: TerminalRunFactsRegister) { + const runtime = { getDriver: () => ({ kind: 'desktop' }), terminalRunFacts: facts } + const mainWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: write input reads only getDriver and terminalRunFacts from the runtime, and isDestroyed/webContents from the window. + return createPtyWriteInput({ mainWindow: mainWindow as never, runtime: runtime as never }) +} + +beforeEach(() => { + ptyOwnership.set(PTY_ID, null) + provider.write.mockReset() +}) + +afterEach(() => { + ptyOwnership.delete(PTY_ID) +}) + +describe('renderer PTY writes: input kind', () => { + it.each(['writePtyInput', 'writePtyInputAccepted'] as const)( + '%s records driving input before the provider write', + async (writer) => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: PTY_ID, incarnationId: 'inc-1' }) + const recordedAtWrite: (number | null)[] = [] + provider.write.mockImplementation(() => { + recordedAtWrite.push(facts.read(PTY_ID, 'inc-1').firstUserInputAt) + }) + + await createWriteInput(facts)[writer]({ id: PTY_ID, data: 'exit\r', inputKind: 'driving' }) + + expect(recordedAtWrite).toEqual([expect.any(Number)]) + } + ) + + it.each([ + ['a launch write', 'launch', 'echo startup\r'], + ['a query reply', 'query-reply', '\x1b[3;4R'], + ['a driving write that is only a reply', 'driving', '\x1b[3;4R'], + ['a driving write that is only focus reports', 'driving', '\x1b[I\x1b[O'] + ] as const)('records nothing for %s', async (_label, inputKind, data) => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: PTY_ID, incarnationId: 'inc-1' }) + + await createWriteInput(facts).writePtyInput({ id: PTY_ID, data, inputKind }) + + expect(provider.write).toHaveBeenCalledOnce() + expect(facts.read(PTY_ID, 'inc-1').firstUserInputAt).toBeNull() + }) +}) diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts index 77b27ef16f0..2f8ea72cd9b 100644 --- a/src/main/ipc/pty/ipc/write-input.ts +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -9,6 +9,7 @@ import { } from '../../../../shared/terminal-input' import { ptyOwnership } from '../provider/ownership-state' import { tryGetProviderForPty } from '../provider/registry' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' import { interactiveOutputCharsByPty, lastInputAtByPty } from '../delivery/visibility-state' export function isMainWindowPtyIpcEvent( @@ -25,7 +26,7 @@ export function isMainWindowPtyIpcEvent( ) } -export type PtyWritePayload = { id: string; data: string } +export type PtyWritePayload = { id: string; data: string; inputKind: TerminalInputKind } export type PtyViewportClaimPayload = { id: string; cols: number; rows: number } export function createPtyWriteInput(deps: { @@ -148,6 +149,12 @@ export function createPtyWriteInput(deps: { const isPtyWriteEventFromMainWindow = (event: IpcMainEvent | IpcMainInvokeEvent): boolean => isMainWindowPtyIpcEvent(event, mainWindow) + const noteRendererPtyInput = (args: PtyWritePayload): void => { + lastInputAtByPty.set(args.id, performance.now()) + interactiveOutputCharsByPty.set(args.id, 0) + runtime?.terminalRunFacts?.recordInput(args.id, args.inputKind, args.data) + } + const writePtyInput = (args: PtyWritePayload): boolean | Promise => { // Why: mobile-presence-lock defense-in-depth — the renderer's onData guard can let one keystroke slip during the state-flip lag, so catch it server-side. See docs/mobile-presence-lock.md. if (runtime?.getDriver(args.id).kind === 'mobile') { @@ -158,9 +165,7 @@ export function createPtyWriteInput(deps: { return false } try { - const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) + noteRendererPtyInput(args) return writePtyProviderInput(provider, args.id, args.data) } catch { return false @@ -180,9 +185,7 @@ export function createPtyWriteInput(deps: { return false } try { - const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) + noteRendererPtyInput(args) return writePtyProviderInput(provider, args.id, args.data) } catch { return false diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index aaace9fac37..5c7c897474a 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -17,7 +17,6 @@ import { stopReplacedPanePty, type PtyKillIpcDeps } from './ipc/renderer-kill' -import { markReplacedPtyStop } from './delivery/exit' import { installPtyWriteIpcHandlers } from './ipc/write' import { installPtySpawnIpcHandler } from './ipc/spawn' import { installPtyRuntimeController } from './runtime/controller' @@ -237,7 +236,6 @@ export function registerPtyHandlers( options, trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, retiredRejectedPtyIds: session.retiredRejectedPtyIds, - reversibleStopOwnersByPtyId: session.reversibleStopOwnersByPtyId, mainWindow, transitionSpawnHiddenRendererPtyDeliveryState: session.transitionSpawnHiddenRendererPtyDeliveryState, @@ -275,8 +273,7 @@ export function registerPtyHandlers( trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, sendPtySpawnedToRenderer: session.sendPtySpawnedToRenderer, syncPtyBackgroundedDelivery: session.syncPtyBackgroundedDelivery, - stopReplacedPty: (id) => - stopReplacedPanePty(killDeps, id, (ptyId) => markReplacedPtyStop(session, ptyId)) + stopReplacedPty: (id) => stopReplacedPanePty(killDeps, id) }) installPtyWriteIpcHandlers({ mainWindow, runtime }) installPtyResizeVisibilityIpc(session) diff --git a/src/main/ipc/pty/register-without-renderer.test.ts b/src/main/ipc/pty/register-without-renderer.test.ts index 513d969f110..a5d19f020fc 100644 --- a/src/main/ipc/pty/register-without-renderer.test.ts +++ b/src/main/ipc/pty/register-without-renderer.test.ts @@ -128,8 +128,8 @@ describe('PTY registration without renderer delivery', () => { throw new Error('missing runtime PTY controller') } - expect(controller.write('daemon-pty', 'local input')).toBe(true) - expect(controller.write(remoteId, 'remote input')).toBe(true) + expect(controller.write('daemon-pty', 'local input', 'driving')).toBe(true) + expect(controller.write(remoteId, 'remote input', 'driving')).toBe(true) await controller.clearBuffer?.('daemon-pty') await controller.clearBuffer?.(remoteId) await expect(controller.attach?.('daemon-pty')).resolves.toBe(true) @@ -147,7 +147,7 @@ describe('PTY registration without renderer delivery', () => { expect(attach).toHaveBeenCalledExactlyOnceWith('daemon-pty') unregisterSshPtyProvider('ssh-a') - expect(controller.write(remoteId, 'disconnected input')).toBe(false) + expect(controller.write(remoteId, 'disconnected input', 'driving')).toBe(false) await expect(controller.probePtyLiveness?.(remoteId)).resolves.toBeNull() expect(local.write).toHaveBeenCalledTimes(1) }) diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index 8f20245505c..e1bfd5d7f10 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -82,7 +82,6 @@ export type PtyRuntimeControllerDeps = { } trustedTerminalHandleEnv: Set retiredRejectedPtyIds: Map - reversibleStopOwnersByPtyId: Map mainWindow?: PtyRendererDelivery transitionSpawnHiddenRendererPtyDeliveryState?: (id: string, hidden: boolean) => void syncPtyBackgroundedDelivery?: (id: string, caller: string) => void diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 06ce1891b69..75ff168a646 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -4,7 +4,6 @@ import type { PtyRuntimeControllerDeps } from './controller-deps' import { spawnPtyFromRuntimeController } from './spawn' import { killPtyFromRuntimeController, - markReversibleStopsFromRuntimeController, retireRejectedPtyFromRuntimeController, stopAndWaitPtyFromRuntimeController } from './kill' @@ -45,9 +44,9 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi }, adoptStablePane, spawn: async (args) => spawnPtyFromRuntimeController(deps, args), - write: (ptyId, data) => writePtyFromRuntimeController(ptyId, data), - writeWithSettlement: (ptyId, data) => - writePtyFromRuntimeController(ptyId, data, { waitForSettlement: true }), + write: (ptyId, data, inputKind) => writePtyFromRuntimeController(deps, ptyId, data, inputKind), + writeWithSettlement: (ptyId, data, inputKind) => + writePtyFromRuntimeController(deps, ptyId, data, inputKind, { waitForSettlement: true }), probePtyLiveness: (ptyId) => probePtyLivenessFromRuntimeController(deps, ptyId), // Why: subscriber-driven ingestion for daemon sessions no renderer pane // ever attached. Local daemon sessions only — SSH panes have their own @@ -57,13 +56,12 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi kill: (ptyId) => killPtyFromRuntimeController(deps, ptyId), retireRejectedPty: (ptyId, stopConfirmed) => retireRejectedPtyFromRuntimeController(deps, ptyId, stopConfirmed), - markReversibleStops: (ptyIds) => markReversibleStopsFromRuntimeController(deps, ptyIds), stopAndWait: (ptyId, opts) => stopAndWaitPtyFromRuntimeController(deps, ptyId, opts), recordUnconfirmedStop: (ptyId) => recordUnconfirmedExplicitSshStop({ store: deps.store, ptyId, - reversible: deps.reversibleStopOwnersByPtyId.has(ptyId) + reversible: runtime?.intentionalPtyStops?.isReversibleStopInFlight(ptyId) ?? false }), getForegroundProcess: (ptyId) => getForegroundProcessFromRuntimeController(ptyId), inspectProcess: (ptyId, options) => inspectProcessFromRuntimeController(ptyId, options), diff --git a/src/main/ipc/pty/runtime/kill.ts b/src/main/ipc/pty/runtime/kill.ts index 98bdc1e97ae..edbd12b0b18 100644 --- a/src/main/ipc/pty/runtime/kill.ts +++ b/src/main/ipc/pty/runtime/kill.ts @@ -19,8 +19,7 @@ export function killPtyFromRuntimeController( rememberSyntheticKillExit, sendPtyExitToRenderer, finishPtyShutdown, - retiredRejectedPtyIds, - reversibleStopOwnersByPtyId + retiredRejectedPtyIds } = deps runtime?.markPtyStopRequested?.(ptyId) let connectionId: string | null | undefined = ptyOwnership.get(ptyId) @@ -31,7 +30,7 @@ export function killPtyFromRuntimeController( store, ptyId, connectionId, - reversible: reversibleStopOwnersByPtyId.has(ptyId), + reversible: runtime?.intentionalPtyStops?.isReversibleStopInFlight(ptyId) ?? false, incarnationId }) } @@ -184,31 +183,6 @@ export function retireRejectedPtyFromRuntimeController( }) } -export function markReversibleStopsFromRuntimeController( - deps: PtyRuntimeControllerDeps, - ptyIds: readonly string[] -): () => void { - const { reversibleStopOwnersByPtyId } = deps - for (const ptyId of ptyIds) { - reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1) - } - let released = false - return () => { - if (released) { - return - } - released = true - for (const ptyId of ptyIds) { - const owners = (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) - 1 - if (owners > 0) { - reversibleStopOwnersByPtyId.set(ptyId, owners) - } else { - reversibleStopOwnersByPtyId.delete(ptyId) - } - } - } -} - /** * Deliberately records no undelivered-stop intent, unlike `killPtyFromRuntimeController`. * diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index f03892a6e31..5fec2c25e26 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -12,16 +12,28 @@ import { writeUnverifiable, type WriteSettlement } from '../../../../shared/pty-write-settlement' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' + +type RuntimeWriteDeps = Pick -export function writePtyFromRuntimeController(ptyId: string, data: string): boolean export function writePtyFromRuntimeController( + deps: RuntimeWriteDeps, ptyId: string, data: string, + inputKind: TerminalInputKind +): boolean +export function writePtyFromRuntimeController( + deps: RuntimeWriteDeps, + ptyId: string, + data: string, + inputKind: TerminalInputKind, options: { waitForSettlement: true } ): WriteSettlement | Promise export function writePtyFromRuntimeController( + deps: RuntimeWriteDeps, ptyId: string, data: string, + inputKind: TerminalInputKind, options?: { waitForSettlement: true } ): boolean | WriteSettlement | Promise { let provider: IPtyProvider @@ -36,6 +48,7 @@ export function writePtyFromRuntimeController( if (!provider.writeWithSettlement) { return writeRefused('provider_cannot_settle') } + deps.runtime?.terminalRunFacts?.recordInput(ptyId, inputKind, data) try { return provider.writeWithSettlement(ptyId, data) } catch { @@ -43,6 +56,7 @@ export function writePtyFromRuntimeController( return writeUnverifiable('provider_threw_after_handoff', true) } } + deps.runtime?.terminalRunFacts?.recordInput(ptyId, inputKind, data) try { return provider.write(ptyId, data) !== false } catch { diff --git a/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts b/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts new file mode 100644 index 00000000000..5701b9f82c9 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeWithRuntimeId } from '../../../runtime/orca-runtime-runtime-id' +import { TerminalIntentionalStops } from '../../../runtime/terminal-intentional-stops' +import { TerminalRunFactsRegister } from '../../../runtime/terminal-run-facts' +import { ptySizes } from '../delivery/visibility-state' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' +import { commitRuntimePtySpawn } from './spawn-commit' +import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' +import type { PtyRuntimeControllerDeps } from './controller-deps' + +const PTY_ID = 'orca-pty-run-facts' +const INCARNATION_ID = 'inc-run-facts' + +const ADOPTED = { + disposition: 'adopted', + owner: { + claim: { kind: 'terminal' }, + generation: 'g1', + phase: 'live', + ptyId: PTY_ID, + surface: { worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'leaf-1', terminalHandle: 'h1' } + } +} + +async function commit( + result: Record, + facts = new TerminalRunFactsRegister(), + intentionalPtyStops = new TerminalIntentionalStops(), + prepare?: (ctx: ReturnType) => void +) { + const runtime = { + terminalRunFacts: facts, + intentionalPtyStops, + // Why the real method: the case under test is what the runtime does with each commit. + noteTerminalSpawnCommit: OrcaRuntimeWithRuntimeId.prototype.noteTerminalSpawnCommit, + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + reflowHeadlessTerminalToPtyGrid: vi.fn(), + seedHeadlessTerminal: vi.fn(), + noteTerminalSpawnCommand: vi.fn() + } + const ports = { runtime, store: undefined, options: {}, sendPtySpawnedToRenderer: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit reads only the ports above; store-less deps skip every persistence branch. + const deps = ports as unknown as PtyRuntimeControllerDeps + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a worktree-less spawn needs only its grid. + const args = { cols: 120, rows: 40 } as unknown as RuntimePtySpawnArgs + const ctx = createRuntimePtySpawnState(deps, args) + const spawned = { id: PTY_ID, incarnationId: INCARNATION_ID, ...result } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each case sets the spawn-result fields the commit reads. + ctx.result = spawned as unknown as typeof ctx.result + prepare?.(ctx) + await commitRuntimePtySpawn(ctx) + return facts.read(PTY_ID, INCARNATION_ID) +} + +describe('runtime spawn commit: run facts', () => { + afterEach(() => { + ptySizes.delete(PTY_ID) + ptyOwnership.delete(PTY_ID) + ptyIncarnationById.delete(PTY_ID) + }) + + it('records a new process as a fresh spawn', async () => { + expect((await commit({})).freshSpawn).toBe(true) + }) + + it('never reads an SSH adoption that omits isReattach as fresh', async () => { + expect((await commit({ agentSessionEnsure: ADOPTED })).freshSpawn).toBe(false) + }) + + it('never reads a cold restore as fresh', async () => { + const coldRestore = { scrollback: 'prior output', cwd: '/tmp' } + + expect((await commit({ coldRestore })).freshSpawn).toBe(false) + }) + + it('keeps a process run facts when the same incarnation commits again', async () => { + const facts = new TerminalRunFactsRegister() + await commit({}, facts) + facts.recordInput(PTY_ID, 'driving', 'ls\r', 100) + + expect(await commit({ isReattach: true }, facts)).toEqual({ + freshSpawn: true, + firstUserInputAt: 100 + }) + }) + + it.each([ + { spawn: 'new process', result: {} }, + { spawn: 'adoption', result: { agentSessionEnsure: ADOPTED } } + ])('lets a committed $spawn supersede a landed stop that no exit pinned', async ({ result }) => { + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + + await commit(result, new TerminalRunFactsRegister(), stops) + + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([]) + }) + + it('records nothing for a spawn discarded because its binding save failed', async () => { + const facts = new TerminalRunFactsRegister() + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + const persistPtyBinding = vi.fn().mockRejectedValue(new Error('disk full')) + + await expect( + commit({}, facts, stops, (ctx) => { + ctx.provider = { ...ctx.provider, shutdown: vi.fn().mockResolvedValue(undefined) } + ctx.hostSessionBinding = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit calls only persistPtyBinding on this store. + store: { persistPtyBinding } as unknown as NonNullable< + typeof ctx.hostSessionBinding + >['store'], + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'leaf-1' + } + }) + ).rejects.toThrow() + + expect(persistPtyBinding).toHaveBeenCalledOnce() + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible']) + }) + + it.each([ + { spawn: 'new process', result: {} }, + { spawn: 'adoption', result: { agentSessionEnsure: ADOPTED } } + ])('records nothing for a $spawn that exited during start', async ({ result }) => { + const facts = new TerminalRunFactsRegister() + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + + await expect( + commit(result, facts, stops, (ctx) => { + ctx.args.worktreeId = 'wt-1' + ctx.deps.runtime!.registerPty = vi.fn(() => { + throw new Error('agent_session_exited_during_start') + }) + }) + ).rejects.toThrow('agent_session_exited_during_start') + + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible']) + }) +}) diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 856039e7d5a..95994e0effc 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -92,6 +92,11 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (rejectedRegistration) { await rejectedRegistration } + // Why here: an adoption returns before the commit site below. + ctx.deps.runtime?.noteTerminalSpawnCommit?.( + ctx.result, + ctx.hostSessionBinding?.expectedSourceBinding + ) ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null) ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle) if (ctx.result.incarnationId) { @@ -187,6 +192,12 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { // Why: non-worktree PTYs have no later surface-registration phase to clear admission intent. ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) } + // Why after registration: a spawn discarded for a failed save or rejected for exiting during + // start must not record facts or end a stop. + ctx.deps.runtime?.noteTerminalSpawnCommit?.( + ctx.result, + ctx.hostSessionBinding?.expectedSourceBinding + ) if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle) } diff --git a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts index 46f16081efa..a299327fc3f 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts @@ -43,7 +43,6 @@ function makeDeps(): PtyRuntimeControllerDeps { finishPtyShutdown, trustedTerminalHandleEnv: new Set(), retiredRejectedPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map(), // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only `operations.ts` (write/clearBuffer) reads `mainWindow`; the spawn preflight and option build never touch it, and a real BrowserWindow cannot exist in vitest. mainWindow: {} as BrowserWindow } diff --git a/src/main/ipc/pty/session.ts b/src/main/ipc/pty/session.ts index e5c59d9b81a..be666fc6248 100644 --- a/src/main/ipc/pty/session.ts +++ b/src/main/ipc/pty/session.ts @@ -10,7 +10,6 @@ import type { PtyRendererDeliveryStateReport } from '../../../shared/pty-renderer-delivery-health' import type { PtyRendererDeliveryDebugSnapshot } from './delivery/debug' -import type { ReplacedPtyStop } from './delivery/exit' import { PtyProducerFlowController } from '../pty-producer-flow-control' import { PtyPendingDataDrainQueue, type PendingPtyData } from '../pty-pending-data-drain-queue' import type { SshPtyOutputIntake } from '../ssh-pty-output-intake' @@ -108,8 +107,6 @@ export type PtyIpcSession = { string, { cleanupTimer: NodeJS.Timeout; incarnationId: string | undefined } > - reversibleStopOwnersByPtyId: Map - replacedPtyStopsById: Map retiredRejectedPtyIds: Map pendingSerializeRequests: Map< string, @@ -238,8 +235,6 @@ export function createPtyIpcSession(args: { sourceCreditPendingPtys: new Set(), backgroundedDeliverySyncByPty: new Map(), syntheticKillExitPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map(), - replacedPtyStopsById: new Map(), retiredRejectedPtyIds: new Map(), pendingSerializeRequests: new Map(), canSendPtyDataToRenderer: unsetSessionFn, diff --git a/src/main/muse/hook-settings.ts b/src/main/muse/hook-settings.ts index 5b5fe3f63fd..0ef3d5d462b 100644 --- a/src/main/muse/hook-settings.ts +++ b/src/main/muse/hook-settings.ts @@ -4,11 +4,11 @@ import { buildManagedCommandHook, createManagedCommandMatcher, getSharedManagedScriptPath, - isPlainObject, wrapPosixHookCommand, wrapWindowsHookCommand, type HookDefinition } from '../agent-hooks/installer-utils' +import { readManagedHookEventsFromJson } from '../agent-hooks/managed-hooks-json-events' const MUSE_SCRIPT_BASE = 'muse-hook' @@ -87,45 +87,9 @@ export function readManagedMuseHookEvents( parsed: unknown, isManagedCommand: (command: string | undefined) => boolean ): Set { - const present = new Set() - if (!isPlainObject(parsed) || !isPlainObject(parsed.hooks)) { - return present - } - for (const event of MUSE_HOOK_EVENTS) { - const definitions = parsed.hooks[event] - if (!Array.isArray(definitions)) { - continue - } - // Why: a hand-edited managed file can hold null definitions, non-array - // hook lists, or null entries — treat all of them as absent so status - // calculation never throws on user content. - if ( - definitions.some((definition) => - managedHookEntries(definition).some((hook) => isManagedCommand(hookEntryCommand(hook))) - ) - ) { - present.add(event) - } - } - return present + return readManagedHookEventsFromJson(parsed, MUSE_HOOK_EVENTS, isManagedCommand) } export function getMuseManagedCommandMatcher(): (command: string | undefined) => boolean { return createManagedCommandMatcher(getMuseManagedScriptFileName()) } - -function managedHookEntries(definition: unknown): readonly unknown[] { - if (!isPlainObject(definition)) { - return [] - } - const hooks = definition.hooks - return Array.isArray(hooks) ? hooks : [] -} - -function hookEntryCommand(hook: unknown): string | undefined { - if (!isPlainObject(hook)) { - return undefined - } - const command = hook.command - return typeof command === 'string' ? command : undefined -} diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts index 16999de0363..4647ba1e0e3 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts @@ -18,6 +18,7 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { DISPATCH_REJECTED_NOT_DELIVERED } from '../../../shared/structured-agent-session-dispatch-rejection' export type ProviderHistoryItem = { /** The provider's own id for this item. Used to claim it at most once; the @@ -55,7 +56,7 @@ export type SubmissionReconciliation = | { clientMessageId: string; outcome: 'rejected'; reason: SubmissionRejectionReason } | { clientMessageId: string; outcome: 'unknown'; reason: SubmissionUnknownReason } -export type SubmissionRejectionReason = 'not_delivered' +export type SubmissionRejectionReason = typeof DISPATCH_REJECTED_NOT_DELIVERED export type SubmissionUnknownReason = | 'history_boundary_inconsistent' @@ -197,6 +198,6 @@ function resolveOne( return { clientMessageId: submission.clientMessageId, outcome: 'rejected', - reason: 'not_delivered' + reason: DISPATCH_REJECTED_NOT_DELIVERED } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 186127847a6..b9e1b49c213 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -8,7 +8,11 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentSessionSubscribeEvent, + AgentSessionTurnCompletionEvent +} from '../../../shared/agent-session-wire' import { DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_HOST_RESTARTED, @@ -318,6 +322,28 @@ describe('a start the chat needed and did not get', () => { expect(errorRows()).toHaveLength(1) }) + it('notifies failed once for the queued messages one start failure refused', async () => { + await host.close(SESSION) + acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) + const completions: AgentSessionTurnCompletionEvent[] = [] + host.subscribeTurnCompletions({ id: 'dot-1', emit: (event) => completions.push(event) }) + await accept('first') + const second = await accept('second') + + await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) + await host.flushAllStreamedEvents() + expect(completions).toEqual([ + { + type: 'completion', + completion: expect.objectContaining({ + sessionId: SESSION, + turnId: agentJournalSubmissionKey(second), + outcome: 'failure' + }) + } + ]) + }) + it.each([ [ 'eligibility', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts index 4e15b23d4d3..4e6390c46d9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts @@ -8,6 +8,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { digestPayload } from '../agent-session-journal/journal-payload-bounds' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' @@ -117,6 +118,19 @@ describe('attachJournal restart reconciliation', () => { expect(dispatch).not.toHaveBeenCalled() }) + it('gives a send the provider never received no verdict and no listing', async () => { + await crashedJournal() + const { adapter } = adapterWith(async () => window()) + + const attached = await attach(adapter) + + // Nobody failed: the crash stranded it, so the chat must not read Failed or be listed by it. + const { items, submissions } = attached.journal.snapshot() + expect( + projectStructuredAgentSessionStatusState(items, submissions, RECORD.lease.runtimeFence) + ).toMatchObject({ summary: { status: null }, latestRequest: null }) + }) + it('still reports a submission unconfirmed when the window cannot decide it', async () => { await crashedJournal() const { adapter, dispatch } = adapterWith(async () => window({ turnInFlight: true })) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index aa3f298a077..24ab5cad012 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -31,7 +31,11 @@ export class StructuredAgentSessionClientDelivery { private readonly onJournalActivity?: (sessionId: string) => void ) { this.statusFeed = createStructuredAgentSessionHostStatusFeed({ sessions, now, deps }) - this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({ sessions, now }) + this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({ + sessions, + now, + readStatusState: (sessionId, journal) => this.statusFeed.statusState(sessionId, journal) + }) this.sendSettlement = new StructuredAgentSessionSendSettlement((sessionId) => this.requireJournal(sessionId) ) @@ -81,7 +85,8 @@ export class StructuredAgentSessionClientDelivery { this.statusFeed.publish(sessionId, journal) this.sendSettlement.publish(sessionId, journal) // Derived here rather than per-subscriber: this edge runs whether or not anyone is - // subscribed, which is the whole reason a backgrounded chat can complete at all. + // subscribed, which is the whole reason a backgrounded chat can complete at all. After the + // status publish, so it reads the projection that publish cached. this.turnCompletionFeed.observe(sessionId, journal) this.onJournalActivity?.(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index 72a908a3361..a2be1610327 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -88,7 +88,11 @@ async function sessionWithRunningTurn() { } } }) - const completions = new StructuredAgentSessionTurnCompletionFeed({ sessions, now: () => clock }) + const completions = new StructuredAgentSessionTurnCompletionFeed({ + sessions, + now: () => clock, + readStatusState: (sessionId, source) => feed.statusState(sessionId, source) + }) const completionEvents: AgentSessionTurnCompletionEvent[] = [] completions.subscribe({ id: 'dot-1', emit: (event) => completionEvents.push(event) }) // Both feeds have seen the turn running, so its settlement is a transition they must judge. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 884fba78a4e..524d8943fac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -95,7 +95,12 @@ async function restartWithPersistedTurn(): Promise { const host = createHost(store) expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) const body = hostTestMessage('persisted conversation') - await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body }) + const sent = await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body }) + if (!sent.ok) { + throw new Error('send was refused') + } + // Delivered, not just accepted: a message still queued at the restart was never a request. + await host.waitForSendSettlement(SESSION, sent.value.clientMessageId) await host.flushAllStreamedEvents() return createHost(await AgentSessionRecordStore.open({ directory, hostId: 'local' })) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index e5da2eece97..a26cd324217 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -191,7 +191,8 @@ describe('StructuredAgentSessionStatusFeed', () => { expect(events.at(-1)).toMatchObject({ session: { status: 'working' } }) record.lease.runtimeFence = 2 feed.publish(SESSION) - expect(events.at(-1)).toMatchObject({ session: { status: 'idle' } }) + // Its only send outlived the host that sent it and became no turn: nothing left to list. + expect(events.at(-1)).toMatchObject({ session: { status: null } }) }) it('publishes working from the pending submission, before the provider replays the turn', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 6e572f0366d..c301cb32233 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -22,7 +22,7 @@ import { type AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' -import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' +import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' @@ -34,6 +34,10 @@ import { export type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-ownership' +export type StructuredAgentSessionStatusState = ReturnType< + typeof projectStructuredAgentSessionStatusState +> + export type StructuredAgentSessionStatusSubscriber = { id: string emit: (event: AgentSessionStatusEvent) => void @@ -142,7 +146,7 @@ export class StructuredAgentSessionStatusFeed { sequence: number readOnly: boolean fence: number | undefined - summary: ReturnType + state: StructuredAgentSessionStatusState } >() @@ -204,6 +208,17 @@ export class StructuredAgentSessionStatusFeed { }) } + /** The projection behind the session's row and the latest request it read, cached per commit, + * so the completion feed follows the same request without snapshotting the journal again. */ + statusState( + sessionId: string, + journal?: AgentSessionJournal + ): StructuredAgentSessionStatusState | null { + const session = this.deps.sessions.get(sessionId) + const source = journal ?? session?.journal + return source ? this.projectionFor(source, this.deps.getRecord(sessionId)) : null + } + /** Re-projects one session after its journal changed; equal projections are not re-sent. */ publish(sessionId: string, journal?: AgentSessionJournal, options?: { replay?: boolean }): void { const session = this.deps.sessions.get(sessionId) @@ -234,35 +249,8 @@ export class StructuredAgentSessionStatusFeed { session: StatusFeedSession, journal: AgentSessionJournal ): AgentSessionStatusSummary { - // An unreadable journal projects as "no turn": the chat itself shows the reset. - const cursor = journal.cursor() - const readOnly = journal.isReadOnly const record = this.deps.getRecord(sessionId) - // The conversation's fence, which a child's end moves: its unanswered sends stop counting. - const fence = record?.lease.runtimeFence - let projection = this.journalProjections.get(journal) - if ( - !projection || - projection.epoch !== cursor.epoch || - projection.sequence !== cursor.sequence || - projection.readOnly !== readOnly || - projection.fence !== fence - ) { - // A journalled submission bumps `lastSequence`, so the send-time working - // signal reaches the cache; the lease fence does not, hence the extra key. - const snapshot = readOnly ? null : journal.snapshot() - projection = { - ...cursor, - readOnly, - fence, - summary: projectStructuredAgentSessionStatusSummary( - snapshot?.items ?? [], - snapshot?.submissions ?? [], - fence - ) - } - this.journalProjections.set(journal, projection) - } + const { summary: projected } = this.projectionFor(journal, record) const providerSession = structuredAgentSessionProviderSessionMetadata(record) // The journal has no model: the record's acknowledged options are where a mid-session // switch lands, so the row follows whichever is in force. @@ -280,7 +268,7 @@ export class StructuredAgentSessionStatusFeed { ...(session.child ? { hostExecutionOwned: true as const, hostExecutionPhase: session.child.phase } : {}), - ...projection.summary, + ...projected, ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' ? { rewindBlockedReason: 'outcome-unknown' as const } : {}), @@ -304,6 +292,41 @@ export class StructuredAgentSessionStatusFeed { } } + private projectionFor( + journal: AgentSessionJournal, + record: AgentSessionRecord | null + ): StructuredAgentSessionStatusState { + // An unreadable journal projects as "no turn": the chat itself shows the reset. + const cursor = journal.cursor() + const readOnly = journal.isReadOnly + // The conversation's fence, which a child's end moves: its unanswered sends stop counting. + const fence = record?.lease.runtimeFence + let projection = this.journalProjections.get(journal) + if ( + !projection || + projection.epoch !== cursor.epoch || + projection.sequence !== cursor.sequence || + projection.readOnly !== readOnly || + projection.fence !== fence + ) { + // A journalled submission bumps `lastSequence`, so the send-time working + // signal reaches the cache; the lease fence does not, hence the extra key. + const snapshot = readOnly ? null : journal.snapshot() + projection = { + ...cursor, + readOnly, + fence, + state: projectStructuredAgentSessionStatusState( + snapshot?.items ?? [], + snapshot?.submissions ?? [], + fence + ) + } + this.journalProjections.set(journal, projection) + } + return projection.state + } + /** A failing sink must never cost the subscribers their status event. */ private sink( summary: AgentSessionStatusSummary, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts index c0e652724af..93d4a54cdc9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it, vi } from 'vitest' -import type { AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTED_NOT_DELIVERED, + DISPATCH_REJECTED_PROVIDER_CLOSED +} from '../../../shared/structured-agent-session-dispatch-rejection' +import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed' const LOCATION = { @@ -10,6 +22,8 @@ const LOCATION = { workspaceKind: 'git-worktree' } as const +const START_FAILURE = 'Claude is not signed in.' + function turn( turnId: string, state: AgentJournalTurnLifecycle['state'], @@ -18,33 +32,102 @@ function turn( return { turnId, state, ...(outcome ? { outcome } : {}) } } +function turnItem(lifecycle: AgentJournalTurnLifecycle, sequence: number): AgentJournalRenderItem { + return { + itemId: `codex:turn:${lifecycle.turnId}`, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'turn', ...lifecycle } + } +} + +function userEntry(clientMessageId: string, sequence: number): AgentJournalRenderItem { + return { + itemId: agentJournalSubmissionKey(clientMessageId), + revision: 0, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] } + } +} + +function sent( + clientMessageId: string, + fields: Partial & Pick +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + providerItemId: null, + reason: null, + submittedAt: 10, + resolvedAt: 20, + handoverRecorded: true, + ...fields + } +} + +const pending = (clientMessageId: string, fence = 1) => + sent(clientMessageId, { dispatchState: 'pending', fence, handedOverAt: 11, resolvedAt: null }) +const refused = (clientMessageId: string, reason = START_FAILURE) => + sent(clientMessageId, { dispatchState: 'rejected', reason }) + function harness(): { feed: StructuredAgentSessionTurnCompletionFeed setTurn: (next: AgentJournalTurnLifecycle | null) => void + setJournal: ( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[], + fence?: number + ) => void setCursor: (next: { epoch: string; sequence: number }) => void observe: () => void events: AgentSessionTurnCompletionEvent[] + outcomes: () => [string, string][] + /** Whether each completion said the user is being asked something. */ + awaitingUser: () => boolean[] listen: () => () => void } { - let current: AgentJournalTurnLifecycle | null = null + let items: AgentJournalRenderItem[] = [] + let submissions: AgentJournalSubmission[] = [] + let fence: number | undefined let cursor = { epoch: 'epoch-1', sequence: 0 } - const journal = { - newestTurn: () => current, - cursor: () => cursor - } + const journal = { cursor: () => cursor } const sessions = new Map([['session-1', { journal, params: { location: LOCATION } }]]) - const feed = new StructuredAgentSessionTurnCompletionFeed({ sessions, now: () => 1_700 }) + const feed = new StructuredAgentSessionTurnCompletionFeed({ + sessions, + now: () => 1_700, + // The status feed's projection, computed as it computes it. + readStatusState: () => projectStructuredAgentSessionStatusState(items, submissions, fence) + }) const events: AgentSessionTurnCompletionEvent[] = [] return { feed, setTurn: (next) => { - current = next + items = next ? [turnItem(next, 1)] : [] + submissions = [] + }, + setJournal: (nextItems, nextSubmissions, nextFence) => { + items = nextItems + submissions = nextSubmissions + fence = nextFence + cursor = { ...cursor, sequence: cursor.sequence + 1 } }, setCursor: (next) => { cursor = next }, observe: () => feed.observe('session-1'), events, + outcomes: () => + events.flatMap((event): [string, string][] => + event.type === 'completion' ? [[event.completion.turnId, event.completion.outcome]] : [] + ), + awaitingUser: () => + events.flatMap((event) => + event.type === 'completion' ? [event.completion.awaitingUser === true] : [] + ), listen: () => feed.subscribe({ id: 'sub', emit: (event) => events.push(event) }) } } @@ -59,7 +142,8 @@ describe('StructuredAgentSessionTurnCompletionFeed', () => { h.setTurn(turn('turn-1', 'completed', 'success')) h.setCursor({ epoch: 'epoch-1', sequence: 2 }) h.observe() - expect(h.events).toEqual([ + // Strict: an idle settle omits `awaitingUser` rather than sending it undefined. + expect(h.events).toStrictEqual([ { type: 'completion', completion: { @@ -261,3 +345,318 @@ describe('StructuredAgentSessionTurnCompletionFeed', () => { expect(emit).not.toHaveBeenCalled() }) }) + +describe('a request the agent or its start refused', () => { + const M1 = agentJournalSubmissionKey('m1') + const M2 = agentJournalSubmissionKey('m2') + const M3 = agentJournalSubmissionKey('m3') + const settledTurn = turnItem(turn('t1', 'completed', 'success'), 2) + + /** A session whose first turn succeeded, as the feed saw it happen. */ + function afterSuccessfulTurn() { + const h = harness() + h.listen() + h.setJournal([userEntry('m1', 1), turnItem(turn('t1', 'running'), 2)], []) + h.observe() + h.setJournal([userEntry('m1', 1), settledTurn], [sent('m1', { dispatchState: 'accepted' })]) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + return h + } + + it('notifies failed once when the only send fails to start, named by its item key', () => { + const h = harness() + h.listen() + h.observe() + h.setJournal([userEntry('m1', 1)], [pending('m1')]) + h.observe() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.observe() + h.observe() + expect(h.events).toEqual([ + { + type: 'completion', + completion: { + scope: LOCATION, + sessionId: 'session-1', + turnId: M1, + outcome: 'failure', + completedAt: 1_700 + } + } + ]) + }) + + it('stays silent on a first observation of a send that had already failed', () => { + // A restart, reopen or re-attach: the failure is history, not news. + const h = harness() + h.listen() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.observe() + h.observe() + expect(h.events).toEqual([]) + }) + + it('re-baselines an epoch replacement that surfaces an older failure', () => { + const h = afterSuccessfulTurn() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.setCursor({ epoch: 'epoch-2', sequence: 1 }) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + }) + + it.each([ + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTED_PROVIDER_CLOSED, + DISPATCH_REJECTED_NOT_DELIVERED + ])('never notifies a send %s, alone or after a turn', (reason) => { + const alone = harness() + alone.listen() + alone.observe() + alone.setJournal([userEntry('m1', 1)], [pending('m1')]) + alone.observe() + alone.setJournal([userEntry('m1', 1)], [refused('m1', reason)]) + alone.observe() + expect(alone.events).toEqual([]) + + // The latest request falls back to the turn already announced, which must not announce again. + const h = afterSuccessfulTurn() + const accepted = sent('m1', { dispatchState: 'accepted' }) + h.setJournal([userEntry('m1', 1), settledTurn, userEntry('m2', 3)], [accepted, pending('m2')]) + h.observe() + h.setJournal( + [userEntry('m1', 1), settledTurn, userEntry('m2', 3)], + [accepted, refused('m2', reason)] + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + }) + + it('never notifies a crash-stranded send that restart reconciliation finds undelivered', () => { + const h = afterSuccessfulTurn() + const items = [userEntry('m1', 1), settledTurn, userEntry('m2', 3)] + const accepted = sent('m1', { dispatchState: 'accepted' }) + h.setJournal(items, [accepted, sent('m2', { dispatchState: 'unknown', recovered: true })], 2) + h.observe() + h.setJournal( + items, + [ + accepted, + sent('m2', { + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_NOT_DELIVERED, + fence: 2, + recovered: true + }) + ], + 2 + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + }) + + it('notifies failed, then success, when a failed start is retried and the retry succeeds', () => { + const h = harness() + h.listen() + h.observe() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.observe() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [refused('m1'), pending('m2')]) + h.observe() + const accepted = sent('m2', { dispatchState: 'accepted' }) + h.setJournal( + [userEntry('m1', 1), userEntry('m2', 2), turnItem(turn('t2', 'running'), 3)], + [refused('m1'), accepted] + ) + h.observe() + h.setJournal( + [userEntry('m1', 1), userEntry('m2', 2), turnItem(turn('t2', 'completed', 'success'), 3)], + [refused('m1'), accepted] + ) + h.observe() + expect(h.outcomes()).toEqual([ + [M1, 'failure'], + ['t2', 'success'] + ]) + }) + + it('notifies each failed start that follows another', () => { + const h = harness() + h.listen() + h.observe() + h.setJournal([userEntry('m1', 1)], [refused('m1')]) + h.observe() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [refused('m1'), pending('m2')]) + h.observe() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [refused('m1'), refused('m2')]) + h.observe() + expect(h.outcomes()).toEqual([ + [M1, 'failure'], + [M2, 'failure'] + ]) + }) + + it.each([ + ['in one commit', [['m2', 'm3']]], + ['oldest first, across commits', [['m2'], ['m3']]], + ['newest first, across commits', [['m3'], ['m2']]] + ])('notifies once for queued sends one start failure refused %s', (_name, batches) => { + const h = afterSuccessfulTurn() + const items = [userEntry('m1', 1), settledTurn, userEntry('m2', 3), userEntry('m3', 4)] + const accepted = sent('m1', { dispatchState: 'accepted' }) + const queued = (id: string) => sent(id, { dispatchState: 'pending', resolvedAt: null }) + const answered = new Set() + const submissions = () => [ + accepted, + ...['m2', 'm3'].map((id) => (answered.has(id) ? refused(id) : queued(id))) + ] + h.setJournal(items, submissions()) + h.observe() + for (const batch of batches) { + batch.forEach((id) => answered.add(id)) + h.setJournal(items, submissions()) + h.observe() + } + expect(h.outcomes()).toEqual([ + ['t1', 'success'], + [M3, 'failure'] + ]) + }) + + it('does not wait on a send left pending at an older fence', () => { + const h = harness() + h.listen() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [pending('m1', 1), pending('m2', 2)], 2) + h.observe() + h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [pending('m1', 1), refused('m2')], 2) + h.observe() + expect(h.outcomes()).toEqual([[M2, 'failure']]) + }) +}) + +describe('a request that settles while the user is asked something', () => { + const M1 = agentJournalSubmissionKey('m1') + + /** An approval the user has not answered; `agentId` makes it a subagent's. */ + function approval( + itemId: string, + sequence: number, + state: 'pending' | 'resolved', + agentId?: string + ): AgentJournalRenderItem { + return { + itemId, + revision: state === 'pending' ? 1 : 2, + sequence, + observedAt: sequence, + ...(agentId ? { agentId } : {}), + body: { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: { state, selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + } + } + + it('notifies once when the main turn settles while a subagent waits on an approval', () => { + const h = harness() + h.listen() + const user = userEntry('m1', 1) + const accepted = [sent('m1', { dispatchState: 'accepted' })] + h.setJournal([user, turnItem(turn('t1', 'running'), 2)], accepted) + h.observe() + h.setJournal( + [user, turnItem(turn('t1', 'running'), 2), approval('a1', 3, 'pending', 'child-1')], + accepted + ) + h.observe() + h.setJournal( + [ + user, + turnItem(turn('t1', 'completed', 'success'), 2), + approval('a1', 3, 'pending', 'child-1') + ], + accepted + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + expect(h.awaitingUser()).toEqual([true]) + + // Answering the prompt settles the session idle on the request already announced. + h.setJournal( + [ + user, + turnItem(turn('t1', 'completed', 'success'), 2), + approval('a1', 3, 'resolved', 'child-1') + ], + accepted + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + }) + + it('notifies a refused send once while a prompt is pending', () => { + const h = harness() + h.listen() + const prompt = approval('a1', 1, 'pending', 'child-1') + h.setJournal([prompt, userEntry('m1', 2)], [pending('m1')]) + h.observe() + h.setJournal([prompt, userEntry('m1', 2)], [refused('m1')]) + h.observe() + expect(h.outcomes()).toEqual([[M1, 'failure']]) + expect(h.awaitingUser()).toEqual([true]) + h.setJournal([approval('a1', 1, 'resolved', 'child-1'), userEntry('m1', 2)], [refused('m1')]) + h.observe() + expect(h.outcomes()).toEqual([[M1, 'failure']]) + }) + + it('sends nothing while the main turn asks for permission, and one event when it settles', () => { + const h = harness() + h.listen() + const user = userEntry('m1', 1) + const accepted = [sent('m1', { dispatchState: 'accepted' })] + h.setJournal([user, turnItem(turn('t1', 'running'), 2)], accepted) + h.observe() + h.setJournal([user, turnItem(turn('t1', 'running'), 2), approval('a1', 3, 'pending')], accepted) + h.observe() + expect(h.events).toEqual([]) + h.setJournal( + [user, turnItem(turn('t1', 'running'), 2), approval('a1', 3, 'resolved')], + accepted + ) + h.observe() + h.setJournal( + [user, turnItem(turn('t1', 'completed', 'success'), 2), approval('a1', 3, 'resolved')], + accepted + ) + h.observe() + expect(h.outcomes()).toEqual([['t1', 'success']]) + // Idle when it settles: the prompt was already answered. + expect(h.awaitingUser()).toEqual([false]) + }) + + it('still waits on a queued send the prompt hides, so the queue notifies once', () => { + const h = harness() + h.listen() + const prompt = approval('a1', 3, 'pending', 'child-1') + const items = [userEntry('m1', 1), turnItem(turn('t1', 'running'), 2), prompt] + const queued = sent('m2', { dispatchState: 'pending', resolvedAt: null }) + const accepted = sent('m1', { dispatchState: 'accepted' }) + h.setJournal([...items, userEntry('m2', 4)], [accepted, queued]) + h.observe() + h.setJournal( + [ + userEntry('m1', 1), + turnItem(turn('t1', 'completed', 'success'), 2), + prompt, + userEntry('m2', 4) + ], + [accepted, queued] + ) + h.observe() + expect(h.events).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts index 1281fbec49b..ca9f64c0573 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts @@ -1,4 +1,5 @@ -// The host's answer to "a turn just finished", derived once per journal commit. +// The host's answer to "a request just finished", derived once per journal commit. A request is +// the one the status row reports: a turn, or a send the agent or its start refused. // // WHY THE HOST DERIVES IT: a structured session runs on the execution host and keeps journalling // whether or not any renderer has a reader mounted. A client that derived completions itself would @@ -10,41 +11,45 @@ // needs, a completion is an edge that has already passed. Keeping a queue would create a durable // obligation with nothing to retire it. -import type { AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { readAgentJournalTurnOutcome } from '../../../shared/agent-session-turn-record' import type { AgentSessionTurnCompletion, AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionLatestRequest } from '../../../shared/structured-agent-session-latest-request' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionStatusState } from './structured-agent-session-status-feed' export type StructuredAgentSessionTurnCompletionSubscriber = { id: string emit: (event: AgentSessionTurnCompletionEvent) => void } -/** Only the newest-turn reader and cursor are needed here; asking for the whole journal would overstate it. */ type CompletionFeedCursor = { epoch: string; sequence: number } -type CompletionFeedJournal = Pick - type CompletionFeedSession = { - journal: CompletionFeedJournal + journal: Pick params: { location: AgentSessionRecord['location'] } } export type StructuredAgentSessionTurnCompletionFeedDeps = { sessions: ReadonlyMap now: () => number + /** The status feed's projection for this commit, so the event follows the request its row reports. */ + readStatusState: ( + sessionId: string, + journal?: AgentSessionJournal + ) => StructuredAgentSessionStatusState | null } -/** Per-session baseline. `settledTurnId` is the last settled turn this feed has accounted for; - * absence of the whole entry — not a null field — is what makes the first observation silent. */ -type SessionBaseline = CompletionFeedCursor & { settledTurnId: string | null } +type RequestMark = Pick -function isSettled(turn: AgentJournalTurnLifecycle | null): turn is AgentJournalTurnLifecycle { - return turn !== null && turn.state !== 'running' +/** Per-session baseline. `settled` is the last settled request this feed has accounted for; + * absence of the whole entry — not a null field — is what makes the first observation silent. */ +type SessionBaseline = CompletionFeedCursor & { settled: RequestMark | null } + +function settledMark(request: StructuredAgentSessionLatestRequest | null): RequestMark | null { + return request && !request.running ? { kind: request.kind, id: request.id } : null } export class StructuredAgentSessionTurnCompletionFeed { @@ -80,29 +85,24 @@ export class StructuredAgentSessionTurnCompletionFeed { /** * One journal publication. Emits at most one completion, and only on the transition into a - * settled turn this feed has not already accounted for. + * settled request this feed has not already accounted for. * * The first observation of a session only records where it is, so restore, restart, rewind and - * a re-read of history all pass through silently. An already-settled turn republished by an - * in-place revision carries the same turn id and so cannot fire twice. + * a re-read of history all pass through silently. An already-settled request republished by an + * in-place revision carries the same identity and so cannot fire twice. */ - observe(sessionId: string, journal?: CompletionFeedJournal): void { + observe(sessionId: string, journal?: AgentSessionJournal): void { const session = this.deps.sessions.get(sessionId) - if (!session) { + const state = session ? this.deps.readStatusState(sessionId, journal) : null + if (!session || !state) { return } - const source = journal ?? session.journal - const cursor = source.cursor() - const turn = source.newestTurn() - const settled = isSettled(turn) ? turn : null + const cursor = (journal ?? session.journal).cursor() + const request = state.latestRequest const baseline = this.baselines.get(sessionId) if (!baseline) { // Baseline only. Whatever the session was already holding is history, not news. - this.baselines.set(sessionId, { - epoch: cursor.epoch, - sequence: cursor.sequence, - settledTurnId: settled?.turnId ?? null - }) + this.baselines.set(sessionId, { ...cursor, settled: settledMark(request) }) return } if (baseline.epoch !== cursor.epoch || cursor.sequence < baseline.sequence) { @@ -111,24 +111,31 @@ export class StructuredAgentSessionTurnCompletionFeed { // newest settled row as a fresh completion. baseline.epoch = cursor.epoch baseline.sequence = cursor.sequence - baseline.settledTurnId = settled?.turnId ?? null + baseline.settled = settledMark(request) return } baseline.sequence = cursor.sequence - if (!settled) { + if (request?.running) { // A running turn clears the mark, so this detector fires on each running → settled // transition rather than on an id it happens not to have seen. - baseline.settledTurnId = null + baseline.settled = null return } - if (baseline.settledTurnId === settled.turnId) { + // Owed work waits, so sends refused one commit at a time announce once, when the last is + // answered. A pending prompt does not wait (structured chat has no other attention producer): + // the event says so itself, and answering it keeps the same identity. + // A withdrawn send leaves the older request latest. + if ( + state.owesWork || + !request || + (baseline.settled?.kind === request.kind && baseline.settled.id === request.id) + ) { return } - baseline.settledTurnId = settled.turnId + baseline.settled = settledMark(request) // ABSENT OUTCOME IS UNKNOWN: a turn the host only saw stop carries no verdict and gets no // event. Inferring success here is the one mistake that would light the dot on a failure. - const outcome = readAgentJournalTurnOutcome(settled) - if (!outcome) { + if (!request.outcome) { return } this.broadcast({ @@ -136,9 +143,11 @@ export class StructuredAgentSessionTurnCompletionFeed { completion: { scope: session.params.location, sessionId, - turnId: settled.turnId, - outcome, - completedAt: this.deps.now() + turnId: request.id, + outcome: request.outcome, + completedAt: this.deps.now(), + // Stated here, not joined from the status stream: remote clients receive the two unordered. + ...(state.summary.status === 'attention' ? { awaitingUser: true } : {}) } }) } diff --git a/src/main/persistence/loading-store/pty-binding-span.ts b/src/main/persistence/loading-store/pty-binding-span.ts index 58bed424a51..903d93c4967 100644 --- a/src/main/persistence/loading-store/pty-binding-span.ts +++ b/src/main/persistence/loading-store/pty-binding-span.ts @@ -10,11 +10,13 @@ export type PtyBindingSpanOutcome = 'fast_lane' | 'flushed' | 'refused' | 'threw */ export type PtyBindingOrigin = 'reattach' | 'spawn' | 'relay_reattach' | 'split' | 'unknown' +export type PtySpawnCommitOrigin = Extract + /** The spawn-commit paths share one rule: a split outranks a reattach, a reattach outranks a spawn. */ export function spawnCommitBindingOrigin( commit: { isReattach?: boolean; agentSessionEnsure?: { disposition: string } }, expectedSourceBinding?: unknown -): PtyBindingOrigin { +): PtySpawnCommitOrigin { if (expectedSourceBinding !== undefined) { return 'split' } diff --git a/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts index b1d185c38b1..2777fdfde4b 100644 --- a/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts +++ b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts @@ -37,7 +37,6 @@ export function createPtySpawnCommitDependencies( sendPtyExitToRenderer: unexpectedPreflight, finishPtyShutdown: unexpectedPreflight, retiredRejectedPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map(), get mainWindow() { return unexpectedPreflight() } diff --git a/src/main/plugins/plugin-host-methods.test.ts b/src/main/plugins/plugin-host-methods.test.ts index ee6e7a5b9d0..7f196c9eb81 100644 --- a/src/main/plugins/plugin-host-methods.test.ts +++ b/src/main/plugins/plugin-host-methods.test.ts @@ -194,10 +194,14 @@ describe('terminal.sendText explicit worktree routing', () => { { includeVisualLayouts: false } ) expect(delegate.sendTerminal).toHaveBeenCalledTimes(1) - expect(delegate.sendTerminal).toHaveBeenCalledWith(terminalId, { - text: 'echo hi', - enter: true - }) + expect(delegate.sendTerminal).toHaveBeenCalledWith( + terminalId, + { + text: 'echo hi', + enter: true + }, + { inputKind: 'driving' } + ) expect(vi.mocked(delegate.listTerminals).mock.invocationCallOrder[0]!).toBeLessThan( vi.mocked(delegate.sendTerminal).mock.invocationCallOrder[0]! ) diff --git a/src/main/plugins/plugin-host-service-bindings.ts b/src/main/plugins/plugin-host-service-bindings.ts index 266b819dbae..731934d5595 100644 --- a/src/main/plugins/plugin-host-service-bindings.ts +++ b/src/main/plugins/plugin-host-service-bindings.ts @@ -3,6 +3,7 @@ import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-hos import type { PluginHostServices } from './plugin-host-methods' import { PluginSecretsStore } from './plugin-secrets-store' import { PluginKvStore } from './plugin-storage-store' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' /** Structural subset of OrcaRuntimeService exposed to plugin facade bindings. */ export type PluginRuntimeDelegate = { @@ -19,7 +20,8 @@ export type PluginRuntimeDelegate = { ): Promise<{ terminals: { handle: string; title: string | null }[] }> sendTerminal( handle: string, - action: { text?: string; enter?: boolean } + action: { text?: string; enter?: boolean }, + options: { inputKind: TerminalInputKind } ): Promise<{ accepted: boolean }> dispatchPluginNotification(input: { pluginId: string @@ -59,7 +61,7 @@ export function bindPluginHostServices(input: { .map((terminal) => ({ id: terminal.handle })) }, sendTerminalText: async (terminalId, action) => { - const result = await delegate.sendTerminal(terminalId, action) + const result = await delegate.sendTerminal(terminalId, action, { inputKind: 'driving' }) return { accepted: result.accepted } }, dispatchPluginNotification: (notification) => delegate.dispatchPluginNotification(notification), diff --git a/src/main/providers/local-pty-spawn-environment.ts b/src/main/providers/local-pty-spawn-environment.ts index 47aaac6cef0..645fcacb2d0 100644 --- a/src/main/providers/local-pty-spawn-environment.ts +++ b/src/main/providers/local-pty-spawn-environment.ts @@ -8,6 +8,7 @@ import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' import { stripPiProcessOwnerEnv } from '../pty/pi-process-owner-env' import { removeInheritedNoColor } from '../pty/terminal-color-env' import { isWindowsGitBashShellPath } from '../git-bash' +import { applyScrubSafeAgentEnvAliases } from '../../shared/agent-hook-scrub-safe-env' import { removeUnspecifiedPaneIdentityEnv } from './local-pty-launch-helpers' import type { LocalPtyLaunchPlan } from './local-pty-launch-plan' import type { LocalPtyProviderOptions } from './local-pty-provider-types' @@ -42,6 +43,8 @@ export function buildLocalPtySpawnEnvironment(args: { if (spawn.env?.TERM) { spawnEnv.TERM = spawn.env.TERM } + // Why after the strips and deletes: an alias must never outlive the value it mirrors. + applyScrubSafeAgentEnvAliases(spawnEnv) spawnEnv.LANG ??= 'en_US.UTF-8' diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 735393449f2..8009260923d 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -2,6 +2,7 @@ import { basename, isAbsolute, join } from 'node:path' import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'node:fs' import type * as pty from 'node-pty' import { usesNodePtySpawnHelper } from '../../shared/node-pty-spawn-helper' +import { TERMINAL_SPAWN_ISSUE_REQUEST } from '../../shared/terminal-spawn-error-copy' import { hostReportsChildExitStatus, wrapShellSpawnForMacosTccAttribution @@ -307,7 +308,6 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu const diag = formatLocalPtyEnvironmentDiag({ shell: shellPath, cwd }) throw new Error( - `Failed to spawn shell "${shellPath}": ${primaryError ?? 'unknown error'} (${diag}). ` + - `If this persists, please file an issue.` + `Failed to spawn shell "${shellPath}": ${primaryError ?? 'unknown error'} (${diag}). ${TERMINAL_SPAWN_ISSUE_REQUEST}` ) } diff --git a/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.meta.json b/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.meta.json new file mode 100644 index 00000000000..5c5284b5056 --- /dev/null +++ b/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-23T07:34:39.531Z", + "platform": "darwin", + "command": ["dsh-tui"], + "cols": 120, + "rows": 40, + "note": "dsh-tui 0.10.2 on @deepseek-ai/dsh 0.1.5-rc.1, macOS arm64, no DEEPSEEK_API_KEY, empty workspace, DSH_TUI_LANG=en", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt b/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt new file mode 100644 index 00000000000..34f9432049a --- /dev/null +++ b/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt @@ -0,0 +1 @@ +[?25l[?2004h[?1004h]11;?[>0q[?1049h[?1000h[?1002h[?1003h[?1006h]0;✦ 🐋 ]8;; ✦ dsh-TUI v0.10.2 █▀▀▀▄█▀▀▀▀█▀▀▀▀█▀▀▀▄█▀▀▀▀█▀▀▀▀█▀▀▀▀██ ███████████ ▄▀▄▄███▀▀▀█▀▀▀█▄▄▄▀▀▀▀▄█▀▀▀█▀▀▀██ ▀▀▀▀▄▄▄▀▀▀██████████ ▄▄▄▄▄▄▄▄▄▀▀▀▀▀▀▀▀▀▀▀▀█▄▄▄▀█▄▄▄▄█▄▄▄▄██▄▄▄▀█▄▄▄▄█▄▄▄▄██ ▄▀▀▀▀▀▀▀▀▀▀▀▀▄▄▀▀▀▀▀▀▀▀▀██▄▀▄█▀▀▀▄███▀▀▀▀█▀▀▀▀█▀▀▀▀ ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄▀▀▀▀▀████████████ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀█▀▀▀██▀▀▀██▄▄▄▀████▀▀▀▀▀▀▄▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀████████████ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀█████████▄▄▄▄█▄▄▄▀█▄▄▄▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀deepseek-flash · Max effort ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄/private/tmp/dsh-ws ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀Tip: Clicktool/thinking/summaryrowstofold;subagentcardsopendetail;… ⚠ The dsh engine (0.1.5-rc.3) is newer than the 0.1.5-rc.1 this UI is validated against, so issues are possible; downgrade via npm i -g @deepseek-ai/dsh@0.1.5-rc.1 for stability, or wait for a dsh-tui update. Explore the uncharted! ▶(Ctrl+P to expand)Contextloaded·Systemprompt18sections·Runtimecontext2items·Skills14·Tools27 ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ ❯  ⛶ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ deepseek-flash · max · dsh-ws]8;; ✦ █ █ █ █ █ █ █ █ █ █ E]8;; ✦ █▀ █ █ █ █▄ █ █ █▀ █ █ Ex]8;; ✦ ds █▀▀▀ █ █ █ █▄▄▄ █ █ █▀▀▀ █ █ Expl]8;; ✦ dsh █▀▀▀▄ ██ ██ ██ █▄▄▄▀ ██ ██ █▀▀▀█ ██ ██ Explo]8;; ✦ dsh- █▀▀▀▄ ██ ██ ██ █▄▄▄▀ ██ ██ █▀▀▀█ ██ ██ Explor]8;; ▀▀]8;; ✦ dsh-T █▀▀▀▄█ ███ ███ ███ █▄▄▄▀█ ██ ███ █▀▀▀██ ███ ███ Explore]8;; ✦ dsh-TU █▀▀▀▄█▀ ███ ███▀ ███ █▄▄▄▀█▄ ██▄ ███ █▀▀▀██▀ ███ ███ Explore ]8;; ✦ dsh-TU █▀▀▀▄█▀ ███ ███▀ ███ █▄▄▄▀█▄ ██▄ ███ █▀▀▀██▀ ███ ███ Explore ]8;; ✦ dsh-TUI ▀▀▄█▀▀▀ ██ ██▀▀▀ ██ ▄▄▀█▄▄▄ █▄▀▄ ██ ▀▀██▀▀▀ ██ ██ Explore th]8;; ▀▀]8;; ✦ dsh-TUI ▀▀▄█▀▀▀▀ ██ ██▀▀▀ ██ ▄▄▀█▄▄▄▄ █▄▀▄ ███ ▀▀██▀▀▀█ ███ ███ Explore the]8;;  dsh-TUI ▀▄█▀▀▀▀ ██ ██▀▀▀ ██ ▄▀█▄▄▄▄ █▄▀▄ ███ ▀██▀▀▀█ ███ ███ xplore the ]8;; dsh-TUI ▄█▀▀▀▀█▀ ███ ██▀▀▀█▀ ███ ▀█▄▄▄▄█▄ █▄▀▄█▀ ████ ██▀▀▀██▄ ████ ████ plore the un]8;; h-TUI █▀▀▀▀█▀▀ ██ █▀▀▀█▀▀ ██ █▄▄▄▄█▄▄ ▄▀▄█▀▀ ███ █▀▀▀██▄▄ ████ ███ ore the unc]8;; - ▀▀▀▀█▀▀▀ █ ▀▀▀█▀▀▀ █ ▄▄▄▄█▄▄▄ ▄▀ ▀▄ re the unch]8;; ▄▄▄ ▀]8;; TUI ▀▀▀█▀▀▀▀ █ ▀▀█▀▀▀ █ ▄▄▄█▄▄▄▄ ▀▄█▀▀▀▄ ███ ▀▀██▄▄▄▀ ███ ███ e the uncha]8;; UI ▀▀█▀▀▀▀ █ ▀█▀▀▀ █ ▄▄█▄▄▄▄ ▄█▀▀▀▄ ███ ▀██▄▄▄▀ ███ ███  the unchar]8;; ▄ ▀▀▀▀▀]8;; I ▀█▀▀▀▀█ ██ █▀▀▀█ ██ ▄█▄▄▄▄█ █▀▀▀▄█ ████ ██▄▄▄▀█ ████ ████ the unchart]8;; █▀▀▀▀█▀ ██ █▀▀▀█▄ ██ █▄▄▄▄█ █▀▀▀▄█ ████ █▄▄▄▀█ ███ ███ he uncharte]8;; ▄▄▀▄▄ ▀▀▀▀]8;; █▀▀▀▀█▀ ██ █▀▀▀█▄ ██ █▄▄▄▄█ █▀▀▀▄█ ████ █▄▄▄▀█ ███ ███ e uncharte]8;; ▀▀█▀▀▀▄ ██ ▀█▄▄▄▀ █ ▄▄█ ▀▄██ ████ ▄▀███ ███ ███ ncharted!]8;; ▄ ▄▄▀▀▀▀▀▀▄▄ ▀ ▀▀ ▀ ▄]8;; ▀▀█▀▀▀▄ ██ ▀█▄▄▄▀ █ ▄▄█ ▀▄██ ████ ▄▀███ ███ ███ ncharted!]8;; ▀█▀▀▀▄█ ███ █▄▄▄▀ █ ▄██ ▄███ █████ ▀████ ████ ████ charted!]8;; █▀▀▀▄█▀ ███ █▄▄▄▀▀ █ ██▄ ███▀ ████ ████▀ ████ ███▄ harted!]8;; ▄▄▄▄ ▄▀▀▀▀▄▄▀▀▀▀▄ ▀  ▀  ▀]8;; █▀▀▀▄█▀▀ ███ █▄▄▄▀▀▀ █ ██▄▄ ███▀▀ ████ ████▀▀ ████ ███▄▄ arted!]8;; ▀▀▀▄█▀▀▀ ██ ▄▄▄▀▀▀▀ █▄▄▄ ██▀▀ ███ ███▀▀ ███ ██▄▄ rted!]8;; ▄  ▄ ▄▀  ▀▄ ▀  ▀]8;; ▀▀▄█▀▀▀▀ ██ ▄▄▀▀▀▀▄ █ █▄▄▄▀ ██▀▀▀▀ ██ ███▀▀▀ ███ ██▄▄▄▄ ted!]8;; ✦ ▀▄█▀▀▀▀ ██ ▄▀▀▀▀▄ █ █▄▄▄▀ ██▀▀▀▀ ██ ██▀▀▀ ███ ██▄▄▄▄ ed!]8;;          ]8;; ✦ d ▄█▀▀▀▀█▀ ███ ▀▀▀▀▄█▀ ██ █▄▄▄▀█▄ ██▀▀▀▀█▀ ███ ██▀▀▀▀ ██ ██▄▄▄▄█▄ d!]8;; ✦ ds █▀▀▀▀█▀▀ ██ ▀▀▀▄█▀▀ ██ █▄▄▄▀█▄▄ █▀▀▀▀█▀ ██ █▀▀▀▀ █ █▄▄▄▄█▄]8;; ✦ ds ▀▀▀▀█▀▀ █ ▀▀▀▄█▀▀ ██ ▄▄▄▀█▄▄ ▀▀▀▀█▀ █ ▀▀▀▀ ▄▄▄▄█▄]8;; ✦ dsh- ▀▀█▀▀▀▀ █ ▀▄█▀▀▀ ██ ▄▀█▄▄▄▄ ▀▀ ▀ ▄▄]8;;  ▄▀▄ ▄  ▀▀▀▄ ▄▀▀▀  ▀▀▀▀▀▀  ▀▀▀▀  ▄▀▀▀ ▀▀▀ ▀▀ ▀▀]8;; ✦ dsh-T ▀▀█▀▀▀▀ █ ▀▄█▀▀▀ ██ ▄▀█▄▄▄▄ ▀▀█▀▀ █ ▀▀▀ ▄▄█▄▄]8;; ✦ dsh-TU ▀█▀▀▀▀█ ██ ▄█▀▀▀█ ███ ▀█▄▄▄▄█ ▀█▀▀█ ██ ▀▄ █ ▄█▄▀█]8;;  ▀▄ ▄  ▀▀▀▀ ▄▀▀▀  ▀▀▄▀▀▀▀▀▀  ▀▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀ ▀▀]8;; ✦ dsh-TUI █▀▀▀▀█▀ ██ █▀▀▀█▀ ██ █▄▄▄▄█▄ █▀▀▀▀█▀ ██ ▀▀▀▄▀ █ █▄▄▄▀█▄]8;; ✦ dsh-TUI █▀▀▀▀█▀▀ ██ █▀▀▀█▀▀ ██ █▄▄▄▄█▄▄ █▀▀█▀▀ ██ ▀▀▀▀ █▄▄█▄▄]8;;  ▄  ▀▀▀  ▀ ▄▀▀▄ ▀▄▀▀▀▀▀  ▀▀▀▀▀ ▀▀▀▀▀ ▀▀ ▀▀ ▀]8;; ✦ dsh-TUI ▀▀▀▀█▀▀▀▀ █ ▀▀▀█▀▀▀ █ ▄▄▄▄█▄▄▄▄ ▀▀▀▀▀ ▀▀▀▀▄ █ ▄▄▄▄▀]8;; dsh-TUI ▀▀█▀▀▀▀ █ ▀█▀▀▀ █ ▄▄█▄▄▄▄ ▀█▀▀▀▀ █ ▀▀▀▀▄ █ ▄█▄▄▄▀]8;; sh-TUI ▀█▀▀▀▀█ ██ █▀▀▀█ ██ ▄█▄▄▄▄█ ▀█▀▀▀▀ █ ▄▀▀▀▄ ██ ▀█▄▄▄▀ E]8;;   ▄▀▄  ▀▀▀▀  ▀▀▀▀ ▄  ▄▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀ ▀]8;; h-TUI █▀▀▀▀█ ██ █▀▀▀██ ██ █▄▄▄▄█ █▀▀▀▀ █ ▀▀▀▄ █ █▄▄▄▀ Ex]8;; -TUI █▀▀▀▀█ ███ █▀▀▀██ ███ █▄▄▄▄█ █▀▀▀▀ █ ▀▀▀▄ █ █▄▄▄▀ Exp]8;; ▄ ▀▀▀ ▀▀▀▀▄ ▄▀▀▄ ▀▀▀▀▄▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀▀▀  ]8;; TUI ▀▀▀▀█ ██ ▀▀▀██ ██ ▄▄▄▄█ ▀▀▀▀ ▀▀▀▄ █ ▄▄▄▀ Expl]8;; UI ▀▀▀██ ██ ▀▀██ ██ ▄▄▄██ Explo]8;; I ▀▀██ ██ ▀██ ██ ▄▄██ ▀ ▄ █ ▀ Explor]8;; ▄▀▄ ▄ ▀▀▀▀ ▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▄▀▀▀ ▀▀▀ ▀ ▀  ]8;; ▀██ ██ ██ ██ ▄██ ▀ ▄ █ ▀ Explore]8;; ██ ██ ██ ██ ██ Explore t]8;; ▄▀ ▄ ▀▀▀▀▄ ▄▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀  ▀▀▀ ▀▀▀ ▀▀]8;; █ █ █ █ █ Explore th]8;; █ █ █ █ Explore the]8;; ▄▀▄ ▄ ▀▀▀▄ ▄▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀ ▄▀▀▀ ▀▀ ▀ ▀ ]8;; █ █ xplore the ]8;; █ █ plore the u]8;; lore the un]8;; ore the unc]8;; re the unch]8;; e the unch]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;; ▀ ▀▀▀▄▄ ▀▀▀▀▀  ]8;; ▀ ▀▀▀ ▀▀▀▄ ▀]8;; ▀▀ ▀▀▀▄  ▀]8;;  ▄▀▄ ▄  ▀▀▀▄ ▄▀▀▀  ▀▀▀▀▀▀  ▀▀▀▀  ▄▀▀▀ ▀▀▀ ▀▀ ▀▀]8;;  ▀▄ ▄  ▀▀▀▀ ▄▀▀▀  ▀▀▄▀▀▀▀▀▀  ▀▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀ ▀▀]8;;  ▄  ▀▀▀  ▀ ▄▀▀▄ ▀▄▀▀▀▀▀  ▀▀▀▀▀ ▀▀▀▀▀ ▀▀ ▀▀ ▀]8;;   ▄▀▄  ▀▀▀▀  ▀▀▀▀ ▄  ▄▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀ ▀]8;; ▄ ▀▀▀ ▀▀▀▀▄ ▄▀▀▄ ▀▀▀▀▄▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀▀▀  ]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;; ▄▀▄ ▄ ▀▀▀▀ ▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▄▀▀▀ ▀▀▀ ▀ ▀▀ ▀▀▀▄▄ ▀▀▀▀▀  ]8;; ▀▀]8;; ▀ ▀▀▀ ▀▀▀▄ ▀]8;; ▀▀]8;; ▄▀ ▄ ▀▀▀▀▄ ▄▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀  ▀▀▀ ▀▀▀ ▀▀]8;; ▀▀ ▀▀▀▄  ▀]8;; ▄▀▄ ▄ ▀▀▀▄ ▄▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀ ▄▀▀▀ ▀▀ ▀ ▀ ]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;; ▀ ▀▀▀▄▄ ▀▀▀▀▀  ]8;; ▀ ▀▀▀ ▀▀▀▄ ▀]8;; ▀▀ ▀▀▀▄  ▀]8;;  ▄▀▄ ▄  ▀▀▀▄ ▄▀▀▀  ▀▀▀▀▀▀  ▀▀▀▀  ▄▀▀▀ ▀▀▀ ▀▀ ▀▀]8;;  ▀▄ ▄  ▀▀▀▀ ▄▀▀▀  ▀▀▄▀▀▀▀▀▀  ▀▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀ ▀▀]8;;  ▄  ▀▀▀  ▀ ▄▀▀▄ ▀▄▀▀▀▀▀  ▀▀▀▀▀ ▀▀▀▀▀ ▀▀ ▀▀ ▀]8;;   ▄▀▄  ▀▀▀▀  ▀▀▀▀ ▄  ▄▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀ ▀]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;; ▄ ▀▀▀ ▀▀▀▀▄ ▄▀▀▄ ▀▀▀▀▄▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀▀▀  ]8;; ▀▀]8;; ▀ ▀▀▀▀▄ ▀▀▀▀▀  ]8;; ▀▀]8;; ▀ ▀▀▀▀ ▀▀▀▄ ▀]8;; ▄▀▄ ▄ ▀▀▀▀ ▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▄▀▀▀ ▀▀▀ ▀ ▀  ]8;; ▀▀▀▀ ▀▀▀▀ ▄▀▄▄]8;; ▀▀ ▀▀▀▄  ▀]8;; ▄▀ ▄ ▀▀▀▀▄ ▄▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀  ▀▀▀ ▀▀▀ ▀▀]8;; ▄▄▄▄  ▄  ▀▀▀▀ ▀▀▀▀]8;; ▄▀▄ ▄ ▀▀▀▄ ▄▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀ ▄▀▀▀ ▀▀ ▀ ▀ ]8;;  ▀▀▀▀ ▀▄▄ ▄▄▄▄ ▄▄▄]8;;  ▄  ▀▀▀▀  ▀▀▀▀]8;; ▀▄▄ ▄▄▄▄  ▄ ▀▀▀]8;;  ▀▀▀▀  ▀▀▀▀ ▀▄▄ ▄▄▄ ]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;; ▄▄▄▄  ▄  ▀▀▀▀ ▀▀▀▀]8;; ▀ ▀▀▀▄▄ ▀▀▀▀▀  ]8;;  ▀▀▀▀ ▀▄▄ ▄▄▄▄ ▄▄▄]8;; ▀ ▀▀▀ ▀▀▀▄ ▀]8;; ▀▀ ▀▀▀▄  ▀]8;;  ▄  ▀▀▀▀  ▀▀▀▀]8;; ▀▄▄ ▄▄▄▄  ▄ ▀▀▀]8;;  ▀▀▀▀  ▀▀▀▀ ▀▄▄ ▄▄▄ ]8;;  ▄▀▄ ▄  ▀▀▀▄ ▄▀▀▀  ▀▀▀▀▀▀  ▀▀▀▀  ▄▀▀▀ ▀▀▀ ▀▀ ▀▀]8;; ▄▄▄▄  ▄  ▀▀▀▀ ▀▀▀▀]8;;  ▀▄ ▄  ▀▀▀▀ ▄▀▀▀  ▀▀▄▀▀▀▀▀▀  ▀▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀ ▀▀]8;;  ▀▀▀▀ ▀▄▄ ▄▄▄▄ ▄▄▄]8;;  ▄  ▀▀▀  ▀ ▄▀▀▄ ▀▄▀▀▀▀▀  ▀▀▀▀▀ ▀▀▀▀▀ ▀▀ ▀▀ ▀]8;;  ▄  ▀▀▀▀  ▀▀▀▀]8;; ▀▀]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;;   ▄▀▄  ▀▀▀▀  ▀▀▀▀ ▄  ▄▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀ ▀]8;; ▀▀]8;; ▀ ▀▀▀▀▀ ▀▀▀▀▀  ]8;; ▀▄▄ ▄▄▄▄  ▄ ▀▀▀]8;; ▄ ▀▀▀ ▀▀▀▀▄ ▄▀▀▄ ▀▀▀▀▄▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀▀▀ ▄ \ No newline at end of file diff --git a/src/main/runtime/agent-prompt-line-settle.test.ts b/src/main/runtime/agent-prompt-line-settle.test.ts index 45d8bca53a6..4ac0d9648ee 100644 --- a/src/main/runtime/agent-prompt-line-settle.test.ts +++ b/src/main/runtime/agent-prompt-line-settle.test.ts @@ -37,7 +37,7 @@ describe('agent prompt line-settle scheduling', () => { () => undefined, 'antigravity' ) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(submitDelayMs - 1) diff --git a/src/main/runtime/agent-prompt-receipt-correlation.test.ts b/src/main/runtime/agent-prompt-receipt-correlation.test.ts index e6b6d7f2793..77e8967b6d3 100644 --- a/src/main/runtime/agent-prompt-receipt-correlation.test.ts +++ b/src/main/runtime/agent-prompt-receipt-correlation.test.ts @@ -34,6 +34,7 @@ describe('agent prompt receipt correlation', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'historical-first', observationTimeoutMs: 0 @@ -41,6 +42,7 @@ describe('agent prompt receipt correlation', () => { await vi.runAllTimersAsync() const first = await firstPromise const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'historical-second', observationTimeoutMs: 0 diff --git a/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts b/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts index c893623a18a..3d0c317a31b 100644 --- a/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts @@ -102,7 +102,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { getForegroundProcess: async () => null }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -130,6 +132,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { }) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', acceptQueued: true, requestId: 'antigravity-pre-invocation', observationTimeoutMs: 20_000 @@ -156,7 +159,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { stateStartedAt: 1_000 }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -171,6 +176,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook, 'codex') const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'hook-queued-first', observationTimeoutMs: 0 @@ -193,6 +199,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { getForegroundProcess: async () => null }) const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'hook-queued-second', observationTimeoutMs: 500 @@ -219,7 +226,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { it('does not write Enter after the PTY generation changes during settlement', async () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime(() => undefined) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('terminal_handle_stale') await vi.advanceTimersByTimeAsync(0) @@ -256,6 +265,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { ) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') @@ -291,7 +301,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { sequenceAtSpawnStart ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -318,9 +330,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { sequenceAtSpawnStart ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -331,7 +343,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -351,8 +365,10 @@ describe('agent prompt submission runtime hook and generation cases', () => { } }) - const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt') - const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt') + const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { inputKind: 'driving' }) + const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await Promise.all([first, second]) @@ -373,6 +389,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'queued-first', observationTimeoutMs: 0 @@ -380,6 +397,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { await vi.runAllTimersAsync() const first = await firstPromise const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'queued-second', observationTimeoutMs: 0 @@ -418,6 +436,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { }) const first = runtime.sendTerminalAgentPrompt(handle, 'obsolete prompt', { + inputKind: 'driving', beforeWrite: async () => { firstWriteReached() await firstGate @@ -430,7 +449,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { 0 ) - const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt') + const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(replacement).resolves.toMatchObject({ accepted: true }) expect(writes.some((data) => data.includes('replacement prompt'))).toBe(true) @@ -444,6 +465,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { let writeChecks = 0 const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + inputKind: 'driving', beforeWrite: () => { writeChecks += 1 if (writeChecks === 2) { @@ -466,6 +488,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { const controller = new AbortController() const { runtime, handle, writes } = await createPromptRuntime(() => undefined) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') @@ -483,6 +506,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { const controller = new AbortController() const { runtime, handle, writes } = await createPromptRuntime(() => undefined) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 873863ada75..700445483a9 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -43,7 +43,9 @@ describe('agent prompt submission runtime', () => { } ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -59,7 +61,9 @@ describe('agent prompt submission runtime', () => { } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -75,7 +79,9 @@ describe('agent prompt submission runtime', () => { runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› review this', Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -93,7 +99,9 @@ describe('agent prompt submission runtime', () => { } }) runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -109,7 +117,9 @@ describe('agent prompt submission runtime', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -122,9 +132,9 @@ describe('agent prompt submission runtime', () => { const { runtime, handle, writes } = await createPromptRuntime(() => undefined) runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -140,9 +150,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -155,9 +165,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -175,7 +185,9 @@ describe('agent prompt submission runtime', () => { ) runtime.onPtyData('pty-prompt', '}\x07\x07', Date.now()) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -197,9 +209,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -214,7 +226,9 @@ describe('agent prompt submission runtime', () => { text: 'Permission required\r\nAllow once\r\nAllow always\r\nReject\r\n' }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -235,7 +249,9 @@ describe('agent prompt submission runtime', () => { } }) runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -257,7 +273,9 @@ describe('agent prompt submission runtime', () => { runtime.onPtyData('pty-prompt', output, Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -271,6 +289,7 @@ describe('agent prompt submission runtime', () => { let writeChecks = 0 const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + inputKind: 'driving', beforeWrite: () => { writeChecks += 1 if (writeChecks === 2) { @@ -291,6 +310,7 @@ describe('agent prompt submission runtime', () => { let writeChecks = 0 const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + inputKind: 'driving', beforeWrite: () => { writeChecks += 1 if (writeChecks === 2) { @@ -319,9 +339,9 @@ describe('agent prompt submission runtime', () => { ) runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -369,7 +389,9 @@ describe('agent prompt submission runtime', () => { ) vi.setSystemTime(2_000) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -389,7 +411,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -408,7 +432,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -432,7 +458,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -453,6 +481,7 @@ describe('agent prompt submission runtime', () => { ) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', acceptQueued: true, requestId: 'queued-output-only', observationTimeoutMs: 0 diff --git a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts index f6298df8b17..3bc936c78a5 100644 --- a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts +++ b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts @@ -111,7 +111,9 @@ describe('agent prompt submit delay on a ConPTY host', () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime() const delayMs = submitDelayFor('review this', 'win32') - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -131,7 +133,7 @@ describe('agent prompt submit delay on a ConPTY host', () => { // Measured ConPTY ingest for 8 KB is 60-89 ms; the old constant charged 1_500 ms. const delayMs = submitDelayFor(prompt, 'win32') expect(delayMs).toBeLessThan(700) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs) @@ -146,7 +148,7 @@ describe('agent prompt submit delay on a ConPTY host', () => { vi.useFakeTimers() const { runtime, handle, writes, submitTimes } = await createPromptRuntime() const prompt = 'y'.repeat(320_000) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') // Every byte is already handed to node-pty here -- the hazard is that the *host* is @@ -167,6 +169,7 @@ describe('agent prompt submit delay on a ConPTY host', () => { const controller = new AbortController() const { runtime, handle, writes } = await createPromptRuntime() const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') @@ -186,7 +189,9 @@ describe('agent prompt submit delay on a ConPTY host', () => { const { runtime, handle, writes } = await createPromptRuntime() const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'darwin') expect(delayMs).toBeLessThan(submitDelayFor(HOST_PROBE_PROMPT, 'win32')) - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -215,7 +220,9 @@ describe('agent prompt submit delay follows the execution host', () => { patchPtyRecord(runtime, { isWsl: true, wslDistro: 'Ubuntu' }) const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') expect(delayMs).toBeGreaterThan(submitDelayFor(HOST_PROBE_PROMPT, 'linux')) - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -235,7 +242,9 @@ describe('agent prompt submit delay follows the execution host', () => { registerSshRemotePlatform('win32') const clientDelayMs = submitDelayFor(HOST_PROBE_PROMPT, 'darwin') const hostDelayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(clientDelayMs) @@ -255,7 +264,9 @@ describe('agent prompt submit delay follows the execution host', () => { registerSshRemotePlatform('linux') const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'linux') expect(delayMs).toBeLessThan(submitDelayFor(HOST_PROBE_PROMPT, 'win32')) - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -277,7 +288,9 @@ describe('agent prompt submit delay follows the execution host', () => { }) registerSshRemotePlatform(undefined) const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -341,7 +354,9 @@ describe('agent prompt render gate on a ConPTY host', () => { useHostPlatform('win32') vi.useFakeTimers() const { runtime, handle, writes, submitTimes } = await createSettlementRuntime() - const submission = runtime.sendTerminalAgentPrompt(handle, 'y'.repeat(320_000)) + const submission = runtime.sendTerminalAgentPrompt(handle, 'y'.repeat(320_000), { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') // Marker at 100 ms + a 1_500 ms quiet window would have submitted at ~1_600 ms, while @@ -370,7 +385,7 @@ describe('agent prompt render gate on a ConPTY host', () => { markerDelayMs: ingestMs - 1_000, noiseUntilMs: ingestMs + 20_000 }) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(ingestMs + 8_000 - 1) @@ -387,7 +402,9 @@ describe('agent prompt render gate on a ConPTY host', () => { useHostPlatform('win32') vi.useFakeTimers() const { runtime, handle, submitTimes } = await createSettlementRuntime() - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -409,7 +426,11 @@ describe('plain terminal send suffix delay', () => { useHostPlatform('win32') vi.useFakeTimers() const { runtime, handle, writes, submitTimes } = await createPromptRuntime() - const send = runtime.sendTerminal(handle, { text: 'z'.repeat(320_000), enter: true }) + const send = runtime.sendTerminal( + handle, + { text: 'z'.repeat(320_000), enter: true }, + { inputKind: 'driving' } + ) // Same hazard as the agent-prompt path: a flat 500 ms wrote Enter mid-paste here. await vi.advanceTimersByTimeAsync(3_342) @@ -429,7 +450,7 @@ describe('plain terminal send suffix delay', () => { const send = runtime.sendTerminal( handle, { text: 'z'.repeat(320_000), enter: true }, - { signal: controller.signal } + { inputKind: 'driving', signal: controller.signal } ) const rejected = expect(send).rejects.toThrow('request_aborted') diff --git a/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts index a9a3b4484a8..eb4acb14d5e 100644 --- a/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts +++ b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts @@ -226,7 +226,7 @@ export class ClaudeAgentTeamsTmuxDispatcher { const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane) const text = tmuxSendKeysText(parsed.positional, parsed.flags.has('-l')) if (text) { - await api.sendTerminal(pane.handle, { text }) + await api.sendTerminal(pane.handle, { text }, { inputKind: 'driving' }) } return '' } diff --git a/src/main/runtime/claude-agent-teams-types.ts b/src/main/runtime/claude-agent-teams-types.ts index 10234f5226d..bddfad17d17 100644 --- a/src/main/runtime/claude-agent-teams-types.ts +++ b/src/main/runtime/claude-agent-teams-types.ts @@ -6,6 +6,7 @@ import type { RuntimeTerminalShow, RuntimeTerminalSplit } from '../../shared/runtime-types' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type AgentTeamsTmuxCompatRequest = { teamId: string @@ -43,7 +44,8 @@ export type AgentTeamsTerminalApi = { readTerminal(handle: string, opts?: { limit?: number }): Promise sendTerminal( handle: string, - action: { text?: string; enter?: boolean; interrupt?: boolean } + action: { text?: string; enter?: boolean; interrupt?: boolean }, + options: { inputKind: TerminalInputKind } ): Promise focusTerminal(handle: string): Promise closeTerminal(handle: string): Promise diff --git a/src/main/runtime/dsh-readiness-transcript.test.ts b/src/main/runtime/dsh-readiness-transcript.test.ts new file mode 100644 index 00000000000..36608b987b0 --- /dev/null +++ b/src/main/runtime/dsh-readiness-transcript.test.ts @@ -0,0 +1,71 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { + getAgentLabel, + isGeminiTerminalTitle, + resolveExplicitTerminalTitleAgentType +} from '../../shared/terminal-title-agent-type' + +// The committed capture of a real `dsh-tui` 0.10.2 launch on @deepseek-ai/dsh 0.1.5-rc.1. +// Every rule below is written against these bytes rather than a remembered screen; see +// docs/reference/agent-pty-transcript-capture.md. +const TRANSCRIPT = readFileSync(join(__dirname, '__fixtures__', 'dsh-tui-ready-no-key.txt'), 'utf8') + +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) + +/** The OSC 0 title DSH-TUI sets, read back out of the capture. */ +function readFirstOscTitle(data: string): string { + // Indexed scan rather than a regex: the delimiters are control characters. + const open = data.indexOf(`${ESC}]0;`) + const bodyStart = open === -1 ? -1 : open + 4 + const terminator = bodyStart === -1 ? -1 : data.indexOf(BEL, bodyStart) + if (terminator === -1) { + throw new Error('the captured transcript carries no BEL-terminated OSC 0 title') + } + return data.slice(bodyStart, terminator) +} + +describe('DSH-TUI readiness from captured terminal bytes', () => { + it('fires the composer-ready signal well before the transcript ends', () => { + const scanner = createDraftPasteReadyScanner('dsh-composer-prompt') + let readyAt = -1 + // Feed it in PTY-sized chunks so a marker split across chunk boundaries is exercised. + for (let offset = 0; offset < TRANSCRIPT.length; offset += 1024) { + const chunk = TRANSCRIPT.slice(offset, offset + 1024) + if (scanner.observe(chunk).ready && readyAt === -1) { + readyAt = offset + chunk.length + } + } + // The composer glyph lands at byte 5910 of ~70KB: readiness must not wait out the + // whale intro that keeps painting behind it (the grok failure mode this signal fixes). + expect(readyAt).toBeGreaterThan(0) + expect(readyAt).toBeLessThan(8192) + }) + + it('arms the quiet-window fallback from DECSET 2004 as well', () => { + const scanner = createDraftPasteReadyScanner('dsh-composer-prompt') + expect(scanner.observe(TRANSCRIPT.slice(0, 40)).armQuietTimer).toBe(true) + }) + + it('identifies the pane from DSH’s own title, not Gemini’s', () => { + const title = readFirstOscTitle(TRANSCRIPT) + // DSH-TUI's idle prefix is `✦`, which is Gemini CLI's WORKING glyph. + expect(title).toContain('✦') + expect(title).toContain('\u{1F40B}') + expect(isGeminiTerminalTitle(title)).toBe(false) + expect(getAgentLabel(title)).toBe('DeepSeek Harness') + expect(resolveExplicitTerminalTitleAgentType(title)).toBe('dsh') + }) + + it('keeps a working DSH title out of Claude’s braille-spinner lane', () => { + // `titlePrefix` cycles through `⠂`/`⠐` while a turn runs (Chat.js + // TITLE_SPINNER_FRAMES), both of which are in the braille block Claude claims. + for (const frame of ['⠂', '⠐']) { + const working = `${frame} \u{1F40B} fix the flaky test` + expect(getAgentLabel(working)).toBe('DeepSeek Harness') + } + }) +}) diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 6b94e5d0ea2..48d81fc79cc 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -27,7 +27,8 @@ import type { import { recordCreatedWorktreeLineage as recordCreatedWorktreeLineageState } from './runtime-worktree-lineage-recording' import { pasteWorktreeStartupDraftWhenReady, - sendWorktreeStartupFollowupWhenReady + sendWorktreeStartupFollowupWhenReady, + waitForWorktreeStartupDraft } from './runtime-worktree-startup-readiness' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import { provisionWorktreeTerminals } from './runtime-worktree-terminal-provisioning' @@ -203,6 +204,29 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM pasteWorktreeStartupDraftWhenReady(this.getWorktreeStartupReadinessHost(), handle, draft) } + /** Only for a newly launched worker, before its first dispatch input. */ + async waitForFreshWorkerComposer( + handle: string, + agent: TuiAgent, + timeoutMs: number + ): Promise { + const initialPtyId = + this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId + const ptyId = await waitForWorktreeStartupDraft( + { ...this.getWorktreeStartupReadinessHost(), getPtyId: () => initialPtyId }, + handle, + agent, + { timeoutMs, requireComposerMarker: true } + ) + if (!ptyId) { + throw new Error('timeout') + } + this.assertLiveTerminalHandleTargetsPty(handle, ptyId) + if (!this.ptysById.get(ptyId)?.connected) { + throw new Error('terminal_handle_stale') + } + } + protected sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void { sendWorktreeStartupFollowupWhenReady(this.getWorktreeStartupReadinessHost(), handle, followup) } diff --git a/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts b/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts index f433c75793d..7cb440b4bac 100644 --- a/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts +++ b/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts @@ -182,6 +182,7 @@ export class OrcaRuntimeWithAttachRemoteTerminalSourceRangeConsumer extends Orca try { await assertTerminalInputWithinLimitWithYield(data) await this.writeTerminalInputChunks(ptyId, data, { + inputKind: 'driving', // Why: a phone can claim the floor while a paste yields between chunks. beforeWrite: () => { if (this.getDriver(ptyId).kind === 'mobile') { diff --git a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts index 391b55ac5b0..3c9c8b7eab9 100644 --- a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts +++ b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts @@ -3,6 +3,7 @@ import { OrcaRuntimeWithResolveTerminalPane } from './orca-runtime-resolve-termi import { PROVEN_ABSENT_LEAF_PTY_TTL_MS } from './orca-runtime-core' import { pruneExpiredProvenAbsentLeafPtyVerdicts } from './proven-absent-leaf-pty-verdicts' import type { RuntimeTerminalSend } from '../../shared/runtime-types' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { RuntimeAgentPromptWriteOptions } from './runtime-terminal-contracts' import { assertTerminalInputWithinLimitWithYield, @@ -103,7 +104,8 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso reserveWrite?: (ptyId: string) => void afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string - } = {} + inputKind: TerminalInputKind + } ): Promise { const pty = this.getLivePtyForHandle(handle) if (pty) { @@ -152,7 +154,7 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso async sendTerminalAgentPrompt( handle: string, prompt: string, - options: RuntimeAgentPromptWriteOptions = {} + options: RuntimeAgentPromptWriteOptions ): Promise { // Why the consuming agent: the foreground process reads the bytes; launchAgent covers startup. const payloadFor = (ptyId: string): string => { diff --git a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts index b9f35478505..dbdf4f1c37a 100644 --- a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts +++ b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts @@ -45,7 +45,7 @@ export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWi // pending and flushes at the ready marker or the 15s // SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query // reply is delayed at most that bound, not lost. - this.ptyController?.write(ptyId, reply) + this.ptyController?.write(ptyId, reply, 'query-reply') } } }) diff --git a/src/main/runtime/orca-runtime-deliver-pending-messages.ts b/src/main/runtime/orca-runtime-deliver-pending-messages.ts index 55c6a8dad5e..2cd1cc69c00 100644 --- a/src/main/runtime/orca-runtime-deliver-pending-messages.ts +++ b/src/main/runtime/orca-runtime-deliver-pending-messages.ts @@ -137,7 +137,7 @@ export class OrcaRuntimeWithDeliverPendingMessages extends OrcaRuntimeWithResolv let settlesInEnterCallback = false try { const payload = formatMessagePointer(unread.length, mailboxHandle) - const wrote = this.ptyController?.write(deliveryPtyId, payload) ?? false + const wrote = this.ptyController?.write(deliveryPtyId, payload, 'driving') ?? false if (!wrote) { return } @@ -171,7 +171,7 @@ export class OrcaRuntimeWithDeliverPendingMessages extends OrcaRuntimeWithResolv if (!currentLeaf || currentLeaf.ptyId !== deliveryPtyId || !currentLeaf.writable) { return } - this.ptyController?.write(deliveryPtyId, '\r') + this.ptyController?.write(deliveryPtyId, '\r', 'driving') } catch { // Terminal may have closed during the delay; mail remains queued for check. } finally { diff --git a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts index d9d6b2acfb9..7f42304eb8b 100644 --- a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts +++ b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts @@ -36,7 +36,7 @@ export class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends OrcaRunt this.ptyController!.hasChildProcesses?.(ptyId) ?? Promise.resolve(false), subscribeToData: (ptyId, listener) => this.subscribeToTerminalData(ptyId, listener), readRecentOutput: (ptyId) => this.recentPtyOutputById.get(ptyId)?.read(), - write: (ptyId, data) => this.ptyController?.write(ptyId, data) + write: (ptyId, data, inputKind) => this.ptyController?.write(ptyId, data, inputKind) } } diff --git a/src/main/runtime/orca-runtime-on-pty-exit.ts b/src/main/runtime/orca-runtime-on-pty-exit.ts index 99fd88373fa..52b7024b818 100644 --- a/src/main/runtime/orca-runtime-on-pty-exit.ts +++ b/src/main/runtime/orca-runtime-on-pty-exit.ts @@ -117,10 +117,9 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte exitIncarnationId ?? pendingIncarnation ?? pty?.incarnationId ?? null ) } - const intentionalStopIncarnation = this.intentionalHandlelessPtyStops.get(ptyId) - const preservesIntentionalHandlelessSurface = - this.intentionalHandlelessPtyStops.has(ptyId) && - (intentionalStopIncarnation === null || intentionalStopIncarnation === incarnationId) + // Why both kinds: a sleep keeps its wake hint, and a restart's replacement takes the pane. + const preservesIntentionallyStoppedSurface = + this.intentionalPtyStops.claimExit(ptyId, exitIncarnationId ?? pty?.incarnationId).length > 0 advertisedUrlWatcher.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) @@ -217,7 +216,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte this.pruneDisconnectedPtyTranscript(pty) } let retirement: Promise | undefined - if (preservesIntentionalHandlelessSurface || preservesAbnormalSshSurface) { + if (preservesIntentionallyStoppedSurface || preservesAbnormalSshSurface) { // Why: relay loss is recoverable; keep the HUB-owned pane addressable through the bounded reconnect grace. this.touchMobileSessionSnapshotsForPty(ptyId, { immediate: true }) } else { diff --git a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts b/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts index de98831cdd5..b09ca3b7b8d 100644 --- a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts +++ b/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts @@ -146,6 +146,7 @@ export class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends OrcaRunt this.providerVisibleRetryAtByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) + this.terminalRunFacts.delete(ptyId) this.disposePtyTitleTracker(ptyId) this.invalidatePtyIncarnationHandle(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index ad7c81f2e87..04cb91e0281 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -61,7 +61,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O ptyId: string, action: { text?: string; enter?: boolean; interrupt?: boolean }, payload: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { return this.terminalWriter.writeAction(ptyId, action, payload, options) } @@ -69,7 +69,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O protected writeTerminalInputChunks( ptyId: string, text: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { return this.terminalWriter.writeChunks(ptyId, text, options) } diff --git a/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts b/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts index 49d55d96c17..6acf8ba325f 100644 --- a/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts +++ b/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts @@ -100,7 +100,7 @@ export class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRunt return await this.claudeAgentTeams.handleTmuxCompat(request, { splitTerminal: (handle, opts) => this.splitTerminal(handle, opts), readTerminal: (handle, opts) => this.readTerminal(handle, opts), - sendTerminal: (handle, action) => this.sendTerminal(handle, action), + sendTerminal: (handle, action, options) => this.sendTerminal(handle, action, options), focusTerminal: (handle) => this.focusTerminal(handle), closeTerminal: (handle) => this.closeTerminal(handle), showTerminal: (handle) => this.showTerminal(handle) diff --git a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts index bfab8f0ba9a..8e9ad65dc32 100644 --- a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts +++ b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts @@ -123,9 +123,9 @@ export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals if (!pty || this.terminalSpawnCommandsByPtyId.has(pty.ptyId)) { return } - if (this.ptyController?.write(pty.ptyId, command)) { + if (this.ptyController?.write(pty.ptyId, command, 'launch')) { // Why: Enter rides its own write so a long command cannot swallow it. - this.ptyController.write(pty.ptyId, '\r') + this.ptyController.write(pty.ptyId, '\r', 'launch') this.noteTerminalSpawnCommand(pty.ptyId, command) } } diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index e4f38e058d3..4b252d57fef 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -45,6 +45,8 @@ import { MailPointerRepointScheduler } from './orchestration/mail-pointer-repoin import { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-registry' import { RuntimeTerminalWriter } from './runtime-terminal-writer' import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls' +import { TerminalIntentionalStops } from './terminal-intentional-stops' +import { TerminalRunFactsRegister, type TerminalSpawnCommit } from './terminal-run-facts' import type { TuiIdleEvidenceSource } from './tui-idle-evidence' import { TUI_IDLE_DEFAULT_TIMEOUT_MS, @@ -233,9 +235,16 @@ export class OrcaRuntimeWithRuntimeId { protected pendingPtyRegistrationIncarnations = new Map() - // Why: exact-stop is the current sleep transaction boundary; its exit must - // leave the renderer's intentional sleeping surface available for wake. - protected intentionalHandlelessPtyStops = new Map() + // Why public: the PTY IPC layer's stop paths write it and its exit delivery reads it. + readonly intentionalPtyStops = new TerminalIntentionalStops() + + readonly terminalRunFacts = new TerminalRunFactsRegister() + + /** Both spawn-commit funnels report each committed process here, once. */ + noteTerminalSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { + this.terminalRunFacts.recordSpawnCommit(commit, expectedSourceBinding) + this.intentionalPtyStops.noteSpawnCommit(commit.id) + } // Why: coalesces title/status-driven session.tabs emits so spinner churn // doesn't fan out (and per-client JSON.stringify) a snapshot several times a @@ -322,7 +331,7 @@ export class OrcaRuntimeWithRuntimeId { protected readonly terminalWaiters = new RuntimeTerminalWaiterRegistry() protected readonly terminalWriter = new RuntimeTerminalWriter( - (ptyId, data) => this.ptyController?.write(ptyId, data) ?? false, + (ptyId, data, inputKind) => this.ptyController?.write(ptyId, data, inputKind) ?? false, (ptyId) => this.getPtyWriteHostPlatform(ptyId), (ptyId) => this.getPtyAgent(ptyId) ) diff --git a/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts b/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts index 7d3645473bc..0f0a856bda2 100644 --- a/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts +++ b/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts @@ -64,7 +64,7 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi const pendingPtyIds = new Set() let generation = 0 let fullyCommitted = false - let releaseReversibleRendererStops = (): void => {} + const settleReversibleStops = new Map void>() try { const resolvedWorktrees = includeTargetResolvedWorktree( [...(await this.getResolvedWorktreeMap()).values()], @@ -148,16 +148,25 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi const stopAndWait = ptyController.stopAndWait.bind(ptyController) const orderedLivePtyIds = [...livePtyIds].sort() - releaseReversibleRendererStops = - ptyController.markReversibleStops?.(orderedLivePtyIds) ?? (() => {}) - const stopResults = await Promise.allSettled( - orderedLivePtyIds.map(async (ptyId) => ({ + for (const ptyId of orderedLivePtyIds) { + settleReversibleStops.set( ptyId, - stopped: await stopAndWait(ptyId, { + this.intentionalPtyStops.mark( + ptyId, + 'reversible', + this.ptysById.get(ptyId)?.incarnationId ?? null + ) + ) + } + const stopResults = await Promise.allSettled( + orderedLivePtyIds.map(async (ptyId) => { + const stopped = await stopAndWait(ptyId, { keepHistory: true, deadlineMs: teardownRpcDeadline(sleepDeadline) }) - })) + settleReversibleStops.get(ptyId)?.(stopped) + return { ptyId, stopped } + }) ) const successfulStopPtyIds = orderedLivePtyIds.filter((_, index) => { const result = stopResults[index] @@ -251,7 +260,9 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi postStopVerified: true } } finally { - releaseReversibleRendererStops() + for (const settleStop of settleReversibleStops.values()) { + settleStop(false) + } if (!fullyCommitted && generation > 0) { const cancelledPtyIds = [...pendingPtyIds].sort() if (cancelledPtyIds.length > 0) { diff --git a/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts index a96ae64e596..d854be6ec68 100644 --- a/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts @@ -47,18 +47,22 @@ export class OrcaRuntimeWithStopExactTerminalsForWorktree extends OrcaRuntimeWit const stoppedPtyIds: string[] = [] for (const ptyId of [...expected].sort()) { - if (opts.keepHistory) { - this.intentionalHandlelessPtyStops.set( - ptyId, - this.ptysById.get(ptyId)?.incarnationId ?? null - ) - } + // Why: exact-stop is the sleep transaction boundary; its exit must leave the sleeping surface for wake. + const settleStop = opts.keepHistory + ? this.intentionalPtyStops.mark( + ptyId, + 'reversible', + this.ptysById.get(ptyId)?.incarnationId ?? null + ) + : null + let stopped = false try { - if (!(await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }))) { - throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId }) - } + stopped = await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }) } finally { - this.intentionalHandlelessPtyStops.delete(ptyId) + settleStop?.(stopped) + } + if (!stopped) { + throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId }) } stoppedPtyIds.push(ptyId) } diff --git a/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts b/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts index bac59e614b2..dd98d82083f 100644 --- a/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts +++ b/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts @@ -159,12 +159,16 @@ describe('runtime terminal handle incarnation fencing', () => { }) expect(replacement?.handle).not.toBe(staleHandle) await expect(runtime.readTerminal(staleHandle)).rejects.toThrow('terminal_handle_stale') - await expect(runtime.sendTerminal(staleHandle, { text: 'stale input' })).rejects.toThrow( - 'terminal_handle_stale' - ) + await expect( + runtime.sendTerminal(staleHandle, { text: 'stale input' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_handle_stale') await expect( - runtime.sendTerminal(replacement!.handle, { text: 'replacement input' }) + runtime.sendTerminal( + replacement!.handle, + { text: 'replacement input' }, + { inputKind: 'driving' } + ) ).resolves.toMatchObject({ accepted: true, handle: replacement!.handle diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts index 2501d0141dd..a1bcdf4b287 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts @@ -85,7 +85,7 @@ describe('OrcaRuntimeService', () => { runtime.sendTerminal( terminal.handle, { text: 'notes', enter: true }, - { beforeWrite, afterWrite } + { inputKind: 'driving', beforeWrite, afterWrite } ) ).rejects.toThrow('terminal_not_writable') expect(writes).toEqual(['notes', '\r']) diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts index 23312f49420..310a1e12c81 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts @@ -150,10 +150,14 @@ describe('OrcaRuntimeService', () => { nextCursor: expect.any(String) }) - const send = await runtime.sendTerminal(terminal.handle, { - text: 'continue', - enter: true - }) + const send = await runtime.sendTerminal( + terminal.handle, + { + text: 'continue', + enter: true + }, + { inputKind: 'driving' } + ) expect(send).toMatchObject({ handle: terminal.handle, accepted: true diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts index 27421489aba..a47f037d40e 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts @@ -602,7 +602,7 @@ describe('OrcaRuntimeService', () => { expect(write).toHaveBeenCalledTimes(2) expect(write.mock.calls[0][0]).toBe('pty-bare') expect(String(write.mock.calls[0][1])).toMatch(/codex/) - expect(write.mock.calls[1]).toEqual(['pty-bare', '\r']) + expect(write.mock.calls[1]).toEqual(['pty-bare', '\r', 'launch']) } finally { vi.useRealTimers() } diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts index 9145bea46f6..d90dc63031b 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts @@ -467,7 +467,7 @@ describe('OrcaRuntimeService', () => { }) const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) - await runtime.sendTerminal(handle, { text: 'continue', enter: true }) + await runtime.sendTerminal(handle, { text: 'continue', enter: true }, { inputKind: 'driving' }) expect(writes).toEqual(['continue', '\r']) }) @@ -490,7 +490,7 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) const prompt = 'line one\nline two\x1b[201~' - const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) await vi.runAllTimersAsync() const result = await sendPromise @@ -535,6 +535,7 @@ describe('OrcaRuntimeService', () => { ) const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'the brief', { + inputKind: 'driving', leadLine: ORCA_DISPATCH_PROMPT_LEAD_LINE }) await vi.runAllTimersAsync() @@ -601,6 +602,7 @@ describe('OrcaRuntimeService', () => { const assertAuthority = vi.fn() const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving', beforeWrite: assertAuthority }) await vi.advanceTimersByTimeAsync(500) @@ -659,7 +661,9 @@ describe('OrcaRuntimeService', () => { 'review this change', agent ) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) if (agent === 'omp') { await sendPromise expect(writes).toEqual([`${buildAgentPromptPasteBytes('review this change')}\r`]) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts index 8b27ce9bb5d..fe3687aab0a 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts @@ -44,7 +44,9 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) await expect(runtime.isTerminalRunningSettledPromptAgent(handle)).resolves.toBe(true) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) await vi.advanceTimersByTimeAsync(1_199) expect(writes).not.toContain('\r') await vi.advanceTimersByTimeAsync(1_500) @@ -78,7 +80,9 @@ describe('OrcaRuntimeService', () => { launchAgent: 'claude' }) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) await vi.advanceTimersByTimeAsync(renderGateCapMs('review this change') - 1) expect(writes).not.toContain('\r') @@ -117,7 +121,9 @@ describe('OrcaRuntimeService', () => { launchAgent: 'codex' }) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) await vi.advanceTimersByTimeAsync(8_000) expect(writes).not.toContain('\r') await vi.advanceTimersByTimeAsync(1_599) @@ -159,7 +165,9 @@ describe('OrcaRuntimeService', () => { launchAgent: 'claude' }) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) // The marker at 100 ms re-arms the cap, but the ingest term is absolute: a prompt this // small is already ingested by then, so the fallback is one flat render timeout later. await vi.advanceTimersByTimeAsync(100 + 8_000 - 1) @@ -193,7 +201,7 @@ describe('OrcaRuntimeService', () => { }) const prompt = `${'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)}\ntail` - const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) await vi.runAllTimersAsync() const result = await sendPromise @@ -230,7 +238,7 @@ describe('OrcaRuntimeService', () => { }) const prompt = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES + 1) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const sendRejection = expect(sendPromise).rejects.toThrow('terminal_not_writable') await vi.runAllTimersAsync() @@ -258,7 +266,7 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) const text = ['x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES), 'tail'].join('') - const result = await runtime.sendTerminal(handle, { text }) + const result = await runtime.sendTerminal(handle, { text }, { inputKind: 'driving' }) expect(result).toMatchObject({ handle, @@ -285,7 +293,7 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) const text = `${'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)}\nline two\nline three` - await runtime.sendTerminal(handle, { text, enter: true }) + await runtime.sendTerminal(handle, { text, enter: true }, { inputKind: 'driving' }) expect(writes.at(-1)).toBe('\r') expect(writes.slice(0, -1).join('')).toBe(text) @@ -312,7 +320,7 @@ describe('OrcaRuntimeService', () => { vi.useFakeTimers() try { - const sendPromise = runtime.sendTerminal(handle, { text }) + const sendPromise = runtime.sendTerminal(handle, { text }, { inputKind: 'driving' }) expect(writes).toEqual([]) @@ -346,7 +354,11 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) await expect( - runtime.sendTerminal(handle, { text: 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) }) + runtime.sendTerminal( + handle, + { text: 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) }, + { inputKind: 'driving' } + ) ).rejects.toThrow(TERMINAL_INPUT_TOO_LARGE_ERROR) expect(writes).toEqual([]) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts index 65757797f70..e122effd17d 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts @@ -230,7 +230,7 @@ describe('OrcaRuntimeService', () => { expect(new Set(handles).size).toBe(handles.length) await expect( - runtime.sendTerminal('term_victim', { text: 'for victim' }) + runtime.sendTerminal('term_victim', { text: 'for victim' }, { inputKind: 'driving' }) ).resolves.toMatchObject({ accepted: true }) expect(writesByPty.get('pty-victim')).toEqual(['for victim']) expect(writesByPty.has('pty-imposter')).toBe(false) @@ -260,7 +260,7 @@ describe('OrcaRuntimeService', () => { const listed = await runtime.listTerminals() expect(listed.terminals[0]?.handle).toBe('term_already_bound') await expect( - runtime.sendTerminal('term_already_bound', { text: 'still routed' }) + runtime.sendTerminal('term_already_bound', { text: 'still routed' }, { inputKind: 'driving' }) ).resolves.toMatchObject({ accepted: true }) expect(writes).toEqual(['still routed']) // the reported-but-not-adopted handle must not resolve to the live pty @@ -347,7 +347,9 @@ describe('OrcaRuntimeService', () => { handle, tail: ['after unavailable'] }) - await expect(runtime.sendTerminal(handle, { text: 'still writable' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'still writable' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ handle, accepted: true }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts index 1ebf15cbc24..9f2e747ba9a 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts @@ -54,7 +54,7 @@ describe('OrcaRuntimeService', () => { tail: ['after restart'] }) await expect( - runtime.sendTerminal('term_exported', { text: 'still writable' }) + runtime.sendTerminal('term_exported', { text: 'still writable' }, { inputKind: 'driving' }) ).resolves.toMatchObject({ handle: 'term_exported', accepted: true @@ -179,7 +179,7 @@ describe('OrcaRuntimeService', () => { ]) expect(getSession().terminalTopologyRevisionByRepoId?.[TEST_REPO_ID]).toBe(1) - await runtime.sendTerminal('term_agent', { text: 'input' }) + await runtime.sendTerminal('term_agent', { text: 'input' }, { inputKind: 'driving' }) await runtime.updateRemoteDesktopViewer('pty-agent', 'viewer', 'client', 132, 41) expect(writes).toEqual([['pty-agent', 'input']]) expect(resize).toHaveBeenCalledWith('pty-agent', 132, 41) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts index 8980dc53f85..4f89b097cbb 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts @@ -65,7 +65,7 @@ describe('OrcaRuntimeService', () => { const afterRestart = await restarted.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) const listed = await restarted.listTerminals(`id:${TEST_WORKTREE_ID}`) restarted.onPtyData('persisted-pty', 'after restart\n', 1) - await restarted.sendTerminal('term_current', { text: 'input' }) + await restarted.sendTerminal('term_current', { text: 'input' }, { inputKind: 'driving' }) await restarted.updateRemoteDesktopViewer('persisted-pty', 'viewer', 'client', 132, 41) expect(beforeRestart.tabs[0]).toMatchObject({ @@ -183,7 +183,9 @@ describe('OrcaRuntimeService', () => { const shown = await runtime.showTerminal(entry!.handle) expect(shown.writable).toBe(true) - await expect(runtime.sendTerminal(entry!.handle, { text: 'hi' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(entry!.handle, { text: 'hi' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) expect(writes).toEqual([['pty-orphan', 'hi']]) diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts index cc73ffa2718..5a5c9521403 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts @@ -418,7 +418,11 @@ describe('OrcaRuntimeService', () => { await Promise.resolve() await Promise.resolve() - expect(write).toHaveBeenCalledWith('pty-startup-draft', `\x1b[200~${draftUrl}\x1b[201~`) + expect(write).toHaveBeenCalledWith( + 'pty-startup-draft', + `\x1b[200~${draftUrl}\x1b[201~`, + 'launch' + ) }) it('keeps the 8s main-runtime startup readiness budget for agents without an override', async () => { @@ -554,7 +558,11 @@ describe('OrcaRuntimeService', () => { await Promise.resolve() await Promise.resolve() - expect(write).toHaveBeenCalledWith('pty-opencode-draft-budget', `\x1b[200~${draftUrl}\x1b[201~`) + expect(write).toHaveBeenCalledWith( + 'pty-opencode-draft-budget', + `\x1b[200~${draftUrl}\x1b[201~`, + 'launch' + ) }) it('rejects explicit startup commands for disabled selected agents', async () => { diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts index 2ec8e73f800..cc2c2cae2ba 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts @@ -83,7 +83,7 @@ describe('OrcaRuntimeService', () => { }) ) await vi.waitFor(() => { - expect(write).toHaveBeenCalledWith('pty-cli-aider-startup', 'fix it\r') + expect(write).toHaveBeenCalledWith('pty-cli-aider-startup', 'fix it\r', 'launch') }) }) @@ -526,7 +526,11 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('pty-explicit-draft', '\x1b[?2004h›', Date.now()) await vi.waitFor(() => { - expect(write).toHaveBeenCalledWith('pty-explicit-draft', `\x1b[200~${draftUrl}\x1b[201~`) + expect(write).toHaveBeenCalledWith( + 'pty-explicit-draft', + `\x1b[200~${draftUrl}\x1b[201~`, + 'launch' + ) }) }) diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index 5e90602755c..1597084eee1 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -27,7 +27,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso ptyId: string, generation: number, pastePayload: string, - options: RuntimeAgentPromptWriteOptions = {} + options: RuntimeAgentPromptWriteOptions ): Promise<{ submits: number; prompt?: RuntimeTerminalPromptDelivery }> { assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) @@ -62,7 +62,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso // beginning when a large frame is split into independently processed chunks. renderGate?.arm() const initialWrite = submitWithPaste ? pastePayload + AGENT_PROMPT_SUBMIT : pastePayload - if (!this.ptyController?.write(ptyId, initialWrite)) { + if (!this.ptyController?.write(ptyId, initialWrite, options.inputKind)) { throw new Error('terminal_not_writable') } } catch (error) { @@ -103,7 +103,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso const baseline = preSubmitBaseline ?? this.getAgentPromptActivity(handle, ptyId, waitTextCache) this.assertAgentPromptPermissionSafe(permissionBaseline, baseline) if (!submitWithPaste) { - if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT)) { + if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, options.inputKind)) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } } diff --git a/src/main/runtime/orchestration/coordinator-runtime-contract.ts b/src/main/runtime/orchestration/coordinator-runtime-contract.ts index 2f4f5bf9b5a..3c98fa30200 100644 --- a/src/main/runtime/orchestration/coordinator-runtime-contract.ts +++ b/src/main/runtime/orchestration/coordinator-runtime-contract.ts @@ -11,7 +11,7 @@ export type CoordinatorRuntime = { sendTerminalAgentPrompt( handle: string, prompt: string, - options?: DispatchPreambleSendOptions + options: DispatchPreambleSendOptions ): Promise listTerminals( worktreeSelector?: string, diff --git a/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts b/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts index 8dbedc9b33c..52016aa1465 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts @@ -24,7 +24,8 @@ export function writeOrchestrationPointerWithSettlement( return writeRefused('provider_cannot_settle') } try { - return settledWrite.call(args.controller, args.ptyId, args.data) + // Why driving: a pointer is input that tells a running agent to read its mail. + return settledWrite.call(args.controller, args.ptyId, args.data, 'driving') } catch { // A partial write that then threw cannot prove the transport took nothing. return writeUnverifiable('provider_threw_after_handoff', true) diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index 208868ce45b..a9fbb528939 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -147,12 +147,13 @@ ${params.taskSpec}` export type DispatchPreambleSendOptions = Pick< RuntimeAgentPromptWriteOptions, - 'leadLine' | 'acceptQueued' | 'observationTimeoutMs' | 'requestId' + 'leadLine' | 'acceptQueued' | 'observationTimeoutMs' | 'requestId' | 'inputKind' > export function dispatchPreambleSendOptions(requestId: string): DispatchPreambleSendOptions { // Why: a delayed provider hook must not revoke an accepted Dispatch. return { + inputKind: 'driving', leadLine: ORCA_DISPATCH_PROMPT_LEAD_LINE, acceptQueued: true, observationTimeoutMs: 0, diff --git a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts index 41081daeb99..ac66912e747 100644 --- a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts @@ -66,6 +66,7 @@ export async function deliverTerminalAgentLaunchPrompt(args: { return false } const sent = await args.runtime.sendTerminalAgentPrompt(args.handle, args.text, { + inputKind: 'launch', // Paired: together these take the queued path, which settles an unobserved turn start into // an `input_accepted` receipt rather than raising it. Without the id the write is verified // strictly and a slow first turn throws. diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index d32232f8447..d6b265c41a9 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -188,10 +188,17 @@ export async function startLocalWorker(args: { effects, timeoutMs: params.timeoutMs ?? 60_000 }) - : await runtime.waitForTerminal(terminalHandle, { - condition: 'tui-idle', - timeoutMs: params.timeoutMs ?? 60_000 - }) + : // ZCode emits SessionStart only after input; its first dispatch must wait for the composer. + agent === 'zcode' && !params.terminal + ? await runtime.waitForFreshWorkerComposer( + terminalHandle, + agent, + params.timeoutMs ?? 60_000 + ) + : await runtime.waitForTerminal(terminalHandle, { + condition: 'tui-idle', + timeoutMs: params.timeoutMs ?? 60_000 + }) if (wait) { persistWorkerSetupWaitOutcome({ ...setupStage, wait }) if (!wait.satisfied) { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts index dd4ce2522ea..d9798a47372 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts @@ -100,7 +100,7 @@ it.each(['unary', 'stream'])('mobile %s bytes do no orchestration database work' method.handler(method.params!.parse(params) as never, { runtime } as never) ).resolves.toMatchObject({ send: { accepted: true } }) } - expect(write).toHaveBeenCalledWith('pty-worker', 'x') + expect(write).toHaveBeenCalledWith('pty-worker', 'x', 'driving') expect(commit).toHaveBeenCalledTimes(1) expect(dbAccess).not.toHaveBeenCalled() expect(takeover).not.toHaveBeenCalled() diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts index 593d097580f..526b09a6320 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts @@ -381,6 +381,7 @@ describe('orchestration worker-start prompt contract', () => { const { runtime, handle } = await createAgentPromptSubmissionRuntime(() => undefined, 'codex') runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) const pending = runtime.sendTerminalAgentPrompt(handle, 'queued prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'busy-swallowed', observationTimeoutMs: 0 diff --git a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts new file mode 100644 index 00000000000..30d32c2ede8 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' + +describe('ZCode first dispatch readiness', () => { + const h = createOrchestrationWorkerReleaseHarness() + afterEach(() => h.cleanup()) + + it('waits for the new composer before delivering exactly one dispatch', async () => { + h.setup() + const gate = h.deferred() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) + const pending = h.startWorker({ agent: 'zcode' }) + await vi.waitFor(() => + expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( + 'term_worker', + 'zcode', + 60_000 + ) + ) + expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + gate.resolve() + 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/terminal/terminal-input-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts index b28fed95098..59fd827f0ce 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts @@ -66,10 +66,11 @@ export async function sendTerminalStreamInput( const floorClaim: MobileInputFloorClaimHolder = { current: null } try { if (!clientId) { - const result = await runtime.sendTerminal(args.terminal, action) + const result = await runtime.sendTerminal(args.terminal, action, { inputKind: 'driving' }) return result.accepted ? 'delivered' : 'rejected' } const result = await runtime.sendTerminal(args.terminal, action, { + inputKind: 'driving', reserveWrite: (writePtyId) => { const claim = runtime.beginMobileInputFloor(writePtyId, clientId) if (!claim) { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index 99c01d902a4..ca7ac0aeb75 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -210,6 +210,7 @@ export const TERMINAL_SEND_METHODS = [ try { result = useSettledAgentPrompt ? await runtime.sendTerminalAgentPrompt(params.terminal, params.text!, { + inputKind: 'driving', beforeWrite, signal, ...(orchestrationMutation @@ -234,6 +235,8 @@ export const TERMINAL_SEND_METHODS = [ { beforeWrite, signal, + // Why: a wire write carries no provenance beyond a client's own query reply. + inputKind: params.inputKind === 'query-reply' ? 'query-reply' : 'driving', ...(reserveWrite ? { reserveWrite } : {}), ...(params.inputKind !== 'query-reply' && mobileFloorClientId ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } diff --git a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts index 29254fbcf3a..824513dd999 100644 --- a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts +++ b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts @@ -45,6 +45,7 @@ describe('terminal agent prompt send RPC', () => { expect(response.ok).toBe(true) expect(runtime.isTerminalRunningSettledPromptAgent).toHaveBeenCalledWith('terminal-1') expect(sendTerminalAgentPrompt).toHaveBeenCalledWith('terminal-1', 'review this change', { + inputKind: 'driving', beforeWrite: undefined, signal: undefined }) @@ -81,7 +82,7 @@ describe('terminal agent prompt send RPC', () => { expect(sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: 'echo x', enter: true, interrupt: false }, - { beforeWrite: undefined, signal: undefined } + { inputKind: 'driving', beforeWrite: undefined, signal: undefined } ) expect(sendTerminalAgentPrompt).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts b/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts index 3dc1f6b2918..f93b772994e 100644 --- a/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts @@ -241,11 +241,15 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'still interactive\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'still interactive\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) handlers.get(16)?.( @@ -434,11 +438,15 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-8', { - text: 'remote-still-interactive\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-8', + { + text: 'remote-still-interactive\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) const frameCountBeforeAck = binaryFrames.length diff --git a/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts b/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts index 03327859c62..57abea846ae 100644 --- a/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts @@ -413,11 +413,15 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'still interactive\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'still interactive\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) binaryFrames.splice(0) diff --git a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts index 45ff2d77b8c..562181b2752 100644 --- a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts @@ -206,11 +206,15 @@ describe('terminal multiplex RPC', () => { ) ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'ls\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'ls\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) const sentAfterSuccessfulClaim = vi.mocked(runtime.sendTerminal).mock.calls.length vi.mocked(runtime.updateRemoteDesktopViewer).mockResolvedValueOnce(false) @@ -248,11 +252,15 @@ describe('terminal multiplex RPC', () => { ) } await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenLastCalledWith('terminal-1', { - text: 'retry', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenLastCalledWith( + 'terminal-1', + { + text: 'retry', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) dataListenerRef.current?.('a') diff --git a/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts b/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts index b7fb20b2f73..ae2298a20dc 100644 --- a/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts @@ -327,11 +327,15 @@ describe('terminal multiplex RPC', () => { ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'echo one\necho two\r\n', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'echo one\necho two\r\n', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) runtime.cleanupSubscription('terminal-multiplex:conn-byte-preserving') @@ -403,11 +407,15 @@ describe('terminal multiplex RPC', () => { ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'printf a\nprintf b\r\n', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'printf a\nprintf b\r\n', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) runtime.cleanupSubscription('terminal-1:desktop-1') diff --git a/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts b/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts index ef6087923bb..eecd7b03859 100644 --- a/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts @@ -309,7 +309,11 @@ describe('terminal multiplex RPC', () => { expect(runtime.sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: 'x', enter: false, interrupt: false }, - { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + { + inputKind: 'driving', + reserveWrite: expect.any(Function), + afterWrite: expect.any(Function) + } ) ) expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan( diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index e597f5e5b13..214a5a2814e 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -347,7 +347,11 @@ describe('terminal output batching', () => { expect(runtime.sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: 'ls\r', enter: false, interrupt: false }, - { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + { + inputKind: 'driving', + reserveWrite: expect.any(Function), + afterWrite: expect.any(Function) + } ) ) expect(beginMobileInputFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index e9fb481aae0..9aa55eb823e 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -232,6 +232,7 @@ describe('terminal send RPC', () => { interrupt: false }, { + inputKind: 'driving', beforeWrite: undefined, reserveWrite: expect.any(Function), afterWrite: expect.any(Function) @@ -370,7 +371,7 @@ describe('terminal send RPC', () => { expect(runtime.sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: '\x1b[3;4R', enter: false, interrupt: false }, - { beforeWrite: undefined } + { beforeWrite: undefined, inputKind: 'query-reply' } ) expect(runtime.mobileTookFloor).not.toHaveBeenCalled() }) @@ -570,7 +571,7 @@ describe('terminal send RPC', () => { enter: false, interrupt: false }, - { beforeWrite: undefined } + { inputKind: 'driving', beforeWrite: undefined } ) }) @@ -653,7 +654,7 @@ describe('terminal send RPC', () => { enter: true, interrupt: false }, - { beforeWrite: expect.any(Function) } + { inputKind: 'driving', beforeWrite: expect.any(Function) } ) }) diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 54aae2321ad..7b540a05e9a 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -12,6 +12,7 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' import type { WriteSettlement } from '../../shared/pty-write-settlement' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type RuntimePtyController = { claimStablePaneCreate?(args: { @@ -93,9 +94,13 @@ export type RuntimePtyController = { stablePaneOwner?: { handle: string; tabId: string; leafId: string } agentSessionEnsure?: AgentSessionClaimedSpawnResult }> - write(ptyId: string, data: string): boolean + write(ptyId: string, data: string, inputKind: TerminalInputKind): boolean /** Three-valued settlement; local providers settle synchronously. */ - writeWithSettlement?(ptyId: string, data: string): WriteSettlement | Promise + writeWithSettlement?( + ptyId: string, + data: string, + inputKind: TerminalInputKind + ): WriteSettlement | Promise /** Attach-only adoption of a live local daemon session so its output streams * to main without a renderer pane; never creates, resizes, or focuses. * False on doubt (absent session, SSH-scoped id, non-daemon provider). */ @@ -106,7 +111,6 @@ export type RuntimePtyController = { ptyId: string, opts?: { keepHistory?: boolean; deadlineMs?: number } ): Promise - markReversibleStops?(ptyIds: readonly string[]): () => void /** Durably records a kill order for an explicit close's unconfirmed stop, replayed when its SSH * host reconnects. True only when an order was written; local PTYs have no later host to ask. */ recordUnconfirmedStop?(ptyId: string): boolean diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 0dcf355bfe0..fa0e7a0afa0 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -16,6 +16,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { RuntimeTerminalSend } from '../../shared/runtime-terminal-contracts' import type { RuntimeTerminalWriteOptions } from './runtime-terminal-writer' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { RuntimeAgentRowSnapshot } from './runtime-worktree-agent-rows' import type { WorkerTerminalHostScope } from './orchestration/worker-terminal-process-liveness' @@ -203,7 +204,9 @@ export type RuntimeProviderSnapshotReadOptions = { } /** Agent-prompt writes add the correlation inputs a queued-acceptance receipt needs. */ -export type RuntimeAgentPromptWriteOptions = RuntimeTerminalWriteOptions & { +export type RuntimeAgentPromptWriteOptions = Omit & { + /** `launch` for the prompt an agent starts with; `driving` for any prompt sent to a running one. */ + inputKind: Exclude /** Raw prompt text for submit scheduling; not written, only used for line-aware delays. */ promptForSchedule?: string /** See buildAgentPromptPasteBytes. */ diff --git a/src/main/runtime/runtime-terminal-writer.ts b/src/main/runtime/runtime-terminal-writer.ts index 7ff6ba4a7f3..10e94f3747f 100644 --- a/src/main/runtime/runtime-terminal-writer.ts +++ b/src/main/runtime/runtime-terminal-writer.ts @@ -1,8 +1,10 @@ import { resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt-injection' import type { TuiAgent } from '../../shared/tui-agent' import { iterateTerminalInputChunks } from '../../shared/terminal-input' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type RuntimeTerminalWriteOptions = { + inputKind: TerminalInputKind signal?: AbortSignal beforeWrite?: (ptyId: string) => void | Promise reserveWrite?: (ptyId: string) => void @@ -12,7 +14,7 @@ export type RuntimeTerminalWriteOptions = { export class RuntimeTerminalWriter { constructor( - private readonly write: (ptyId: string, data: string) => boolean, + private readonly write: (ptyId: string, data: string, inputKind: TerminalInputKind) => boolean, private readonly getWriteHostPlatform: (ptyId: string) => NodeJS.Platform = () => process.platform, private readonly getAgent: (ptyId: string) => TuiAgent | null = () => null @@ -22,7 +24,7 @@ export class RuntimeTerminalWriter { ptyId: string, action: { text?: string; enter?: boolean; interrupt?: boolean }, payload: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { // Why: direct terminal.send can carry paste-sized text from RPC/mobile // clients; chunk text before PTY/ConPTY while preserving suffix separation. @@ -54,7 +56,7 @@ export class RuntimeTerminalWriter { throw error } options.reserveWrite?.(ptyId) - if (!this.write(ptyId, suffix)) { + if (!this.write(ptyId, suffix, options.inputKind)) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } await options.afterWrite?.(ptyId) @@ -65,7 +67,7 @@ export class RuntimeTerminalWriter { } await options.beforeWrite?.(ptyId) options.reserveWrite?.(ptyId) - if (!this.write(ptyId, payload)) { + if (!this.write(ptyId, payload, options.inputKind)) { throw new Error('terminal_not_writable') } await options.afterWrite?.(ptyId) @@ -74,14 +76,14 @@ export class RuntimeTerminalWriter { async writeChunks( ptyId: string, text: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { const chunks = iterateTerminalInputChunks(text) let chunk = chunks.next() while (!chunk.done) { await options.beforeWrite?.(ptyId) options.reserveWrite?.(ptyId) - if (!this.write(ptyId, chunk.value)) { + if (!this.write(ptyId, chunk.value, options.inputKind)) { throw new Error('terminal_not_writable') } await options.afterWrite?.(ptyId) diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts new file mode 100644 index 00000000000..75cbf2e6231 --- /dev/null +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -0,0 +1,213 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../shared/agent-session-wire' +import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' +import { AgentHookServer, _internals } from '../agent-hooks/server' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { indexedStatusFeedSession } from '../native-chat/agent-session-wire/structured-agent-session-status-feed-test-session' +import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' +import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +// A request that failed reads as failed on every surface the host feeds: the journal's verdict +// travels the real feed, the status-store ingest and `worktree ps`, never just the projection. +const SESSION = 'verdict-session' +const WORKSPACE_ID = 'workspace-1' +const SUBJECT = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE_ID, + workspaceKind: 'git-worktree' + }, + SESSION +) +const TURN_IDENTITY = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 0 +} as const + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + _internals.resetCachesForTests() + root = await mkdtemp(join(tmpdir(), 'orca-verdict-rows-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +async function openJournal(): Promise { + return journals.open({ + identity: { + sessionId: SESSION, + workspaceId: WORKSPACE_ID, + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, SESSION) + }) +} + +/** What the host's real feed publishes for this journal. */ +function publishedSummary(journal: AgentSessionJournal): AgentSessionStatusSummary { + const session = indexedStatusFeedSession({ journal }) + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([[SESSION, session]]), + getRecord: () => agentSessionRecordFixture(), + now: () => 1_000 + }) + const events: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'list', emit: (event) => events.push(event) }) + const snapshot = events.find((event) => event.type === 'snapshot') + const summary = snapshot?.type === 'snapshot' ? snapshot.sessions[0] : undefined + if (!summary) { + throw new Error('the feed published no session') + } + return summary +} + +function ingest(summary: AgentSessionStatusSummary) { + const store = new AgentHookServer() + store.ingestStructuredStatus(summary, SUBJECT) + const hookSnapshots = store.getStatusSnapshot() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attaching agent rows reads and writes only `worktreeId`, `status`, `hasHostSidebarActivity` and `agents`. + const row = { + worktreeId: WORKSPACE_ID, + status: 'inactive', + hasHostSidebarActivity: false, + agents: [] + } as unknown as RuntimeWorktreePsSummary + attachRuntimeWorktreeAgentRows({ + summaries: new Map([[WORKSPACE_ID, row]]), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `getSummary` below resolves every row by id, so the path index is never read. + pathIndex: { byPath: new Map(), byRealPath: new Map() } as never, + missingWorktreeIds: new Set(), + workingTerminalEvidenceByWorktreeId: new Map(), + rowSources: collectRuntimeWorktreeAgentSources({ + mirroredWorktreeIdByTabId: new Map(), + connectedPtyEvidence: { + tabIds: new Set(), + paneKeys: new Set(), + ptyIdByTerminalHandle: new Map() + }, + hookSnapshots + }), + orchestrationByPaneKey: null, + getSummary: (map, _p, _m, id) => map.get(id) ?? null + }) + return { status: hookSnapshots[0], ps: row.agents[0] } +} + +describe('a request that failed reads as failed through the feed, the ingest and worktree ps', () => { + it('reads a chat whose only send the agent start refused as failed, not interrupted', async () => { + const journal = await openJournal() + await journal.appendSubmission({ + clientMessageId: 'first', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + fence: 1, + handoverRecorded: true + }) + await journal.rejectQueuedSubmissions(1, 'Claude is not signed in.') + + const summary = publishedSummary(journal) + expect(summary).toMatchObject({ status: 'idle', turnOutcome: 'failure', latestPrompt: 'hello' }) + const { status, ps } = ingest(summary) + expect(status).toMatchObject({ + state: 'done', + mainAgent: { state: 'done', outcome: 'failure' } + }) + expect(status?.interrupted).not.toBe(true) + expect(ps).toMatchObject({ + state: 'done', + mainAgent: { state: 'done', outcome: 'failure' }, + interrupted: false + }) + }) + + it('reads a cancelled structured turn as interrupted for readers that predate the verdict', async () => { + const journal = await openJournal() + await journal.appendItem( + TURN_IDENTITY, + { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + outcome: 'cancellation', + completedAt: 5 + }, + { fence: 1 } + ) + + const { status, ps } = ingest(publishedSummary(journal)) + expect(status).toMatchObject({ state: 'done', interrupted: true }) + expect(ps).toMatchObject({ + mainAgent: { state: 'done', outcome: 'cancellation' }, + interrupted: true + }) + }) + + it('publishes a main agent that failed while its subagent runs, on the row that still works', async () => { + const journal = await openJournal() + await journal.appendItem( + TURN_IDENTITY, + { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'failure', completedAt: 5 }, + { fence: 1 } + ) + const summary: AgentSessionStatusSummary = { + ...publishedSummary(journal), + backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'working' }] + } + + const { status, ps } = ingest(summary) + expect(status).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'failure' } + }) + // The row carries the main agent's own clock, which dates the failure apart from the working row. + expect(ps).toMatchObject({ + state: 'working', + mainAgent: { + state: 'done', + outcome: 'failure', + stateStartedAt: status?.mainAgent?.stateStartedAt + }, + interrupted: false + }) + }) + + it('lists nothing for a chat whose only send the user withdrew', async () => { + const journal = await openJournal() + await journal.appendSubmission({ + clientMessageId: 'first', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + fence: 1, + handoverRecorded: true + }) + await journal.rejectQueuedSubmissions(1, 'provider_cancelled_before_start') + + expect(publishedSummary(journal)).toMatchObject({ status: null }) + expect(ingest(publishedSummary(journal)).ps).toBeUndefined() + }) +}) diff --git a/src/main/runtime/runtime-worktree-agent-rows.ts b/src/main/runtime/runtime-worktree-agent-rows.ts index 20c17f9b01a..310b0f6bc52 100644 --- a/src/main/runtime/runtime-worktree-agent-rows.ts +++ b/src/main/runtime/runtime-worktree-agent-rows.ts @@ -60,6 +60,7 @@ export function attachRuntimeWorktreeAgentRows(args: { toolName: source.toolName, toolInput: source.toolInput, interrupted: source.interrupted, + ...(source.mainAgent ? { mainAgent: source.mainAgent } : {}), stateStartedAt: source.stateStartedAt, updatedAt: source.updatedAt, ...(source.structuredHost === 'owned' ? { structuredHostOwned: true as const } : {}) diff --git a/src/main/runtime/runtime-worktree-agent-source.ts b/src/main/runtime/runtime-worktree-agent-source.ts index 984f20e0955..fd98d7914b1 100644 --- a/src/main/runtime/runtime-worktree-agent-source.ts +++ b/src/main/runtime/runtime-worktree-agent-source.ts @@ -1,5 +1,6 @@ import type { StructuredHostStatus } from '../../shared/agent-hook-listener/listener-event' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' +import type { AgentMainAgentStatus } from '../../shared/main-agent-status' export type RuntimeWorktreeAgentSource = { paneKey: string @@ -15,6 +16,7 @@ export type RuntimeWorktreeAgentSource = { toolName: string | null toolInput: string | null interrupted: boolean + mainAgent?: AgentMainAgentStatus stateStartedAt: number updatedAt: number /** Projected by the structured session host; `owned` rows stay fresh past the staleness window. */ diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts index 058d378df11..e03f458134f 100644 --- a/src/main/runtime/runtime-worktree-pty-agent-sources.ts +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -4,6 +4,7 @@ import { type ParsedAgentStatusPayload } from '../../shared/agent-status-types' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { agentVerdictFields } from '../../shared/agent-main-agent-verdict' import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' @@ -47,7 +48,8 @@ export function collectRuntimeWorktreePtyAgentSources(args: { lastAssistantMessage: entry.lastAssistantMessage ?? null, toolName: entry.toolName ?? null, toolInput: entry.toolInput ?? null, - interrupted: entry.interrupted ?? false, + interrupted: false, + ...agentVerdictFields(entry), stateStartedAt: entry.stateStartedAt, // A replay advances delivery order, not the age of the evidence shown by worktree.ps. updatedAt: entry.evidenceObservedAt ?? entry.receivedAt, diff --git a/src/main/runtime/runtime-worktree-startup-readiness.test.ts b/src/main/runtime/runtime-worktree-startup-readiness.test.ts new file mode 100644 index 00000000000..e96a072f7ed --- /dev/null +++ b/src/main/runtime/runtime-worktree-startup-readiness.test.ts @@ -0,0 +1,71 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + waitForWorktreeStartupDraft, + type WorktreeStartupReadinessHost +} from './runtime-worktree-startup-readiness' + +describe('fresh worker composer readiness', () => { + afterEach(() => vi.useRealTimers()) + + function fixture(replay?: string) { + let listener = (_data: string): void => {} + const unsubscribe = vi.fn() + const host: WorktreeStartupReadinessHost = { + getPtyId: () => 'pty-1', + getForegroundProcess: async () => 'zcode', + subscribeToData: (_ptyId, onData) => { + listener = onData + return unsubscribe + }, + readRecentOutput: () => replay, + write: vi.fn() + } + return { host, emit: (data: string) => listener(data), unsubscribe } + } + + it('accepts the captured composer while the banner continues repainting', async () => { + vi.useFakeTimers() + const h = fixture() + const pending = waitForWorktreeStartupDraft(h.host, 'term-1', 'zcode', { + timeoutMs: 45_000, + requireComposerMarker: true + }) + const data = readFileSync(join(__dirname, '__fixtures__', 'zcode-composer-ready.txt'), 'utf8') + for (let offset = 0; offset < data.length; offset += 4096) { + h.emit(data.slice(offset, offset + 4096)) + } + await expect(pending).resolves.toBe('pty-1') + expect(h.unsubscribe).toHaveBeenCalledOnce() + expect(h.host.write).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('cleans up the deadline when the composer was already captured', async () => { + vi.useFakeTimers() + const h = fixture('\x1b[?1049h╭') + await expect( + waitForWorktreeStartupDraft(h.host, 'term-1', 'zcode', { + timeoutMs: 45_000, + requireComposerMarker: true + }) + ).resolves.toBe('pty-1') + expect(h.unsubscribe).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not accept shell decoration or a square startup dialog', async () => { + vi.useFakeTimers() + const h = fixture('╭ shell\n\x1b[?1049h\x1b[?2004h┌ Sign in ┐') + const pending = waitForWorktreeStartupDraft(h.host, 'term-1', 'zcode', { + timeoutMs: 45_000, + requireComposerMarker: true + }) + await vi.advanceTimersByTimeAsync(44_999) + expect(h.unsubscribe).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + await expect(pending).resolves.toBeNull() + expect(h.unsubscribe).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/runtime-worktree-startup-readiness.ts b/src/main/runtime/runtime-worktree-startup-readiness.ts index 77b4e1315db..779f16ec6a4 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.ts @@ -4,6 +4,7 @@ import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-sca import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import type { TuiAgent } from '../../shared/tui-agent' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { WorktreeStartupDraftPaste, WorktreeStartupFollowup @@ -19,7 +20,7 @@ export type WorktreeStartupReadinessHost = { hasChildProcesses?: (ptyId: string) => Promise subscribeToData: (ptyId: string, listener: (data: string) => void) => () => void readRecentOutput: (ptyId: string) => string | undefined - write: (ptyId: string, data: string) => void + write: (ptyId: string, data: string, inputKind: TerminalInputKind) => void } export function pasteWorktreeStartupDraftWhenReady( @@ -33,7 +34,7 @@ export function pasteWorktreeStartupDraftWhenReady( console.warn('[worktree-create] agent did not become ready for draft paste') return } - host.write(ptyId, `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}`) + host.write(ptyId, `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}`, 'launch') }) .catch((error) => console.warn('[worktree-create] failed to paste startup draft:', error)) } @@ -49,7 +50,7 @@ export function sendWorktreeStartupFollowupWhenReady( console.warn('[worktree-create] agent did not become ready for follow-up prompt') return } - host.write(ptyId, `${followup.prompt}\r`) + host.write(ptyId, `${followup.prompt}\r`, 'launch') }) .catch((error) => console.warn('[worktree-create] failed to send startup follow-up prompt:', error) @@ -89,7 +90,8 @@ export async function waitForWorktreeStartupFollowup( export function waitForWorktreeStartupDraft( host: WorktreeStartupReadinessHost, handle: string, - agent: TuiAgent + agent: TuiAgent, + options: { timeoutMs?: number; requireComposerMarker?: boolean } = {} ): Promise { const ptyId = host.getPtyId(handle) if (!ptyId) { @@ -118,11 +120,14 @@ export function waitForWorktreeStartupDraft( resolve(value) } const observe = (data: string): void => { + if (settled) { + return + } const result = scanner.observe(data) if (result.ready) { return finish(ptyId) } - if (result.armQuietTimer) { + if (result.armQuietTimer && !options.requireComposerMarker) { if (quietTimer) { clearTimeout(quietTimer) } @@ -130,10 +135,13 @@ export function waitForWorktreeStartupDraft( } } unsubscribe = host.subscribeToData(ptyId, observe) + hardTimer = setTimeout( + () => finish(null), + options.timeoutMs ?? resolveDraftPasteReadyTimeoutMs(agent) + ) const replay = host.readRecentOutput(ptyId) if (replay) { observe(replay) } - hardTimer = setTimeout(() => finish(null), resolveDraftPasteReadyTimeoutMs(agent)) }) } diff --git a/src/main/runtime/terminal-close-observed-exit-test-fixture.ts b/src/main/runtime/terminal-close-observed-exit-test-fixture.ts index 628139923b0..81287bafc59 100644 --- a/src/main/runtime/terminal-close-observed-exit-test-fixture.ts +++ b/src/main/runtime/terminal-close-observed-exit-test-fixture.ts @@ -37,8 +37,7 @@ export async function runObservedExitSocketScenario(scenario: ObservedExitSocket rememberSyntheticKillExit: harness.session.rememberSyntheticKillExit, sendPtyExitToRenderer: harness.session.sendPtyExitToRenderer, finishPtyShutdown, - retiredRejectedPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map() + retiredRejectedPtyIds: new Map() } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stop/kill read only these controller ports and optional store; spawn ports are unused. const deps = ports as unknown as PtyRuntimeControllerDeps diff --git a/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts b/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts new file mode 100644 index 00000000000..e806e88320c --- /dev/null +++ b/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts @@ -0,0 +1,80 @@ +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { scanSourceTree } from '../../shared/source-scan/source-tree-scan' +import { + findCallsMissingArgument, + type RequiredCallArgument +} from '../../shared/source-scan/call-argument-scan' + +/** + * Every PTY write names its input kind, and the compiler enforces that everywhere except the + * runtime files split out with `@ts-nocheck`. There a missing kind would compile and silently + * record nothing, so this scan is the ratchet for them. + */ +const MAIN_ROOT = resolve(__dirname, '..') + +const namesInputKind = (literal: string): boolean => /\binputKind\b|\.\.\./.test(literal) +const kindAt = (index: number, receiver?: RegExp): RequiredCallArgument => ({ + index, + ...(receiver ? { receiver } : {}), + acceptsObjectLiteral: namesInputKind +}) +const CONTROLLER = /[Cc]ontroller\??\s*$/ + +const KIND_ARGUMENT_BY_METHOD: Record = { + write: kindAt(2, CONTROLLER), + writeWithSettlement: kindAt(2, CONTROLLER), + sendTerminal: kindAt(2), + sendTerminalAgentPrompt: kindAt(2), + writeTerminalAction: kindAt(3), + writeTerminalInputChunks: kindAt(2), + writeTerminalAgentPrompt: kindAt(4), + writeAction: kindAt(3), + writeChunks: kindAt(2) +} + +// Why multiline: some unchecked files open with a lint directive before `@ts-nocheck`. +const uncheckedSources = scanSourceTree(MAIN_ROOT).filter((file) => + /^\/\/ @ts-nocheck\b/m.test(file.source) +) + +describe('PTY write call sites the compiler cannot check', () => { + it('scans the unchecked runtime files that write to a PTY', () => { + expect(uncheckedSources.map((file) => file.relativePath)).toEqual( + expect.arrayContaining([ + 'runtime/orca-runtime-deliver-pending-messages.ts', + 'runtime/orca-runtime-create-pty-headless-terminal-state.ts', + 'runtime/orca-runtime-write-terminal-agent-prompt.ts', + 'runtime/orca-runtime-sync-window-graph.ts' + ]) + ) + }) + + it('finds a write, prompt or send that leaves out its kind', () => { + const planted = [ + '', + 'this.ptyController?.write(ptyId, reply)', + "this.ptyController.write(ptyId, '\\r', 'launch')", + "await this.sendTerminal(handle, { text: 'a, b' }, { beforeWrite })", + 'await this.sendTerminalAgentPrompt(handle, prompt, { ...options })', + 'other.write(ptyId, data)', + 'const controller = this.ptyController; controller.write(ptyId, data)' + ].join('\n') + + expect(findCallsMissingArgument(planted, KIND_ARGUMENT_BY_METHOD)).toEqual([ + '2: .write(ptyId, reply)', + "4: .sendTerminal(handle, { text: 'a, b' }, { beforeWrite })", + '7: .write(ptyId, data)' + ]) + }) + + it('passes an input kind at every write', () => { + const missing = uncheckedSources.flatMap((file) => + findCallsMissingArgument(file.source, KIND_ARGUMENT_BY_METHOD).map( + (site) => `${file.relativePath}:${site}` + ) + ) + + expect(missing).toEqual([]) + }) +}) diff --git a/src/main/runtime/terminal-intentional-stop-exit.test.ts b/src/main/runtime/terminal-intentional-stop-exit.test.ts new file mode 100644 index 00000000000..0c3f996153f --- /dev/null +++ b/src/main/runtime/terminal-intentional-stop-exit.test.ts @@ -0,0 +1,288 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { BrowserWindow } from 'electron' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { Store } from '../persistence/loading-store/store' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { wirePtyIpcSession } from '../ipc/pty/delivery/wire-session' +import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state' +import { bindProviderListeners } from '../ipc/pty/provider/bind-listeners' +import { + stopRendererOwnedPty, + stopReplacedPanePty, + type PtyKillIpcDeps +} from '../ipc/pty/ipc/renderer-kill' +import { ptyIncarnationById, ptyOwnership } from '../ipc/pty/provider/ownership-state' +import { getLocalPtyProvider, setLocalPtyProvider } from '../ipc/pty/provider/registry' +import { createPtyIpcSession } from '../ipc/pty/session' +import type { IPtyProvider } from '../providers/types' +import { OrcaRuntimeService } from './orca-runtime' +import { + INCARNATION_ID, + LEAF_ID, + PTY_ID, + REPO_ID, + TAB_ID, + WORKTREE_ID, + WORKTREE_PATH, + makeSession +} from './__fixtures__/orca-runtime-terminal-close-continuity-state-fixture' +import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' + +const REPLACEMENT_PTY_ID = 'pty-close-continuity-replacement' +const REPLACEMENT_INCARNATION_ID = '77777777-7777-4777-8777-777777777777' +const LATER_INCARNATION_ID = '88888888-8888-4888-8888-888888888888' + +const directories: string[] = [] +const stores: Store[] = [] +const priorProvider = getLocalPtyProvider() +afterEach(() => { + vi.useRealTimers() + setLocalPtyProvider(priorProvider) + ptyOwnership.delete(PTY_ID) + ptyIncarnationById.delete(PTY_ID) + for (const store of stores.splice(0)) { + store.freezeWrites() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +/** A real store and runtime with one bound pane, and main's exit delivery to a renderer stub. + * `lateProviderExit`: the kill's reply overtakes the exit, so main synthesizes one and the + * provider's own exit arrives later through its listener. */ +function createHarness(opts: { lateProviderExit?: boolean; folder?: boolean } = {}) { + const directory = mkdtempSync(join(tmpdir(), 'orca-intentional-stop-')) + directories.push(directory) + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: new ProfileStateSqliteAuthority( + join(directory, 'profile-state.db'), + 'intentional-stop' + ) + }) + stores.push(store) + store.addRepo({ + id: REPO_ID, + path: WORKTREE_PATH, + displayName: 'Fixture', + badgeColor: 'gray', + addedAt: 1, + // Why: a folder workspace resolves without git, which the sleep transaction needs. + ...(opts.folder ? { kind: 'folder' as const } : {}) + }) + store.setWorkspaceSession(advanceTerminalTopologyRevision(makeSession(), WORKTREE_ID)) + store.flushOrThrow() + const runtime = new OrcaRuntimeService(store) + runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: INCARNATION_ID + }) + ptyOwnership.set(PTY_ID, null) + ptyIncarnationById.set(PTY_ID, INCARNATION_ID) + let emitProviderExit: + | ((payload: { id: string; code: number; incarnationId?: string }) => void) + | undefined + const provider = { + onData: () => () => {}, + onExit: (listener: typeof emitProviderExit) => { + emitProviderExit = listener + return () => {} + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the listeners bind only onData and onExit, and the renderer kill hands the provider to the shutdown port below. + setLocalPtyProvider(provider as unknown as IPtyProvider) + const rendererSend = vi.fn() + const window = { isDestroyed: () => false, webContents: { send: rendererSend } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: exit delivery reads only isDestroyed and webContents.send. + const session = createPtyIpcSession({ mainWindow: window as unknown as BrowserWindow, runtime }) + wirePtyIpcSession(session) + bindProviderListeners(session) + const deps: PtyKillIpcDeps = { + store, + runtime, + getLocalPtyProviderStartupPromise: () => undefined, + // The provider's own exit, delivered the way its listener delivers it. + shutdownProviderAndDetectExit: async (_provider, id) => { + if (opts.lateProviderExit) { + return false + } + runtime.onPtyExit(id, 0, INCARNATION_ID, { providerExitObserved: true }) + session.sendPtyExitToRenderer({ id, code: 0, incarnationId: INCARNATION_ID }) + return true + }, + rememberSyntheticKillExit: session.rememberSyntheticKillExit, + sendPtyExitToRenderer: session.sendPtyExitToRenderer + } + return { + store, + runtime, + deps, + emitProviderExit: (incarnationId: string) => + emitProviderExit?.({ id: PTY_ID, code: 0, incarnationId }), + boundPtyId: () => + store.getWorkspaceSession().terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId?.[LEAF_ID] ?? null, + tabIds: () => (store.getWorkspaceSession().tabsByWorktree[WORKTREE_ID] ?? []).map((t) => t.id), + rendererExits: () => + rendererSend.mock.calls.filter(([channel]) => channel === 'pty:exit').map(([, p]) => p) + } +} + +describe('intentional stops keep the pane through the exit', () => { + it('retires the pane when an ordinary close ends the process', async () => { + const harness = createHarness() + + await stopRendererOwnedPty(harness.deps, { id: PTY_ID }) + + expect(harness.boundPtyId()).toBeNull() + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: 0, incarnationId: INCARNATION_ID } + ]) + }) + + it('keeps the tab and its wake binding when the renderer hibernates the pane', async () => { + const harness = createHarness() + + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(PTY_ID) + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: 0, incarnationId: INCARNATION_ID, preserveRendererBinding: true } + ]) + }) + + it('keeps a typed pane that a restart replaces, and binds the replacement', async () => { + const harness = createHarness() + harness.runtime.terminalRunFacts.recordSpawnCommit({ + id: PTY_ID, + incarnationId: INCARNATION_ID + }) + harness.runtime.terminalRunFacts.recordInput(PTY_ID, 'driving', 'ls\r') + + await stopReplacedPanePty(harness.deps, PTY_ID) + expect(harness.boundPtyId()).toBe(PTY_ID) + await harness.store.persistPtyBinding({ + worktreeId: WORKTREE_ID, + tabId: TAB_ID, + leafId: LEAF_ID, + ptyId: REPLACEMENT_PTY_ID, + incarnationId: REPLACEMENT_INCARNATION_ID, + origin: 'spawn' + }) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(REPLACEMENT_PTY_ID) + expect( + harness.store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[ + makePaneKey(TAB_ID, LEAF_ID) + ] + ).toBe(REPLACEMENT_INCARNATION_ID) + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: 0, incarnationId: INCARNATION_ID, replacedByRestart: true } + ]) + }) + + it('labels the exit for both a sleep and a restart that stop the same process', async () => { + const harness = createHarness() + const settleSleep = harness.runtime.intentionalPtyStops.mark( + PTY_ID, + 'reversible', + INCARNATION_ID + ) + + await stopReplacedPanePty(harness.deps, PTY_ID) + settleSleep(true) + + expect(harness.boundPtyId()).toBe(PTY_ID) + expect(harness.rendererExits()).toEqual([ + { + id: PTY_ID, + code: 0, + incarnationId: INCARNATION_ID, + preserveRendererBinding: true, + replacedByRestart: true + } + ]) + }) + + it('keeps the pane through the synthetic exit and the provider exit that follows it', async () => { + const harness = createHarness({ lateProviderExit: true }) + + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + harness.emitProviderExit(INCARNATION_ID) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(PTY_ID) + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: -1, incarnationId: INCARNATION_ID, preserveRendererBinding: true } + ]) + }) + + it('never reads the exit of a later process on the same id as the stop', async () => { + const harness = createHarness({ lateProviderExit: true }) + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + harness.runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: LATER_INCARNATION_ID + }) + ptyIncarnationById.set(PTY_ID, LATER_INCARNATION_ID) + await harness.store.persistPtyBinding({ + worktreeId: WORKTREE_ID, + tabId: TAB_ID, + leafId: LEAF_ID, + ptyId: PTY_ID, + incarnationId: LATER_INCARNATION_ID, + origin: 'reattach' + }) + + harness.emitProviderExit(LATER_INCARNATION_ID) + + // Why wait: an unstopped exit retires the pane through an async durable save. + await vi.waitFor(() => expect(harness.boundPtyId()).toBeNull()) + expect(harness.rendererExits().at(-1)).toEqual({ + id: PTY_ID, + code: 0, + incarnationId: LATER_INCARNATION_ID + }) + }) + + it('forgets the stop once the duplicate-exit window after it closes', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness({ lateProviderExit: true }) + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + + vi.advanceTimersByTime(SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS - 1) + expect(harness.runtime.intentionalPtyStops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([ + 'reversible' + ]) + vi.advanceTimersByTime(1) + + expect(harness.runtime.intentionalPtyStops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([]) + }) + + it('keeps the tab and its wake binding when the runtime puts the worktree to sleep', async () => { + const harness = createHarness({ folder: true }) + const inventories = [[{ id: PTY_ID, worktreeId: WORKTREE_ID, cwd: WORKTREE_PATH, title: 'a' }]] + harness.runtime.setPtyController({ + write: () => true, + kill: () => true, + stopAndWait: async (ptyId) => { + harness.runtime.onPtyExit(ptyId, -1, INCARNATION_ID, { providerExitObserved: true }) + return true + }, + getForegroundProcess: async () => null, + listProcesses: async () => inventories.shift() ?? [] + }) + + await harness.runtime.sleepTerminalsForWorktree(`id:${WORKTREE_ID}`) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(PTY_ID) + }) +}) diff --git a/src/main/runtime/terminal-intentional-stops.test.ts b/src/main/runtime/terminal-intentional-stops.test.ts new file mode 100644 index 00000000000..d37fc4147e8 --- /dev/null +++ b/src/main/runtime/terminal-intentional-stops.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state' +import { TerminalIntentionalStops } from './terminal-intentional-stops' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('terminal intentional stops', () => { + it('keeps the mark while a second overlapping owner still holds it', () => { + const stops = new TerminalIntentionalStops() + const settleFirst = stops.mark('pty-1', 'reversible', 'inc-1') + const settleSecond = stops.mark('pty-1', 'reversible', 'inc-1') + + settleFirst(false) + + expect(stops.isReversibleStopInFlight('pty-1')).toBe(true) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible']) + settleSecond(false) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual([]) + }) + + it('still reads an exit that lands after the stop settled, until the window closes', () => { + vi.useFakeTimers() + const stops = new TerminalIntentionalStops() + const settle = stops.mark('pty-ssh', 'reversible', 'inc-1') + + settle(true) + vi.advanceTimersByTime(SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS - 1) + + expect(stops.isReversibleStopInFlight('pty-ssh')).toBe(false) + expect(stops.claimExit('pty-ssh', 'inc-1')).toEqual(['reversible']) + vi.advanceTimersByTime(1) + expect(stops.claimExit('pty-ssh', 'inc-1')).toEqual([]) + }) + + it('drops the mark at once when the stop fails', () => { + const stops = new TerminalIntentionalStops() + + stops.mark('pty-1', 'replaced', 'inc-1')(false) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual([]) + }) + + it('reads the synthetic exit and the provider exit of the same process alike', () => { + const stops = new TerminalIntentionalStops() + const settle = stops.mark('pty-1', 'replaced', null) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['replaced']) + settle(true) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['replaced']) + expect(stops.claimExit('pty-1', 'inc-2')).toEqual([]) + }) + + it('never marks the exit of another process that reuses the id', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-1', 'reversible', 'inc-1') + + expect(stops.claimExit('pty-1', 'inc-2')).toEqual([]) + }) + + it('starts a new stop of the same id fresh once the prior one settled', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-1', 'reversible', 'inc-1')(true) + + const settle = stops.mark('pty-1', 'replaced', 'inc-2') + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual([]) + expect(stops.claimExit('pty-1', 'inc-2')).toEqual(['replaced']) + settle(false) + expect(stops.claimExit('pty-1', 'inc-2')).toEqual([]) + }) + + it('labels one exit with every kind of stop that overlapped on it', () => { + const stops = new TerminalIntentionalStops() + const settleSleep = stops.mark('pty-1', 'reversible', 'inc-1') + const settleRestart = stops.mark('pty-1', 'replaced', 'inc-1') + + expect(stops.isReversibleStopInFlight('pty-1')).toBe(true) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible', 'replaced']) + + settleSleep(true) + expect(stops.isReversibleStopInFlight('pty-1')).toBe(false) + settleRestart(false) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible']) + }) + + it('keeps a landed stop when a later stop of the same process fails', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-1', 'reversible', 'inc-1')(true) + + stops.mark('pty-1', 'reversible', 'inc-1')(false) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible']) + }) + + it('lets a new process on the id supersede a landed stop no exit pinned', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-unpinned', 'reversible', null)(true) + stops.mark('pty-pinned', 'reversible', 'inc-1')(true) + stops.mark('pty-in-flight', 'replaced', null) + + for (const ptyId of ['pty-unpinned', 'pty-pinned', 'pty-in-flight']) { + stops.noteSpawnCommit(ptyId) + } + + expect(stops.claimExit('pty-unpinned', null)).toEqual([]) + expect(stops.claimExit('pty-pinned', 'inc-1')).toEqual(['reversible']) + expect(stops.claimExit('pty-in-flight', null)).toEqual(['replaced']) + }) +}) diff --git a/src/main/runtime/terminal-intentional-stops.ts b/src/main/runtime/terminal-intentional-stops.ts new file mode 100644 index 00000000000..b89bc709e5f --- /dev/null +++ b/src/main/runtime/terminal-intentional-stops.ts @@ -0,0 +1,122 @@ +import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state' + +/** + * Why main stopped a PTY on purpose. Both kinds keep the pane's binding through the exit: + * - `reversible`: sleep or hibernation; the binding is the wake hint. + * - `replaced`: a restart stops the old process so a new one can take the pane. + */ +export type TerminalIntentionalStopKind = 'reversible' | 'replaced' + +type IntentionalStopOwners = { inFlight: number; stopped: boolean } + +type IntentionalStop = { + /** Null until known; the first exit that claims the stop pins it to that process. */ + incarnationId: string | null + /** Why per kind: overlapping stops of different kinds each hold their own label on the exit. */ + ownersByKind: Map + expiryTimer?: ReturnType +} + +const NO_INTENTIONAL_STOP: readonly TerminalIntentionalStopKind[] = [] + +function hasInFlightOwners(stop: IntentionalStop): boolean { + return [...stop.ownersByKind.values()].some((owners) => owners.inFlight > 0) +} + +/** The one register of PTY stops main made on purpose, read by every exit path. */ +export class TerminalIntentionalStops { + private readonly stopsByPtyId = new Map() + + /** Registers one owner's stop; call the result once with whether the stop landed. */ + mark( + ptyId: string, + kind: TerminalIntentionalStopKind, + incarnationId: string | null + ): (stopped: boolean) => void { + let stop = this.stopsByPtyId.get(ptyId) + if (stop && this.joins(stop, incarnationId)) { + clearTimeout(stop.expiryTimer) + stop.expiryTimer = undefined + stop.incarnationId ??= incarnationId + } else { + clearTimeout(stop?.expiryTimer) + stop = { incarnationId, ownersByKind: new Map() } + this.stopsByPtyId.set(ptyId, stop) + } + const owners = stop.ownersByKind.get(kind) ?? { inFlight: 0, stopped: false } + owners.inFlight += 1 + stop.ownersByKind.set(kind, owners) + const owned = stop + let settled = false + return (stopped) => { + if (settled || this.stopsByPtyId.get(ptyId) !== owned) { + return + } + settled = true + owners.stopped ||= stopped + owners.inFlight -= 1 + if (owners.inFlight === 0 && !owners.stopped) { + owned.ownersByKind.delete(kind) + } + this.settleIfIdle(ptyId, owned) + } + } + + /** The kinds of stop this exit ends; empty when the process was not stopped on purpose. */ + claimExit( + ptyId: string, + exitIncarnationId: string | null | undefined + ): readonly TerminalIntentionalStopKind[] { + const stop = this.stopsByPtyId.get(ptyId) + if (!stop) { + return NO_INTENTIONAL_STOP + } + if (stop.incarnationId && exitIncarnationId && stop.incarnationId !== exitIncarnationId) { + return NO_INTENTIONAL_STOP + } + stop.incarnationId ??= exitIncarnationId ?? null + return [...stop.ownersByKind.keys()] + } + + /** Whether a stop of this PTY that may still be undone is in flight. */ + isReversibleStopInFlight(ptyId: string): boolean { + return (this.stopsByPtyId.get(ptyId)?.ownersByKind.get('reversible')?.inFlight ?? 0) > 0 + } + + /** A process committed on this id. A landed stop's process is dead, so an entry no exit ever + * pinned could otherwise claim the new process's exit as the stop. */ + noteSpawnCommit(ptyId: string): void { + const stop = this.stopsByPtyId.get(ptyId) + if (stop && stop.incarnationId === null && !hasInFlightOwners(stop)) { + clearTimeout(stop.expiryTimer) + this.stopsByPtyId.delete(ptyId) + } + } + + // Why: a settled entry joins only its own known process, so an id reused by a process whose + // incarnation is not yet known never inherits the old stop. + private joins(stop: IntentionalStop, incarnationId: string | null): boolean { + if (stop.incarnationId !== null && stop.incarnationId === incarnationId) { + return true + } + return hasInFlightOwners(stop) && (stop.incarnationId === null || incarnationId === null) + } + + private settleIfIdle(ptyId: string, stop: IntentionalStop): void { + if (hasInFlightOwners(stop)) { + return + } + if (stop.ownersByKind.size === 0) { + this.stopsByPtyId.delete(ptyId) + return + } + // Why a window: an SSH exit can arrive after the stop settles, and a synthetic exit can be + // followed by the provider's own; both describe the same stopped process. + stop.expiryTimer = setTimeout(() => { + if (this.stopsByPtyId.get(ptyId) === stop) { + this.stopsByPtyId.delete(ptyId) + } + }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) + stop.expiryTimer.unref?.() + } +} diff --git a/src/main/runtime/terminal-interactive-wait-visibility.test.ts b/src/main/runtime/terminal-interactive-wait-visibility.test.ts index 3a6470b41f9..b79ea7a3270 100644 --- a/src/main/runtime/terminal-interactive-wait-visibility.test.ts +++ b/src/main/runtime/terminal-interactive-wait-visibility.test.ts @@ -92,9 +92,9 @@ describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { await expect( assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) ).rejects.toThrow('terminal_guard_permission') - await expect(runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') }) it('lets a dispatch preamble through once the same lane is working', async () => { diff --git a/src/main/runtime/terminal-query-responder.test.ts b/src/main/runtime/terminal-query-responder.test.ts index 9db883529a0..f479be9e47a 100644 --- a/src/main/runtime/terminal-query-responder.test.ts +++ b/src/main/runtime/terminal-query-responder.test.ts @@ -21,6 +21,7 @@ import { setTerminalViewAttributes } from './terminal-view-attribute-store' import type { TerminalViewAttributes, TerminalViewRgb } from '../../shared/terminal-view-attributes' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' const settingsState = { terminalMainSideEffectAuthority: true as boolean, @@ -58,9 +59,11 @@ type RendererBufferStub = { data: string; cols: number; rows: number } function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = {}) { const runtime = new OrcaRuntimeService(store) const replies: { ptyId: string; data: string }[] = [] + const inputKinds: TerminalInputKind[] = [] runtime.setPtyController({ - write: (ptyId, data) => { + write: (ptyId, data, inputKind) => { replies.push({ ptyId, data }) + inputKinds.push(inputKind) return true }, kill: () => true, @@ -74,7 +77,7 @@ function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = } : {}) }) - return { runtime, replies } + return { runtime, replies, inputKinds } } /** Awaits the per-PTY emulator writeChain so queued chunk links (and the @@ -146,7 +149,7 @@ describe('reply parity for hidden-dropped chunks', () => { ['kitty CSI ? u default flags', '\x1b[?u', ['\x1b[?0u']], ['kitty CSI ? u reports pushed flags', '\x1b[=5;1u\x1b[?u', ['\x1b[?5u']] ])('%s', async (_label, chunk, expectedReplies) => { - const { runtime, replies } = createResponderRuntime() + const { runtime, replies, inputKinds } = createResponderRuntime() markHiddenRendererPty('pty-q') runtime.onPtyData('pty-q', chunk, Date.now()) @@ -154,6 +157,8 @@ describe('reply parity for hidden-dropped chunks', () => { expect(replies.map((reply) => reply.data)).toEqual(expectedReplies) expect(replies.every((reply) => reply.ptyId === 'pty-q')).toBe(true) + // Why: a reply written as driving input would read the untouched run as typed. + expect(inputKinds).toEqual(expectedReplies.map(() => 'query-reply')) }) it.each([ diff --git a/src/main/runtime/terminal-run-facts-input.test.ts b/src/main/runtime/terminal-run-facts-input.test.ts new file mode 100644 index 00000000000..a1f26d9b686 --- /dev/null +++ b/src/main/runtime/terminal-run-facts-input.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' +import { writePtyFromRuntimeController } from '../ipc/pty/runtime/operations' +import { getLocalPtyProvider, setLocalPtyProvider } from '../ipc/pty/provider/registry' +import type { IPtyProvider } from '../providers/types' +import { settledWriteStub } from '../providers/settled-pty-write-stub' +import { OrcaRuntimeService } from './orca-runtime' +import type { RuntimePtyController } from './runtime-pty-controller-contract' +import { writeOrchestrationPointerWithSettlement } from './orchestration/mailbox-pointer-pty-write' +import { sendTerminalStreamInput } from './rpc/methods/terminal/terminal-input-delivery' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' +import { + pasteWorktreeStartupDraftWhenReady, + sendWorktreeStartupFollowupWhenReady, + type WorktreeStartupReadinessHost +} from './runtime-worktree-startup-readiness' + +vi.mock('../git/worktree', () => { + const worktrees = [ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/run-facts', + isBare: false, + isMainWorktree: false + } + ] + return { + listWorktrees: vi.fn().mockResolvedValue(worktrees), + listWorktreesStrict: vi.fn().mockResolvedValue(worktrees) + } +}) + +const PTY_ID = 'pty-run-facts-input' +const priorProvider = getLocalPtyProvider() + +type FreshRun = { + runtime: OrcaRuntimeService + controller: RuntimePtyController + handle: string + /** The kind each provider write was sent with, and whether input was recorded by then. */ + writes: { data: string; inputRecorded: boolean }[] + kinds: TerminalInputKind[] + firstUserInputAt: () => number | null +} + +/** A fresh shell whose controller writes go through main's real write funnel to a fake provider. */ +async function createFreshRun(): Promise { + const runtime = new OrcaRuntimeService(makeStore() as never) + const firstUserInputAt = (): number | null => + runtime.terminalRunFacts.read(PTY_ID, null).firstUserInputAt + const writes: FreshRun['writes'] = [] + const kinds: TerminalInputKind[] = [] + const write = (_id: string, data: string): boolean => { + writes.push({ data, inputRecorded: firstUserInputAt() !== null }) + if (data.endsWith('\r')) { + runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', Date.now()) + } + return true + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the write funnel calls only write and writeWithSettlement on the provider. + setLocalPtyProvider({ + write, + writeWithSettlement: settledWriteStub(write) + } as unknown as IPtyProvider) + const controller: RuntimePtyController = { + spawn: async () => ({ id: PTY_ID }), + write: (id, data, inputKind) => { + kinds.push(inputKind) + return writePtyFromRuntimeController({ runtime }, id, data, inputKind) + }, + writeWithSettlement: (id, data, inputKind) => { + kinds.push(inputKind) + return writePtyFromRuntimeController({ runtime }, id, data, inputKind, { + waitForSettlement: true + }) + }, + kill: () => true, + getForegroundProcess: async () => null + } + runtime.setPtyController(controller) + const terminal = await runtime.createTerminal('path:/tmp/worktree-a', { launchAgent: 'aider' }) + runtime.terminalRunFacts.recordSpawnCommit({ id: PTY_ID }) + return { runtime, controller, handle: terminal.handle, writes, kinds, firstUserInputAt } +} + +afterEach(() => { + vi.useRealTimers() + setLocalPtyProvider(priorProvider) +}) + +describe('run facts: the controller write funnel', () => { + it('records terminal.send input before the write that could end the process', async () => { + const run = await createFreshRun() + + await run.runtime.sendTerminal( + run.handle, + { text: 'exit', enter: true }, + { inputKind: 'driving' } + ) + + expect(run.firstUserInputAt()).not.toBeNull() + expect(run.writes.map((write) => write.inputRecorded)).toEqual([true, true]) + }) + + it('records stream input from a client', async () => { + const run = await createFreshRun() + + await sendTerminalStreamInput(run.runtime, { + terminal: run.handle, + text: 'l', + client: undefined, + isMobile: false + }) + + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records a dispatched agent prompt before its first write', async () => { + vi.useFakeTimers() + const run = await createFreshRun() + + const submission = run.runtime.sendTerminalAgentPrompt(run.handle, 'review this', { + inputKind: 'driving' + }) + await vi.runAllTimersAsync() + await submission.catch(() => undefined) + + expect(run.firstUserInputAt()).not.toBeNull() + expect(run.writes[0]?.inputRecorded).toBe(true) + }) + + it('reads a run launched with a prompt as untyped until someone drives it', async () => { + vi.useFakeTimers() + const run = await createFreshRun() + + const submission = run.runtime.sendTerminalAgentPrompt(run.handle, 'start here', { + inputKind: 'launch' + }) + await vi.runAllTimersAsync() + await submission.catch(() => undefined) + + expect(run.writes.length).toBeGreaterThan(0) + expect(run.firstUserInputAt()).toBeNull() + + await run.runtime.sendTerminal(run.handle, { text: 'y' }, { inputKind: 'driving' }) + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records a mailbox pointer, which drives the running agent', async () => { + const run = await createFreshRun() + + await writeOrchestrationPointerWithSettlement({ + ptyId: PTY_ID, + data: 'You have 1 unread message.', + controller: run.controller + }) + + expect(run.kinds).toEqual(['driving']) + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records nothing for a client query reply', async () => { + const run = await createFreshRun() + + await run.runtime.sendTerminal(run.handle, { text: 'y' }, { inputKind: 'query-reply' }) + + expect(run.firstUserInputAt()).toBeNull() + }) + + it.each([ + ['a terminal reply', '\x1b[3;4R'], + ['focus reports', '\x1b[I\x1b[O'], + ['the focus-in a desktop sends on reattaching a remote pane', '\x1b[I'] + ])('records nothing for stream input that is only %s', async (_label, text) => { + const run = await createFreshRun() + + await sendTerminalStreamInput(run.runtime, { + terminal: run.handle, + text, + client: undefined, + isMobile: false + }) + + expect(run.writes).toHaveLength(1) + expect(run.firstUserInputAt()).toBeNull() + }) + + it('records a reply mixed with a keystroke', async () => { + const run = await createFreshRun() + + await run.runtime.sendTerminal(run.handle, { text: '\x1b[3;4Rx' }, { inputKind: 'driving' }) + + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records dashboard preview typing before the write that could end the process', async () => { + const run = await createFreshRun() + + await expect(run.runtime.writeTerminalPreviewInput(PTY_ID, 'exit\r')).resolves.toBe(true) + + expect(run.writes.map((write) => write.inputRecorded)).toEqual([true]) + }) + + it('records nothing for dashboard preview bytes that are only a reply or focus reports', async () => { + const run = await createFreshRun() + + await run.runtime.writeTerminalPreviewInput(PTY_ID, '\x1b[3;4R') + await run.runtime.writeTerminalPreviewInput(PTY_ID, '\x1b[O\x1b[I') + + expect(run.writes).toHaveLength(2) + expect(run.firstUserInputAt()).toBeNull() + }) +}) + +describe('run facts: a created worktree’s startup writes', () => { + function readinessHost(run: FreshRun): WorktreeStartupReadinessHost { + return { + getPtyId: () => PTY_ID, + getForegroundProcess: async () => 'aider', + subscribeToData: () => () => {}, + // Why: bracketed paste enabled, then quiet, is the default draft-ready signal. + readRecentOutput: () => '\x1b[?2004h', + write: (ptyId, data, inputKind) => run.controller.write(ptyId, data, inputKind) + } + } + + it('reads a run whose only input was its create-time draft and follow-up as untyped', async () => { + vi.useFakeTimers() + const run = await createFreshRun() + const host = readinessHost(run) + + pasteWorktreeStartupDraftWhenReady(host, run.handle, { agent: 'aider', content: 'plan it' }) + sendWorktreeStartupFollowupWhenReady(host, run.handle, { + expectedProcess: 'aider', + prompt: 'and ship it' + }) + await vi.runAllTimersAsync() + + expect(run.kinds).toEqual(['launch', 'launch']) + expect(run.writes).toHaveLength(2) + expect(run.firstUserInputAt()).toBeNull() + }) +}) diff --git a/src/main/runtime/terminal-run-facts.test.ts b/src/main/runtime/terminal-run-facts.test.ts new file mode 100644 index 00000000000..e1982ebc968 --- /dev/null +++ b/src/main/runtime/terminal-run-facts.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { TerminalRunFactsRegister } from './terminal-run-facts' + +describe('terminal run facts', () => { + it('reads a run main never saw committed as not fresh', () => { + expect(new TerminalRunFactsRegister().read('pty-1', 'inc-1')).toEqual({ + freshSpawn: false, + firstUserInputAt: null + }) + }) + + it('keeps the first user input across later input and a re-registration of the same process', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + facts.recordInput('pty-1', 'driving', 'ls\r', 200) + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1', isReattach: true }) + + expect(facts.read('pty-1', 'inc-1')).toEqual({ freshSpawn: true, firstUserInputAt: 100 }) + }) + + it('starts a new process clean', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-2' }, { tabId: 'source-tab' }) + + expect(facts.read('pty-1', 'inc-2')).toEqual({ freshSpawn: true, firstUserInputAt: null }) + expect(facts.read('pty-1', 'inc-1')).toEqual({ freshSpawn: false, firstUserInputAt: null }) + }) + + it('never reads a reattached process as fresh', () => { + const facts = new TerminalRunFactsRegister() + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1', isReattach: true }) + + expect(facts.read('pty-1', 'inc-1').freshSpawn).toBe(false) + }) + + it('starts clean when a commit carries no incarnation to tell it from a new process', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + + facts.recordSpawnCommit({ id: 'pty-1', isReattach: true }) + + expect(facts.read('pty-1', null)).toEqual({ freshSpawn: false, firstUserInputAt: null }) + }) + + it.each([ + ['a launch write', 'launch', 'echo startup\r'], + ['a query reply', 'query-reply', '\x1b[1;1R'], + ['driving bytes that are only a terminal reply', 'driving', '\x1b[1;1R'], + ['driving bytes that are only focus reports', 'driving', '\x1b[I\x1b[O'] + ] as const)('records nothing for %s', (_label, inputKind, data) => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + + facts.recordInput('pty-1', inputKind, data, 100) + + expect(facts.read('pty-1', 'inc-1').firstUserInputAt).toBeNull() + }) +}) diff --git a/src/main/runtime/terminal-run-facts.ts b/src/main/runtime/terminal-run-facts.ts new file mode 100644 index 00000000000..1fb3e411b6c --- /dev/null +++ b/src/main/runtime/terminal-run-facts.ts @@ -0,0 +1,90 @@ +import { + spawnCommitBindingOrigin, + type PtySpawnCommitOrigin +} from '../persistence/loading-store/pty-binding-span' +import { isTerminalQueryReply } from '../../shared/terminal-query-reply' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' + +export type TerminalRunFacts = { + /** This process was started for its pane, not reattached, adopted or cold-restored. */ + freshSpawn: boolean + /** When input first drove this process, from any client or driver; null if none has. */ + firstUserInputAt: number | null +} + +// Why: a paired client's xterm answers focus changes (CSI I / CSI O) through input that carries no +// provenance; the desktop renderer already excludes them via xterm's user-input signal. +// oxlint-disable-next-line no-control-regex -- focus reports are ESC-framed sequences by definition. +const TERMINAL_FOCUS_REPORTS_ONLY_RE = new RegExp('^(?:\\u001b\\[[IO])+$') + +/** Input with no provenance that no person typed: a whole terminal reply or only focus reports. */ +function isUntypedTerminalInput(payload: string): boolean { + return isTerminalQueryReply(payload) || TERMINAL_FOCUS_REPORTS_ONLY_RE.test(payload) +} + +export type TerminalSpawnCommit = Parameters[0] & { + id: string + incarnationId?: string + coldRestore?: object +} + +/** A cold restore starts a new process for a pane that had one, so it is never fresh. */ +type TerminalRunSpawnOrigin = PtySpawnCommitOrigin | 'cold-restore' + +type TerminalRunRecord = { + incarnationId: string | null + spawnOrigin: TerminalRunSpawnOrigin + firstUserInputAt: number | null +} + +/** Main's per-process facts about one PTY run, keyed by the incarnation they describe. */ +export class TerminalRunFactsRegister { + private readonly runsByPtyId = new Map() + + /** Once per process: a re-registration of the same incarnation keeps its facts. Without an + * incarnation a commit cannot be told from a new process, so it starts clean. */ + recordSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { + const incarnationId = commit.incarnationId ?? null + if ( + incarnationId !== null && + this.runsByPtyId.get(commit.id)?.incarnationId === incarnationId + ) { + return + } + const origin = spawnCommitBindingOrigin(commit, expectedSourceBinding) + this.runsByPtyId.set(commit.id, { + incarnationId, + spawnOrigin: origin === 'spawn' && commit.coldRestore !== undefined ? 'cold-restore' : origin, + firstUserInputAt: null + }) + } + + /** The one record point both write funnels call just before the provider write, because input + * such as `exit` can end the process before the write returns. The payload check backs up a + * writer that labels a reply or focus report as driving. */ + recordInput(ptyId: string, inputKind: TerminalInputKind, data: string, now = Date.now()): void { + if (inputKind !== 'driving') { + return + } + const run = this.runsByPtyId.get(ptyId) + if (run && run.firstUserInputAt === null && !isUntypedTerminalInput(data)) { + run.firstUserInputAt = now + } + } + + /** A run main never saw committed reads as not fresh, which keeps today's close-on-exit. */ + read(ptyId: string, incarnationId: string | null | undefined): TerminalRunFacts { + const run = this.runsByPtyId.get(ptyId) + if (!run || (run.incarnationId && incarnationId && run.incarnationId !== incarnationId)) { + return { freshSpawn: false, firstUserInputAt: null } + } + return { + freshSpawn: run.spawnOrigin === 'spawn' || run.spawnOrigin === 'split', + firstUserInputAt: run.firstUserInputAt + } + } + + delete(ptyId: string): void { + this.runsByPtyId.delete(ptyId) + } +} diff --git a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts index b7c5ed95068..a25ed9817fb 100644 --- a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts +++ b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts @@ -96,9 +96,9 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { const probe = vi.fn(async () => false) const { runtime, handle, write } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') expect(probe).toHaveBeenCalledWith(STALE_PTY_ID) expect(write).not.toHaveBeenCalled() @@ -108,9 +108,9 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { const probe = vi.fn(async () => false) const { runtime, handle, write } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe }) - await expect(runtime.sendTerminalAgentPrompt(handle, 'do the thing')).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'do the thing', { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') expect(write).not.toHaveBeenCalled() }) @@ -120,12 +120,14 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { probePtyLiveness: async () => null }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ handle, accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('treats a throwing probe as unknown and proceeds', async () => { @@ -135,11 +137,13 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { } }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('proceeds when the probe answers live (restored session before its pane remounts)', async () => { @@ -147,21 +151,25 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { probePtyLiveness: async () => true }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('proceeds unchanged when the controller exposes no probe', async () => { const { runtime, handle, write } = await makeRuntimeWithLeafHandle({}) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('never probes when the provider synchronously knows the id (live pty)', async () => { @@ -171,24 +179,26 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { hasPty: (ptyId) => ptyId === STALE_PTY_ID }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) expect(probe).not.toHaveBeenCalled() - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('reuses a proven-absent verdict across repeated sends instead of re-probing', async () => { const probe = vi.fn(async () => false) const { runtime, handle } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe }) - await expect(runtime.sendTerminal(handle, { text: 'a' })).rejects.toThrow( - 'terminal_not_writable' - ) - await expect(runtime.sendTerminal(handle, { text: 'b' })).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminal(handle, { text: 'a' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') + await expect( + runtime.sendTerminal(handle, { text: 'b' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') expect(probe).toHaveBeenCalledTimes(1) }) @@ -201,17 +211,19 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { hasPty: (ptyId) => livePtyIds.has(ptyId) }) - await expect(runtime.sendTerminal(handle, { text: 'a' })).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminal(handle, { text: 'a' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') // Same id recreated by a fresh spawn: provider knowledge must beat the verdict. livePtyIds.add(STALE_PTY_ID) - await expect(runtime.sendTerminal(handle, { text: 'b' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'b' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) expect(probe).toHaveBeenCalledTimes(1) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'b') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'b', 'driving') }) }) diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts index 53fcb42285b..ec88c3368a3 100644 --- a/src/main/runtime/tui-idle-evidence.test.ts +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { evaluateTuiIdle, + isTuiIdleReadyVerdict, hasQuietMuseReadyPrompt, nameOnlyIdleNeedsCorroboration, type TuiIdleEvaluationInput, @@ -174,3 +175,41 @@ describe('nameOnlyIdleNeedsCorroboration', () => { expect(nameOnlyIdleNeedsCorroboration(null, 'claude ~/p/repo')).toBe(true) }) }) + +describe('a DSH pane settles tui-idle on its own hook', () => { + const base = { + record: { lastAgentStatus: null, lastOutputAt: null, lastOscTitle: '\u2726 \u{1F40B} repo' }, + rendererTitle: undefined, + readPositiveBodyEvidence: () => false, + readMuseReadyBodyEvidence: () => false, + readTailBlockedReason: () => null, + agent: 'dsh' as const, + firstPartyStatus: { state: 'done' as const, updatedAt: Date.now() }, + quiescenceMs: 1_000 + } satisfies TuiIdleEvaluationInput + + const ready = (over: Partial = {}) => + isTuiIdleReadyVerdict(evaluateTuiIdle({ ...base, ...over })) + + it('settles on a fresh first-party done', () => { + // The regression: DSH's title carries no idle (its rest glyph is Gemini's working one), + // so every title-reading tier failed and `terminal wait --for tui-idle` ran to timeout + // against an already-ready composer. + expect(ready()).toBe(true) + }) + + it('does not settle while the same pane reports working', () => { + expect(ready({ firstPartyStatus: { state: 'working', updatedAt: Date.now() } })).toBe(false) + }) + + it('does not settle on a stale done', () => { + expect( + ready({ firstPartyStatus: { state: 'done', updatedAt: Date.now() - 31 * 60 * 1000 } }) + ).toBe(false) + }) + + it('leaves other agents on the title lanes', () => { + // Scoped on purpose: an agent whose hooks report child turns can emit `done` mid-turn. + expect(ready({ agent: 'claude' })).toBe(false) + }) +}) diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 7acfc610894..4b9eb120c93 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -1,5 +1,8 @@ import type { AgentStatus } from '../../shared/agent-detection' -import { isFreshNonDoneAgentStatus } from '../../shared/agent-status-freshness' +import { + AGENT_STATUS_STALE_AFTER_MS, + isFreshNonDoneAgentStatus +} from '../../shared/agent-status-freshness' import type { AgentStatusState } from '../../shared/agent-status-types' import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' import { getSyntheticAgentTerminalTitle } from '../../shared/synthetic-agent-title' @@ -68,6 +71,34 @@ export function hasExplicitIdleTitle( return false } +/** + * Tier 1, first-party: the agent's own hook says the turn ENDED. + * + * Why DSH needs its own lane: the other tiers all read the title, and DSH cannot carry idle + * there. Its rest prefix is `✦`, which is Gemini's WORKING glyph, so the title detector + * deliberately reports no status for a DSH pane at all (see agent-title-status.ts) — which + * left `tui-idle` with nothing to settle on, and a supervised worker waiting on a ready + * composer until its timeout. + * + * Why a hook `done` is trustworthy here where a title would not be: it is the agent's own + * account of its own turn, and `normalizeDshEvent` drops SubagentStart/SubagentStop, so a + * `done` row for a DSH pane is the LEAD's, never a child's finishing early. + * + * Scoped rather than general: for agents whose hooks do report child turns, a `done` row + * can arrive mid-turn, and settling on it is exactly the #6011 class this file exists to + * prevent. + */ +export function hasFreshDoneFirstPartyStatus( + agent: TuiAgent | null | undefined, + status: FirstPartyAgentStatus, + staleAfterMs = AGENT_STATUS_STALE_AFTER_MS +): boolean { + if (agent !== 'dsh' || status?.state !== 'done') { + return false + } + return Date.now() - status.updatedAt <= staleAfterMs +} + /** Tier 2: the agent's own status stream says this turn is still open. */ export function hasFreshWorkingFirstPartyStatus(status: FirstPartyAgentStatus): boolean { return isFreshNonDoneAgentStatus(status ?? undefined) @@ -208,6 +239,11 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { if (hasExplicitIdleTitle(input.record, input.rendererTitle) || input.readPositiveBodyEvidence()) { return READY_STRONG } + // Why beside the title lane, not after the veto: both are tier 1, and a first-party `done` + // and a fresh `working` cannot both hold — the same row carries one state. + if (hasFreshDoneFirstPartyStatus(input.agent, input.firstPartyStatus)) { + return READY_STRONG + } if (hasFreshWorkingFirstPartyStatus(input.firstPartyStatus)) { // Why blocked/waiting stays pending: the agent says it is waiting on the user, which is // when a dialog is on screen, so the screen read must still run. diff --git a/src/main/runtime/zcode-readiness-transcript.test.ts b/src/main/runtime/zcode-readiness-transcript.test.ts index 2319915ab68..bd9909f89de 100644 --- a/src/main/runtime/zcode-readiness-transcript.test.ts +++ b/src/main/runtime/zcode-readiness-transcript.test.ts @@ -21,6 +21,18 @@ function readTranscript(): string { } describe('ZCode readiness from captured terminal bytes', () => { + it('accepts a fresh composer after the renderer adopts the terminal handle', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'worker-zcode', + foregroundProcess: 'zcode', + launchAgent: 'zcode', + data: '\x1b[?1049h╭' + }) + await expect( + runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000) + ).resolves.toBeUndefined() + }) + it('never emits an OSC title, so no title lane can settle its wait', () => { const data = readTranscript() expect(data).toContain(String.fromCharCode(27)) diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index ca273aee07d..55937134bb1 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -3,6 +3,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { PtyListedSession, PtySessionListScope } from '../../shared/pty-listed-session' import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' @@ -75,8 +76,8 @@ export type PtyApi = { /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean }> - write: (id: string, data: string) => void - writeAccepted: (id: string, data: string) => Promise + write: (id: string, data: string, inputKind: TerminalInputKind) => void + writeAccepted: (id: string, data: string, inputKind: TerminalInputKind) => Promise onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void resize: (id: string, cols: number, rows: number) => void claimViewport: (id: string, cols: number, rows: number) => void diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index e9b19dbe43a..225aec9618a 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -71,11 +72,11 @@ export const ptySessionControlApi = { /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean }> => ipcRenderer.invoke('pty:spawn', opts), - write: (id: string, data: string): void => { - ipcRenderer.send('pty:write', { id, data }) + write: (id: string, data: string, inputKind: TerminalInputKind): void => { + ipcRenderer.send('pty:write', { id, data, inputKind }) }, - writeAccepted: (id: string, data: string): Promise => - ipcRenderer.invoke('pty:writeAccepted', { id, data }), + writeAccepted: (id: string, data: string, inputKind: TerminalInputKind): Promise => + ipcRenderer.invoke('pty:writeAccepted', { id, data, inputKind }), onWriteUnavailable: (callback: (payload: { id: string }) => void): (() => void) => { const handler = (_event: Electron.IpcRendererEvent, payload: { id: string }): void => callback(payload) diff --git a/src/relay/pty-handler-shell-recovery.test.ts b/src/relay/pty-handler-shell-recovery.test.ts new file mode 100644 index 00000000000..0d34aa80ff9 --- /dev/null +++ b/src/relay/pty-handler-shell-recovery.test.ts @@ -0,0 +1,196 @@ +import './mock-descendant-sweep' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PROCESS_BOUNDARY_GROUND } from '../shared/terminal-mode-reset-profiles' +import { RelayDispatcher, type RelayClientSessionIdentity } from './dispatcher' +import { encodeJsonRpcFrame, MessageType } from './protocol' +import { PtyHandler } from './pty-handler' +import { TEST_PTY_ID_MINT_EPOCH } from './pty-handler-test-harness' +import { RelayPtySourcePublication } from './relay-pty-source-publication' +import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + +const { mockPtySpawn, mockConfirmShellForeground } = vi.hoisted(() => ({ + mockPtySpawn: vi.fn(), + mockConfirmShellForeground: vi.fn() +})) + +vi.mock('node-pty', () => ({ spawn: mockPtySpawn })) +vi.mock('../main/daemon/pty-subprocess/pty-shell-foreground-confirmation', () => ({ + confirmPtyShellForeground: mockConfirmShellForeground +})) + +const endpointIdentity: RelayClientSessionIdentity = { + principal: 'endpoint-principal', + authenticated: true, + allowSessionOwner: true, + authenticationKind: 'endpoint-credential' +} + +// A command that pushed kitty keyboard flags and died without popping them. +const DYING_COMMAND = '\x1b]133;C\x07\x1b[>1u' +const COMMAND_DONE = '\x1b]133;D;130\x07' +const PROMPT = '$ ' + +type Frame = { + method?: string + id?: number + params?: Record + result?: Record +} + +function requestFrame(id: number, method: string, params: Record): Buffer { + return encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0) +} + +function decode(buffer: Buffer): Frame | null { + if (buffer[0] !== MessageType.Regular) { + return null + } + const length = buffer.readUInt32BE(9) + return JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) +} + +describe.each([ + ['source-credit delivery', true], + ['legacy delivery', false] +])('PtyHandler shell recovery over %s', (_, sourceCredit) => { + let dispatcher: RelayDispatcher + let handler: PtyHandler + let writes: Buffer[] + let emitData: (data: string) => void + let emitExit: (event: { exitCode: number }) => void + let ptyId: string + let originalPlatform: PropertyDescriptor | undefined + + beforeEach(async () => { + vi.useFakeTimers() + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + writes = [] + mockConfirmShellForeground.mockReset() + mockPtySpawn.mockReset() + mockPtySpawn.mockReturnValue({ + pid: process.pid, + onData: vi.fn((callback: (data: string) => void) => (emitData = callback)), + onExit: vi.fn((callback: (event: { exitCode: number }) => void) => (emitExit = callback)), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + destroy: vi.fn() + }) + dispatcher = new RelayDispatcher( + (data, settle) => { + writes.push(Buffer.from(data)) + queueMicrotask(() => settle({ ok: true })) + return true + }, + { supportsWriteCallback: true, writableHighWaterMark: () => 0 }, + endpointIdentity + ) + handler = new PtyHandler(dispatcher, undefined, TEST_PTY_ID_MINT_EPOCH) + if (sourceCredit) { + let publication: RelayPtySourcePublication | undefined + const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => + publication?.onCreditAvailable(id) + ) + publication = new RelayPtySourcePublication(dispatcher, adapter, (id) => + handler.handleSourcePublicationCapacity(id) + ) + handler.setSourcePublication(publication) + dispatcher.feed( + requestFrame(1, 'pty.openClient', { + protocolVersion: 1, + clientInstanceId: 'client-1', + requestedRole: 'session-owner', + capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 256 * 1024 } } + }) + ) + await vi.advanceTimersByTimeAsync(0) + } + dispatcher.feed(requestFrame(2, 'pty.spawn', {})) + await vi.advanceTimersByTimeAsync(0) + ptyId = String(response(2)?.id) + }) + + afterEach(async () => { + await handler.dispose({ waitForPhysicalExit: false }).catch(() => {}) + dispatcher.dispose() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + vi.useRealTimers() + }) + + function response(id: number): Record | undefined { + return writes.map(decode).find((frame) => frame?.id === id)?.result + } + + function published(): string { + const frames = writes.map(decode).filter((frame) => frame?.method === 'pty.data') + if (sourceCredit) { + expect(frames.every((frame) => frame?.params?.sourceLengthSu !== undefined)).toBe(true) + } + return frames.map((frame) => frame?.params?.data).join('') + } + + async function replay(): Promise { + dispatcher.feed( + requestFrame(3, 'pty.attach', { + id: ptyId, + requireReplay: true, + suppressReplayNotification: true + }) + ) + await vi.advanceTimersByTimeAsync(0) + return String(response(3)?.replay ?? '') + } + + async function stream(...chunks: string[]): Promise { + for (const chunk of chunks) { + emitData(chunk) + } + await vi.advanceTimersByTimeAsync(50) + } + + it('grounds a dead command before the prompt, live and in replay alike', async () => { + mockConfirmShellForeground.mockResolvedValue(true) + await stream(DYING_COMMAND, `${COMMAND_DONE}${PROMPT}`) + + const expected = `${DYING_COMMAND}${COMMAND_DONE}${PROCESS_BOUNDARY_GROUND}${PROMPT}` + expect(mockConfirmShellForeground).toHaveBeenCalledOnce() + expect(published()).toBe(expected) + expect(await replay()).toBe(expected) + }) + + it('leaves the bytes unchanged when the shell does not own the foreground', async () => { + mockConfirmShellForeground.mockResolvedValue(false) + await stream(DYING_COMMAND, `${COMMAND_DONE}${PROMPT}`) + + const expected = `${DYING_COMMAND}${COMMAND_DONE}${PROMPT}` + expect(published()).toBe(expected) + expect(await replay()).toBe(expected) + }) + + it('delivers a dying app’s oversized final frame and the ground behind it', async () => { + mockConfirmShellForeground.mockResolvedValue(true) + const finalFrame = 'x'.repeat(20 * 1024) + await stream(DYING_COMMAND, `${finalFrame}${COMMAND_DONE}${PROMPT}`) + + const expected = `${DYING_COMMAND}${finalFrame}${COMMAND_DONE}${PROCESS_BOUNDARY_GROUND}${PROMPT}` + expect(published()).toBe(expected) + expect(await replay()).toBe(expected) + }) + + it('flushes the held prompt when the shell exits mid-proof', async () => { + mockConfirmShellForeground.mockReturnValue(new Promise(() => {})) + await stream(DYING_COMMAND, `${COMMAND_DONE}${PROMPT}`) + expect(published()).not.toContain(PROMPT) + + emitExit({ exitCode: 0 }) + await vi.advanceTimersByTimeAsync(50) + + expect(published()).toBe(`${DYING_COMMAND}${COMMAND_DONE}${PROMPT}`) + }) +}) diff --git a/src/relay/pty-handler-spawn-environment.test.ts b/src/relay/pty-handler-spawn-environment.test.ts index eff0f68f2fa..22993322a9c 100644 --- a/src/relay/pty-handler-spawn-environment.test.ts +++ b/src/relay/pty-handler-spawn-environment.test.ts @@ -606,6 +606,54 @@ describe('PtyHandler', () => { expect(callArgs.env.ORCA_TAB_ID).toBe('tab-1') }) + it('mirrors pane identity onto its scrub-safe aliases for a remote spawn', async () => { + // Why the relay and not just the shared helper: a remote pane's env is built here, and an + // agent whose harness drops KEY/TOKEN names (DSH) has nothing to attribute its hooks to. + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + env: { ORCA_PANE_KEY: 'tab-1:0', ORCA_AGENT_LAUNCH_TOKEN: 't' } + }) + + const callArgs = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(callArgs.env.ORCA_AGENT_PANE).toBe('tab-1:0') + expect(callArgs.env.ORCA_AGENT_LAUNCH).toBe('t') + expect(callArgs.env.ORCA_PANE_KEY).toBe('tab-1:0') + }) + + it("drops an alias the relay's own env carries when the spawn claims no identity", async () => { + // Why: the relay is itself startable from an Orca pane, so inheriting either name would + // attribute this pane's hooks to whichever row started the relay. + const previous = { + pane: process.env.ORCA_PANE_KEY, + launch: process.env.ORCA_AGENT_LAUNCH_TOKEN, + paneAlias: process.env.ORCA_AGENT_PANE + } + process.env.ORCA_PANE_KEY = 'relays-own-pane' + process.env.ORCA_AGENT_LAUNCH_TOKEN = 'relays-own-launch' + process.env.ORCA_AGENT_PANE = 'stale-alias' + try { + await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) + } finally { + for (const [key, value] of [ + ['ORCA_PANE_KEY', previous.pane], + ['ORCA_AGENT_LAUNCH_TOKEN', previous.launch], + ['ORCA_AGENT_PANE', previous.paneAlias] + ] as const) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + const callArgs = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(callArgs.env.ORCA_PANE_KEY).toBeUndefined() + expect(callArgs.env.ORCA_AGENT_PANE).toBeUndefined() + expect(callArgs.env.ORCA_AGENT_LAUNCH).toBeUndefined() + }) + it('passes PTY and explicit launch identity to env augmenters', async () => { const seenContexts: { id: string diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 2daa4311234..fe53e5dd236 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -18,6 +18,7 @@ import { import { inspectPtyChildProcesses, processHasChildren } from './pty-child-process-inspection' import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces' +import { applyScrubSafeAgentEnvAliases } from '../shared/agent-hook-scrub-safe-env' import { addWslEnvKeys } from '../shared/wsl-env' import { ORCA_IMAGE_PROTOCOL_ENV, @@ -76,6 +77,8 @@ import { } from '../shared/pty-startup-ingress' import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend' import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' +import { TerminalShellRecoveryBarrier } from '../main/daemon/terminal-shell-recovery-barrier' +import { confirmPtyShellForeground } from '../main/daemon/pty-subprocess/pty-shell-foreground-confirmation' import { resolveAgentForegroundProcessesBatch, resolveRemoteForegroundEvidence, @@ -246,6 +249,7 @@ type ManagedPty = { forceKillSent?: boolean gracefulKillSent?: boolean startupIngress?: PtyStartupIngress + recoveryBarrier?: TerminalShellRecoveryBarrier startupIngressIntent?: ReturnType ownerBackend: PtyOwnerBackend agentSessionOwners?: AgentSessionOwnerBinding[] @@ -850,6 +854,22 @@ export class PtyHandler { if (!result.TERM) { result.TERM = 'xterm-256color' } + // Why: the relay's own process env can carry pane identity (it is itself startable from + // an Orca pane), and unlike the local and daemon builders this one never dropped it. A + // spawn that specified no identity would then inherit someone else's, and every agent's + // hook would report against that pane. Drop it before mirroring, so an alias can only + // ever carry identity this spawn actually asked for. + for (const key of ['ORCA_PANE_KEY', 'ORCA_AGENT_LAUNCH_TOKEN'] as const) { + if (!rendererEnv || !Object.hasOwn(rendererEnv, key)) { + delete result[key] + } + } + // Why here and not only in the local/daemon builders: a remote pane's env is built HERE, + // and the client forwards only the canonical pane-identity names. An agent whose harness + // scrubs those names (DSH drops any env var whose name contains KEY or TOKEN) would find + // nothing to attribute its hooks to, so remote status would silently never appear even + // with the remote hook installed. + applyScrubSafeAgentEnvAliases(result) // Why last, not beside the scrubbers above: the relay runs those BEFORE envToDelete, // so an envToDelete of CONDA_PREFIX would otherwise re-create the broken pair. dropIncoherentCondaActivationEnv(result, process.platform) @@ -960,11 +980,19 @@ export class PtyHandler { : {} ) } - managed.startupIngress ??= new PtyStartupIngress({ + const isDead = (): boolean => managed.disposed === true + const recoveryBarrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground: () => + confirmPtyShellForeground({ process: managed.pty, shellPath: managed.shellPath, isDead }), + release: emitIngressData, + isAlive: () => !isDead() + }) + managed.recoveryBarrier = recoveryBarrier + managed.startupIngress = new PtyStartupIngress({ ...(managed.startupIngressIntent ? { intent: managed.startupIngressIntent } : {}), ownerBackend: managed.ownerBackend, write: (data) => managed.pty.write(data), - onEmission: emitIngressData + onEmission: (emission) => recoveryBarrier.accept(emission) }) const startup = managed.startupCommand if (startup?.waitForShellReady) { @@ -1054,6 +1082,10 @@ export class PtyHandler { managed.startupCommand = undefined } managed.startupIngress?.drainAndClose() + // Why after drainAndClose: drained ingress bytes re-enter the barrier; a + // teardown mid-proof must still deliver the held prompt before exit. + managed.recoveryBarrier?.flushPending() + managed.recoveryBarrier?.dispose() } private notifyExitListener(managed: ManagedPty): void { diff --git a/src/renderer/src/components/activity/activity-clear-completed.test.ts b/src/renderer/src/components/activity/activity-clear-completed.test.ts index e0cee5aa9d8..79191ce3252 100644 --- a/src/renderer/src/components/activity/activity-clear-completed.test.ts +++ b/src/renderer/src/components/activity/activity-clear-completed.test.ts @@ -61,6 +61,7 @@ import { isClearableActivityThread, planClearCompletedActivity } from './activity-clear-completed' +import { activityThreadStatusId } from './activity-thread-presentation' function makeThread(paneKey: string, overrides: Partial = {}): AgentPaneThread { return { @@ -81,7 +82,7 @@ function makeThread(paneKey: string, overrides: Partial = {}): } } -function doneEvent(interrupted: boolean): ActivityEvent { +function doneEvent(interrupted: boolean, outcome?: 'failure'): ActivityEvent { return { id: 'evt', state: 'done', @@ -89,7 +90,16 @@ function doneEvent(interrupted: boolean): ActivityEvent { observedAt: 5_000, worktree: makeWorktree(), repo: null, - entry: { interrupted } as ActivityEvent['entry'], + entry: { + paneKey: 'evt-pane', + state: 'done', + prompt: '', + updatedAt: 5_000, + stateStartedAt: 5_000, + stateHistory: [], + interrupted, + ...(outcome ? { mainAgent: { state: 'done', outcome, stateStartedAt: 5_000 } } : {}) + }, tab: makeTab(), agentType: 'claude', agentAlive: false, @@ -102,6 +112,7 @@ const blockedThread = makeThread('t-blocked:1', { currentAgentState: 'blocked' } const waitingThread = makeThread('t-waiting:1', { currentAgentState: 'waiting' }) const doneThread = makeThread('t-done:1', { latestEvent: doneEvent(false) }) const interruptedThread = makeThread('t-interrupted:1', { latestEvent: doneEvent(true) }) +const failedThread = makeThread('t-failed:1', { latestEvent: doneEvent(false, 'failure') }) function makeRetained(paneKey: string): RetainedAgentEntry { return { @@ -125,10 +136,34 @@ describe('isClearableActivityThread', () => { it('clears only completed and interrupted threads', () => { expect(isClearableActivityThread(doneThread)).toBe(true) expect(isClearableActivityThread(interruptedThread)).toBe(true) + expect(activityThreadStatusId(failedThread)).toBe('failed') + expect(isClearableActivityThread(failedThread)).toBe(true) expect(isClearableActivityThread(workingThread)).toBe(false) expect(isClearableActivityThread(blockedThread)).toBe(false) expect(isClearableActivityThread(waitingThread)).toBe(false) }) + + it('reads a live thread whose main agent failed as failed, but keeps it while subagents run', () => { + const heldEntry = { + ...doneEvent(false).entry, + state: 'working' as const, + mainAgent: { state: 'done' as const, outcome: 'failure' as const, stateStartedAt: 5_000 } + } + const held = makeThread('t-held:1', { + currentAgentState: 'working', + currentAgentEntry: heldEntry + }) + expect(activityThreadStatusId(held)).toBe('failed') + expect(isClearableActivityThread(held)).toBe(false) + const succeeded = makeThread('t-ok:1', { + currentAgentState: 'working', + currentAgentEntry: { + ...heldEntry, + mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 5_000 } + } + }) + expect(activityThreadStatusId(succeeded)).toBe('working') + }) }) describe('clearCompletedActivity', () => { diff --git a/src/renderer/src/components/activity/activity-clear-completed.ts b/src/renderer/src/components/activity/activity-clear-completed.ts index 2bb39fdff43..e365152c9bb 100644 --- a/src/renderer/src/components/activity/activity-clear-completed.ts +++ b/src/renderer/src/components/activity/activity-clear-completed.ts @@ -18,11 +18,15 @@ export type ClearCompletedActivityPlan = { clearedThreadCount: number } -/** A thread is clearable when it needs nothing from the user: completed or interrupted, +/** A thread is clearable when it needs nothing from the user: completed, failed or interrupted, * with no fresh live working/monitoring/blocked/waiting state. */ export function isClearableActivityThread(thread: AgentPaneThread): boolean { const id = activityThreadStatusId(thread) - return id === 'done' || id === 'interrupted' + // Why: a failed main agent reads failed while its subagents still run; that thread is still live. + if (thread.currentAgentState) { + return false + } + return id === 'done' || id === 'failed' || id === 'interrupted' } export function planClearCompletedActivity( diff --git a/src/renderer/src/components/activity/activity-pane-events.ts b/src/renderer/src/components/activity/activity-pane-events.ts index 6a147beac81..0ee287af187 100644 --- a/src/renderer/src/components/activity/activity-pane-events.ts +++ b/src/renderer/src/components/activity/activity-pane-events.ts @@ -27,7 +27,9 @@ function historyEntrySnapshot( toolName: undefined, toolInput: undefined, lastAssistantMessage: undefined, - interrupted: history.interrupted + interrupted: history.interrupted, + // The live row's main agent belongs to its current state, not to this snapshot. + mainAgent: history.mainAgent } } diff --git a/src/renderer/src/components/activity/activity-thread-grouping.ts b/src/renderer/src/components/activity/activity-thread-grouping.ts index cab4fbcf8c3..9bf99cc67cf 100644 --- a/src/renderer/src/components/activity/activity-thread-grouping.ts +++ b/src/renderer/src/components/activity/activity-thread-grouping.ts @@ -21,11 +21,11 @@ const ACTIVITY_STATUS_GROUP_RANK: Record = { waiting: 0, blocked: 1, permission: 2, - interrupted: 3, - working: 4, - monitoring: 5, - unverifiable: 6, - failed: 7, + failed: 3, + interrupted: 4, + working: 5, + monitoring: 6, + unverifiable: 7, done: 8, idle: 9 } diff --git a/src/renderer/src/components/activity/activity-thread-presentation.ts b/src/renderer/src/components/activity/activity-thread-presentation.ts index 5541b3da493..e38c802f867 100644 --- a/src/renderer/src/components/activity/activity-thread-presentation.ts +++ b/src/renderer/src/components/activity/activity-thread-presentation.ts @@ -2,6 +2,10 @@ import type { AgentDotState } from '@/components/AgentStateDot' import { formatAgentTypeLabel } from '@/lib/agent-status' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview' +import { + agentMainAgentVerdict, + agentVerdictDisplayMark +} from '../../../../shared/agent-main-agent-verdict' import { getActivityThreadTaskTitle, getActivityThreadWorkspaceTitle, @@ -68,7 +72,12 @@ export function agentTitle(event: ActivityEvent): string { return 'Agent working' } if (event.state === 'done') { - return event.entry.interrupted ? 'Agent interrupted' : 'Agent finished' + const verdict = agentMainAgentVerdict(event.entry) + return verdict === 'failure' + ? 'Agent failed' + : verdict === 'cancellation' + ? 'Agent interrupted' + : 'Agent finished' } return event.state === 'waiting' ? 'Agent waiting for input' : 'Agent needs input' } @@ -91,7 +100,12 @@ export function agentMeta(event: ActivityEvent): string { return `${agent} ${event.state}` } if (event.state === 'done') { - return event.entry.interrupted ? `${agent} interrupted` : `${agent} completed` + const verdict = agentMainAgentVerdict(event.entry) + return verdict === 'failure' + ? `${agent} failed` + : verdict === 'cancellation' + ? `${agent} interrupted` + : `${agent} completed` } return event.state === 'waiting' ? `${agent} waiting` : `${agent} blocked` } @@ -120,13 +134,18 @@ export function statusPreviewForEntry( export type ActivityThreadStatusId = AgentDotState /** Single classifier behind grouping, labels, and clear-completed; the only place the - * interrupted predicate is spelled. */ + * verdict predicate is spelled. */ export function activityThreadStatusId(thread: AgentPaneThread): ActivityThreadStatusId { + // Why: a failed main agent outranks the subagent work still holding its row live. + if (thread.currentAgentEntry && agentVerdictDisplayMark(thread.currentAgentEntry) === 'failed') { + return 'failed' + } const paneEntry = paneActivityEntry(thread) const state = threadCurrentState(thread) ?? 'done' - const interrupted = paneEntry ? paneEntry.interrupted : thread.latestEvent?.entry.interrupted - if (!thread.currentAgentState && state === 'done' && interrupted) { - return 'interrupted' + const verdictEntry = paneEntry ?? thread.latestEvent?.entry + const verdictDot = verdictEntry ? agentVerdictDisplayMark(verdictEntry) : null + if (!thread.currentAgentState && state === 'done' && verdictDot) { + return verdictDot } return state } @@ -149,7 +168,7 @@ function threadCurrentState( ) } -// Interrupted rows deliberately keep the done glyph (#2569). +// Interrupted rows deliberately keep the done glyph (#2569); a failure is a fault and does not. export function threadAgentState(thread: AgentPaneThread): AgentDotState { const id = activityThreadStatusId(thread) return id === 'interrupted' ? 'done' : id diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index fb80712e65f..4b38d8d1b0d 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -11,6 +11,7 @@ import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep' import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview' import { agentNoUpdateLabel, formatCompactDuration } from '@/lib/agent-row-decay-state' import { agentRowDotState as asDotState } from '@/lib/agent-row-dot-state' +import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict' import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' import { useAgentRowConversationName } from './use-agent-row-conversation-name' @@ -29,7 +30,7 @@ function stateDotTooltipLabel( dotState: AgentDotState, now: number ): string { - if (agent.entry.interrupted === true) { + if (dotState === 'interrupted') { return 'Interrupted by user' } // Why: report the observation, not a verdict on the agent — the elapsed gap is what @@ -141,7 +142,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const toolName = showsTool ? (agent.entry.toolName?.trim() ?? '') : '' const toolInput = showsTool ? (agent.entry.toolInput?.trim() ?? '') : '' const lastAssistantMessage = agent.entry.lastAssistantMessage?.trim() ?? '' - const isInterrupted = agent.entry.interrupted === true + const verdictDotState = agentVerdictDisplayMark(agent.entry) + const isInterrupted = verdictDotState === 'interrupted' const lineage = agent.lineage const isLineageChild = lineage?.depth === 1 const lineageChildCount = lineage?.childCount ?? 0 @@ -152,10 +154,10 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ lineageChildCount === 1 ? 'agent' : 'agents' }` : [formatAgentTypeLabel(agent.agentType), model].filter(Boolean).join(' · ') - // Why: interrupted is a terminal outcome, so surface it in the leading state dot. - const dotState: AgentDotState = isInterrupted - ? 'interrupted' - : asDotState(agent.state, agent.entry.workingMode) + // Why: a stop or a failure is a terminal outcome, so surface it in the leading state dot; a + // failure does so even while subagents still run. + const dotState: AgentDotState = + verdictDotState ?? asDotState(agent.state, agent.entry.workingMode) const dotTooltipLabel = stateDotTooltipLabel(agent, dotState, now) // Why: the elapsed gap is the whole content of an `unverifiable` row, so it rides the // row's own timestamp slot rather than hiding in a hover tooltip. diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts index 91b445dd78f..b5ee4ae239c 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts @@ -51,6 +51,47 @@ describe('lastEnteredDoneAt shares the Smart Sort completion clock', () => { expect(lastEnteredDoneAt(row(entry))).toBe(2_000) expect(agentEntryCompletionAt(entry)).toBeNull() }) + + it('dates a failed turn as a completion, and a stopped one only for display', () => { + const verdictDone = (outcome: 'failure' | 'cancellation') => + doneEntry({ + stateStartedAt: 2_000, + mainAgent: { state: 'done', outcome, stateStartedAt: 2_000 } + }) + expect(agentEntryCompletionAt(verdictDone('failure'))).toBe(2_000) + expect(lastEnteredDoneAt(row(verdictDone('failure')))).toBe(2_000) + expect(agentEntryCompletionAt(verdictDone('cancellation'))).toBeNull() + expect(lastEnteredDoneAt(row(verdictDone('cancellation')))).toBe(2_000) + }) + + it('dates a main agent that failed while its subagents run by when it failed', () => { + const held = (outcome: 'failure' | 'cancellation') => + doneEntry({ + state: 'working', + stateStartedAt: 3_000, + mainAgent: { state: 'done', outcome, stateStartedAt: 2_500 } + }) + expect(agentEntryCompletionAt(held('failure'))).toBeNull() + expect(lastEnteredDoneAt(row(held('failure')))).toBe(2_500) + expect(lastEnteredDoneAt(row(held('cancellation')))).toBeNull() + }) + + it('reads the verdict history carries when a boundary displaced the completion', () => { + const history = { state: 'done' as const, prompt: '', startedAt: 1_500 } + const boundary = (outcome?: 'failure' | 'cancellation') => + doneEntry({ + sessionBoundary: true, + stateHistory: [ + { + ...history, + ...(outcome ? { mainAgent: { state: 'done', outcome, stateStartedAt: 1_500 } } : {}) + } + ] + }) + expect(agentEntryCompletionAt(boundary())).toBe(1_500) + expect(agentEntryCompletionAt(boundary('failure'))).toBe(1_500) + expect(agentEntryCompletionAt(boundary('cancellation'))).toBeNull() + }) }) describe('lastEnteredDoneAt subagent rows', () => { diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts index fe101862957..28d7a2572bb 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts @@ -1,4 +1,8 @@ import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' +import { + agentTurnStoppedByUser, + agentVerdictDisplayMark +} from '../../../../shared/agent-main-agent-verdict' import type { DashboardAgentRow } from './useDashboardData' /** @@ -22,10 +26,14 @@ export function lastEnteredDoneAt( if (completedAt !== null) { return completedAt } - // Why: display is looser than ranking — an interrupted turn still shows when it stopped. - if (entry.state === 'done' && entry.interrupted === true && entry.sessionBoundary !== true) { + // Why: display is looser than ranking — a stopped turn still shows when it ended. + if (entry.state === 'done' && agentTurnStoppedByUser(entry) && entry.sessionBoundary !== true) { return entry.stateStartedAt } + // Why: a failed main agent reads failed while its subagents run, so it shows when it failed. + if (entry.state !== 'done' && entry.mainAgent && agentVerdictDisplayMark(entry) === 'failed') { + return entry.mainAgent.stateStartedAt + } for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) { if (entry.stateHistory[i].state === 'done') { return entry.stateHistory[i].startedAt diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts index d1440f4e5ef..97640f2c2ae 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts @@ -68,7 +68,12 @@ function makeTab(overrides: Partial & { id: string }): TerminalTab } } -function makeAgentRow(args: { paneKey: string; state: AgentStatusState; interrupted?: boolean }) { +function makeAgentRow(args: { + paneKey: string + state: AgentStatusState + interrupted?: boolean + mainAgent?: AgentStatusEntry['mainAgent'] +}) { const entry: AgentStatusEntry = { state: args.state, prompt: 'Fix it', @@ -78,7 +83,8 @@ function makeAgentRow(args: { paneKey: string; state: AgentStatusState; interrup terminalTitle: 'Claude', stateHistory: [], agentType: 'claude', - interrupted: args.interrupted + interrupted: args.interrupted, + mainAgent: args.mainAgent } return { @@ -134,6 +140,34 @@ describe('collectRetainedAgentsOnDisappear', () => { expect(result.toRetain).toEqual([]) }) + it('retains a failed done row so the failure stays visible, but not a cancelled one', () => { + const retainedFor = (outcome: 'failure' | 'cancellation') => + collectRetainedAgentsOnDisappear({ + previousAgents: new Map([ + [ + 'tab-1:1', + { + row: makeAgentRow({ + paneKey: 'tab-1:1', + state: 'done', + mainAgent: { state: 'done', outcome, stateStartedAt: 100 } + }), + worktreeId: 'wt-1' + } + ] + ]), + currentAgents: new Map(), + retainedAgentsByPaneKey: {}, + retentionSuppressedPaneKeys: {}, + recentlyClosedAgentStatusTabIds: {}, + recentlyRetiredAgentStatusPaneKeys: {} + }).toRetain + + expect(retainedFor('failure')).toHaveLength(1) + expect(retainedFor('failure')[0]?.entry.mainAgent?.outcome).toBe('failure') + expect(retainedFor('cancellation')).toEqual([]) + }) + it('refreshes the retained snapshot when a reused paneKey starts a newer run', () => { // Why: a reused paneKey (same tab+pane, fresh agent start after a prior // retained run) produces a newer startedAt. Without the freshness check diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index f0ab639e2ba..f26baaafc37 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -15,6 +15,7 @@ import { type AgentStatusEntry } from '../../../../shared/agent-status-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { agentTurnStoppedByUser } from '../../../../shared/agent-main-agent-verdict' import { createWorktreeTabBucketProjection, @@ -299,13 +300,12 @@ export function collectRetainedAgentsOnDisappear(args: { if (args.recentlyClosedAgentStatusTabIds[ownerTabId]) { continue } - // Why: only keep a sticky snapshot when the agent finished cleanly - // (state === 'done' and not interrupted). Explicit teardown paths mark + // Why: only keep a sticky snapshot when the agent finished and the user did not + // stop it; a failure is kept so it stays visible. Explicit teardown paths mark // pane keys as suppression candidates, so a close/quit/crash cannot // resurrect a stale `done` row on the next sync. const lastState = prev.row.state - const wasInterrupted = prev.row.entry.interrupted === true - if (lastState !== 'done' || wasInterrupted) { + if (lastState !== 'done' || agentTurnStoppedByUser(prev.row.entry)) { continue } toRetain.push({ diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx index ba210d74994..4b31d0b0357 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx @@ -10,6 +10,7 @@ import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import type { AgentJournalTurnOutcome } from '../../../../shared/agent-session-journal-types' import type { + AgentSessionStatusEvent, AgentSessionTurnCompletion, AgentSessionTurnCompletionEvent } from '../../../../shared/agent-session-wire' @@ -27,7 +28,9 @@ type TestStore = { type BridgeMocks = { store: TestStore | null emitters: ((event: AgentSessionTurnCompletionEvent) => void)[] + statusEmitters: ((event: AgentSessionStatusEvent) => void)[] subscribeCompletions: Mock + subscribeStatus: Mock supportsCapability: Mock unsubscribe: Mock } @@ -35,7 +38,9 @@ type BridgeMocks = { const mocks = vi.hoisted(() => ({ store: null, emitters: [], + statusEmitters: [], subscribeCompletions: vi.fn(), + subscribeStatus: vi.fn(), supportsCapability: vi.fn(), unsubscribe: vi.fn() })) @@ -58,11 +63,16 @@ vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ })) vi.mock('@/runtime/structured-agent-session-client', () => ({ - subscribeStructuredAgentSessionTurnCompletions: mocks.subscribeCompletions + subscribeStructuredAgentSessionTurnCompletions: mocks.subscribeCompletions, + subscribeStructuredAgentSessionStatus: mocks.subscribeStatus })) import { StructuredAgentSessionAttentionBridge } from './StructuredAgentSessionAttentionBridge' import { resetStructuredAgentSessionTurnCompletionFeedsForTests } from '@/runtime/structured-agent-session-turn-completion-feed' +import { + getStructuredAgentSessionStatusFeed, + resetStructuredAgentSessionStatusFeedsForTests +} from '@/runtime/structured-agent-session-status-feed' import { makeTabGroup, makeUnifiedTab, @@ -176,7 +186,15 @@ describe('StructuredAgentSessionAttentionBridge', () => { } }) resetStructuredAgentSessionTurnCompletionFeedsForTests() + resetStructuredAgentSessionStatusFeedsForTests() mocks.emitters.length = 0 + mocks.statusEmitters.length = 0 + mocks.subscribeStatus.mockImplementation( + (_target: unknown, emit: (event: AgentSessionStatusEvent) => void) => { + mocks.statusEmitters.push(emit) + return Promise.resolve({ unsubscribe: vi.fn() }) + } + ) mocks.subscribeCompletions.mockImplementation( (_target: unknown, emit: (event: AgentSessionTurnCompletionEvent) => void) => { mocks.emitters.push(emit) @@ -215,6 +233,7 @@ describe('StructuredAgentSessionAttentionBridge', () => { cleanup() vi.unstubAllGlobals() resetStructuredAgentSessionTurnCompletionFeedsForTests() + resetStructuredAgentSessionStatusFeedsForTests() }) it('lights the unread indicators when the host reports a successful turn', async () => { @@ -235,14 +254,14 @@ describe('StructuredAgentSessionAttentionBridge', () => { worktreeId: WORKSPACE, paneKey: CHAT_SUBJECT, agentState: 'done', - agentInterrupted: false + agentTurnOutcome: 'success' }) }) // A settled turn is news whichever way it settled, exactly as the CLI lane treats one. The - // difference is wording, and it rides the notification flag that already says "stopped". + // difference is wording, which main picks from the verdict. it.each(['failure', 'cancellation'] as const)( - 'lights the indicators and says stopped for a %s the host reports', + 'lights the indicators and hands main the %s the host reports', async (outcome) => { render() await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce()) @@ -254,7 +273,7 @@ describe('StructuredAgentSessionAttentionBridge', () => { paneDot: 'agent-completion', tabDot: 'agent-completion' }) - expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentInterrupted: true }) + expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentTurnOutcome: outcome }) } ) @@ -299,6 +318,48 @@ describe('StructuredAgentSessionAttentionBridge', () => { } ) + // Remote clients receive the status and completion streams over separate sockets, unordered, so + // the wording must come from the completion alone. The mirror is set to disagree in each case. + function mirrorStatus(status: 'idle' | 'attention'): void { + mocks.statusEmitters[0]?.({ + type: 'status', + session: { + sessionId: SESSION, + workspaceId: 'host-side-workspace', + agent: 'claude', + status, + latestPrompt: 'Ship it', + updatedAt: 1 + } + }) + } + + it.each([ + { awaitingUser: true, mirror: 'idle', agentState: 'blocked' }, + { awaitingUser: undefined, mirror: 'attention', agentState: 'done' } + ] as const)( + 'words awaitingUser=$awaitingUser as $agentState whatever the status mirror says ($mirror)', + async ({ awaitingUser, mirror, agentState }) => { + const stopStatus = getStructuredAgentSessionStatusFeed({ kind: 'local' }).activate() + render() + await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce()) + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + const completion = turnCompletion() + + act(() => { + mirrorStatus(mirror) + hostStream()({ + type: 'completion', + completion: awaitingUser ? { ...completion, awaitingUser } : completion + }) + }) + + expect(indicators().paneDot).toBe('agent-completion') + expect(onlyDispatch()).toMatchObject({ agentState, agentTurnOutcome: 'success' }) + stopStatus() + } + ) + it('lights nothing for a turn whose outcome the host never stated', async () => { render() await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce()) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 92d3128d082..b0c546b3f9b 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -8,7 +8,8 @@ import { } from '../../../../shared/agent-status-child-work-projection' import { continueMainAgentStatus, - isAgentStatusHeldOpenByChildWork + isAgentStatusHeldOpenByChildWork, + mainAgentTurnInterrupted } from '../../../../shared/agent-lead-status-fold' import { mainAgentStatusEqual, agentSubagentsEqual } from '../../../../shared/agent-status-types' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' @@ -98,6 +99,8 @@ function projectStatus( state: agentStatus.state, ...(agentStatus.workingMode ? { workingMode: agentStatus.workingMode } : {}), mainAgent, + // Derived from `mainAgent`, so the equality below needs no second check of it. + interrupted: mainAgentTurnInterrupted(mainAgent), prompt: summary.latestPrompt, agentType: tab.agentSessionAgent, // The host projects these from the journal so the row reads like a hook-reported one: diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts index eedf8506e1c..334e4639add 100644 --- a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts @@ -147,7 +147,7 @@ export function createClaudeModelSwitchConfirmationObserver(args: { // exact Claude warning defaults to “Yes” and needs only one Enter. const accepted = args.submitConfirmation ? args.submitConfirmation() !== false - : sendRuntimePtyInput(args.settings, args.ptyId, NATIVE_CHAT_SUBMIT) + : sendRuntimePtyInput(args.settings, args.ptyId, NATIVE_CHAT_SUBMIT, 'driving') if (!accepted) { finish('unknown') return diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts index f77ba77e274..aba65acf8db 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts @@ -56,20 +56,20 @@ export function sendNativeChatMessageWithImageAttachments( ), trimmedText.length > 0 )) { - sendRuntimePtyInput(settings, ptyId, payload) + sendRuntimePtyInput(settings, ptyId, payload, 'driving') } if (trimmedText.length > 0) { delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => { - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text), 'driving') delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') markSubmitted() }) }) return } delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') markSubmitted() }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index d8cf90167ec..0071b73b21d 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -138,7 +138,12 @@ describe('sendNativeChatMessage', () => { ]) await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6) }) @@ -194,13 +199,19 @@ describe('sendNativeChatMessageVerified', () => { expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith( SETTINGS, PTY, - buildNativeChatPasteBytes('/model sonnet') + buildNativeChatPasteBytes('/model sonnet'), + 'driving' ) await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) expect(await result).toBe(true) - expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) expect( sendRuntimePtyInputVerified.mock.calls.some( (call) => call[2] === NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT @@ -234,7 +245,12 @@ describe('sendNativeChatMessageVerified', () => { const submits = sendRuntimePtyInput.mock.calls.filter((call) => call[2] === NATIVE_CHAT_SUBMIT) // Only the verified path's Enter — chat's delayed Enter was cancelled. expect(submits).toHaveLength(0) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) }) it('returns false when the delayed Enter wait is aborted', async () => { @@ -347,9 +363,14 @@ describe('sendNativeChatMessageWithImageAttachments', () => { '\x1b[200~@"C:\\Images\\screen shot.png"\x1b[201~ ' ]) vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'describe') + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'describe', 'driving') vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) }) it('clears the line, then bracket-pastes image paths before prompt text', () => { @@ -372,10 +393,20 @@ describe('sendNativeChatMessageWithImageAttachments', () => { ]) vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'what do you see?') + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + 'what do you see?', + 'driving' + ) vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(4) }) @@ -396,7 +427,12 @@ describe('sendNativeChatMessageWithImageAttachments', () => { vi.advanceTimersByTime(1) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(3) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) }) it('treats whitespace-only prompt input as attachment-only', () => { @@ -450,7 +486,7 @@ describe('empty prompt submit', () => { it('submits an empty prompt with a bare Enter', () => { submitNativeChatPrompt(SETTINGS, PTY) expect(sendRuntimePtyInput).toHaveBeenCalledOnce() - expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT, 'driving') }) }) @@ -483,16 +519,17 @@ describe('sendNativeChatAskAnswer', () => { ) vi.advanceTimersByTime(0) - expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '1') + expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '1', 'driving') vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS) - expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '2') + expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '2', 'driving') vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS) expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( SETTINGS, PTY, - buildNativeChatPasteBytes('custom answer') + buildNativeChatPasteBytes('custom answer'), + 'driving' ) }) @@ -533,7 +570,7 @@ describe('sendNativeChatAskAnswer', () => { const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '2' }], onSettled) await vi.advanceTimersByTimeAsync(handle.settleAfterMs) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, '2') + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, '2', 'driving') expect(onSettled).not.toHaveBeenCalled() resolveAccepted(true) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index a845fe10d16..1eb7a676103 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -72,7 +72,12 @@ export function clearUnsubmittedAgentInput( ptyId: string, options?: NativeChatSendOptions ): void { - sendRuntimePtyInput(settings, ptyId, options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) + sendRuntimePtyInput( + settings, + ptyId, + options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + 'driving' + ) } /** @@ -104,7 +109,7 @@ export function clearThenWrite( // An unreadable terminal is unconfirmed; the maximal clear remains safe. } if (!cleared) { - sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX) + sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX, 'driving') } writeBody() }) @@ -140,11 +145,11 @@ export function sendNativeChatMessage( if (isCancelled()) { return } - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text), 'driving') // Schedule from the actual body write: an overdue clear-confirm callback // must not collapse the required body-to-Enter gap after a renderer stall. delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') markSubmitted() }) }) @@ -203,12 +208,13 @@ export async function sendNativeChatMessageVerified( const bodyAccepted = await sendRuntimePtyInputVerified( settings, ptyId, - buildNativeChatPasteBytes(text) + buildNativeChatPasteBytes(text), + 'driving' ) if (!bodyAccepted || signal?.aborted || !(await waitForNativeChatSubmit(signal))) { return false } - return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT) + return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') } /** Types a slash command as individual keys so Codex opens its command palette. */ @@ -224,7 +230,7 @@ export async function typeNativeChatCommand( command, signal, write: async (key) => - (await sendRuntimePtyInputVerified(settings, ptyId, key)) ? 'accepted' : 'rejected' + (await sendRuntimePtyInputVerified(settings, ptyId, key, 'driving')) ? 'accepted' : 'rejected' }) return outcome === 'accepted' } @@ -253,7 +259,9 @@ export function sendNativeChatTypedCommand( if (isCancelled()) { return 'rejected' } - return (await sendRuntimePtyInputVerified(settings, ptyId, key)) ? 'accepted' : 'rejected' + return (await sendRuntimePtyInputVerified(settings, ptyId, key, 'driving')) + ? 'accepted' + : 'rejected' } }).then(finish, () => finish('rejected')) }, @@ -269,7 +277,7 @@ export function sendNativeChatTypedCommand( /** Submit a TUI prompt with no body (Enter only) — e.g. a plain submit when the * composer is empty. */ export function submitNativeChatPrompt(settings: RuntimeSettings, ptyId: string): void { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') } /** @@ -297,10 +305,10 @@ export function sendNativeChatAskAnswer( // Why: inference must use the remote host's acceptance result, not // the fire-and-forget renderer dispatch result. verifiedWrites.push( - sendRuntimePtyInputVerified(settings, ptyId, bytes).catch(() => false) + sendRuntimePtyInputVerified(settings, ptyId, bytes, 'driving').catch(() => false) ) } else { - sendRuntimePtyInput(settings, ptyId, bytes) + sendRuntimePtyInput(settings, ptyId, bytes, 'driving') } }, index * NATIVE_CHAT_QUESTION_STEP_MS) ) diff --git a/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts b/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts index 573eca9d455..76a49ff8e3b 100644 --- a/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts +++ b/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { AgentSessionTurnCompletion } from '../../../../shared/agent-session-wire' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import { createTestStore, @@ -230,7 +231,7 @@ describe('dispatchStructuredTurnCompletionAttention', () => { expect(indicators().paneDot).toBe('agent-completion') }) - it('words a successful turn as finished and a stopped one through the shipped interrupted flag', () => { + it('hands main the host verdict, which picks finished, failed or stopped', () => { dispatchStructuredTurnCompletionAttention(structuredTab(), completion()) // 'done' is the host's report that the turn settled, not a reading of the status row: main // words a 'working' state as "working", which would announce a finished turn as unfinished. @@ -238,16 +239,39 @@ describe('dispatchStructuredTurnCompletionAttention', () => { source: 'agent-task-complete', surface: 'agent-session', agentState: 'done', - agentInterrupted: false + agentTurnOutcome: 'success' }) + for (const [outcome, turnId] of [ + ['cancellation', 'turn-2'], + ['failure', 'turn-3'] + ] as const) { + dispatched.length = 0 + seed() + dispatchStructuredTurnCompletionAttention(structuredTab(), completion({ outcome, turnId })) + expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentTurnOutcome: outcome }) + } + }) + + it('calls back a send refused before any turn, named by its journal item key, as failed', () => { + dispatchStructuredTurnCompletionAttention( + structuredTab(), + completion({ outcome: 'failure', turnId: agentJournalSubmissionKey('m1') }) + ) + expect(indicators().paneDot).toBe('agent-completion') + expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentTurnOutcome: 'failure' }) + }) + + it('asks for input when the host settled a request while a prompt waits on the user', () => { + // e.g. a subagent's approval is unanswered. + dispatchStructuredTurnCompletionAttention(structuredTab(), completion({ awaitingUser: true })) + expect(indicators().paneDot).toBe('agent-completion') + expect(onlyDispatch()).toMatchObject({ agentState: 'blocked', agentTurnOutcome: 'success' }) + dispatched.length = 0 seed() - dispatchStructuredTurnCompletionAttention( - structuredTab(), - completion({ outcome: 'cancellation', turnId: 'turn-2' }) - ) - expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentInterrupted: true }) + dispatchStructuredTurnCompletionAttention(structuredTab(), completion()) + expect(onlyDispatch()).toMatchObject({ agentState: 'done' }) }) it('says done even while the status row still reads working, because the host settled the turn', () => { @@ -270,7 +294,7 @@ describe('dispatchStructuredTurnCompletionAttention', () => { } }) dispatchStructuredTurnCompletionAttention(structuredTab(), completion()) - expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentInterrupted: false }) + expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentTurnOutcome: 'success' }) }) it('delivers an id the acknowledgement round trip dismisses when the user reads the chat', () => { diff --git a/src/renderer/src/components/native-chat/structured-attention-dispatch.ts b/src/renderer/src/components/native-chat/structured-attention-dispatch.ts index f0afca80e13..4553d79b0a5 100644 --- a/src/renderer/src/components/native-chat/structured-attention-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-attention-dispatch.ts @@ -13,10 +13,11 @@ * adapter, and the same delivery tail — so suppression, acknowledgement, addressing, the success * sound and the blocked-permission fallback all have exactly one implementation. * - * EVERY SETTLED TURN NOTIFIES, matching the CLI lane: success says "finished", and failure and - * cancellation say "stopped" through the shipped `agentInterrupted` flag rather than a second - * vocabulary. A turn with no outcome is UNKNOWN — the host sends no event for one, and nothing - * here may turn that absence into success. + * EVERY SETTLED TURN NOTIFIES, matching the CLI lane: the outcome picks the wording — "finished", + * "failed" or "stopped" — exactly as the hook lane's verdict does. A turn with no outcome is UNKNOWN — the host sends no event for one, and nothing + * here may turn that absence into success. A request that settles while a prompt (a subagent's + * approval, say) waits on the user is worded "needs input" instead, as the hook lane words a + * blocked row. * * Unread and delivery come out of ONE `resolveAgentAttention` decision. "Do not alert me about * something I am watching" is already answered by focus, in the surface adapter's viewed gates and @@ -107,10 +108,10 @@ export function dispatchStructuredTurnCompletionAttention( ...(row?.agentType ? { agentType: row.agentType } : {}), // 'done' is what the host told us, not an inference from the row — the row's own state // can still read 'working' when the completion outruns the status re-projection, and - // main words a 'working' notification as "working". The outcome picks the wording from - // there: interrupted covers failure and cancellation alike. - agentState: 'done', - agentInterrupted: completion.outcome !== 'success', + // main words a 'working' notification as "working". The outcome picks the wording from there. + // `awaitingUser` is the row's 'blocked': the user has a prompt to answer. + agentState: completion.awaitingUser ? 'blocked' : 'done', + agentTurnOutcome: completion.outcome, ...(row?.prompt ? { agentPrompt: row.prompt } : {}), ...(row?.lastAssistantMessage ? { agentLastAssistantMessage: row.lastAssistantMessage } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts index 71a654c0927..f59d86303f1 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts @@ -25,7 +25,7 @@ export function useNativeChatComposerInterrupt(args: { } const target = resolveTarget() if (target) { - sendRuntimePtyInput(target.settings, target.ptyId, ESC) + sendRuntimePtyInput(target.settings, target.ptyId, ESC, 'driving') } }, [cancelPendingSends, isWorking, onStop, resolveTarget]) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx index 864c62f6f91..f2cbd8df93c 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx @@ -195,7 +195,8 @@ describe('useNativeChatInteractiveSend', () => { expect(mocks.sendRuntimePtyInput).toHaveBeenCalledWith( { terminalTabId: 'tab-1' }, 'pty-1', - '\x1b' + '\x1b', + 'driving' ) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index 43adabae8f3..3d270cde49b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -76,7 +76,12 @@ export function useNativeChatInteractiveSend( if (!targetPtyId) { return } - sendRuntimePtyInput(getSettingsForAgentTabRuntimeOwner(terminalTabId), targetPtyId, raw) + sendRuntimePtyInput( + getSettingsForAgentTabRuntimeOwner(terminalTabId), + targetPtyId, + raw, + 'driving' + ) }, [terminalTabId, targetPtyId] ) diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index fdce0b62f83..10f65234c6f 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -25,6 +25,7 @@ const AGENT_STATUS_TOOLTIP_STATUSES = new Set([ 'working', 'monitoring', 'permission', + 'failed', 'interrupted', 'done' ]) @@ -58,7 +59,7 @@ const StatusIndicator = React.memo(function StatusIndicator({