Merge remote-tracking branch 'origin/main' into nwparker/worktree-create-technical

# Conflicts:
#	tests/e2e/ssh-docker-transport-drop-recovery.spec.ts
This commit is contained in:
Neil
2026-09-05 13:57:45 -07:00
18 changed files with 855 additions and 107 deletions
@@ -0,0 +1,63 @@
name: Performance contracts
on:
schedule:
- cron: '15 9 * * *'
workflow_dispatch:
pull_request:
paths:
- '.github/workflows/performance-contracts.yml'
- 'config/vitest.performance.config.ts'
- 'config/oxlint-performance-audit.json'
- 'config/oxlint-plugins/*performance.mjs'
- 'config/oxlint-plugins/quadratic-buffer-concat.mjs'
- 'config/scripts/*-plugin.test.mjs'
# Keep in sync with the contract list in config/vitest.performance.config.ts;
# without these a rename lands green and only breaks the next nightly.
- 'src/main/sqlite/sync-database.test.ts'
- 'src/main/runtime/orchestration/db/row-column-lists.test.ts'
- 'src/relay/fs-path-metadata-symlink-concurrency.test.ts'
- 'src/renderer/src/components/editor/rich-markdown-list-tokenizers.test.ts'
- 'src/renderer/src/components/editor/rich-markdown-lowlight-cache.test.ts'
- 'src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts'
- 'src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-queue-retention.test.ts'
permissions:
contents: read
concurrency:
group: performance-contracts-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
contracts:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- name: Run operation-count and retention contracts
run: pnpm test:perf:contracts --reporter=default --reporter=json --outputFile=performance-contracts.json
# Source-only scan: identical on every OS, so run it once.
- name: Audit production performance patterns
if: always() && matrix.os == 'ubuntu-latest'
shell: bash
run: pnpm --silent audit:perf > performance-audit.json
- uses: actions/upload-artifact@v7
if: always()
with:
name: performance-contracts-${{ matrix.os }}
path: performance-contracts.json
if-no-files-found: error
- uses: actions/upload-artifact@v7
if: always() && matrix.os == 'ubuntu-latest'
with:
name: performance-audit
path: performance-audit.json
if-no-files-found: error
+5
View File
@@ -2,6 +2,10 @@
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "react", "react-hooks", "react-perf", "unicorn"],
"jsPlugins": [
{
"name": "sort-comparator-performance",
"specifier": "./config/oxlint-plugins/sort-comparator-performance.mjs"
},
{
"name": "mobile-pairing",
"specifier": "./config/oxlint-plugins/mobile-pairing-qrcode-import.mjs"
@@ -23,6 +27,7 @@
"correctness": "error"
},
"rules": {
"sort-comparator-performance/no-repeated-collator": "warn",
"app-store-performance/require-selector": "error",
"app-store-performance/no-identity-selector": "error",
"app-store-performance/no-fresh-selector-result": "error",
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "../node_modules/oxlint/configuration_schema.json",
"plugins": [],
"categories": {
"correctness": "off",
"suspicious": "off",
"pedantic": "off",
"perf": "off",
"style": "off",
"restriction": "off",
"nursery": "off"
},
"jsPlugins": [
{
"name": "app-store-performance",
"specifier": "../config/oxlint-plugins/app-store-performance.mjs"
},
{
"name": "quadratic-buffer-concat",
"specifier": "../config/oxlint-plugins/quadratic-buffer-concat.mjs"
},
{
"name": "sort-comparator-performance",
"specifier": "../config/oxlint-plugins/sort-comparator-performance.mjs"
}
],
"rules": {
"app-store-performance/require-selector": "warn",
"app-store-performance/no-identity-selector": "warn",
"app-store-performance/no-fresh-selector-result": "warn",
"quadratic-buffer-concat/no-loop-carried-concat": "warn",
"sort-comparator-performance/no-repeated-collator": "warn"
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/out", "**/*.test.*", "**/*.spec.*"]
}
@@ -0,0 +1,60 @@
const FUNCTION_TYPES = new Set([
'ArrowFunctionExpression',
'FunctionExpression',
'FunctionDeclaration'
])
function propertyName(node) {
if (node?.type !== 'MemberExpression') {
return null
}
if (!node.computed && node.property.type === 'Identifier') {
return node.property.name
}
return node.property.type === 'Literal' ? node.property.value : null
}
function isInlineSortComparator(node) {
for (let parent = node.parent; parent; parent = parent.parent) {
if (!FUNCTION_TYPES.has(parent.type)) {
continue
}
const call = parent.parent
return (
call?.type === 'CallExpression' &&
call.arguments[0] === parent &&
['sort', 'toSorted'].includes(propertyName(call.callee))
)
}
return false
}
function isCollatorConstruction(node) {
return (
node.callee?.object?.type === 'Identifier' &&
node.callee.object.name === 'Intl' &&
propertyName(node.callee) === 'Collator'
)
}
function createRule(context) {
function inspect(node) {
const optionedComparison =
node.type === 'CallExpression' &&
propertyName(node.callee) === 'localeCompare' &&
node.arguments.length >= 3
if ((optionedComparison || isCollatorConstruction(node)) && isInlineSortComparator(node)) {
context.report({
node,
message:
'Create one Intl.Collator before sorting and reuse its compare method; resolving collation options inside the comparator repeats setup for every comparison. Preserve the locale, options, and tie-breaker.'
})
}
}
return { CallExpression: inspect, NewExpression: inspect }
}
export default {
meta: { name: 'sort-comparator-performance' },
rules: { 'no-repeated-collator': { create: createRule } }
}
+38
View File
@@ -0,0 +1,38 @@
# Performance regression checks
`pnpm --silent audit:perf > performance-audit.json` scans production `src/` with
the existing app-store and buffer-concatenation rules plus the sort-comparator
rule. Warnings are advisory in this full inventory; tool/parser failures fail.
New warning findings on changed lines fail `pnpm check:code-quality:changed`.
Tests, generated files, `mobile/` and `cloud/` are outside this source audit.
The sort rule detects optioned `localeCompare` and `Intl.Collator` construction
inside inline `sort`/`toSorted` callbacks. Construct one collator outside the
callback, preserving locale, options and tie-breakers. If the locale changes at
runtime, reconstruct at the next sort or key the cache by locale. Bare comparisons
and standalone equality checks are allowed. There is no autofix or interprocedural
analysis: named comparators, aliases, custom methods and deferred callbacks need
manual review. A warning identifies repeated setup, not proof of visible lag.
`pnpm test:perf:contracts` runs the explicit selection in
`vitest.performance.config.ts`: SQLite statement reuse and schema parity, relay
filesystem concurrency, tokenizer rejection, highlighting cache, queued
cancellation, terminal backing-memory retention and detector fixtures. Missing
listed files fail configuration loading. Tests run serially, without retries,
and inherit the full suite's setup and forced-GC support. This makes existing
regression coverage easy to run and attribute; it does not create new workload
coverage by itself.
`.github/workflows/performance-contracts.yml` runs daily and manually on Linux,
macOS and Windows, and on PRs changing this tooling or any listed contract file.
It uploads per-OS JSON test results, plus the source inventory once from Linux
because that scan is OS-independent. Its schedule starts after merge. Run the existing
`test:e2e:terminal-perf:scale:report` for rendered typing/frame budgets and
`test:e2e:ssh-docker-perf` for real transport behavior. Relay unit tests do not
measure SSH RTT, WSL scheduling or a packaged Electron renderer.
To extend coverage, select a production-path regression with an operation-count,
identity, queue-admission or retained-memory oracle. Confirm it fails with the
old behavior. Use controlled, counterbalanced benchmark samples for timings;
avoid new machine-dependent millisecond gates in the normal unit suite. A green
source scan and these contracts cannot establish that the whole app is fast.
+133
View File
@@ -18018,6 +18018,139 @@
"The sentinel changes a pane title within an existing layout; concurrent split and close conflicts remain separate coverage."
],
"demotionRule": "Demote if a failed push suppresses an identical retry, a successful equal write resumes redundant churn, or the routed observer journey flakes without a diagnosed cause."
},
{
"id": "ssh.docker-recovery-and-resource-lifecycle",
"title": "Docker SSH reconnect, host faults, listing and watcher lifecycle",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "electron-docker-ssh",
"surfaces": [
"SSH terminal recovery",
"SSH remote resource ownership",
"remote file listing",
"remote explorer watcher recovery",
"Electron test process cleanup"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["ssh"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["ssh"],
"coverageNotes": "A macOS Electron client drives a Linux Docker SSH execution host. The six-spec suite passed ten enabled cases with clean worker exit (5.2m). The formerly skipped frozen-host input case now waits for recovered authority before sending input and passed four separate executions (one initial and three repetitions). The existing flooded-shell fixme remains an explicitly reproduced application gap.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/18018",
"https://github.com/stablyai/orca/pull/18546",
"https://github.com/stablyai/orca/issues/12547"
],
"invariant": "Transport loss and frozen-host silence must preserve the remote session; host relay loss may rebind a pane without accumulating reattachable leases. Reconnects must preserve usable terminal content, bounded PTYs/fds/processes, complete large listings, and independently recoverable watcher processes. Electron test shutdown must release inherited pipes after confirmed root exit without closing live-process pipes.",
"oracle": "Poll a changed connected SSH authority after injected faults, then require terminal output and appropriate PTY identity. Read remote process/fd state, listFiles replies, and rendered explorer rows. Resolve Playwright cleanup only after the root process exits and its inherited pipes close; live-process pipes remain untouched.",
"commands": [
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-transport-drop-recovery.spec.ts tests/e2e/ssh-docker-half-open-link.spec.ts tests/e2e/ssh-docker-quick-open-large-listing.spec.ts tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts tests/e2e/ssh-docker-resource-accumulation.spec.ts tests/e2e/ssh-docker-watcher-isolation.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/helpers/electron-process-shutdown.unit.test.ts"
],
"testFiles": [
"tests/e2e/ssh-docker-transport-drop-recovery.spec.ts",
"tests/e2e/ssh-docker-half-open-link.spec.ts",
"tests/e2e/ssh-docker-quick-open-large-listing.spec.ts",
"tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts",
"tests/e2e/ssh-docker-resource-accumulation.spec.ts",
"tests/e2e/ssh-docker-watcher-isolation.spec.ts",
"tests/e2e/helpers/electron-process-shutdown.unit.test.ts"
],
"assertionRefs": [
{
"file": "tests/e2e/ssh-docker-transport-drop-recovery.spec.ts",
"assertions": [
"preserves transport-drop PTY and scrollback, replaces relay-loss binding, and keeps one reattachable lease per pane"
]
},
{
"file": "tests/e2e/ssh-docker-half-open-link.spec.ts",
"assertions": [
"leaves connected after host freeze and renders process-produced output after recovery"
]
},
{
"file": "tests/e2e/ssh-docker-quick-open-large-listing.spec.ts",
"assertions": [
"returns both a bounded client page and a complete legacy-client remote listing"
]
},
{
"file": "tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts",
"assertions": [
"restores shell scrollback and full-screen output and opens a usable fresh tab"
]
},
{
"file": "tests/e2e/ssh-docker-resource-accumulation.spec.ts",
"assertions": [
"keeps remote pts devices, relay fds, process counts and inherited master fds bounded"
]
},
{
"file": "tests/e2e/ssh-docker-watcher-isolation.spec.ts",
"assertions": [
"keeps rendered explorer changes and terminal output live after watcher crash and repairs a deleted watcher artifact"
]
},
{
"file": "tests/e2e/helpers/electron-process-shutdown.unit.test.ts",
"assertions": [
"releases inherited pipes after confirmed exit, including prior exit",
"retains live-process pipes on shutdown timeout"
]
}
],
"evidenceRuns": [
{
"date": "2026-09-05",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/helpers/electron-process-shutdown.unit.test.ts",
"durationSeconds": 0.168,
"summary": "All three shutdown regression tests passed; disabling pipe release fails the first two by timeout. Two half-open Electron repetitions separately passed in 1.7m without worker teardown timeout."
},
{
"date": "2026-09-05",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-transport-drop-recovery.spec.ts tests/e2e/ssh-docker-half-open-link.spec.ts tests/e2e/ssh-docker-quick-open-large-listing.spec.ts tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts tests/e2e/ssh-docker-resource-accumulation.spec.ts tests/e2e/ssh-docker-watcher-isolation.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"durationSeconds": 312,
"summary": "Six specs: ten passed, two existing fixme skipped, clean worker shutdown. Baseline same enabled suite: ten passed but worker teardown timed out (7.3m)."
}
],
"runtimeBudget": {
"p95Seconds": 420,
"scope": "per Electron Docker test; measured suite p95 and CI soak not yet established"
},
"flakeHistory": {
"status": "flaky",
"evidence": "Baseline: ten enabled tests passed, two fixme skipped, worker teardown timed out (7.3m). After pipe cleanup: ten passed and worker exited cleanly (5.2m); two half-open repeats passed (1.7m). The formerly skipped thaw-input case failed before its recovered-authority wait and passed 1+3 executions afterward (56.9s + 2.6m). Flood failed both its original input oracle and a strengthened producer-completion oracle after recovery."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Disabling exited-process pipe release causes two shutdown contract tests to time out; restoring it passes 3/3. Baseline Docker worker teardown failed; final six-spec enabled run and half-open repeats exit successfully. Frozen-host input fails without the post-thaw recovered-authority wait and passes four runs with it. Full product fault/recovery mutation coverage and CI history remain missing."
},
"performanceBudget": {
"required": true,
"evidence": "Test-only bounded pipe destruction and authority polling; no production polling, subprocesses, or runtime work added. Remote resources are counted instead of using wall-clock leak thresholds."
},
"promotionCriteria": [
"Require complete six-spec repeat runs with clean worker shutdown.",
"Resolve the remaining #18018 flooded-shell reproduction and remove its fixme marker.",
"Collect CI runtime and flake history plus product red/green evidence before blocking."
],
"knownGaps": [
"The disconnected 48MB flood still loses its relay channel: original post-flood input marker failed in 60s, and waiting for the finite producer completion marker failed in 120s. It remains an explicit #18018 fixme reproduction; frozen-host input is re-enabled after four successful runs.",
"Linux and Windows desktop clients, WSL, folder workspaces, paired runtimes and live agent CLIs are not exercised by these Docker specs.",
"Some legacy assertions inspect terminal serialization or backing state rather than rendered DOM; no blanket visual coverage claim.",
"No p95 CI history or full product mutation proof."
],
"demotionRule": "Keep experimental while any recovery reproduction fails or any teardown, identity, resource-count, or rendered oracle flakes; never promote by extending sleeps or retries."
}
]
}
@@ -0,0 +1,45 @@
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs'
function lint(source) {
return runOxlintPluginOnSource({
pluginName: 'sort-comparator-performance',
pluginPath: path.resolve('config/oxlint-plugins/sort-comparator-performance.mjs'),
rules: { 'sort-comparator-performance/no-repeated-collator': 'warn' },
source
})
}
describe('sort comparator performance', () => {
it('reports repeated collation setup in inline sort and toSorted callbacks', () => {
const findings = lint(`
rows.sort((a, b) => a.name.localeCompare(b.name, locale, { sensitivity: 'base' }))
rows.toSorted(function (a, b) { return new Intl.Collator('sv').compare(a, b) })
rows['sort']((a, b) => Intl.Collator('en', { numeric: true }).compare(a, b))
rows.sort((a, b) => a['localeCompare'](b, undefined, options))
`)
expect(findings).toHaveLength(4)
expect(
findings.every(
(finding) => finding.code === 'sort-comparator-performance(no-repeated-collator)'
)
).toBe(true)
})
it('allows one collator per sort, bare comparisons, and unrelated callbacks', () => {
expect(
lint(`
const collator = new Intl.Collator(locale, options)
rows.sort((a, b) => collator.compare(a.name, b.name) || a.id.localeCompare(b.id))
rows.toSorted(collator.compare)
const equal = a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0
rows.map(a => new Intl.Collator(a.locale))
rows.sort((a, b) => {
function deferred() { return new Intl.Collator(locale) }
return a - b
})
`)
).toEqual([])
})
})
+33
View File
@@ -0,0 +1,33 @@
import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { defineConfig } from 'vitest/config'
import baseConfig from './vitest.config'
const contracts = [
'src/main/sqlite/sync-database.test.ts',
'src/main/runtime/orchestration/db/row-column-lists.test.ts',
'src/relay/fs-path-metadata-symlink-concurrency.test.ts',
'src/renderer/src/components/editor/rich-markdown-list-tokenizers.test.ts',
'src/renderer/src/components/editor/rich-markdown-lowlight-cache.test.ts',
'src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts',
'src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-queue-retention.test.ts',
'config/scripts/app-store-performance-plugin.test.mjs',
'config/scripts/quadratic-buffer-concat-plugin.test.mjs',
'config/scripts/sort-comparator-performance-plugin.test.mjs'
]
for (const contract of contracts) {
if (!existsSync(resolve(contract))) {
throw new Error(`Missing performance contract: ${contract}`)
}
}
export default defineConfig({
...baseConfig,
test: {
...baseConfig.test,
include: contracts,
fileParallelism: false,
retry: 0
}
})
+2
View File
@@ -10,6 +10,8 @@
},
"main": "./out/main/index.js",
"scripts": {
"audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src",
"test:perf:contracts": "vitest run --config config/vitest.performance.config.ts",
"format": "oxfmt --write .",
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-runtime-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
@@ -0,0 +1,200 @@
import { describe, expect, it } from 'vitest'
import {
detectTerminalWaitBlockedReason,
isKnownReadyPromptPreview
} from './terminal-wait-detection'
import { buildTerminalWaitText } from './terminal-wait-tail-state'
// Why these shapes: Codex agents working on Orca print `rg` hits from this very detector and its
// specs, so quoted prompt wording lands in scrollback while the terminal sits at its input box.
const QUOTED_DETECTOR_SOURCE_LINE =
"└ if (hooksindex !== -1 && normalized.includes('press enter to confirm', hooksindex)) {"
const QUOTED_PERMISSION_FIXTURE_LINE =
" └ 236: 'Permission required\\nThis command requires permission\\nAllow once\\nAllow always\\nReject\\n',"
function codexIdleScreen(): string[] {
return [
'• Done. The detector bounding is in place and the suite passes.',
'',
'› Ask Codex to do anything',
'',
' gpt-6-astra medium · ~/orca/workspaces/orca/fix-wait-detector-scrollback'
]
}
function codexScrollback(quotedLines: string[], trailingLineCount: number): string[] {
const lines: string[] = [
'• Explored',
' └ Search press enter to confirm in src/main/runtime',
' Read terminal-wait-detection.ts',
'',
'• Ran rg -n "press enter to confirm" src/main/runtime/terminal-wait-detection.ts src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts',
' └ src/main/runtime/terminal-wait-detection.ts',
' src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts',
' src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts',
...quotedLines
]
for (let index = 0; index < trailingLineCount; index += 1) {
lines.push(` ${index}: unrelated codex narration about hook wiring and sandbox policy`)
}
return lines
}
function waitTextFor(lines: string[]): string {
return buildTerminalWaitText(lines, '', '')
}
describe('detectTerminalWaitBlockedReason scrollback bounding', () => {
it('ignores detector source quoted by rg output far above an idle Codex input box', () => {
const waitText = waitTextFor([
...codexScrollback([QUOTED_DETECTOR_SOURCE_LINE], 300),
...codexIdleScreen()
])
expect(waitText).toContain('press enter to confirm')
expect(detectTerminalWaitBlockedReason(waitText)).toBeNull()
})
it('ignores a quoted permission fixture in scrollback above an idle Codex input box', () => {
const waitText = waitTextFor([
...codexScrollback([QUOTED_PERMISSION_FIXTURE_LINE], 300),
...codexIdleScreen()
])
expect(waitText.toLowerCase()).toContain('allow once')
expect(detectTerminalWaitBlockedReason(waitText)).toBeNull()
})
it('ignores quoted prompt wording just above the live-dialog window', () => {
// Why 10: with the 3-line idle screen the quoted lines sit 13-14 non-blank lines from the bottom.
const waitText = waitTextFor([
...codexScrollback([QUOTED_DETECTOR_SOURCE_LINE, QUOTED_PERMISSION_FIXTURE_LINE], 10),
...codexIdleScreen()
])
expect(detectTerminalWaitBlockedReason(waitText)).toBeNull()
})
it('does not let quoted scrollback wording veto a Codex ready header', () => {
const waitText = waitTextFor([
...codexScrollback([QUOTED_PERMISSION_FIXTURE_LINE], 40),
' >_ OpenAI Codex (v0.153.3)',
' model: gpt-6-astra medium /model to change',
' directory: ~/orca/workspaces/orca/fix-wait-detector-scrollback'
])
expect(isKnownReadyPromptPreview(waitText)).toBe(true)
})
})
// Real dialog text: terminal-creation-and-readiness-part-07.spec.ts and agent-status-and-waits.spec.ts.
const LIVE_CODEX_PROMPTS: { name: string; lines: string[]; reason: string }[] = [
{
name: 'hooks review',
lines: [
'Hooks need review',
'2 hooks are new or changed.',
'1. Review hooks',
'2. Trust all and continue',
'Press enter to confirm or esc to go back'
],
reason: 'codex-hooks-review-prompt'
},
{
name: 'trust workspace',
lines: ['Do you trust this workspace directory?', '1. Yes', '2. No'],
reason: 'codex-trust-workspace'
},
{
name: 'update',
lines: [
'Update available! 0.131.0 -> 0.132.0',
'1. Update now',
'2. Skip',
'Press enter to continue'
],
reason: 'codex-update-prompt'
},
{
name: 'cwd selection',
lines: [
'Choose working directory to resume this session',
' Session = latest cwd recorded in the resumed session',
' Current = your current working directory',
' Press enter to continue'
],
reason: 'codex-cwd-prompt'
},
{
name: 'model migration',
lines: [
'Codex just got an upgrade. Introducing gpt-5.1-codex-max.',
'We recommend switching from gpt-5-codex to gpt-5.1-codex-max.',
'Press enter to continue'
],
reason: 'codex-model-migration-prompt'
},
{
name: 'grant permissions',
lines: [
'Would you like to grant these permissions?',
'1. Yes, grant these permissions for this turn',
'2. No, continue without permissions',
'Press enter to confirm or esc to cancel'
],
reason: 'codex-interactive-prompt'
},
{
name: 'permission required',
lines: [
'Permission required',
'This command requires permission',
'Allow once',
'Allow always',
'Reject'
],
reason: 'codex-interactive-prompt'
}
]
describe('detectTerminalWaitBlockedReason live prompts', () => {
for (const prompt of LIVE_CODEX_PROMPTS) {
it(`still blocks on a live ${prompt.name} prompt after long scrollback`, () => {
const waitText = waitTextFor([
...codexScrollback([QUOTED_DETECTOR_SOURCE_LINE, QUOTED_PERMISSION_FIXTURE_LINE], 300),
...prompt.lines
])
expect(detectTerminalWaitBlockedReason(waitText)).toBe(prompt.reason)
})
it(`blocks on a live ${prompt.name} prompt rendered with blank spacer rows`, () => {
// Why: the visible-screen probe joins raw rows, so blank rows between dialog lines must not eat the window.
const spaced = prompt.lines.flatMap((line) => [line, '', ''])
const screen = [
' >_ OpenAI Codex (v0.153.3)',
'',
...spaced,
'',
' gpt-6-astra medium · ~/orca/workspaces/orca/fix-wait-detector-scrollback',
''
].join('\n')
expect(detectTerminalWaitBlockedReason(screen)).toBe(prompt.reason)
})
}
it('reports the newest prompt when a live dialog follows a stale one at the bottom', () => {
const waitText = waitTextFor([
'Update available! 0.131.0 -> 0.132.0',
'Press enter to continue',
' >_ OpenAI Codex (v0.132.0)',
' model: gpt-5.5 high /model to change',
' directory: ~/orca/workspaces/orca/cli-debug',
'Hooks need review',
'Press enter to confirm'
])
expect(detectTerminalWaitBlockedReason(waitText)).toBe('codex-hooks-review-prompt')
})
})
+21 -18
View File
@@ -4,6 +4,11 @@ import {
type AgentStatus
} from '../../shared/agent-detection'
import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types'
import {
isTerminalWaitWhitespace,
startOfLastLines,
startOfLastNonBlankLines
} from './terminal-wait-tail-window'
const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i
const CLAUDE_IDLE_PREFIX = '\u2733'
@@ -153,11 +158,6 @@ function findAntigravityReadyPromptIndex(normalized: string): number | null {
return modelIndex !== null && promptIndex !== null ? Math.max(modelIndex, promptIndex) : null
}
function isTerminalWaitWhitespace(value: string, index: number): boolean {
const code = value.charCodeAt(index)
return code === 32 || (code >= 9 && code <= 13)
}
export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE =
/update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i
@@ -206,25 +206,28 @@ function isCursorApprovalChoiceLine(line: string): boolean {
)
}
function startOfLastLines(value: string, count: number): number {
let cursor = value.length
for (let seen = 0; seen < count; seen += 1) {
const previous = value.lastIndexOf('\n', cursor - 1)
if (previous === -1) {
return 0
}
cursor = previous
}
return cursor + 1
}
// Why bounded: answered dialogs and quoted prompt wording (agents grep this file and its specs) stay in the
// retained tail; only a dialog owning the screen bottom is live. Real Codex dialogs (trust, hooks review,
// update, exec approval) are 4-8 lines; the slack covers a wrapped command or a longer hook list.
const LIVE_PROMPT_TAIL_LINES = 12
function findTerminalWaitBlockedSignal(
normalized: string
fullTail: string
): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null {
// Why: one combined negative scan over the up-to-256 KiB tail avoids a dozen full-tail searches when no prompt can match.
const windowStart = startOfLastNonBlankLines(fullTail, LIVE_PROMPT_TAIL_LINES)
const normalized = windowStart === 0 ? fullTail : fullTail.slice(windowStart)
// Why: one combined negative scan avoids a dozen searches when no prompt can match.
if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) {
return null
}
const signal = findBlockedSignalInLiveWindow(normalized)
// Why: callers compare this index against ready-header indexes found over the full tail.
return signal === null ? null : { reason: signal.reason, index: signal.index + windowStart }
}
function findBlockedSignalInLiveWindow(
normalized: string
): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null {
const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = []
const updateIndex = normalized.lastIndexOf('update available')
if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) {
@@ -0,0 +1,48 @@
// Line-window primitives over a newline-joined terminal tail, shared by the wait-blocked prompt rules.
export function isTerminalWaitWhitespace(value: string, index: number): boolean {
const code = value.charCodeAt(index)
return code === 32 || (code >= 9 && code <= 13)
}
/** Offset where the last `count` lines begin (0 when the tail is shorter). */
export function startOfLastLines(value: string, count: number): number {
let cursor = value.length
for (let seen = 0; seen < count; seen += 1) {
const previous = value.lastIndexOf('\n', cursor - 1)
if (previous === -1) {
return 0
}
cursor = previous
}
return cursor + 1
}
/** Like `startOfLastLines`, but blank rows don't count toward the window. */
// Why: the visible-screen probe joins raw rows, so blank spacer rows must not eat a dialog's window.
export function startOfLastNonBlankLines(value: string, count: number): number {
let seen = 0
let lineEnd = value.length
for (;;) {
const lineStart = value.lastIndexOf('\n', lineEnd - 1) + 1
if (hasNonWhitespaceBetween(value, lineStart, lineEnd)) {
seen += 1
if (seen >= count) {
return lineStart
}
}
if (lineStart === 0) {
return 0
}
lineEnd = lineStart - 1
}
}
function hasNonWhitespaceBetween(value: string, start: number, end: number): boolean {
for (let index = start; index < end; index += 1) {
if (!isTerminalWaitWhitespace(value, index)) {
return true
}
}
return false
}
+6 -6
View File
@@ -194,9 +194,11 @@ describe('SyncDatabase read-only opens under contention', () => {
const contended = await contendedDatabase(10_000)
const startedAt = Date.now()
const reader = new SyncDatabase(contended.path, { readonly: true })
openDatabases.push(reader)
let thrown: unknown
try {
new SyncDatabase(contended.path, { readonly: true }).prepare('SELECT id FROM items').all()
reader.prepare('SELECT id FROM items').all()
} catch (error) {
thrown = error
}
@@ -210,11 +212,9 @@ describe('SyncDatabase read-only opens under contention', () => {
const contended = await contendedDatabase(10_000)
const startedAt = Date.now()
expect(() =>
new SyncDatabase(contended.path, { readonly: true, timeout: 400 })
.prepare('SELECT id FROM items')
.all()
).toThrow(/database is locked/)
const reader = new SyncDatabase(contended.path, { readonly: true, timeout: 400 })
openDatabases.push(reader)
expect(() => reader.prepare('SELECT id FROM items').all()).toThrow(/database is locked/)
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(350)
})
@@ -1,4 +1,4 @@
import type { Page } from '@stablyai/playwright-test'
import { expect, type Page } from '@stablyai/playwright-test'
import {
DOCKER_SSH_PROXY_JUMP_REMOTE_REPO_PATH,
@@ -227,3 +227,33 @@ export async function reconnectDisconnectedDockerSshRelayTarget(
): Promise<void> {
return performDockerSshRelayReconnect(page, targetId, false)
}
export async function recoverDockerSshRelayAfterFault(
page: Page,
targetId: string,
injectFault: () => void | Promise<void>
): Promise<void> {
const readAuthority = () =>
page.evaluate((id) => window.__store?.getState().sshConnectionStates.get(id), targetId)
const before = await readAuthority()
expect(before).toMatchObject({
status: 'connected',
providerEpoch: expect.any(String),
connectionGeneration: expect.any(Number)
})
await injectFault()
// The pre-fault connected publication can remain visible until the next IPC event.
await expect
.poll(
async () => {
const after = await readAuthority()
return (
after?.status === 'connected' &&
(after.providerEpoch !== before?.providerEpoch ||
after.connectionGeneration !== before?.connectionGeneration)
)
},
{ timeout: 120_000, message: 'SSH authority did not recover after the injected fault' }
)
.toBe(true)
}
@@ -20,6 +20,16 @@ function hasExited(proc: ChildProcess): boolean {
return proc.exitCode !== null || proc.signalCode !== null
}
function releaseExitedProcessPipes(proc: ChildProcess): void {
if (!hasExited(proc)) {
return
}
// Detached SSH helpers can retain inherited pipes after Electron itself exits.
for (const stream of proc.stdio) {
stream?.destroy()
}
}
function waitForExit(proc: ChildProcess, timeoutMs: number): Promise<boolean> {
if (hasExited(proc)) {
return Promise.resolve(true)
@@ -167,12 +177,16 @@ export async function forceQuitElectronAppForE2E(app: ElectronApplication): Prom
}
}
await waitForExit(proc, PROCESS_EXIT_TIMEOUT_MS)
releaseExitedProcessPipes(proc)
// Hands the dead app back to Playwright so worker teardown has nothing left to wait on.
await app.close().catch(() => undefined)
}
export async function closeElectronAppForE2E(app: ElectronApplication): Promise<void> {
const proc = app.process()
const releasePipes = (): void => releaseExitedProcessPipes(proc)
proc.once('exit', releasePipes)
releasePipes()
try {
await withTimeout(app.close(), GRACEFUL_CLOSE_TIMEOUT_MS, 'Timed out closing Electron app')
if (proc) {
@@ -185,6 +199,9 @@ export async function closeElectronAppForE2E(app: ElectronApplication): Promise<
if (proc) {
await forceKillProcessTree(proc)
}
} finally {
proc.off('exit', releasePipes)
releasePipes()
}
}
@@ -0,0 +1,59 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import type { ChildProcess } from 'node:child_process'
import type { ElectronApplication } from '@stablyai/playwright-test'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { closeElectronAppForE2E } from './electron-process-shutdown'
function exitedAppFixture() {
const proc = Object.assign(new EventEmitter(), {
exitCode: null as number | null,
signalCode: null,
stdio: [new PassThrough(), new PassThrough(), new PassThrough()]
})
const pipesClosed = Promise.all(
proc.stdio.map((stream) => new Promise<void>((resolve) => stream.once('close', resolve)))
)
const close = vi.fn(() => pipesClosed)
const app = {
process: () => proc as unknown as ChildProcess,
close
} as unknown as ElectronApplication
return { proc, app, close }
}
afterEach(() => vi.useRealTimers())
describe('Electron shutdown with inherited pipes', () => {
it('releases retained pipes only after Electron exits, settling Playwright cleanup', async () => {
const { proc, app, close } = exitedAppFixture()
const closing = closeElectronAppForE2E(app)
expect(close).toHaveBeenCalledOnce()
expect(proc.stdio.every((stream) => !stream.destroyed)).toBe(true)
proc.exitCode = 0
proc.emit('exit', 0, null)
await closing
expect(proc.stdio.every((stream) => stream.destroyed)).toBe(true)
expect(proc.listenerCount('exit')).toBe(0)
})
it('releases pipes when Electron already exited before cleanup starts', async () => {
const { proc, app } = exitedAppFixture()
proc.exitCode = 0
await closeElectronAppForE2E(app)
expect(proc.stdio.every((stream) => stream.destroyed)).toBe(true)
})
it('does not release pipes if shutdown times out without confirmed process exit', async () => {
vi.useFakeTimers()
const { proc, app } = exitedAppFixture()
const closing = closeElectronAppForE2E(app)
await vi.advanceTimersByTimeAsync(10_000)
await closing
expect(proc.stdio.every((stream) => !stream.destroyed)).toBe(true)
expect(proc.listenerCount('exit')).toBe(0)
for (const stream of proc.stdio) {
stream.destroy()
}
})
})
+11 -9
View File
@@ -68,7 +68,7 @@ test.describe('Docker SSH half-open link', () => {
const ptyId = await waitForActivePanePtyId(orcaPage, 60_000)
const runId = String(Date.now())
await execInTerminal(orcaPage, ptyId, `echo LIVE_${runId}`)
await execInTerminal(orcaPage, ptyId, `printf 'LIVE_%s\\n' ${runId}`)
await waitForTerminalOutput(orcaPage, `LIVE_${runId}`, 60_000)
expect(await readSshStatus(orcaPage, remote.targetId)).toBe('connected')
@@ -78,13 +78,15 @@ test.describe('Docker SSH half-open link', () => {
const frozenAt = Date.now()
let verdict: string | null = 'connected'
while (Date.now() - frozenAt < LOST_VERDICT_BUDGET_MS) {
verdict = await readSshStatus(orcaPage, remote.targetId)
if (verdict !== 'connected') {
break
}
await orcaPage.waitForTimeout(1_000)
}
await expect
.poll(
async () => {
verdict = await readSshStatus(orcaPage, remote.targetId)
return verdict
},
{ timeout: LOST_VERDICT_BUDGET_MS, message: 'frozen host remained connected' }
)
.not.toBe('connected')
const verdictMs = Date.now() - frozenAt
console.log(
`[half-open] ${JSON.stringify({ verdict, verdictMs, budgetMs: LOST_VERDICT_BUDGET_MS })}`
@@ -105,7 +107,7 @@ test.describe('Docker SSH half-open link', () => {
.poll(() => readSshStatus(orcaPage, remote.targetId), { timeout: 120_000 })
.toBe('connected')
const recoveredPtyId = await waitForActivePanePtyId(orcaPage, 60_000)
await execInTerminal(orcaPage, recoveredPtyId, `echo RECOVERED_${runId}`)
await execInTerminal(orcaPage, recoveredPtyId, `printf 'RECOVERED_%s\\n' ${runId}`)
await waitForTerminalOutput(orcaPage, `RECOVERED_${runId}`, 90_000)
} finally {
if (target && paused) {
@@ -1,6 +1,6 @@
import path from 'node:path'
import { readFileSync } from 'node:fs'
import type { ElectronApplication, Page } from '@playwright/test'
import type { ElectronApplication } from '@playwright/test'
import { test, expect } from './helpers/orca-app'
import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles'
import { sshRemotePtyLeaseAllowsReattach, type SshRemotePtyLease } from '../../src/shared/ssh-types'
@@ -19,7 +19,10 @@ import {
startDockerSshRelayTarget,
type DockerSshRelayTarget
} from './helpers/docker-ssh-relay-target'
import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection'
import {
connectDockerSshRelayTarget,
recoverDockerSshRelayAfterFault
} from './helpers/docker-ssh-relay-connection'
import {
clearDockerSshRelayFaults,
dropDockerSshRelayTransport,
@@ -47,13 +50,6 @@ const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
* with only the first cannot tell a resume from a silent cold start
* (docs/reference/ssh-execution-boundary.md).
*/
async function readSshStatus(orcaPage: Page, targetId: string) {
return orcaPage.evaluate(
(targetId) => window.__store?.getState().sshConnectionStates.get(targetId)?.status ?? null,
targetId
)
}
/**
* Every lease `reattachKnownPtys` would feed to `pty.attach` on the next connect, read from the
* durable store rather than from the renderer — leases are main-owned and never published.
@@ -123,7 +119,9 @@ test.describe('SSH transport drop recovery', () => {
enableDockerSshRelayTargetShellTitle(target)
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
const remote = await connectDockerSshRelayTarget(orcaPage, target)
const remote = await connectDockerSshRelayTarget(orcaPage, target, {
relayGracePeriodSeconds: 0
})
await ensureTerminalVisible(orcaPage, 45_000)
await waitForActiveTerminalManager(orcaPage, 60_000)
const ptyId = await waitForActivePanePtyId(orcaPage, 60_000)
@@ -135,20 +133,12 @@ test.describe('SSH transport drop recovery', () => {
await execInTerminal(orcaPage, ptyId, `printf 'DROP_MARKER_%s\\n' ${markerSuffix}`)
await waitForTerminalOutput(orcaPage, marker, 30_000)
const dropped = dropDockerSshRelayTransport(target)
expect(dropped, 'no live SSH connection was found to drop').toBeGreaterThan(0)
// Nothing below calls ssh.connect(). Recovery has to come from the client's own ladder,
// which is the behaviour users depend on and the thing a scripted reconnect never exercised.
await expect
.poll(() => readSshStatus(orcaPage, remote.targetId), {
timeout: 120_000,
message: 'SSH target never returned to connected after the transport was dropped'
})
.toBe('connected')
await recoverDockerSshRelayAfterFault(orcaPage, remote.targetId, () => {
expect(dropDockerSshRelayTransport(target!)).toBeGreaterThan(0)
})
await waitForActiveTerminalManager(orcaPage, 60_000)
await waitForActivePanePtyId(orcaPage, 60_000)
expect(await waitForActivePanePtyId(orcaPage, 60_000)).toBe(ptyId)
// The pane must still show what it had. A blank pane here is the reported bug.
await waitForTerminalOutput(orcaPage, marker, 60_000)
@@ -171,10 +161,7 @@ test.describe('SSH transport drop recovery', () => {
}
})
// Fixme: fails in CI on its first real run — the pane keeps its PTY and repaints, but a command
// run after the flood produces no output within the poll budget. Same shape as #18018 (deaf pane
// after a stalled host resumes), and not caused by this spec. Tracked there; the three verdict
// assertions around it stay enforced.
// #18018: local authority-aware recovery still loses the flooded pane's relay channel.
test.fixme('stays bounded when a disconnected shell floods its pty', async ({
orcaPage
}, testInfo) => {
@@ -196,7 +183,9 @@ test.describe('SSH transport drop recovery', () => {
enableDockerSshRelayTargetShellTitle(target)
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
const remote = await connectDockerSshRelayTarget(orcaPage, target)
const remote = await connectDockerSshRelayTarget(orcaPage, target, {
relayGracePeriodSeconds: 0
})
await ensureTerminalVisible(orcaPage, 45_000)
await waitForActiveTerminalManager(orcaPage, 240_000)
const ptyId = await waitForActivePanePtyId(orcaPage, 240_000)
@@ -215,18 +204,12 @@ test.describe('SSH transport drop recovery', () => {
await execInTerminal(
orcaPage,
ptyId,
`yes "$(printf 'ORCA_%s' FLOOD_LINE)" | head -c 48000000; echo FLOODED`
`yes "$(printf 'ORCA_%s' FLOOD_LINE)" | head -c 48000000; printf 'FLOO%s\\n' DED`
)
await waitForTerminalOutput(orcaPage, 'ORCA_FLOOD_LINE', 30_000, 20_000)
const dropped = dropDockerSshRelayTransport(target)
expect(dropped).toBeGreaterThan(0)
await expect
.poll(() => readSshStatus(orcaPage, remote.targetId), {
timeout: 120_000,
message: 'SSH target never returned to connected'
})
.toBe('connected')
await recoverDockerSshRelayAfterFault(orcaPage, remote.targetId, () => {
expect(dropDockerSshRelayTransport(target!)).toBeGreaterThan(0)
})
await waitForActiveTerminalManager(orcaPage, 240_000)
// Why a generous ceiling: this is an OOM guard, not a memory budget. Unbounded retention of
@@ -237,6 +220,9 @@ test.describe('SSH transport drop recovery', () => {
`relay grew ${afterRssKb - baselineRssKb}KB after 48MB of undeliverable output`
).toBeLessThan(200_000)
// Wait for the finite producer to finish before sending a shell command behind it.
await waitForTerminalOutput(orcaPage, 'FLOODED', 120_000, 20_000)
// And the session must still be usable, not merely alive.
const markerSuffix = Date.now()
const marker = `FLOOD_AFTER_${markerSuffix}`
@@ -274,7 +260,9 @@ test.describe('SSH transport drop recovery', () => {
enableDockerSshRelayTargetShellTitle(target)
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
const remote = await connectDockerSshRelayTarget(orcaPage, target)
const remote = await connectDockerSshRelayTarget(orcaPage, target, {
relayGracePeriodSeconds: 0
})
await ensureTerminalVisible(orcaPage, 45_000)
await waitForActiveTerminalManager(orcaPage, 60_000)
const ptyId = await waitForActivePanePtyId(orcaPage, 60_000)
@@ -284,15 +272,9 @@ test.describe('SSH transport drop recovery', () => {
await execInTerminal(orcaPage, ptyId, `printf 'KILL_MARKER_%s\\n' ${markerSuffix}`)
await waitForTerminalOutput(orcaPage, marker, 30_000)
const killed = killDockerSshRelayDaemon(target)
expect(killed, 'no relay process was found to kill').toBeGreaterThan(0)
await expect
.poll(() => readSshStatus(orcaPage, remote.targetId), {
timeout: 120_000,
message: 'SSH target never returned to connected after the relay was killed'
})
.toBe('connected')
await recoverDockerSshRelayAfterFault(orcaPage, remote.targetId, () => {
expect(killDockerSshRelayDaemon(target!)).toBeGreaterThan(0)
})
await waitForActiveTerminalManager(orcaPage, 60_000)
// The verdict, expressed as the only thing a user can observe: the pane is now backed by a
@@ -346,7 +328,9 @@ test.describe('SSH transport drop recovery', () => {
enableDockerSshRelayTargetShellTitle(target)
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
const remote = await connectDockerSshRelayTarget(orcaPage, target)
const remote = await connectDockerSshRelayTarget(orcaPage, target, {
relayGracePeriodSeconds: 0
})
await ensureTerminalVisible(orcaPage, 45_000)
await waitForActiveTerminalManager(orcaPage, 60_000)
await waitForActivePanePtyId(orcaPage, 60_000)
@@ -356,16 +340,12 @@ test.describe('SSH transport drop recovery', () => {
for (let generation = 1; generation <= 5; generation++) {
const previousPtyId = await waitForActivePanePtyId(orcaPage, 60_000)
expect(
killDockerSshRelayDaemon(target),
'no relay process was found to kill'
).toBeGreaterThan(0)
await expect
.poll(() => readSshStatus(orcaPage, remote.targetId), {
timeout: 120_000,
message: `SSH target never reconnected after relay kill ${generation}`
})
.toBe('connected')
await recoverDockerSshRelayAfterFault(orcaPage, remote.targetId, () => {
expect(
killDockerSshRelayDaemon(target!),
'no relay process was found to kill'
).toBeGreaterThan(0)
})
await waitForActiveTerminalManager(orcaPage, 120_000)
// Transport status can still be connected while the pane retains its old binding.
await expect
@@ -425,7 +405,7 @@ test.describe('SSH transport drop recovery', () => {
enableDockerSshRelayTargetShellTitle(target)
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await connectDockerSshRelayTarget(orcaPage, target)
await connectDockerSshRelayTarget(orcaPage, target, { relayGracePeriodSeconds: 0 })
await ensureTerminalVisible(orcaPage, 45_000)
await waitForActiveTerminalManager(orcaPage, 60_000)
const ptyId = await waitForActivePanePtyId(orcaPage, 60_000)
@@ -453,17 +433,8 @@ test.describe('SSH transport drop recovery', () => {
}
})
/**
* Known broken on main, kept as the reproduction. The verdict test above passes: after a 30s
* freeze the pane keeps its PTY and repaints its scrollback. What does not come back is the
* shell — a command run afterwards produces no output within 60s, so the pane is live-looking and
* deaf. Measured twice at `waitForTerminalOutput(STALL_AFTER_…)`, and it reproduces unchanged
* with the reattach-token/delivery-ownership fix applied, so that is not the cause.
*
* Split out rather than folded into the test above so the `unverifiable` verdict stays enforced
* in CI instead of being masked by this failure.
*/
test.fixme('accepts input again after a frozen host resumes', async ({ orcaPage }, testInfo) => {
// #18018: wait for the recovered authority before input; a retained manager can still be disconnected.
test('accepts input again after a frozen host resumes', async ({ orcaPage }, testInfo) => {
test.slow()
let target: DockerSshRelayTarget | null = null
try {
@@ -471,13 +442,17 @@ test.describe('SSH transport drop recovery', () => {
enableDockerSshRelayTargetShellTitle(target)
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await connectDockerSshRelayTarget(orcaPage, target)
const remote = await connectDockerSshRelayTarget(orcaPage, target, {
relayGracePeriodSeconds: 0
})
await ensureTerminalVisible(orcaPage, 45_000)
await waitForActiveTerminalManager(orcaPage, 60_000)
const ptyId = await waitForActivePanePtyId(orcaPage, 60_000)
await withStalledDockerSshRelayTarget(target, async () => {
await orcaPage.waitForTimeout(30_000)
await recoverDockerSshRelayAfterFault(orcaPage, remote.targetId, async () => {
await withStalledDockerSshRelayTarget(target!, async () => {
await orcaPage.waitForTimeout(30_000)
})
})
await waitForActiveTerminalManager(orcaPage, 60_000)