Merge origin/main into ota-fu-terminal-cell-metrics

Main's 14 commits since 52dc32f9ea merge without source conflicts. The
RPC corpus conflicted on every golden's `baseline` and on the manifest:
main's #23535 repinned to 78771646af while this branch pins its own
commit. Main moved no golden body since the merge base, so each golden
takes main's bytes with this branch's `baseline` and, on the 14 goldens
`terminal-mount-adapters.ts` mounts, this branch's `adapterSha256`; the
manifest keeps the branch pin. The next commit repins to this merge.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-28 02:30:13 -04:00
473 changed files with 13239 additions and 1853 deletions
+18 -7
View File
@@ -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:
+33
View File
@@ -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
+10 -4
View File
@@ -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()
+18 -4
View File
@@ -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
+52 -2
View File
@@ -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 }}
+69 -25
View File
@@ -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:
+1
View File
@@ -179,6 +179,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a> &nbsp;
<a href="https://deepseek-harness.github.io/deepseek-harness/"><kbd><img src="src/shared/agent-icons/dsh.png" alt="DeepSeek Harness logo" width="16" valign="middle" /> DeepSeek Harness</kbd></a> &nbsp;
<a href="https://zcode.z.ai/en/docs"><kbd><img src="src/shared/agent-icons/zcode.png" alt="ZCode logo" width="16" valign="middle" /> ZCode</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a> &nbsp;
+1
View File
@@ -0,0 +1 @@
[]
+71
View File
@@ -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)
})
})
+17 -3
View File
@@ -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 {
@@ -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)
})
@@ -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']
}
}
@@ -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)
})
@@ -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)
+92
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
const tracked = trackE2eFailures(summary.failures, records, now)
const details = (failure) =>
`<details><summary>${escape(failure.status)}: ${escape(failure.file)} — ${escape(failure.title)}</summary><pre>${escape(failure.message)}</pre></details>`
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) => `<pre>${escape(error.message ?? error.value)}</pre>`),
'',
'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`
)
}
}
@@ -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: '<timeout>' }] }]
},
{
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('&lt;timeout&gt;')
expect(renderE2eFailures(summary)).toContain('startup failed')
})
@@ -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 }
}
+100
View File
@@ -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)
}
}
+110
View File
@@ -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 }))
})
}
+95
View File
@@ -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 }])
})
@@ -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
}
+41
View File
@@ -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()
}
+60
View File
@@ -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()
}
+78
View File
@@ -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 })
}
}
)
+113
View File
@@ -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)
}
@@ -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)
}
})
+62
View File
@@ -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
}
}
+92
View File
@@ -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'])
})
})
+37 -2
View File
@@ -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)))
+44
View File
@@ -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)
}
)
@@ -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.
}
}
}
+3
View File
@@ -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'
]
+14 -2
View File
@@ -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'")
+2 -2
View File
@@ -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) {
+7 -1
View File
@@ -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
@@ -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')
+64
View File
@@ -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 }
@@ -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])
}
}
})
+205
View File
@@ -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()
}
@@ -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')
+4
View File
@@ -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",
+3 -8
View File
@@ -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,
+1
View File
@@ -179,6 +179,7 @@ Funciona con **cualquier agente CLI** — si corre en una terminal, corre en Orc
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a> &nbsp;
<a href="https://deepseek-harness.github.io/deepseek-harness/"><kbd><img src="../../src/shared/agent-icons/dsh.png" alt="DeepSeek Harness logo" width="16" valign="middle" /> DeepSeek Harness</kbd></a> &nbsp;
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="ZCode logo" width="16" valign="middle" /> ZCode</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
+1
View File
@@ -183,6 +183,7 @@ Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, i
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Logo Cursor" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="Logo GitHub Copilot" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Logo Muse" width="16" valign="middle" /> Muse</kbd></a> &nbsp;
<a href="https://deepseek-harness.github.io/deepseek-harness/"><kbd><img src="../../src/shared/agent-icons/dsh.png" alt="DeepSeek Harness logo" width="16" valign="middle" /> DeepSeek Harness</kbd></a> &nbsp;
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="Logo ZCode" width="16" valign="middle" /> ZCode</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="Logo OpenCode" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="Logo MiMo Code" width="16" valign="middle" /> MiMo Code</kbd></a> &nbsp;
+1
View File
@@ -179,6 +179,7 @@ PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意の
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a> &nbsp;
<a href="https://deepseek-harness.github.io/deepseek-harness/"><kbd><img src="../../src/shared/agent-icons/dsh.png" alt="DeepSeek Harness logo" width="16" valign="middle" /> DeepSeek Harness</kbd></a> &nbsp;
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="ZCode logo" width="16" valign="middle" /> ZCode</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
+1
View File
@@ -179,6 +179,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a> &nbsp;
<a href="https://deepseek-harness.github.io/deepseek-harness/"><kbd><img src="../../src/shared/agent-icons/dsh.png" alt="DeepSeek Harness logo" width="16" valign="middle" /> DeepSeek Harness</kbd></a> &nbsp;
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="ZCode logo" width="16" valign="middle" /> ZCode</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a> &nbsp;
+1
View File
@@ -179,6 +179,7 @@ Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca.
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Logotipo do Cursor" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="Logotipo do GitHub Copilot" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Logotipo do Muse" width="16" valign="middle" /> Muse</kbd></a> &nbsp;
<a href="https://deepseek-harness.github.io/deepseek-harness/"><kbd><img src="../../src/shared/agent-icons/dsh.png" alt="DeepSeek Harness logo" width="16" valign="middle" /> DeepSeek Harness</kbd></a> &nbsp;
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="Logotipo do ZCode" width="16" valign="middle" /> ZCode</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="Logotipo do OpenCode" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="Logotipo do MiMo Code" width="16" valign="middle" /> MiMo Code</kbd></a> &nbsp;
+1
View File
@@ -179,6 +179,7 @@ VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a> &nbsp;
<a href="https://deepseek-harness.github.io/deepseek-harness/"><kbd><img src="../../src/shared/agent-icons/dsh.png" alt="DeepSeek Harness logo" width="16" valign="middle" /> DeepSeek Harness</kbd></a> &nbsp;
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="ZCode logo" width="16" valign="middle" /> ZCode</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
+35 -2
View File
@@ -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
+145
View File
@@ -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.
+3
View File
@@ -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
@@ -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) |
@@ -6,7 +6,7 @@
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
"scenarioSha256": "c583058382c949e977b7a1287895ed9ccb8cb408b684aef186b629976ff1da4d",
"platform": "darwin",
"scenarioVersion": 1,
@@ -6,7 +6,7 @@
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
"scenarioSha256": "651cb070a4b4ba7deddeb37c08886094b83f006e7cbe2bef65d5433ce0430dff",
"platform": "darwin",
"scenarioVersion": 1,
@@ -6,7 +6,7 @@
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
"scenarioSha256": "e0ce643010302d304ba3611bd43932c6cdd0fa9290a3ecd4fffb4fc92c758b77",
"platform": "darwin",
"scenarioVersion": 1,
@@ -6,7 +6,7 @@
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
"scenarioSha256": "c6b3b4be6b55daf53ee1f5ff755e73a032c4da5379f90f4973736c032eb79414",
"platform": "darwin",
"scenarioVersion": 1,
@@ -6,7 +6,7 @@
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
"scenarioSha256": "a71815661dee6129a1b133c398b37ea89a9e4939b45626dada16889acabb2afe",
"platform": "darwin",
"scenarioVersion": 1,
@@ -6,7 +6,7 @@
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
"scenarioSha256": "af837de2e273c5f03130b51d6e1a9bff9ff99a4e8d1c9554a53134acb5924c72",
"platform": "darwin",
"scenarioVersion": 1,
@@ -6,7 +6,7 @@
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
"scenarioSha256": "7981ac87c4397b9703330835327c70828cb5a76633f1c4803ded1b66520d184d",
"platform": "darwin",
"scenarioVersion": 1,
@@ -6,7 +6,7 @@
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
"scenarioSha256": "9f1c39e91a97266b0a550d2b2d061592ae050078401ef23177dd2e00db67bf04",
"platform": "darwin",
"scenarioVersion": 1,
@@ -6,7 +6,7 @@
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
"scenarioSha256": "c858cfca59148548146b4c0a775e9518d3ab826ef268ccc9af60b04e52cc9e3d",
"platform": "darwin",
"scenarioVersion": 1,
@@ -6,7 +6,7 @@
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
"scenarioSha256": "2f0a6aa8ce100edbce9ccf64c81efe64356fccbdcd2e16f561360e2b11b5700f",
"platform": "darwin",
"scenarioVersion": 1,
@@ -6,7 +6,7 @@
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
"scenarioSha256": "7099d7311b2046dde21a2750201718995c0869bc4f17fb7f3ce2b997ce0e6506",
"platform": "darwin",
"scenarioVersion": 1,
@@ -6,7 +6,7 @@
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
"scenarioSha256": "8b4a06c1b2918d5ed5f40102c1c7cb411e02aba5e7601e98658d673634784bb0",
"platform": "darwin",
"scenarioVersion": 1,
@@ -6,7 +6,7 @@
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
"scenarioSha256": "793e954960d371c6477d4cd3d70a5215c0bd90764684eafdb1e1a2e09790cc86",
"platform": "darwin",
"scenarioVersion": 1,
@@ -6,7 +6,7 @@
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
"scenarioSha256": "46b720fed417d29f79dbde4cc7941579f2d6a2f920bae24234537480079e7de5",
"platform": "darwin",
"scenarioVersion": 1,
+2 -1
View File
@@ -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<Exclude<AgentDotState, 'working' | 'monitoring'>, string> = {
@@ -13,6 +13,7 @@ const DOT_COLORS: Record<Exclude<AgentDotState, 'working' | 'monitoring'>, strin
blocked: '#ef4444',
waiting: '#ef4444',
interrupted: '#ef4444',
failed: '#ef4444',
idle: 'rgba(115,115,115,0.4)'
}
const WORKING_COLOR = '#eab308'
+14 -69
View File
@@ -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 (
<MountedBottomDrawer
visible={visible}
<KeyedBottomDrawer
{...drawerProps}
sheet={visible ? SHOWN : null}
sheetKey={sheetKey}
onClose={onClose}
onHidden={handleHidden}
dragContentToDismiss={dragContentToDismiss}
contentScrollable={contentScrollable}
fillAvailable={fillAvailable}
interactive={interactive}
zIndex={zIndex}
onAfterClose={onAfterClose}
>
{children}
</MountedBottomDrawer>
{() => children}
</KeyedBottomDrawer>
)
}
+15 -7
View File
@@ -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 (
<BottomDrawer visible={visible} onClose={content.onCancel}>
<ConfirmContent {...content} />
</BottomDrawer>
)
}
export function ConfirmContent({
title,
message,
confirmLabel = 'Confirm',
@@ -22,9 +30,9 @@ export function ConfirmModal({
destructive = false,
onConfirm,
onCancel
}: Props) {
}: ContentProps) {
return (
<BottomDrawer visible={visible} onClose={onCancel}>
<>
<View style={styles.content}>
<Text style={styles.title}>{title}</Text>
{message ? <Text style={styles.message}>{message}</Text> : null}
@@ -52,7 +60,7 @@ export function ConfirmModal({
</Text>
</Pressable>
</View>
</BottomDrawer>
</>
)
}
+126 -106
View File
@@ -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 (
<>
<ActionSheetModal
visible={controller.showOverflow}
title="Review Actions"
message={
controller.reviewedUnstagedCount > 0
? `${controller.reviewedUnstagedCount} reviewed unstaged files can be staged`
: undefined
}
actions={overflowActions}
onClose={() => controller.setShowOverflow(false)}
/>
<ActionSheetModal
visible={controller.sendSheet !== null}
title="Send Notes"
message={sendSheetMessage(controller)}
actions={sendActions}
onClose={() => controller.setSendSheet(null)}
/>
<ConfirmModal
visible={controller.discardTarget !== null}
title="Discard File"
message={
controller.discardTarget
? `Discard changes to "${controller.discardTarget.filePath}"? This cannot be undone.`
: undefined
}
confirmLabel="Discard"
destructive
onConfirm={() => {
const target = controller.discardTarget
controller.setDiscardTarget(null)
if (target) {
void controller.runGitMutation('git.discard', target)
}
}}
onCancel={() => controller.setDiscardTarget(null)}
/>
<NoteComposerDrawer controller={controller} />
<CompletionDrawer controller={controller} />
</>
<KeyedBottomDrawer
sheet={controller.sheet}
sheetKey={reviewSheetKey}
onClose={(presented) => controller.closeSheet(presented.kind)}
>
{(presented) => <ReviewSheetContent controller={controller} sheet={presented} />}
</KeyedBottomDrawer>
)
}
function useSendActions(controller: ReturnType<typeof useMobileDiffReviewController>) {
function ReviewSheetContent({ controller, sheet }: Props & { sheet: ReviewSheet }) {
switch (sheet.kind) {
case 'actions':
return <ReviewActionsContent controller={controller} />
case 'send':
return <SendNotesContent controller={controller} load={sheet.load} />
case 'discard':
return (
<ConfirmContent
title="Discard File"
message={`Discard changes to "${sheet.target.filePath}"? This cannot be undone.`}
confirmLabel="Discard"
destructive
onConfirm={() => {
controller.closeSheet('discard')
void controller.runGitMutation('git.discard', sheet.target)
}}
onCancel={() => controller.closeSheet('discard')}
/>
)
case 'composer':
return <NoteComposerContent controller={controller} composer={sheet.composer} />
case 'completion':
return <CompletionContent controller={controller} />
}
}
function ReviewActionsContent({ controller }: Props) {
const overflowActions = useOverflowActions(controller)
return (
<ActionSheetContent
title="Review Actions"
message={
controller.reviewedUnstagedCount > 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 (
<ActionSheetContent
title="Send Notes"
message={sendSheetMessage(controller, load)}
actions={sendActions}
onClose={() => controller.closeSheet('send')}
/>
)
}
function useSendActions(
controller: ReturnType<typeof useMobileDiffReviewController>,
load: SendSheetState
) {
return useMemo<ActionSheetAction[]>(() => {
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<typeof useMobileDiffReviewControl
onPress: () => void controller.copyNotes()
}
]
}, [controller])
}, [controller, load])
}
function useOverflowActions(controller: ReturnType<typeof useMobileDiffReviewController>) {
@@ -111,7 +139,6 @@ function useOverflowActions(controller: ReturnType<typeof useMobileDiffReviewCon
label: 'Send Unsent Notes',
icon: Send,
disabled: controller.unsentComments.length === 0,
skipAutoClose: true,
onPress: () => void controller.openSendSheet()
},
{
@@ -152,65 +179,61 @@ function useOverflowActions(controller: ReturnType<typeof useMobileDiffReviewCon
}
function sendSheetMessage(
controller: ReturnType<typeof useMobileDiffReviewController>
controller: ReturnType<typeof useMobileDiffReviewController>,
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 (
<BottomDrawer visible={composer !== null} onClose={controller.closeComposer}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={keyboardPadding > 0 ? { paddingBottom: keyboardPadding } : undefined}
>
<View style={styles.composerHeader}>
<View>
<Text style={styles.drawerTitle}>
{composer?.mode === 'edit' ? 'Edit Note' : 'Add Note'}
</Text>
<Text style={styles.drawerSubtitle}>
{composer?.mode === 'create' && composer.lineNumber > 0
? `Line ${composer.lineNumber}`
: 'File note'}
</Text>
</View>
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={controller.closeComposer}
accessibilityRole="button"
accessibilityLabel="Cancel note"
>
<X size={18} color={colors.textPrimary} strokeWidth={2.2} />
</Pressable>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={keyboardPadding > 0 ? { paddingBottom: keyboardPadding } : undefined}
>
<View style={styles.composerHeader}>
<View>
<Text style={styles.drawerTitle}>
{composer.mode === 'edit' ? 'Edit Note' : 'Add Note'}
</Text>
<Text style={styles.drawerSubtitle}>
{composer.mode === 'create' && composer.lineNumber > 0
? `Line ${composer.lineNumber}`
: 'File note'}
</Text>
</View>
<TextInput
style={styles.composerInput}
value={controller.composerBody}
onChangeText={controller.setComposerBody}
multiline
autoFocus
placeholder="Review note"
placeholderTextColor={colors.textMuted}
accessibilityLabel={composerLabel(composer)}
/>
<View style={styles.drawerButtonRow}>
{composer?.mode === 'edit' ? (
<DeleteNoteButton onPress={controller.deleteComment} />
) : null}
<SaveNoteButton controller={controller} composer={composer} />
</View>
</KeyboardAvoidingView>
</BottomDrawer>
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={controller.closeComposer}
accessibilityRole="button"
accessibilityLabel="Cancel note"
>
<X size={18} color={colors.textPrimary} strokeWidth={2.2} />
</Pressable>
</View>
<TextInput
style={styles.composerInput}
value={controller.composerBody}
onChangeText={controller.setComposerBody}
multiline
autoFocus
placeholder="Review note"
placeholderTextColor={colors.textMuted}
accessibilityLabel={composerLabel(composer)}
/>
<View style={styles.drawerButtonRow}>
{composer.mode === 'edit' ? <DeleteNoteButton onPress={controller.deleteComment} /> : null}
<SaveNoteButton controller={controller} composer={composer} />
</View>
</KeyboardAvoidingView>
)
}
@@ -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 (
<BottomDrawer
visible={controller.showCompletion}
onClose={() => controller.setShowCompletion(false)}
>
<>
<Text style={styles.drawerTitle}>Review Complete</Text>
<Text style={styles.drawerSubtitle}>
{mobileReviewCountLabel(controller.queue.length, 'file', 'files')} reviewed,{' '}
@@ -297,6 +317,6 @@ function CompletionDrawer({ controller }: Props) {
<Text style={styles.primaryButtonText}>Send Notes</Text>
</Pressable>
</View>
</BottomDrawer>
</>
)
}
@@ -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}
+7 -2
View File
@@ -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 (
<View style={[styles.row, { paddingLeft: depth * INDENT_PER_DEPTH }]}>
@@ -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: <T,>(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<string, unknown> = {}
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 (
<MountedBottomDrawer visible={visible} onClose={onClose} onHidden={onHidden}>
{null}
</MountedBottomDrawer>
)
}
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())
})
})
@@ -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)
})
})
@@ -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<string>
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<number, Drawer>
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 (
<Profiler
id="drawer"
// Why: runs once per commit after its subtree's layout effects, so it numbers commits.
onRender={() => {
modals.commit++
}}
>
<KeyedBottomDrawer<Sheet>
sheet={sheet}
sheetKey={(s) => s.name}
onClose={onClose}
onAfterClose={onAfterClose}
>
{(presented) =>
createElement('SheetContent', { name: presented.name, version: presented.version })
}
</KeyedBottomDrawer>
</Profiler>
)
}
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)
}
}
})
})
@@ -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<T> = Omit<MountedBottomDrawerProps, 'visible' | 'onClose' | 'onHidden' | 'children'> & {
/** 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<T> = {
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<T>({
sheet,
sheetKey,
onClose,
onAfterClose,
children,
...drawerProps
}: Props<T>) {
const [state, setState] = useState<Presentation<T>>(() => ({
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 (
<MountedBottomDrawer
{...drawerProps}
visible={!next.closing}
onClose={() => onClose(presented)}
onHidden={handleHidden}
>
{children(presented)}
</MountedBottomDrawer>
)
}
function followRequest<T>(
state: Presentation<T>,
sheet: T | null,
sheetKey: (sheet: T) => string
): Presentation<T> {
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 }
}
@@ -40,6 +40,7 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial<Record<TuiAgent, ImageSourcePropT
hermes: require('../../../src/shared/agent-icons/hermes.png'),
devin: require('../../../src/shared/agent-icons/devin.png'),
muse: require('../../../src/shared/agent-icons/muse.png'),
dsh: require('../../../src/shared/agent-icons/dsh.png'),
zcode: require('../../../src/shared/agent-icons/zcode.png'),
openclaw: require('../../../src/shared/agent-icons/openclaw.png')
}
@@ -0,0 +1,309 @@
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DiffComment } from '../../../src/shared/diff-comment-types'
import type { RpcClient } from '../transport/rpc-client'
import type { ReviewScreenState } from '../session/mobile-diff-review-screen-model'
// iOS cannot present a review sheet while another is still on screen (even mid-close): the second
// presentation silently fails and every later tap on the screen is swallowed. These drive the real
// controller and keyed drawer; only the native drawer is mocked, and its finished hide animation is
// played by calling its `onHidden`.
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
KeyboardAvoidingView: 'KeyboardAvoidingView',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
StyleSheet: { create: <T>(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<typeof useMobileDiffReviewController>
type Deferred = { promise: Promise<unknown>; resolve: (value: unknown) => void }
function deferred(): Deferred {
let resolve: (value: unknown) => void = () => {}
const promise = new Promise<unknown>((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<string, Deferred>
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<void> {
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<void> {
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<void> {
let marking: Promise<void> = Promise.resolve()
act(() => {
marking = controller.markReviewed()
})
return marking
}
async function completeReview(): Promise<void> {
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)
})
})
@@ -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,
@@ -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)
}
}
})
})
@@ -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<typeof reviewSheetIntents>
export function reviewSheetKey(sheet: ReviewSheet): ReviewSheetKind {
return sheet.kind
}
@@ -110,13 +110,14 @@ function mountSendSites(client: ReturnType<typeof clientFixture>, 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
@@ -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<SetStateAction<ReviewScreenState>>
setCurrentIndex: Dispatch<SetStateAction<number>>
setComposer: Dispatch<SetStateAction<ComposerState | null>>
setComposerBody: Dispatch<SetStateAction<string>>
setActionError: Dispatch<SetStateAction<string | null>>
setShowCompletion: Dispatch<SetStateAction<boolean>>
sheets: Pick<ReviewSheetIntents, 'openSheet' | 'openSheetWhenIdle' | 'closeSheet'>
}
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 () => {
@@ -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<MobileDiffReviewQueueFilter>(initialFilter)
const [currentIndex, setCurrentIndex] = useState(0)
const [activeHunkIndex, setActiveHunkIndex] = useState<number | null>(null)
const [composer, setComposer] = useState<ComposerState | null>(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<string | null>(null)
const [busyAction, setBusyAction] = useState<string | null>(null)
const [discardTarget, setDiscardTarget] = useState<MobileDiffReviewQueueItem | null>(null)
const [showOverflow, setShowOverflow] = useState(false)
const [sendSheet, setSendSheet] = useState<SendSheetState | null>(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
@@ -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<SetStateAction<MobileDiffReviewQueueFilter>>
setCurrentIndex: Dispatch<SetStateAction<number>>
setActiveHunkIndex: Dispatch<SetStateAction<number | null>>
setComposer: Dispatch<SetStateAction<ComposerState | null>>
setComposerBody: Dispatch<SetStateAction<string>>
setActionError: Dispatch<SetStateAction<string | null>>
setBusyAction: Dispatch<SetStateAction<string | null>>
setSendSheet: Dispatch<SetStateAction<SendSheetState | null>>
setShowCompletion: Dispatch<SetStateAction<boolean>>
sheets: ReviewSheetIntents
loadReviewData: () => Promise<void>
onOpenSession: () => void
onReconnect: ((hostId: string) => void | Promise<void>) | 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
})
@@ -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<typeof vi.fn>
let setSendSheet: ReturnType<typeof vi.fn>
let sheets: {
openSheet: Mock<ReviewSheetIntents['openSheet']>
closeSheet: Mock<ReviewSheetIntents['closeSheet']>
updateSendSheet: Mock<ReviewSheetIntents['updateSendSheet']>
}
let saveCommentsAndReviewState: ReturnType<typeof vi.fn>
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 () => {
@@ -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<SetStateAction<string | null>>
setSendSheet: Dispatch<SetStateAction<SendSheetState | null>>
sheets: Pick<ReviewSheetIntents, 'openSheet' | 'closeSheet' | 'updateSendSheet'>
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,
+1
View File
@@ -22,6 +22,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial<Record<TuiAgent, string>>
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',
@@ -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<typeof useInteractions>
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()
+84 -3
View File
@@ -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> = {}): 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')
+43 -3
View File
@@ -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<RuntimeWorktreeAgentRow, 'state' | 'interrupted' | 'mainAgent'>
// 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<RuntimeWorktreeAgentRow, 'state' | 'workingMode' | 'interrupted' | 'updatedAt'>,
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<RuntimeWorktreeAgentRow, 'state' | 'interrupted' | 'mainAgent' | 'stateStartedAt'>
): 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
@@ -21,6 +21,13 @@ function agent(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktre
}
}
function done(
outcome: 'success' | 'failure',
stateStartedAt = 1
): NonNullable<RuntimeWorktreeAgentRow['mainAgent']> {
return { state: 'done', outcome, stateStartedAt }
}
function worktree(overrides: Partial<Worktree> = {}): 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' })] })]
@@ -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
)
}
+1 -1
View File
@@ -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": {
@@ -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()]
]
@@ -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<string, CommandBuilders>([
remote: (path) => [getMuseRemoteManagedCommand(path)]
}
],
[
'dsh',
{
local: (path) => [getDshManagedCommand(path)],
remote: (path) => [getDshRemoteManagedCommand(path)]
}
],
[
'zcode',
{
@@ -0,0 +1,38 @@
import { isPlainObject } from './installer-utils'
/**
* Which of `events` an Orca-owned `{ hooks: { <event>: [{ 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<string> {
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
}
@@ -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.
@@ -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
@@ -42,7 +42,8 @@ const NEW_TURN_EVENT: Record<AgentHookSource, string | null> = {
'mimo-code': null,
'command-code': null,
muse: 'UserPromptSubmit',
zcode: 'SessionStart'
zcode: 'SessionStart',
dsh: 'SessionStart'
}
function reviveRetiredPane(source: unknown, hookEventName: string): boolean {

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