diff --git a/.gitattributes b/.gitattributes index 1aa7969e805..1b447a9189e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -31,6 +31,10 @@ # the reviewable change, and pin LF because they are compared byte-for-byte. # Not -diff: the shell diff is the review surface when a wrapper does change. /src/main/__fixtures__/shell-wrapper-snapshots/*.txt linguist-generated=true text eol=lf +# Captured agent PTY transcripts. -text, not `text eol=lf` like the wrapper snapshots above: +# these carry real CR and CRLF bytes as the terminal emitted them, and line-ending +# normalisation on a Windows checkout would rewrite the evidence the fixture exists to be. +/src/main/runtime/__fixtures__/*.txt -text # Generated runtime English subset: compared byte-for-byte by # verify:localization-runtime-catalog, so a CRLF checkout would fail the gate. /src/renderer/src/i18n/en-runtime-required.json linguist-generated=true text eol=lf diff --git a/.github/workflows/pi-owner-runtime.yml b/.github/workflows/pi-owner-runtime.yml new file mode 100644 index 00000000000..373afb7a539 --- /dev/null +++ b/.github/workflows/pi-owner-runtime.yml @@ -0,0 +1,29 @@ +name: Pi owner runtime verification +on: + pull_request: + paths: + - 'src/main/pi/agent-status-handler-source.ts' + - 'tests/tools/pi-owner-runtime-smoke.mjs' + - '.github/workflows/pi-owner-runtime.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + runtime: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Install pinned extension loader + run: npm install --prefix .cache/pi-owner --ignore-scripts --no-audit --no-fund @earendil-works/pi-coding-agent@0.83.0 + - name: Verify real owner exit and hook delivery + run: node tests/tools/pi-owner-runtime-smoke.mjs .cache/pi-owner/node_modules/@earendil-works/pi-coding-agent diff --git a/.github/workflows/pi-provider-runtime.yml b/.github/workflows/pi-provider-runtime.yml new file mode 100644 index 00000000000..837c38baf9a --- /dev/null +++ b/.github/workflows/pi-provider-runtime.yml @@ -0,0 +1,28 @@ +name: Pi extension provider verification +on: + pull_request: + paths: + - 'src/shared/commit-message-agent-specs-primary.ts' + - 'tests/tools/pi-provider-runtime-smoke.mjs' + - '.github/workflows/pi-provider-runtime.yml' +permissions: + contents: read +jobs: + runtime: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Install pinned Pi runtime + run: npm install --prefix .cache/pi-provider --ignore-scripts --no-audit --no-fund @earendil-works/pi-coding-agent@0.84.2 + - name: Verify extension model generation before and after + run: node tests/tools/pi-provider-runtime-smoke.mjs .cache/pi-provider/node_modules/@earendil-works/pi-coding-agent/dist/cli.js diff --git a/.gitignore b/.gitignore index 913dfc4a045..e5207a25015 100644 --- a/.gitignore +++ b/.gitignore @@ -103,7 +103,9 @@ docs/** !docs/agent-skill-sharing-implementation-checklist.md !docs/mobile-terminal-shortcut-bar.md !docs/reference/ +!docs/reference/agent-pty-transcript-capture.md !docs/reference/agent-status-store.md +!docs/reference/antigravity-readiness-evidence.md !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md !docs/reference/ime-regression-checklist.md diff --git a/AGENTS.md b/AGENTS.md index 5ff66b95b0f..f1ce31e404b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,10 @@ All changes must consider folder workspaces as well as git worktrees. Don't assu The execution host owns agent status in one store, the hook server's, and every reader (sidebar, `worktree ps`, mobile, dashboard) subscribes to it. Before adding a producer, a cache, or a reader-side precedence rule, read [`docs/reference/agent-status-store.md`](./docs/reference/agent-status-store.md): new producers write into that store, and readers keep only presentation policy. +## Agent Terminal Screens + +A rule that reads what an agent CLI paints on a terminal — readiness, blocked prompts, idle — must be written against a captured transcript, not a remembered screen. Record one with [`docs/reference/agent-pty-transcript-capture.md`](./docs/reference/agent-pty-transcript-capture.md), which keeps escapes and wrapping intact and scrubs account identifiers before they reach git. Antigravity readiness has no transcript yet and five failed attempts without one; before touching it, read [`docs/reference/antigravity-readiness-evidence.md`](./docs/reference/antigravity-readiness-evidence.md). + ## Remote Wire Compatibility Clients and remote Orca servers update independently, so mixed versions are the normal state. Before changing anything a paired client and host exchange — RPC params, stream frames, or the content either side publishes over them — follow [`docs/reference/remote-wire-compatibility.md`](./docs/reference/remote-wire-compatibility.md). A new optional field is safe; a new stream opcode must be capability-negotiated because decoders drop unknown opcodes silently; and changing what the host publishes reaches old clients even with no wire change. diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0ba70ddcf1e..4e1f44bd7c1 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10,6 +10,81 @@ } }, "gates": [ + { + "id": "runtime.connection-owned-host-status", + "title": "Host status recovers with its owning connection", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime", + "layer": "service-integration-and-e2e", + "surfaces": [ + "sidebar host status", + "desktop runtime connection", + "browser primary connection" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["remote-runtime"], + "coverageNotes": "Real authenticated sockets plus isolated desktop and headless hosts with desktop and browser viewers; deterministic lifecycle tests cover stale results and reader deadlines.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/19163"], + "invariant": "Failed bootstrap and authenticated reconnect converge without UI triggers; one connection owner publishes verified status, with no independent healthy status polling.", + "oracle": "Observe automatic recovery, retained runtime identity on failure, ordered publications, exact request counts, isolated viewer outages, and retirement on disconnect.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/runtime-host-status-owner.test.ts src/main/ipc/runtime-environment-status-recovery.test.ts src/main/ipc/runtime-environment-status-connection.test.ts src/renderer/src/store/slices/runtime-status-snapshot.test.ts src/renderer/src/web/web-runtime-status-owner.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/runtime-host-status-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/shared/runtime-host-status-owner.test.ts", + "src/main/ipc/runtime-environment-status-recovery.test.ts", + "src/main/ipc/runtime-environment-status-connection.test.ts", + "src/renderer/src/store/slices/runtime-status-snapshot.test.ts", + "src/renderer/src/web/web-runtime-status-owner.test.ts", + "tests/e2e/runtime-host-status-recovery.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/ipc/runtime-environment-status-recovery.test.ts", + "assertions": [ + "recovers a saved host after its first status check fails, without another UI request" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-10", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/runtime-host-status-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "summary": "Desktop-host and headless-host journeys passed with desktop and browser viewers.", + "durationSeconds": 31.3 + } + ], + "runtimeBudget": { + "p95Seconds": 180, + "scope": "Target excluding builds; measured p95 not established." + }, + "flakeHistory": { + "status": "soaking", + "evidence": "Local candidate runs passed; no sustained CI history yet." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "First-status-failure oracle failed on main 58ff95becb40 (one request instead of two) and passes on the candidate. E2E verifies candidate recovery, not a baseline comparison." + }, + "performanceBudget": { + "required": false, + "evidence": "Deterministic tests assert one shared request and no healthy owner polling." + }, + "promotionCriteria": ["Collect repeated CI runs without unexplained flakes."], + "knownGaps": [ + "No live Linux, Windows, SSH, or mixed-version pair validation.", + "TCP interruption exercises reconnect, not a full real host process restart.", + "The outage begins on the first saved-host check, not by relaunching a preseeded desktop profile." + ], + "demotionRule": "Keep experimental until repeated runs establish reliability; preserve request-count and lifecycle assertions." + }, { "id": "mobile-push.headless-startup-and-policy", "title": "Headless push lifecycle and mobile delivery policy", diff --git a/config/scripts/capture-agent-pty-transcript.mjs b/config/scripts/capture-agent-pty-transcript.mjs new file mode 100644 index 00000000000..a60d0adbdd5 --- /dev/null +++ b/config/scripts/capture-agent-pty-transcript.mjs @@ -0,0 +1,283 @@ +/** + * Records a live agent CLI session through a real PTY into a test fixture, bytes intact. + * + * Why a PTY and not `agy | tee`: a pipe is not a terminal, so the CLI renders its + * non-interactive path — no alternate screen, no caret, no dialogs. The detector under + * test only ever sees the PTY shape, so that is the only shape worth capturing. + * + * Nothing here strips escapes, folds CRs, or rewraps lines: the transcript is written + * exactly as the terminal received it. See docs/reference/agent-pty-transcript-capture.md. + */ +import { createWriteStream, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + formatFindings, + redactTranscript, + scanTranscriptForSecrets +} from './pty-transcript-secret-scan.mjs' + +const REPO_ROOT = resolve(import.meta.dirname, '..', '..') +const FIXTURE_DIR = join(REPO_ROOT, 'src', 'main', 'runtime', '__fixtures__') +const STOP_KEY = 0x1d // Ctrl-], consumed by the recorder and never forwarded to the agent. +const NAME_RE = /^[a-z0-9][a-z0-9-]*$/ + +const USAGE = `Capture a raw agent PTY transcript into src/main/runtime/__fixtures__/. + + node config/scripts/capture-agent-pty-transcript.mjs --name [options] -- [args...] + node config/scripts/capture-agent-pty-transcript.mjs --scan [--redact] + +Options + --name Output fixture name, e.g. antigravity-ready-personal-non-gemini + --out Write somewhere other than the fixture directory + --cols --rows Pin the PTY size (default: this terminal's size, else 120x40) + --duration Stop unattended after N seconds + --send ":" Type into the PTY at (repeatable; \\r \\n \\t \\e escapes) + --note "" Recorded in the .meta.json sidecar + --scan Scan existing transcripts for identifiers/credentials and exit + --redact With --scan: rewrite each finding as a same-length placeholder + +Press Ctrl-] to end a capture. That key is consumed here, so the agent keeps whatever +dialog it is showing — which is the only way to capture a dialog that owns the screen.` + +function parseArgs(argv) { + const options = { cols: null, rows: null, duration: null, scan: [], sends: [], redact: false } + const command = [] + let cursor = 0 + let afterSeparator = false + while (cursor < argv.length) { + const arg = argv[cursor] + if (afterSeparator) { + command.push(arg) + cursor += 1 + continue + } + if (arg === '--') { + afterSeparator = true + } else if (arg === '--redact') { + options.redact = true + } else if (arg === '--help' || arg === '-h') { + options.help = true + } else if (arg === '--scan') { + while (cursor + 1 < argv.length && !argv[cursor + 1].startsWith('--')) { + cursor += 1 + options.scan.push(argv[cursor]) + } + } else if (arg === '--send') { + cursor += 1 + options.sends.push(parseSend(argv[cursor])) + } else if (arg.startsWith('--')) { + const key = arg.slice(2) + cursor += 1 + options[key] = argv[cursor] + } + cursor += 1 + } + for (const key of ['cols', 'rows', 'duration']) { + options[key] = options[key] == null ? null : Number(options[key]) + } + return { options, command } +} + +// String.fromCharCode, not a literal: the formatter rewrites an escape sequence into a raw +// control byte in source, which is unreadable and survives badly in diffs. +const ESC = String.fromCharCode(27) +const SEND_ESCAPES = { r: '\r', n: '\n', t: '\t', e: ESC, '\\': '\\' } + +/** `":"` — a keystroke to deliver at a fixed offset, for an unattended dialog capture. */ +function parseSend(value) { + const separator = String(value ?? '').indexOf(':') + if (separator === -1) { + throw new Error(`--send expects ":", got ${String(value)}`) + } + const atMs = Number(value.slice(0, separator)) + if (!Number.isFinite(atMs)) { + throw new Error( + `--send delay must be a number of milliseconds, got ${value.slice(0, separator)}` + ) + } + const text = value + .slice(separator + 1) + .replace(/\\(.)/g, (whole, code) => SEND_ESCAPES[code] ?? whole) + return { atMs, text } +} + +function runScan(files, redact) { + let failed = false + for (const file of files) { + const path = resolve(file) + const text = readFileSync(path, 'utf8') + if (redact) { + const { text: redacted, redacted: count } = redactTranscript(text) + writeFileSync(path, redacted) + console.log(`${file}: redacted ${count} span(s) in place, same length each.`) + continue + } + const findings = scanTranscriptForSecrets(text) + console.log(formatFindings(file, findings)) + failed ||= findings.length > 0 + } + return failed ? 1 : 0 +} + +function resolveSpawn(command) { + // node-pty cannot run a .cmd/.bat shim directly on Windows; those need cmd.exe. + if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(command[0])) { + return { file: 'cmd.exe', args: ['/c', `"${command[0]}"`, ...command.slice(1)] } + } + return { file: command[0], args: command.slice(1) } +} + +async function runCapture(options, command) { + const name = options.name + if (typeof name === 'string' && !NAME_RE.test(name)) { + console.error(`--name must be lowercase kebab-case; got ${name}`) + return 2 + } + const outPath = options.out ? resolve(options.out) : join(FIXTURE_DIR, `${name}.txt`) + mkdirSync(dirname(outPath), { recursive: true }) + + const pty = await import('node-pty').catch((error) => { + console.error( + `node-pty failed to load. Build it for plain node first: + node config/scripts/ensure-native-runtime.mjs --runtime=node +${String(error)}` + ) + return null + }) + if (pty === null) { + return 2 + } + + const cols = options.cols ?? process.stdout.columns ?? 120 + const rows = options.rows ?? process.stdout.rows ?? 40 + const { file, args } = resolveSpawn(command) + const term = pty.spawn(file, args, { + name: 'xterm-256color', + cols, + rows, + cwd: process.cwd(), + env: { ...process.env, TERM: 'xterm-256color' }, + encoding: null + }) + + const sink = createWriteStream(outPath) + let recording = true + term.onData((chunk) => { + const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk + // Why recording stops before the kill: an agent repaints an idle frame on its way out, so + // a transcript that keeps writing through shutdown ends on that frame instead of on the + // state you stopped to capture. A mid-turn or dialog capture cannot survive that. + if (recording) { + sink.write(bytes) + } + process.stdout.write(bytes) + }) + + const wasRaw = process.stdin.isTTY === true && process.stdin.isRaw === true + if (process.stdin.isTTY) { + process.stdin.setRawMode(true) + } + process.stdin.resume() + let stopping = false + const stop = () => { + if (stopping) { + return + } + stopping = true + recording = false + try { + term.kill() + } catch { + // The agent may have exited on its own; the transcript is already on disk. + } + } + process.stdin.on('data', (chunk) => { + if (chunk.includes(STOP_KEY)) { + stop() + return + } + term.write(chunk.toString('binary')) + }) + // Why scripted input: a dialog capture has to be driven, and CI (or an agent) has no TTY to + // type into. The keystrokes ride the same PTY a human's would, so the capture is unchanged. + const sendTimers = options.sends.map((send) => setTimeout(() => term.write(send.text), send.atMs)) + const durationTimer = options.duration === null ? null : setTimeout(stop, options.duration * 1000) + + const exitCode = await new Promise((resolveExit) => { + term.onExit(({ exitCode: code }) => resolveExit(code ?? 0)) + }) + for (const timer of sendTimers) { + clearTimeout(timer) + } + if (durationTimer !== null) { + clearTimeout(durationTimer) + } + if (process.stdin.isTTY) { + process.stdin.setRawMode(wasRaw) + } + process.stdin.pause() + await new Promise((done) => sink.end(done)) + + writeMeta(outPath, { command, cols, rows, note: options.note ?? null, exitCode }) + const findings = scanTranscriptForSecrets(readFileSync(outPath, 'utf8')) + console.log(`\nTranscript: ${outPath}`) + console.log(formatFindings('scrub check', findings)) + if (findings.length > 0) { + console.log( + `Scrub with: + node config/scripts/capture-agent-pty-transcript.mjs --scan ${outPath} --redact` + ) + } + return 0 +} + +function writeMeta(outPath, details) { + const metaPath = outPath.replace(/\.txt$/, '.meta.json') + writeFileSync( + metaPath, + `${JSON.stringify( + { + capturedAt: new Date().toISOString(), + platform: process.platform, + command: details.command, + cols: details.cols, + rows: details.rows, + note: details.note, + exitCode: details.exitCode + }, + null, + 2 + )}\n` + ) +} + +async function main() { + const { options, command } = parseArgs(process.argv.slice(2)) + if (options.help === true) { + console.log(USAGE) + return 0 + } + if (options.scan.length > 0) { + return runScan(options.scan, options.redact) + } + if (command.length === 0 || (options.name === undefined && options.out === undefined)) { + console.error(USAGE) + return 2 + } + return runCapture(options, command) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().then( + (code) => { + process.exitCode = code + }, + (error) => { + console.error(error) + process.exitCode = 1 + } + ) +} + +export { parseArgs, resolveSpawn } diff --git a/config/scripts/pty-transcript-secret-scan.mjs b/config/scripts/pty-transcript-secret-scan.mjs new file mode 100644 index 00000000000..1d93204ccda --- /dev/null +++ b/config/scripts/pty-transcript-secret-scan.mjs @@ -0,0 +1,135 @@ +// Finds account identifiers and credentials in a captured PTY transcript before it is committed. +import os from 'node:os' + +// Why same-length replacements: a transcript's value is its exact wrapping and column +// alignment. Shortening a redacted span reflows the screen and destroys the evidence. +const EMAIL_DOMAIN = '@example.com' +const PLACEHOLDER_UUID = '00000000-0000-4000-8000-000000000000' + +/** Ordered most-specific first; the first pattern to claim a span owns it. */ +function buildPatterns() { + const username = os.userInfo().username + const hostname = os.hostname() + const patterns = [ + { kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g }, + { kind: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{20,}/g }, + { kind: 'google-refresh-token', re: /\b1\/\/[0-9A-Za-z_-]{20,}/g }, + { kind: 'vendor-key', re: /\b(?:sk-|ghp_|gho_|github_pat_|xoxb-|xoxp-)[A-Za-z0-9_-]{16,}/g }, + { kind: 'bearer-token', re: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi }, + { kind: 'email', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g }, + // Why a UUID counts: agy prints a resumable conversation id on exit, and installation and + // project ids look the same. They identify the operator's session, not just its shape. + { kind: 'uuid', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi }, + { kind: 'opaque-token', re: /\b[A-Za-z0-9_-]{40,}\b/g } + ] + if (username.length >= 3) { + patterns.splice(5, 0, { kind: 'local-username', re: literalPattern(username) }) + } + if (hostname.length >= 3) { + patterns.splice(5, 0, { kind: 'local-hostname', re: literalPattern(hostname) }) + } + return patterns +} + +function literalPattern(value) { + return new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g') +} + +/** + * @param {string} text raw transcript, escapes intact + * @returns {{kind: string, line: number, column: number, index: number, match: string}[]} + */ +export function scanTranscriptForSecrets(text) { + const claimed = [] + const findings = [] + for (const { kind, re } of buildPatterns()) { + re.lastIndex = 0 + let match = re.exec(text) + while (match !== null) { + const start = match.index + const end = start + match[0].length + if (!claimed.some(([from, to]) => start < to && end > from)) { + claimed.push([start, end]) + if (!isAlreadyScrubbed(kind, match[0])) { + findings.push({ kind, index: start, match: match[0], ...locate(text, start) }) + } + } + match = re.exec(text) + } + } + return findings.sort((left, right) => left.index - right.index) +} + +// Why: a scrubbed fixture must verify clean, so this scanner has to recognise its own +// placeholders — otherwise "prove it's gone" can never pass and the check gets ignored. +const PLACEHOLDER_DOMAIN_RE = /@(?:example\.(?:com|org|net)|localhost)$/i + +function isAlreadyScrubbed(kind, match) { + if (kind === 'email') { + return PLACEHOLDER_DOMAIN_RE.test(match) + } + if (kind === 'uuid') { + return match.toLowerCase() === PLACEHOLDER_UUID + } + return /^(.)\1*$/.test(match) +} + +function locate(text, index) { + let line = 1 + let lineStart = 0 + for (let cursor = 0; cursor < index; cursor += 1) { + if (text.charCodeAt(cursor) === 10) { + line += 1 + lineStart = cursor + 1 + } + } + return { line, column: index - lineStart + 1 } +} + +/** Same-length stand-in so redaction cannot reflow the captured screen. */ +export function placeholderFor(kind, length) { + if (kind === 'uuid' && length === PLACEHOLDER_UUID.length) { + return PLACEHOLDER_UUID + } + if (kind === 'email' && length > EMAIL_DOMAIN.length) { + return 'u'.repeat(length - EMAIL_DOMAIN.length) + EMAIL_DOMAIN + } + return kind === 'local-username' || kind === 'local-hostname' + ? 'x'.repeat(length) + : 'X'.repeat(length) +} + +/** @returns {{text: string, redacted: number}} */ +export function redactTranscript(text) { + const findings = scanTranscriptForSecrets(text) + let out = '' + let cursor = 0 + for (const finding of findings) { + out += text.slice(cursor, finding.index) + out += placeholderFor(finding.kind, finding.match.length) + cursor = finding.index + finding.match.length + } + return { text: out + text.slice(cursor), redacted: findings.length } +} + +export function formatFindings(label, findings) { + if (findings.length === 0) { + return `${label}: clean — no account identifier or credential shapes found.` + } + const rows = findings.map( + (finding) => ` ${finding.line}:${finding.column} ${finding.kind} ${preview(finding.match)}` + ) + return [`${label}: ${findings.length} finding(s) — scrub before committing.`, ...rows].join('\n') +} + +// Why a codepoint test and not a character class: a control-byte range written as an escape is +// folded back into raw 0x00-0x1f bytes by the formatter, which makes this file binary to the VCS +// and leaves the one file gating real PTY data into history unreviewable in a diff. +function preview(value) { + const head = value.length <= 24 ? value : `${value.slice(0, 21)}...` + let printable = '' + for (const char of head) { + printable += (char.codePointAt(0) ?? 0) < 0x20 ? '?' : char + } + return printable +} diff --git a/config/scripts/pty-transcript-secret-scan.test.mjs b/config/scripts/pty-transcript-secret-scan.test.mjs new file mode 100644 index 00000000000..2d3cd894da0 --- /dev/null +++ b/config/scripts/pty-transcript-secret-scan.test.mjs @@ -0,0 +1,133 @@ +// The scrub gate is the only thing standing between a live agent transcript and a +// committed account identifier, so it is pinned on the shapes those transcripts carry. +import { readdirSync, readFileSync } from 'node:fs' +import os from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + formatFindings, + placeholderFor, + redactTranscript, + scanTranscriptForSecrets +} from './pty-transcript-secret-scan.mjs' +import { parseArgs, resolveSpawn } from './capture-agent-pty-transcript.mjs' + +describe('pty transcript secret scan', () => { + it('finds the account row of a ready screen', () => { + const findings = scanTranscriptForSecrets('Antigravity CLI 1.1.17\njin.woo@acme.dev (Business)') + expect(findings).toHaveLength(1) + expect(findings[0]).toMatchObject({ kind: 'email', line: 2, column: 1 }) + }) + + it('finds credentials an agent may echo while signing in', () => { + const kinds = scanTranscriptForSecrets( + [ + 'token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVP', + 'key: AIzaSyA1234567890abcdefghijklmnopqrstu', + 'refresh: 1//0gLm34XyZabcdefghijklmnopqrstuvwx', + 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345' + ].join('\n') + ).map((finding) => finding.kind) + expect(kinds).toEqual(['jwt', 'google-api-key', 'google-refresh-token', 'bearer-token']) + }) + + it('flags this machine’s own username, which a prompt line leaks', () => { + const username = os.userInfo().username + const findings = scanTranscriptForSecrets(`~/Users/${username}/orca/repo\n> `) + expect(findings.some((finding) => finding.kind === 'local-username')).toBe(true) + }) + + it('finds the resumable conversation id agy prints on exit', () => { + const findings = scanTranscriptForSecrets( + 'Resume with -c (or command below):\nagy --conversation=26dc1986-9eec-456a-a534-d93e5c1076c2' + ) + expect(findings).toHaveLength(1) + expect(findings[0].kind).toBe('uuid') + expect(placeholderFor('uuid', findings[0].match.length)).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ) + }) + + it('reports a clean transcript as clean', () => { + const findings = scanTranscriptForSecrets('Antigravity CLI 1.1.17\nSonnet 4.6 (High)\n> ') + expect(findings).toEqual([]) + expect(formatFindings('fixture', findings)).toContain('clean') + }) + + it('claims a span once, so a token inside an email is not double-reported', () => { + const findings = scanTranscriptForSecrets('longlivedaccountname@corp.internal') + expect(findings).toHaveLength(1) + }) + + it('passes a fixture that is already scrubbed, so "prove it is gone" can succeed', () => { + const scrubbed = `uuuu@example.com\n${'X'.repeat(44)}` + expect(scanTranscriptForSecrets(scrubbed)).toEqual([]) + }) +}) + +describe('redaction', () => { + it('replaces every finding with the same number of characters', () => { + // Why length matters: the fixture's value is its exact wrapping. A shorter + // replacement reflows the screen and invalidates the capture. + const text = 'Antigravity CLI 1.1.17\njin.woo@acme.dev (Antigravity Business)\n> ' + const { text: redacted, redacted: count } = redactTranscript(text) + expect(count).toBe(1) + expect(redacted).toHaveLength(text.length) + expect(redacted).not.toContain('jin.woo@acme.dev') + expect(scanTranscriptForSecrets(redacted)).toEqual([]) + expect(redactTranscript(redacted).redacted).toBe(0) + }) + + it('keeps a redacted email shaped like an email', () => { + expect(placeholderFor('email', 'a@b.example.com'.length)).toMatch(/^u+@example\.com$/) + }) + + it('leaves the rest of the screen byte-for-byte untouched', () => { + const text = 'line one\nuser@corp.io\nline three' + expect(redactTranscript(text).text.split('\n')[2]).toBe('line three') + }) +}) + +describe('committed transcripts', () => { + // Why in CI and not just in the recorder: a transcript is committed once and read forever. + // The capture-time warning is skippable; this is not. + const fixtureDir = join(import.meta.dirname, '..', '..', 'src', 'main', 'runtime', '__fixtures__') + const transcripts = readdirSync(fixtureDir).filter((entry) => entry.endsWith('.txt')) + + it.each(transcripts)('%s carries no account identifier or credential', (name) => { + const findings = scanTranscriptForSecrets(readFileSync(join(fixtureDir, name), 'utf8')) + expect(formatFindings(name, findings)).toContain('clean') + }) +}) + +describe('capture argv', () => { + it('splits recorder options from the agent command', () => { + const { options, command } = parseArgs([ + '--name', + 'antigravity-ready-personal-non-gemini', + '--cols', + '120', + '--', + 'agy', + '--model', + 'sonnet' + ]) + expect(options.name).toBe('antigravity-ready-personal-non-gemini') + expect(options.cols).toBe(120) + expect(command).toEqual(['agy', '--model', 'sonnet']) + }) + + it('collects a multi-file scan list', () => { + const { options } = parseArgs(['--scan', 'a.txt', 'b.txt', '--redact']) + expect(options.scan).toEqual(['a.txt', 'b.txt']) + expect(options.redact).toBe(true) + }) + + it('routes a Windows shim through cmd.exe, which node-pty cannot spawn directly', () => { + expect(resolveSpawn(['agy.cmd', '--model', 'sonnet'])).toEqual( + process.platform === 'win32' + ? { file: 'cmd.exe', args: ['/c', '"agy.cmd"', '--model', 'sonnet'] } + : { file: 'agy.cmd', args: ['--model', 'sonnet'] } + ) + }) +}) diff --git a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs index a8c2cb3f4e7..253605781cf 100644 --- a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs +++ b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs @@ -46,6 +46,7 @@ const WINDOWS_SHIM_SPAWN_ALLOWLIST = [ 'config/scripts/electron-builder-config.test.mjs', 'config/scripts/ensure-native-runtime.test.mjs', 'config/scripts/live-remote-freeze-rpc.mjs', + 'config/scripts/pty-transcript-secret-scan.test.mjs', 'config/scripts/remote-agent-session-authority-repro.mjs', // Platform-local build paths; the win32 branch is dead code on both. 'config/scripts/build-mac-local.mjs', diff --git a/docs/reference/agent-pty-transcript-capture.md b/docs/reference/agent-pty-transcript-capture.md new file mode 100644 index 00000000000..934f0028a93 --- /dev/null +++ b/docs/reference/agent-pty-transcript-capture.md @@ -0,0 +1,129 @@ +# Capturing an agent PTY transcript + +Orca's readiness and blocked-prompt rules are text rules over what an agent CLI paints on a +terminal. They are only as good as the screens they were written against. This is how to record +one, byte for byte, so a rule can be pinned to evidence instead of to a remembered screen. + +Related: [`antigravity-readiness-evidence.md`](./antigravity-readiness-evidence.md) names the +specific Antigravity transcripts that are still missing and what each one decides. + +## The recorder + +``` +node config/scripts/capture-agent-pty-transcript.mjs --name [options] -- [args...] +``` + +It allocates a real PTY, spawns the agent inside it, mirrors the session to your terminal so you +can drive it by hand, and appends every byte it receives to +`src/main/runtime/__fixtures__/.txt`. It does not strip escapes, fold `\r`, rewrap +lines, or normalise anything — the file is what the terminal received. + +- **Ending a capture:** press Ctrl+]. The recorder consumes that key and + never forwards it, which is the only way to end a capture _while a dialog still owns the + screen_. Quitting the agent instead would first dismiss the dialog you came to record. +- `--cols N --rows M` pin the PTY size (default: your terminal's). Wrapping is part of the + evidence, so record the size — the sidecar does it for you. +- `--duration S` stops unattended after S seconds, for a screen that needs no interaction. +- `--send ":"` types into the PTY at a fixed offset, repeatable, with `\r` `\n` `\t` `\e` + escapes. A dialog capture has to be driven, and an unattended run (CI, or an agent) has no TTY to + type into; the keystrokes ride the same PTY a human's would. For example, the committed + `antigravity-dialog-model-picker.txt` was recorded with + `--duration 24 --send "14000:/model" --send "16000:\r"`, which leaves the picker owning the + screen when the capture stops. +- `--note ""` records the account type, plan, model and CLI version in the sidecar. +- `--out ` writes outside the fixture directory (use it for a first dry run). + +Each capture also writes `.meta.json` with the timestamp, platform, command, +PTY size, note and exit code. Commit it with the transcript; the version and account type behind +a screen are not recoverable from the bytes. + +**Prerequisite:** `node-pty` must be built for plain Node: + +``` +node config/scripts/ensure-native-runtime.mjs --runtime=node +``` + +Orca itself does not need to be running, and the recorder never touches Orca state. + +### Platform notes + +- **macOS / Linux:** nothing special. `TERM=xterm-256color` is set for the child. +- **Windows:** run it from Windows Terminal / PowerShell, not a Git Bash (MSYS) pane — MSYS + rewrites arguments that start with `/`, which mangles the `cmd.exe /c` hand-off. A `.cmd` or + `.bat` agent shim cannot be spawned by node-pty directly, so the recorder routes those through + `cmd.exe` for you. +- **WSL:** capture _inside_ the distro (run the recorder from the distro's checkout). Recording + `wsl.exe` from the Windows side adds the login-shell banner to the transcript. +- **SSH:** record on the execution host. A transcript recorded locally is not evidence about what + a remote agent prints. + +## Privacy: scrub before committing + +A live agent screen routinely contains things that must not enter git history: + +| Scrub | Why | +| ---------------------------------------------------------------------- | ---------------------------------------------------- | +| Account email / sign-in identifier | The account row on a ready screen prints it verbatim | +| Org, tenant or team name | Identifies a customer | +| Machine hostname and OS username | Appear in prompts, paths and the OSC title | +| Absolute home paths (`/Users/`, `C:\Users\`) | Contain the username | +| JWTs, `AIza…` keys, `1//…` refresh tokens, `Bearer …`, `sk-…`, `ghp_…` | Live credentials; a sign-in screen can echo one | +| Private repo, branch and ticket names | Leak roadmap detail | +| Anything you pasted into the agent during the capture | You typed it; it is in the transcript | + +The recorder scans the file as soon as the capture ends and prints every hit with a line and +column. To scrub: + +``` +node config/scripts/capture-agent-pty-transcript.mjs --scan src/main/runtime/__fixtures__/.txt --redact +``` + +Redaction replaces each finding with a **same-length** placeholder (`u…u@example.com`, `XXXX…`). +Length matters: a transcript's value is its exact wrapping and column alignment, and a shorter +replacement reflows the screen and destroys the evidence. + +### Verify it is gone + +1. `node config/scripts/capture-agent-pty-transcript.mjs --scan src/main/runtime/__fixtures__/.txt` + must print `clean` and exit `0`. It recognises its own placeholders, so a scrubbed file passes. +2. Grep for the specifics the scanner cannot know: + `rg -n -i -- "$(whoami)|||" src/main/runtime/__fixtures__/.txt` +3. Read it once with escapes visible: `LC_ALL=C cat -v src/main/runtime/__fixtures__/.txt`. + The scanner matches shapes; only a human catches a project name. +4. Check the sidecar too — `--note` text is free-form and is committed. + +`config/scripts/pty-transcript-secret-scan.test.mjs` re-scans every committed +`__fixtures__/*.txt`, so a transcript that skips step 1 fails the suite. + +## Consuming a transcript in a test + +Feed the raw bytes through the runtime rather than into a matcher directly: escape handling, +tail retention and title tracking all live in `onPtyData`, and a rule tested on pre-normalised +text is tested on something no pane ever sees. + +`src/main/runtime/agent-transcript-pane-test-harness.ts` builds the pane; +`src/main/runtime/terminal-interactive-wait-visibility.test.ts` (cursor-agent) and +`src/main/runtime/antigravity-readiness-transcripts.test.ts` (Antigravity) are the two consumers. + +## Worked example: the Antigravity captures + +The six committed `antigravity-*.txt` fixtures were recorded this way on macOS against +`agy` 1.1.25. Two points generalise: + +- **Reach a state without mutating the operator's config.** The ready-screen captures ran in a + directory the CLI already trusted, so no trust answer was written. Where a dialog could only be + reached by signing the operator out or deleting their settings, it was left uncaptured and + recorded as such rather than forced. +- **An environment variable is a legitimate capture knob** where a setting is not. + `AGY_CLI_HIDE_ACCOUNT_INFO=1` produced a second ready screen with no account row, which is + evidence no amount of reasoning about the first screen could have supplied. It changes nothing + on disk. + +## Known gap in the existing captures + +The three `cursor-agent-*.txt` fixtures contain **no escape bytes and no carriage returns**. +Whatever produced them went through a renderer and a clipboard, so they preserve wording and +box-drawing glyphs but not the caret, the cursor moves, the repaints, or whether the CLI uses the +alternate screen buffer. They are good enough for the wording-based rules built on them and are +not evidence for anything else. New captures made with this recorder keep those bytes; the +Antigravity scaffold asserts their presence so a pasted screen cannot pass as a capture. diff --git a/docs/reference/antigravity-readiness-evidence.md b/docs/reference/antigravity-readiness-evidence.md new file mode 100644 index 00000000000..0010fa76ded --- /dev/null +++ b/docs/reference/antigravity-readiness-evidence.md @@ -0,0 +1,263 @@ +# Antigravity readiness: what the transcripts show + +`findAntigravityReadyPromptIndex` in `src/main/runtime/terminal-wait-detection.ts` decides whether +an Antigravity pane is ready for a prompt. It has been written five times, each version tuned +against a five-line screen typed from memory into a `.spec.ts` fixture. Three of the first four +were found worse than the bug they replaced, and the fifth was reverted. + +Real transcripts now exist. They were recorded from a live `agy` on macOS with +[`agent-pty-transcript-capture.md`](./agent-pty-transcript-capture.md) and are committed under +`src/main/runtime/__fixtures__/`. `src/main/runtime/antigravity-readiness-transcripts.test.ts` +replays them through the runtime. + +**Headline: on real output the current detector is inverted.** It refuses a genuinely ready screen +and accepts a live model picker. The five attempts argued about which extra condition to add; none +of them had noticed that the condition they all shared — a line beginning with the model name — +never matches a real Antigravity ready screen at all. + +## Versions + +| Thing | Value | +| ------------------------- | ----------------------------- | +| `agy --version` | `1.1.25` | +| Banner printed by the TUI | `Antigravity CLI 1.2.0` | +| Captured | 2026-09-10, macOS, 120x40 PTY | + +The binary and its own banner disagree. Any rule keyed to a version string must read the banner, +not `--version`, and must tolerate the two disagreeing. + +## What the captures are + +| Fixture | What it is | +| -------------------------------------------- | --------------------------------------------------------- | +| `antigravity-ready-api-key-gemini-model.txt` | Ready screen, API-key identity, Gemini 3.7 Flash (Low) | +| `antigravity-ready-account-info-hidden.txt` | The same ready screen with `AGY_CLI_HIDE_ACCOUNT_INFO=1` | +| `antigravity-dialog-trust-workspace.txt` | Workspace trust dialog, live and unanswered | +| `antigravity-dialog-model-picker.txt` | `/model` picker, live and unanswered | +| `antigravity-dialog-command-palette.txt` | Slash-command palette, live and unanswered | +| `antigravity-dialog-dismissed.txt` | `/model` picker dismissed with esc, then settled | +| `antigravity-busy-mid-turn.txt` | A real turn, recording stopped while the spinner was live | +| `antigravity-busy-turn-ended.txt` | The same turn after it ended and the composer returned | + +## What could not be captured, and why + +Nothing below was faked. Each is a case the recorder could not reach without changing the +operator's account state or configuration, which is out of bounds. + +| Missing | Why | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `antigravity-ready-business-non-gemini.txt` | This machine has no OAuth session — the CLI prints _"You are currently not signed in"_ and authenticates from `GEMINI_API_KEY`. Reaching a Business ready screen means signing someone in. | +| A non-Gemini model on any ready screen | `agy models` offers 11 models, all Gemini, and `settings.json` pins `modelProvider: gemini`. A non-Gemini row is not reachable from this account. | +| `antigravity-dialog-sign-in.txt` | Unsetting `GEMINI_API_KEY` does not reach the sign-in dialog; the CLI refuses to start because `modelProvider` is pinned. Reaching it means editing the operator's `settings.json`. | +| `antigravity-dialog-theme-picker.txt` | There is no `/theme` command in 1.2.0 (`Unknown command: /theme`). The picker appears only in first-run onboarding, which means deleting the operator's config. | +| `antigravity-dialog-privacy-notice.txt` | First-run onboarding, as above. | +| `antigravity-dialog-update-banner.txt` | Cannot be forced; no update was pending during the session. | + +Each remains as a named, skipping case in the suite so it is visible rather than forgotten. + +## What the transcripts show + +### 1. The ready screen's model row is not at the start of a line + +The ready screen prints a block-glyph logo down the left, and the identity, model and path rows are +painted **on the same physical lines as the logo**. What Orca derives is: + +``` +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) +▄▀▀ ▀▀▄ ~ +``` + +The detector requires `normalized.startsWith('gemini', trimmedStart)` on a trimmed line. The +trimmed line starts with `▀`. It never matches. Measured three ways on the real screen: + +| Input | `isKnownReadyPromptPreview` | +| ------------------------------------------------------ | --------------------------- | +| Real ready screen | `false` | +| The same screen with the logo glyphs stripped | `true` | +| Real ready screen followed by the live `/model` picker | `true` | + +So the logo — decoration, and suppressible with `AGY_CLI_HIDE_LOGO` — is what decides readiness +today, and the live dialog is what supplies the model line the ready screen could not. + +### 2. The dialog is what satisfies the model rule + +`/model` prints its options one per line: + +``` +Gemini 3.8 Flash +> Gemini 3.7 Flash (current) +Gemini 3.1 Pro +``` + +Those lines _do_ begin with `Gemini`, and a bare `>` composer line sits earlier in the same tail +from before the picker opened. Both halves of the rule are satisfied **while a dialog owns the +screen**, and the pane reads ready. This is the false-ready hazard the last three attempts were +each trying to close, reproduced from a real capture. + +### 3. `>` is the dialog selection marker, not only the composer caret + +Every dialog uses `>` to mark the highlighted row: `> Yes, I trust this folder`, +`> Gemini 3.7 Flash (current)`, `> /add-dir`. The idle composer is a line whose whole trimmed +content is `>`. That distinction is the only thing separating them, which means the relaxation +proposed in PRs #15840 and #15852 — accept any line _beginning_ with `>` — would make the trust +dialog and the model picker read as ready. On 1.2.0 the idle composer is a bare `>`; those PRs' +1.1.17 mode-banner claim could not be reproduced here and may be mode-specific. + +### 4. There is no email account row, and the row can be switched off entirely + +For an API-key user the identity row reads literally `Gemini API key`. There is no `@`, no +domain, nothing an account-row rule can key on. Separately, `AGY_CLI_HIDE_ACCOUNT_INFO=1` — a +supported environment variable in the binary — removes the row from a fully ready screen, which +`antigravity-ready-account-info-hidden.txt` captures. + +### 5. Dialogs are drawn two different ways, and the banner is never reprinted + +The trust dialog and the sign-in splash take the **alternate screen** (`ESC[?1049h` … `ESC[?1049l`). +The model picker and command palette are drawn **in place on the main screen** with erase-to-EOL. +After dismissal the CLI prints `⎿ Exited /model command` and redraws the composer — it does **not** +reprint the banner. The header stays where it was at startup. + +### 6. Rows are positioned with cursor addressing, not newlines + +The status row is written with absolute and relative moves (`ESC[13;99H`, `ESC[83X ESC[83C`), so +`? for shortcuts` and `Gemini 3.7 Flash · low` end up on one derived line. Any rule that assumes +one screen row equals one `\n`-delimited line is reading a different document than the user sees. + +## 8. Busy frames park the caret exactly like idle frames — the spinner is what differs + +The frame that ends a turn-in-progress and the frame that ends an idle screen park the cursor with +the **same bytes**. Only the hint row differs, and the park erases it: + +``` +idle: ? for shortcuts ESC[83X ESC[83C Gemini 3.7 Flash · low CR ESC[2A ESC[2C ESC[?25h +busy: esc to cancel ESC[85X ESC[85C Gemini 3.7 Flash · low CR ESC[2A ESC[2C ESC[?25h +``` + +So a rule that keys on "the caret is the last thing in the tail" cannot tell busy from idle **on the +frame alone**. What saves it is what comes next. Each spinner tick is its own repaint with its own +park, two rows higher than the frame's: + +``` +ESC[?25l CR ESC[2A ⣯ Generating ESC[11D ESC[?25h +ESC[?25l CR ESC[2A ⣟ Generating. ESC[12D ESC[?25h +``` + +That second `CR ESC[2A` splices the composer row away, so the retained tail during a live turn ends +on the spinner row, not on the caret. Measured on `antigravity-busy-mid-turn.txt`: + +| Capture | last retained line | bare `>` line present | +| -------------------------------------------- | ------------------ | --------------------- | +| `antigravity-ready-api-key-gemini-model.txt` | `>` | **yes** | +| `antigravity-busy-mid-turn.txt` | `⣟ Generating...` | **no** | + +**Consequence for a caret-based rule:** it already answers "not ready" for a real mid-turn capture, +because there is no bare caret in the tail to match. A constructed input that keeps the park bytes +and only edits the status text is not faithful to a live turn — a live turn has a spinner row +repainting _below_ the composer. + +**The residual window, and the clause it implies.** Between a frame park and the next spinner tick +the tail does end on the bare caret and is indistinguishable from idle. The gap is one tick +interval. Any readiness path gated on sustained quiescence is safe, because ticks keep arriving and +the pane is never quiet; a path that only inspects retained text is not. For those paths the +evidence supports one clause, and only one: + +> **A braille glyph (U+2800–U+28FF) on the last visible line of the retained tail means working.** + +That predicate already exists in this file for cursor-agent (`CURSOR_BUSY_SPINNER_RE`) and should be +reused rather than reinvented. It must be scoped to the **last visible line**, not the whole tail: +a first-run transcript prints `⠾ Signing in...` during startup, which would otherwise pin a ready +screen as busy forever. + +Nothing else in the capture distinguishes the two states. The hint row (`esc to cancel` versus +`? for shortcuts`) is erased by the park in both cases, the park offsets are identical, and +`ESC[?25l`/`ESC[?25h` fencing appears around every repaint, idle or busy. + +## Confirmed / refuted, by attempt + +Evidence column names the fixture; all quoted text is from the committed transcripts. + +### Attempt 1 — the rule at HEAD + +| # | Claim | Verdict | Evidence | +| ---- | -------------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1.1 | A ready screen prints the banner `Antigravity CLI` | **Confirmed** | `Antigravity CLI 1.2.0` in both ready fixtures | +| 1.1b | …and its last occurrence in the tail is the live one | **Refuted** | The trust dialog's own body says _"Antigravity CLI requires permission to read, edit, and execute files here"_, so `lastIndexOf` lands inside the dialog | +| 1.2 | The model row begins with the vendor word `Gemini` | **Refuted** | `▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low)` — the logo precedes it; never at line start | +| 1.3 | The caret line's whole trimmed content is `>` | **Confirmed** on 1.2.0 idle | bare `>` in both ready fixtures | +| 1.3b | …and only the composer prints `>` | **Refuted** | `> Yes, I trust this folder`, `> Gemini 3.7 Flash (current)`, `> /add-dir` | +| 1.4 | A ready screen prints the workspace path on its own line | **Refuted** | the path shares its line with logo glyphs (`▄▀▀ ▀▀▄ ~`) | + +### Attempt 2 (loop 1) — blacklist the model line + +| # | Claim | Verdict | Evidence | +| --- | ------------------------------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------- | +| 2.1 | Dialog model-row wording is enumerable | **Refuted** | the palette lists 50+ commands with free-form descriptions; the picker prints whatever models the account offers | +| 2.2 | A dialog never reproduces a real model row | **Refuted** | the `/model` picker prints four real model rows, one per line, at line start | + +### Attempt 3 (loop 2) — structural ordering on `headerIndex` + +| # | Claim | Verdict | Evidence | +| --- | -------------------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------- | +| 3.1 | A live dialog is printed below the ready chrome | **Confirmed** for in-place dialogs | picker and palette append below the composer | +| 3.2 | The banner is reprinted when a dialog is dismissed | **Refuted** | `antigravity-dialog-dismissed.txt` shows `⎿ Exited /model command` and a redrawn composer, no banner | +| 3.3 | Antigravity does not use the alternate screen | **Refuted** | `ESC[?1049h` opens the trust dialog and the sign-in splash | +| 3.4 | No full repaint per keystroke | **Partly refuted** | typing `/mod` repaints the palette region on each keystroke with `ESC[K` | + +Because of 3.2, `headerIndex` cannot be the anchor: it never advances. Ordering can only be +expressed against the model/caret positions, which is what 1.2 and 1.3b just invalidated. + +### Attempt 4 (loop 3) — require a positive account row + +| # | Claim | Verdict | Evidence | +| --- | ---------------------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| 4.1 | Every ready screen prints an account row | **Refuted, twice** | API-key identity prints `Gemini API key` (no `@`); `AGY_CLI_HIDE_ACCOUNT_INFO=1` removes the row entirely | +| 4.2 | A startup dialog never contains an `@`-and-`.` token | **Not reachable here** | none of the captured dialogs contains one, but the palette shows free-form skill descriptions, which are user-authored text | +| 4.3 | The account row is distinguishable from prose | **Refuted** | the row is not a distinct line; it shares one with the logo | + +### Attempt 5 (PR #19749, reverted) — ordering + account row + +| # | Claim | Verdict | Evidence | +| --- | -------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 5.1 | Ordering plus an account row separates ready from dialog | **Refuted** | the account row is optional (4.1) and the ordering anchor never moves (3.2) | +| 5.2 | Executing both builds was sufficient verification | **Refuted** | the executed input was the hand-written fixture, so the check reproduced the fixture's assumptions. The real screen disagrees with that fixture on the model row, the path row and the account row | +| 5.3 | The wedge is a model-name problem | **Refuted** | it is a line-start problem. Even `Gemini 3.7 Flash (Low)` — a Gemini model — fails, because a logo glyph precedes it | + +### Cross-cutting + +| # | Question | Answer | +| --- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| X1 | Does `agy` set an OSC title distinguishing busy from idle? | **No.** Not one OSC title sequence appears in any capture. Title-based readiness is unavailable for this agent | +| X2 | Does it repaint with bare `\r`? | **Yes**, constantly, plus `ESC[K` and absolute cursor moves | +| X3 | Does the caret survive in the tail? | **Yes** — a bare `>` line is present in every ready capture | +| X4 | Banner-to-caret distance | ~8 derived lines on a 120x40 PTY; the banner falls outside the 6-line preview window, so only the full retained tail can see it | +| X5 | Pane title on the trust screen versus ready | Identical: none | + +## Can attempt six be written? + +Yes — but not as a variation on any of the five. Every one of them refined a predicate over +`\n`-delimited lines, and that is the layer where the evidence says the information is not. + +What the captures support: + +- **The one stable, dialog-free ready marker is a line whose entire trimmed content is `>`.** It is + present in every ready capture and absent from every dialog capture, because a dialog's `>` always + carries its selected row's label. This is a much narrower rule than any attempt used, and it is + the only one that survived contact with the transcripts. +- **Drop the model-row requirement.** It matches dialogs and not ready screens. Keeping it inverted + the detector. +- **Do not require an account row.** It is optional by environment variable and carries no email for + API-key users. +- **Do not anchor on `headerIndex`.** The banner is printed once and never reprinted. +- **The blocked-signal path already works** for the trust dialog: `antigravity-dialog-trust-workspace.txt` + is correctly refused today, by wording, not by structure. + +What is still unknown and should be captured before shipping: the sign-in, theme, privacy and +update dialogs, and any ready screen where the composer is not idle (accept-edits and plan mode, +which PRs #15840 and #15852 describe from a screenshot). A bare-`>` rule is only as good as the +claim that those modes still end on a bare `>`; that claim is untested. + +The honest summary is that this is a screen-shaped problem being solved with line-shaped tools. A +rule over the derived tail can be made much better than what ships today, but the durable fix is to +ask the terminal emulator what the bottom row of the screen actually is, rather than inferring it +from a byte stream that was written with cursor addressing. diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index e09d1631a8d..67e90132866 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -9,6 +9,7 @@ vi.mock('react-native', async () => { const Text = ({ children, ...props }: { children?: unknown }): unknown => React.createElement('Text', props, children) return { + ActivityIndicator: 'ActivityIndicator', Animated: { Text, Value: class { @@ -268,12 +269,12 @@ describe('MobileNativeChatMessage', () => { expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(0) }) - it('renders the turn status row under a user message', () => { + it('renders the settled turn status row under a user message', () => { const tree = render(userMessage([{ type: 'text', text: 'go' }]), { structuredActivityUi: true, - turnStatus: { startedAt: Date.now(), thinking: true, workedSeconds: null } + turnStatus: { startedAt: Date.now() - 3_000, thinking: false, workedSeconds: 3 } }) - expect(textIn(tree.root)).toContain('Thinking') + expect(textIn(tree.root)).toContain('Worked for 3s') }) it('does not render a turn status row without one', () => { diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 5d013688249..9b480fdd7d9 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -82,7 +82,7 @@ function MobileNativeChatMessageImpl({ /** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */ fontScale?: number onOpenFile?: (relativePath: string) => void - /** This turn's status row, rendered under a user message (desktop parity). */ + /** This settled turn's status row, rendered under its user message. */ turnStatus?: NativeChatTurnStatus | null /** Whether the turn caret has disclosed this turn's activity. */ turnExpanded?: boolean diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 997083cb26e..389beb8eaad 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -73,6 +73,7 @@ export function MobileNativeChatOverlay({ agentWorking={controller.nativeChatAgentWorking} canStop={controller.nativeChatCanStop} structuredActivityUi={controller.nativeChatStructured} + turnIndicator={controller.nativeChatTurnIndicator} workingStartedAt={controller.nativeChatWorkingStartedAt} settledTurns={controller.nativeChatSettledTurns} streaming={streaming} diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts index 78ac01e0d37..6b6a1d49e07 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -7,18 +7,8 @@ vi.mock('react-native', async () => { const Text = ({ children, ...props }: { children?: unknown }): unknown => React.createElement('Text', props, children) return { - Animated: { - Text, - Value: class { - constructor(private value: number) {} - setValue(next: number): void { - this.value = next - } - }, - loop: (animation: unknown) => animation, - sequence: () => ({ start: vi.fn(), stop: vi.fn() }), - timing: () => ({ start: vi.fn(), stop: vi.fn() }) - }, + ActivityIndicator: (props: Record) => + React.createElement('ActivityIndicator', props), Pressable: ({ children, ...props }: { children?: unknown }) => React.createElement('Pressable', props, children), Text, @@ -49,6 +39,7 @@ describe('MobileNativeChatTurnStatus', () => { startedAt: number | null thinking: boolean workedSeconds?: number | null + activityText?: string | null expanded?: boolean onToggleExpanded?: () => void }): ReactTestRenderer { @@ -61,12 +52,16 @@ describe('MobileNativeChatTurnStatus', () => { const labels = (node: ReactTestInstance): string[] => node.findAllByType('Text' as never).map((text) => String(text.children.join(''))) - it('reads "Thinking" before the turn produces output', () => { + const spinners = (node: ReactTestInstance): ReactTestInstance[] => + node.findAllByType('ActivityIndicator' as never) + + it('reads "Thinking" beside one spinner while the turn reasons', () => { const tree = render({ startedAt: Date.now(), thinking: true }) expect(labels(tree.root)).toEqual(['Thinking']) + expect(spinners(tree.root)).toHaveLength(1) }) - it('counts up once the turn is producing output', () => { + it('counts up on that same single row when the turn is not reasoning', () => { const startedAt = Date.now() const tree = render({ startedAt, thinking: false }) expect(labels(tree.root)).toEqual(['Working for 0s']) @@ -74,6 +69,19 @@ describe('MobileNativeChatTurnStatus', () => { vi.advanceTimersByTime(12_000) }) expect(labels(tree.root)).toEqual(['Working for 12s']) + expect(spinners(tree.root)).toHaveLength(1) + }) + + it('lets provider activity text beat both fallbacks and hold the clock', () => { + const tree = render({ + startedAt: Date.now(), + thinking: true, + activityText: 'Running pnpm test' + }) + expect(labels(tree.root)).toEqual(['Running pnpm test']) + expect(spinners(tree.root)).toHaveLength(1) + // No label consumes the duration, so nothing schedules a tick for it. + expect(vi.getTimerCount()).toBe(0) }) it('settles to a tappable "Worked for" row that toggles the turn', () => { @@ -98,9 +106,10 @@ describe('MobileNativeChatTurnStatus', () => { expect(labels(tree.root)).toEqual(['Worked for 5s']) }) - it('holds no interval once the turn has settled', () => { - render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 }) + it('holds no interval, and no spinner, once the turn has settled', () => { + const tree = render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 }) expect(vi.getTimerCount()).toBe(0) + expect(spinners(tree.root)).toHaveLength(0) }) it('announces the live row to assistive tech', () => { diff --git a/mobile/src/session/MobileNativeChatTurnStatus.tsx b/mobile/src/session/MobileNativeChatTurnStatus.tsx index 4ce73cdcd38..acf922265f6 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.tsx +++ b/mobile/src/session/MobileNativeChatTurnStatus.tsx @@ -1,7 +1,8 @@ -import { useEffect, useRef, useState } from 'react' -import { Animated, Pressable, StyleSheet, Text, View } from 'react-native' +import { useEffect, useState } from 'react' +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native' import { ChevronRight } from 'lucide-react-native' import { + formatNativeChatActiveTurnLabel, formatNativeChatTurnStatusLabel, NATIVE_CHAT_TURN_STATUS_COPY, nativeChatElapsedSeconds @@ -25,48 +26,38 @@ function useElapsedSeconds(startedAt: number | null, counting: boolean): number return counting ? nativeChatElapsedSeconds(startedAt, mountedAt, now) : 0 } -/** The per-turn status row — "Thinking", then "Working for 12s" while the turn - * runs, settling to a tappable "Worked for 3m 4s" that discloses the turn's - * tool activity. Desktop parity: `NativeChatWorkingStatus`. */ +/** The per-turn status row. While the turn runs it is the one live indicator — a + * spinner beside what the provider says it is doing, else "Thinking", else + * "Working for 12s". It settles to a tappable "Worked for 3m 4s" that discloses + * the turn's tool activity. Desktop parity: `NativeChatTurnActivityLine` for the + * live row, `NativeChatWorkingStatus` for the settled one. */ export function MobileNativeChatTurnStatus({ startedAt, thinking, workedSeconds, + activityText, expanded = false, onToggleExpanded }: { startedAt: number | null thinking: boolean workedSeconds?: number | null + /** Provider activity copy for a live turn; outranks the other two labels. */ + activityText?: string | null expanded?: boolean onToggleExpanded?: () => void }): React.JSX.Element { - const counting = !thinking && workedSeconds == null + const settled = workedSeconds != null + const counting = !settled && !thinking && !activityText?.trim() const elapsedSeconds = useElapsedSeconds(startedAt, counting) - const label = formatNativeChatTurnStatusLabel({ thinking, workedSeconds, elapsedSeconds }) + const label = settled + ? formatNativeChatTurnStatusLabel({ thinking, workedSeconds, elapsedSeconds }) + : formatNativeChatActiveTurnLabel({ activityText, thinking, elapsedSeconds }) - const pulse = useRef(new Animated.Value(1)).current - useEffect(() => { - if (!thinking) { - pulse.setValue(1) - return - } - const animation = Animated.loop( - Animated.sequence([ - Animated.timing(pulse, { toValue: 0.45, duration: 700, useNativeDriver: true }), - Animated.timing(pulse, { toValue: 1, duration: 700, useNativeDriver: true }) - ]) - ) - animation.start() - return () => animation.stop() - }, [pulse, thinking]) - - const rowStyle = [styles.row, thinking ? null : styles.rowSettled] - - if (workedSeconds != null && onToggleExpanded) { + if (settled && onToggleExpanded) { return ( [...rowStyle, pressed && styles.pressed]} + style={({ pressed }) => [styles.row, styles.rowSettled, pressed && styles.pressed]} onPress={onToggleExpanded} hitSlop={6} accessibilityRole="button" @@ -83,11 +74,14 @@ export function MobileNativeChatTurnStatus({ return ( - {label} + {settled ? null : } + + {label} + ) } @@ -109,7 +103,8 @@ const styles = StyleSheet.create({ }, label: { color: colors.textMuted, - fontSize: typography.bodySize + fontSize: typography.bodySize, + flexShrink: 1 }, caretOpen: { transform: [{ rotate: '90deg' }] diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 63bfb715445..c573d89cc89 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -73,6 +73,7 @@ type Overrides = { onSend?: (text: string) => Promise pending?: Parameters[0]['pending'] structuredActivityUi?: boolean + turnIndicator?: Parameters[0]['turnIndicator'] agentWorking?: boolean canStop?: boolean sendSurfaceId?: string @@ -273,20 +274,74 @@ describe('MobileNativeChatView', () => { return (renderedRow(id) as { props: Record }).props } + function footerProps(): Record | null { + const list = renderer!.root.find((node) => node.type === 'FlatList') + const footer = list.props.ListFooterComponent as + | { props: Record } + | null + | undefined + return footer?.props ?? null + } + function workingIndicators(): ReactTestInstance[] { return renderer!.root.findAll((node) => node.type === 'WorkingIndicator') } - it('gives the live user turn a status row and drops the three-dot indicator', async () => { - const folded = [userTurn('u1', 'go')] + it('puts the live status at the turn tail and drops the three-dot indicator', async () => { + const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'still working')] await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) const props = rowProps('u1') expect(props.structuredActivityUi).toBe(true) - expect(props.turnStatus).toMatchObject({ thinking: true, workedSeconds: null }) + expect(props.turnStatus).toBeNull() + // Nothing reports reasoning, so the one live footer counts instead of guessing. + expect(footerProps()).toMatchObject({ thinking: false, workedSeconds: null }) + expect(listIds().at(-1)).toBe('a1') expect(props.activeTurnIsWorking).toBe(true) expect(workingIndicators()).toHaveLength(0) }) + it('reports the live turn as thinking only when its journal says it is reasoning', async () => { + const folded = [userTurn('u1', 'go')] + await render({ + messages: folded, + folded, + structuredActivityUi: true, + agentWorking: true, + turnIndicator: { thinking: true, activityText: null } + }) + expect(rowProps('u1').turnStatus).toBeNull() + expect(footerProps()).toMatchObject({ thinking: true, workedSeconds: null }) + }) + + it('hands the live row the provider activity copy that outranks its fallbacks', async () => { + const folded = [userTurn('u1', 'go')] + await render({ + messages: folded, + folded, + structuredActivityUi: true, + agentWorking: true, + turnIndicator: { thinking: true, activityText: 'Running pnpm test' } + }) + expect(footerProps()).toMatchObject({ + thinking: true, + activityText: 'Running pnpm test' + }) + }) + + it('keeps the activity copy on the live footer instead of a historical row', async () => { + const folded = [userTurn('u1', 'go'), userTurn('u2', 'again')] + await render({ + messages: folded, + folded, + structuredActivityUi: true, + agentWorking: true, + turnIndicator: { thinking: false, activityText: 'Running pnpm test' } + }) + expect(rowProps('u1')).not.toHaveProperty('turnActivityText') + expect(rowProps('u2')).not.toHaveProperty('turnActivityText') + expect(footerProps()).toMatchObject({ activityText: 'Running pnpm test' }) + }) + it('keeps the bridge lane on the three-dot indicator with no turn status', async () => { const folded = [userTurn('u1', 'go')] await render({ messages: folded, folded, agentWorking: true }) @@ -294,13 +349,15 @@ describe('MobileNativeChatView', () => { expect(props.structuredActivityUi).toBe(false) expect(props.turnStatus).toBeNull() expect(props.activeTurnIsWorking).toBe(false) + expect(footerProps()).toBeNull() expect(workingIndicators()).toHaveLength(1) }) it('settles the finished turn to a tappable duration', async () => { const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'done')] await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true }) - expect(rowProps('u1').turnStatus).toMatchObject({ thinking: false, workedSeconds: null }) + expect(rowProps('u1').turnStatus).toBeNull() + expect(footerProps()).toMatchObject({ thinking: false, workedSeconds: null }) await update({ messages: folded, folded, structuredActivityUi: true, agentWorking: false }) const settled = rowProps('u1') expect(settled.turnStatus).toMatchObject({ thinking: false }) @@ -309,6 +366,7 @@ describe('MobileNativeChatView', () => { ) expect(settled.onToggleTurn).toBeTypeOf('function') expect(settled.activeTurnIsWorking).toBe(false) + expect(footerProps()).toBeNull() }) it('hangs no status row on an assistant row', async () => { @@ -317,6 +375,7 @@ describe('MobileNativeChatView', () => { expect(rowProps('a1').turnStatus).toBeNull() // The assistant row still belongs to the live turn, so its tool row stays visible. expect(rowProps('a1').activeTurnIsWorking).toBe(true) + expect(footerProps()).toMatchObject({ workedSeconds: null }) }) it('does not carry a running turn clock across chat surfaces', async () => { @@ -331,7 +390,7 @@ describe('MobileNativeChatView', () => { agentWorking: true, sendSurfaceId: 'host\0worktree\0tab-a' }) - expect(rowProps('u1').turnStatus).toMatchObject({ startedAt: 1_000 }) + expect(footerProps()).toMatchObject({ startedAt: 1_000 }) vi.setSystemTime(12_000) const secondTab = [userTurn('u2', 'second')] @@ -343,7 +402,7 @@ describe('MobileNativeChatView', () => { sendSurfaceId: 'host\0worktree\0tab-b' }) - expect(rowProps('u2').turnStatus).toMatchObject({ startedAt: 12_000 }) + expect(footerProps()).toMatchObject({ startedAt: 12_000 }) } finally { vi.useRealTimers() } diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 440e05e6b9d..67fc93506a9 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -13,7 +13,10 @@ import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-ha import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native' import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import type { + NativeChatLiveTurnIndicator, + NativeChatSettledTurns +} from '../../../src/shared/native-chat-turn-status' import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-view-styles' import { @@ -53,6 +56,8 @@ type Props = { /** Structured lane: per-turn "Working for N" status plus live tool progress, * replacing the bridge lane's static three-dot working row (desktop parity). */ structuredActivityUi?: boolean + /** What labels the live turn's one indicator row (structured lane only). */ + turnIndicator?: NativeChatLiveTurnIndicator | null /** Structured lane: host-recorded turn timing feeding the per-turn status rows. */ workingStartedAt?: number | null settledTurns?: NativeChatSettledTurns | null @@ -136,6 +141,7 @@ export function MobileNativeChatView({ agentWorking, canStop = agentWorking, structuredActivityUi = false, + turnIndicator = null, workingStartedAt, settledTurns, onStop, @@ -259,14 +265,17 @@ export function MobileNativeChatView({ [hasMore, loadingEarlier, onLoadEarlier] ) - // Per-turn "Thinking / Working for N / Worked for N" rows. The structured lane - // owns them; the bridge lane keeps its three-dot indicator. + // Per-turn status rows: one live indicator while the turn runs, then a settled + // "Worked for N" row. The structured lane owns them; the bridge lane keeps its + // three-dot indicator. const turns = useMobileNativeChatTurnDisclosure({ messages: data, enabled: structuredActivityUi, isWorking: agentWorking === true, workingStartedAt, settledTurns, + thinking: turnIndicator?.thinking === true, + activityText: turnIndicator?.activityText ?? null, scopeKey: sendSurfaceId }) @@ -331,11 +340,12 @@ export function MobileNativeChatView({ ) : null } ListFooterComponent={ - turns.activeTurnIsUnanchored && turns.active ? ( + structuredActivityUi && agentWorking && turns.active ? ( ) : null } diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index da1b91acb25..36c07215e8e 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -6,7 +6,10 @@ import type { } from '../../../src/shared/native-chat-ask' import type { detectAgentPermission } from './mobile-native-chat-permission' import type { parseAgentQuestion } from './mobile-native-chat-question' -import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import type { + NativeChatLiveTurnIndicator, + NativeChatSettledTurns +} from '../../../src/shared/native-chat-turn-status' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts' import type { useMobileNativeChatSession } from './use-mobile-native-chat-session' @@ -29,6 +32,8 @@ export type MobileNativeChatController = { /** Structured lane: drives the per-turn status row and live tool progress. */ nativeChatStructured: boolean nativeChatAgentWorking: boolean + /** What labels the live turn's one indicator row; null off the structured lane. */ + nativeChatTurnIndicator: NativeChatLiveTurnIndicator | null /** Structured lane: host-recorded turn timing for the per-turn status rows. */ nativeChatWorkingStartedAt: number | null nativeChatSettledTurns: NativeChatSettledTurns | null diff --git a/mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts b/mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts new file mode 100644 index 00000000000..2dddad48e16 --- /dev/null +++ b/mobile/src/session/use-mobile-bridge-chat-prompt-writes.ts @@ -0,0 +1,65 @@ +import type { MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' +import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' +import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask' +import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' +import type { MobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' + +/** The bridge lane's four prompt/interrupt write seams. They share one enable + * gate and chain through the answer seam's `cancelPending`, so a caller cannot + * wire one of them to a different lane or forget to drop in-flight answer + * writes before an Escape. The structured lane answers over RPC instead. */ +export function useMobileBridgeChatPromptWrites(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + agentRef: MutableRefObject + /** Changes on chat session swap; cancels pending writes when it does. */ + sessionId: string | null + streamIdentity: string + onSendError: (message: string) => void +}): { + answerAsk: MobileNativeChatAnswerSend['answerAsk'] + cancelAsk: () => Promise + respondPermission: (send: string) => Promise + stop: () => void +} { + const { client, enabled, handleRef, deviceTokenRef, streamIdentity, onSendError } = args + const { answerAsk, cancelPending } = useMobileNativeChatAnswerSend({ + client, + enabled, + handleRef, + deviceTokenRef, + agentRef: args.agentRef, + sessionId: args.sessionId, + streamIdentity, + onSendError + }) + const cancelAsk = useMobileNativeChatCancelAsk({ + client, + enabled, + handleRef, + deviceTokenRef, + cancelPending, + onSendError + }) + const respondPermission = useMobileNativeChatPermissionSend({ + client, + enabled, + handleRef, + deviceTokenRef, + onSendError + }) + const stop = useMobileNativeChatStop({ + client, + enabled, + handleRef, + deviceTokenRef, + streamIdentity, + cancelPending, + onSendError + }) + return { answerAsk, cancelAsk, respondPermission, stop } +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 5b941367451..4087694b567 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -2,10 +2,7 @@ import { useLayoutEffect, useRef, type MutableRefObject } from 'react' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import type { MobileNativeChatTab } from './mobile-native-chat-eligibility' -import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' -import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' -import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send' @@ -14,10 +11,10 @@ import { useMobileNativeChatSessionOptionController } from './use-mobile-native- import { useMobileNativeChatSessionLane } from './use-mobile-native-chat-session-lane' import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge' import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' -import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes' import { useThrottledLatestValue } from './use-throttled-latest-value' import type { MobileNativeChatController } from './mobile-native-chat-controller-contract' +import { useMobileBridgeChatPromptWrites } from './use-mobile-bridge-chat-prompt-writes' import { useMobileNativeChatActiveResolution } from './use-mobile-native-chat-active-resolution' export type { MobileNativeChatController } from './mobile-native-chat-controller-contract' @@ -171,42 +168,19 @@ export function useMobileNativeChatController(args: { ? client != null && activeChatSessionId != null && connState === 'connected' : nativeChatInputLeaseReady && connState === 'connected' - const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } = - useMobileNativeChatAnswerSend({ - client, - enabled: inputSendable && !activeChatStructured, - handleRef: activeHandleRef, - deviceTokenRef, - agentRef: activeChatAgentRef, - sessionId: activeChatSessionId, - streamIdentity, - onSendError - }) - - const handleNativeChatCancelAsk = useMobileNativeChatCancelAsk({ - client, - enabled: inputSendable && !activeChatStructured, - handleRef: activeHandleRef, - deviceTokenRef, - cancelPending: cancelNativeChatAnswer, - onSendError - }) - - const legacyHandleNativeChatRespondPermission = useMobileNativeChatPermissionSend({ - client, - enabled: inputSendable && !activeChatStructured, - handleRef: activeHandleRef, - deviceTokenRef, - onSendError - }) - - const handleNativeChatStop = useMobileNativeChatStop({ + const { + answerAsk: handleNativeChatAnswerAsk, + cancelAsk: handleNativeChatCancelAsk, + respondPermission: legacyHandleNativeChatRespondPermission, + stop: handleNativeChatStop + } = useMobileBridgeChatPromptWrites({ client, enabled: inputSendable && !activeChatStructured, handleRef: activeHandleRef, deviceTokenRef, + agentRef: activeChatAgentRef, + sessionId: activeChatSessionId, streamIdentity, - cancelPending: cancelNativeChatAnswer, onSendError }) @@ -299,6 +273,7 @@ export function useMobileNativeChatController(args: { /** Structured lane: drives the per-turn status row and live tool progress. */ nativeChatStructured: activeChatStructured, nativeChatAgentWorking, + nativeChatTurnIndicator: activeChatStructured ? structuredNativeChat.turnIndicator : null, nativeChatWorkingStartedAt: activeChatStructured ? structuredNativeChat.workingStartedAt : null, nativeChatSettledTurns: activeChatStructured ? structuredNativeChat.settledTurns : null, nativeChatCanStop: activeChatStructured diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index 5620c1390b2..a67a6a88663 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -28,6 +28,8 @@ export function useMobileNativeChatTurnDisclosure({ isWorking, workingStartedAt, settledTurns, + thinking = false, + activityText = null, scopeKey }: { messages: readonly NativeChatMessage[] @@ -36,12 +38,16 @@ export function useMobileNativeChatTurnDisclosure({ workingStartedAt?: number | null /** Host-recorded durations; they outrank whatever this client observed. */ settledTurns?: NativeChatSettledTurns | null + /** Whether the turn is reasoning right now, derived from its journal content. */ + thinking?: boolean + /** What the provider says the live turn is doing; outranks the other labels. */ + activityText?: string | null /** Host/worktree/tab identity for timing and disclosure isolation. */ scopeKey: string }): { active: NativeChatTurnStatus | null - /** True when the live turn has no user message to hang its status row under. */ - activeTurnIsUnanchored: boolean + /** The live turn's provider activity copy, for the footer row. */ + activeActivityText: string | null onToggleTurn: (turnKey: string) => void resolveRow: (index: number, message: NativeChatMessage) => MobileNativeChatTurnRow } { @@ -51,6 +57,7 @@ export function useMobileNativeChatTurnDisclosure({ isWorking, workingStartedAt, settledTurns, + thinking, scopeKey }) const [expandedTurns, setExpandedTurns] = useState<{ @@ -93,17 +100,16 @@ export function useMobileNativeChatTurnDisclosure({ }, [enabled, messages]) const { active, activeTurnKey, completedByTurn } = turnStatuses + const activeActivityText = enabled && isWorking ? (activityText ?? null) : null const resolveRow = useCallback( (index: number, message: NativeChatMessage): MobileNativeChatTurnRow => { const turnKey = turnKeys[index] const turnStatus = !enabled || message.role !== 'user' ? null - : turnKey === activeTurnKey - ? active - : turnKey - ? (completedByTurn[turnKey] ?? null) - : null + : turnKey + ? (completedByTurn[turnKey] ?? null) + : null return { turnStatus, turnExpanded: turnKey ? expandedTurnIds.has(turnKey) : false, @@ -120,15 +126,14 @@ export function useMobileNativeChatTurnDisclosure({ (turnKey === undefined && activeTurnKey === MOBILE_UNANCHORED_TURN_KEY)) } }, - [turnKeys, enabled, activeTurnKey, active, completedByTurn, expandedTurnIds, isWorking] + [turnKeys, enabled, activeTurnKey, completedByTurn, expandedTurnIds, isWorking] ) return { active, + activeActivityText, /** Stable for a given chat scope, so it never disturbs a row's memo. */ onToggleTurn: toggleExpandedTurn, - activeTurnIsUnanchored: - enabled && active != null && activeTurnKey === MOBILE_UNANCHORED_TURN_KEY, resolveRow } } diff --git a/mobile/src/session/use-mobile-native-chat-turn-status.ts b/mobile/src/session/use-mobile-native-chat-turn-status.ts index 5686acde515..f7cbb2dd887 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-status.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-status.ts @@ -1,7 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { - nativeChatTurnHasResponse, reduceNativeChatTurnTiming, selectNativeChatTurnStatuses, type NativeChatSettledTurns, @@ -27,6 +26,7 @@ export function useMobileNativeChatTurnStatus({ isWorking, workingStartedAt, settledTurns, + thinking = false, scopeKey }: { messages: readonly NativeChatMessage[] @@ -35,6 +35,8 @@ export function useMobileNativeChatTurnStatus({ workingStartedAt?: number | null /** Host-recorded durations; they outrank whatever this client observed. */ settledTurns?: NativeChatSettledTurns | null + /** Whether the turn is reasoning right now, derived from its journal content. */ + thinking?: boolean /** Host/worktree/tab identity. Timings never carry across chat surfaces. */ scopeKey: string }): { @@ -45,7 +47,6 @@ export function useMobileNativeChatTurnStatus({ const latestUserIndex = enabled ? messages.findLastIndex((message) => message.role === 'user') : -1 - const hasCurrentTurnResponse = enabled && nativeChatTurnHasResponse(messages, latestUserIndex) const latestUserId = latestUserIndex !== -1 ? (messages[latestUserIndex]?.id ?? null) : null const activeTurnKey = latestUserId ?? MOBILE_UNANCHORED_TURN_KEY const [scopedTiming, setScopedTiming] = useState(() => ({ @@ -95,6 +96,7 @@ export function useMobileNativeChatTurnStatus({ // turn re-renders ~20x/s. Without this, every settled turn's row gets fresh // props each tick and the memoized message rows all re-render. const turnIsWorking = enabled && isWorking + const turnIsThinking = enabled && thinking const settledByTurn = enabled ? (settledTurns ?? undefined) : undefined const statuses = useMemo( () => @@ -102,17 +104,10 @@ export function useMobileNativeChatTurnStatus({ activeTurnKey, isWorking: turnIsWorking, workingStartedAt, - hasCurrentTurnResponse, + thinking: turnIsThinking, settledByTurn }), - [ - timingByTurn, - activeTurnKey, - turnIsWorking, - workingStartedAt, - hasCurrentTurnResponse, - settledByTurn - ] + [timingByTurn, activeTurnKey, turnIsWorking, workingStartedAt, turnIsThinking, settledByTurn] ) return { ...statuses, activeTurnKey } } diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 5c458bbdd9f..938e881aec7 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -11,10 +11,12 @@ import { import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' +import { hasUnansweredStructuredAgentSessionDispatch } from '../../../src/shared/structured-agent-session-projection' import { activeStructuredAgentSessionTurnId, - hasUnansweredStructuredAgentSessionDispatch -} from '../../../src/shared/structured-agent-session-projection' + isStructuredAgentSessionThinking +} from '../../../src/shared/structured-agent-session-live-turn' +import { selectStructuredAgentTurnActivity } from '../../../src/shared/native-chat-turn-activity' import { pendingStructuredApproval, pendingStructuredQuestion, @@ -31,6 +33,7 @@ import type { RpcClient } from '../transport/rpc-client' import type { MobileChatPermission } from './mobile-native-chat-permission' import type { MobileChatQuestion } from './mobile-native-chat-question' import type { MobileNativeChatSession } from './use-mobile-native-chat-session' +import type { NativeChatLiveTurnIndicator } from '../../../src/shared/native-chat-turn-status' import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state' import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' @@ -43,6 +46,8 @@ type StructuredMobileSession = ReturnType ({ thinking, activityText }), [thinking, activityText]) const status = state.status === 'idle' ? 'idle' : state.status const approvalPrompt = useMemo( () => state.items.find(pendingStructuredApproval) ?? null, @@ -296,6 +307,7 @@ export function useMobileStructuredAgentSession(args: { turnId !== null || hasUnansweredStructuredAgentSessionDispatch(state.submissions, state.fence), turnId, + turnIndicator, ...turnTiming, sendWithOutcome, cancel, diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx new file mode 100644 index 00000000000..8369ff03a17 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -0,0 +1,138 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +function journalItem( + sequence: number, + body: AgentJournalRenderItem['body'] +): AgentJournalRenderItem { + return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body } +} + +function snapshot(items: AgentJournalRenderItem[], fence: number): AgentSessionSubscribeEvent { + const newest = items.length + return { + type: 'snapshot', + sessionId: 'session-1', + fence, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + fence, + direction: 'tail', + items, + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-1', sequence: 1 }, + newest: { epoch: 'epoch-1', sequence: newest }, + nextCursor: { epoch: 'epoch-1', sequence: newest + 1 } + }, + liveCursor: { epoch: 'epoch-1', sequence: newest }, + hasOlder: false, + hasNewer: false + } + } as AgentSessionSubscribeEvent +} + +/** What the one live indicator row reads, resolved off the session journal. */ +describe('useMobileStructuredAgentSession turn indicator', () => { + let renderer: ReactTestRenderer | null = null + let hook: ReturnType | null = null + let listener: ((value: unknown) => void) | null = null + const sendRequest = vi.fn(async (method: string) => ({ + ok: true, + result: + method === 'agentSession.options' + ? { + models: [{ id: 'gpt-fast', label: 'GPT Fast', isDefault: true, efforts: [] }], + current: { model: 'gpt-fast' } + } + : {}, + _meta: { runtimeId: 'r1' } + })) + const subscribe = vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { + listener = onData + return vi.fn() + }) + const client = { sendRequest, subscribe } as unknown as RpcClient + // Stable across renders: a fresh callback would re-run the hold/subscribe effect + // and release the session out from under the test. + const onSendError = vi.fn() + + function Harness(): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + agent: 'codex', + onSendError + } as never) + return null + } + + beforeEach(() => { + vi.clearAllMocks() + listener = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null + }) + + const runningTurn = journalItem(1, { kind: 'turn', turnId: 'turn-1', state: 'running' }) + const reasoning = journalItem(2, { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }] + }) + + it('reads the live turn as reasoning while reasoning is its newest content', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).not.toBeNull()) + + act(() => { + listener?.(snapshot([runningTurn, reasoning], 3)) + }) + + expect(hook?.turnIndicator).toEqual({ thinking: true, activityText: null }) + }) + + it('hands the row the provider copy once real content ends the reasoning', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).not.toBeNull()) + + act(() => { + listener?.( + snapshot( + [ + runningTurn, + reasoning, + journalItem(3, { + kind: 'tool-call', + name: 'shell', + input: { command: 'pnpm lint' }, + state: 'running' + }), + journalItem(4, { kind: 'status', text: 'Updating the plan' }) + ], + 3 + ) + ) + }) + + expect(hook?.turnIndicator).toEqual({ thinking: false, activityText: 'Updating the plan' }) + }) +}) diff --git a/package.json b/package.json index 9feaad74882..4f03d793aa6 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", "test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills.test.ts src/relay/skill-install-handler.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts", "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", + "capture:agent-transcript": "node config/scripts/ensure-native-runtime.mjs --runtime=node && node config/scripts/capture-agent-pty-transcript.mjs", "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", "check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs", diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 44d7e3a1251..7a8ecf54344 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -569,8 +569,11 @@ describe('Claude structured journal translation', () => { translator.handle(message('assistant', 'assistant-thinking', [{ type: 'thinking', thinking }])) expect(state.items.at(-1)?.body).toEqual({ - kind: 'status', - text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + kind: 'message', + role: 'reasoning', + blocks: [ + { type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } + ] }) }) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 478645d62ed..8b71149cba2 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -180,8 +180,11 @@ export function createClaudeJournalTranslator( const thinking = claudeThinkingText(outputEnvelope) if (thinking) { deps.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), { - kind: 'status', - text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + kind: 'message', + role: 'reasoning', + blocks: [ + { type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } + ] }) changed = true } diff --git a/src/main/codex/codex-notice-item-translation.test.ts b/src/main/codex/codex-notice-item-translation.test.ts index 15f638c4015..14a87cb8cc5 100644 --- a/src/main/codex/codex-notice-item-translation.test.ts +++ b/src/main/codex/codex-notice-item-translation.test.ts @@ -22,11 +22,16 @@ describe('plan document translation', () => { expect( codexItemBody({ id: 'r', type: 'reasoning', summary: ['Thinking through the problem.'] }) ).toEqual({ - kind: 'status', - text: 'Thinking through the problem.' + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Thinking through the problem.' }] }) expect(codexStreamingJournalItem({ id: 'r', type: 'reasoning' }, 'Thinking…')).toEqual({ - body: { kind: 'status', text: 'Thinking…' }, + body: { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Thinking…' }] + }, handled: true }) }) diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index 3f8ec524002..53cf94e265c 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -10,6 +10,7 @@ import { codexItemIdentity, codexJournalItem, codexMessageBlocks, + codexStreamingJournalItem, CodexTurnOrdinals, MAX_CODEX_TURN_ORDINAL_BYTES, MAX_CODEX_TURN_ORDINAL_ENTRIES, @@ -601,12 +602,18 @@ describe('codex item bodies', () => { body: { kind: 'status', text, presentation: 'plan-document' }, handled: true }) + // A plan is a durable artifact, so it must never read as the model reasoning now. + expect(codexItemBody({ type: 'plan', id: 'plan-document', text })).not.toMatchObject({ + kind: 'message', + role: 'reasoning' + }) }) - it('renders reasoning as status and exposes an unknown item as a provider frame', () => { + it('renders reasoning as a typed message and exposes an unknown item as a provider frame', () => { expect(codexItemBody({ type: 'reasoning', id: 'r', text: 'thinking' })).toEqual({ - kind: 'status', - text: 'thinking' + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'thinking' }] }) expect(codexItemBody({ type: 'reasoning', id: 'r' })).toBeNull() expect(codexItemBody({ type: 'agentMessage', id: 'm', text: '' })).toBeNull() @@ -617,6 +624,15 @@ describe('codex item bodies', () => { }) }) + it('keeps non-reasoning item streams as status activity', () => { + expect( + codexStreamingJournalItem({ type: 'somethingCodexAddedLater', id: 'x' }, 'still working') + ).toEqual({ + body: { kind: 'status', text: 'still working' }, + handled: true + }) + }) + it('gives an mcp tool call a typed body with its own arguments as input', () => { expect( codexItemBody({ @@ -843,7 +859,11 @@ describe('codex item bodies', () => { summary: ['first', 'second'], content: [{ text: 'fallback' }] }) - ).toEqual({ kind: 'status', text: 'first\nsecond' }) + ).toEqual({ + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'first\nsecond' }] + }) }) it('refuses a value that is not a thread item at all', () => { diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 52d7d5ae47f..34f07eefda7 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -85,6 +85,10 @@ export type CodexJournalItem = { handled: boolean } +function reasoningMessageBody(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'reasoning', blocks: [{ type: 'text', text }] } +} + function commandItem(item: CodexThreadItem): CodexJournalItem { const output = readFirstString(item, ['aggregatedOutput', 'aggregated_output']) const bounded = output === null ? null : boundInlineText(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) @@ -272,7 +276,7 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { handled: true } } - if (item.type === 'reasoning' || item.type === 'plan') { + if (item.type === 'reasoning') { const text = readTextContent(item, 'text') ?? readTextContent(item, 'summary') ?? @@ -281,7 +285,7 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { body: text === null ? null - : { kind: 'status', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }, + : reasoningMessageBody(boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text), handled: true } } @@ -331,5 +335,11 @@ export function codexStreamingJournalItem(item: CodexThreadItem, text: string): } } const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS) - return { body: { kind: 'status', text: bounded.text }, handled: true } + return { + body: + item.type === 'reasoning' + ? reasoningMessageBody(bounded.text) + : { kind: 'status', text: bounded.text }, + handled: true + } } diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index f8a5c7a1671..b5e60699ce1 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -799,8 +799,9 @@ describe('codex journal translation', () => { const reduced = new Map(tap.rows.map((row) => [row.key, row.body])) expect(reduced.get('orca:codex-item%3Athread-abc%3Ar-1')).toEqual({ - kind: 'status', - text: 'thinking' + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'thinking' }] }) expect(reduced.get('orca:codex-item%3Athread-abc%3Apatch-1')).toMatchObject({ kind: 'diff', diff --git a/src/main/daemon/headless-osc-link-ranges.test.ts b/src/main/daemon/headless-osc-link-ranges.test.ts new file mode 100644 index 00000000000..cf8f9f757e2 --- /dev/null +++ b/src/main/daemon/headless-osc-link-ranges.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { HeadlessEmulator } from './headless-emulator' + +// Why this suite: collectHeadlessOscLinkRanges skips its per-cell scan when +// xterm holds no OSC 8 registration. That skip is only safe if it can never +// fire while a link is reachable, so each case below pins one way it could. +let emulator: HeadlessEmulator | undefined + +const link = (uri: string, text: string): string => `\x1b]8;;${uri}\x1b\\${text}\x1b]8;;\x1b\\` + +afterEach(() => { + emulator?.dispose() + emulator = undefined +}) + +describe('headless OSC link ranges', () => { + it('finds a link written into the buffer', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write(`before ${link('https://example.com/a', 'CLICK')} after`) + + const ranges = emulator.getSnapshot().oscLinks ?? [] + expect(ranges).toHaveLength(1) + expect(ranges[0]).toMatchObject({ row: 0, uri: 'https://example.com/a' }) + }) + + it('returns nothing for a buffer that never emitted a link', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('plain output with no hyperlink\r\n'.repeat(50)) + + expect(emulator.getSnapshot().oscLinks).toEqual([]) + }) + + // The dangerous case: restored ranges are seeded without xterm registering + // anything, so an early-out keyed only on the registry would drop them. + it('still maps restored ranges when the buffer itself has no link', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('restored row') + const restored = { row: 0, startCol: 0, endCol: 4, uri: 'https://example.com/restored' } + emulator.setRestoredOscLinks([restored]) + + expect(emulator.getSnapshot().oscLinks).toEqual([restored]) + }) + + it('finds links far down a long scrollback, not just the visible screen', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24, scrollback: 5_000 }) + await emulator.write(`${link('https://example.com/top', 'TOP')}\r\n`) + await emulator.write('filler\r\n'.repeat(2_000)) + + const ranges = emulator.getSnapshot({ scrollbackRows: 5_000 }).oscLinks ?? [] + expect(ranges.map((range) => range.uri)).toContain('https://example.com/top') + }) + + it('keeps every distinct link when several are present', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write( + `${link('https://example.com/1', 'ONE')} ${link('https://example.com/2', 'TWO')}` + ) + + const uris = (emulator.getSnapshot().oscLinks ?? []).map((range) => range.uri) + expect(uris).toContain('https://example.com/1') + expect(uris).toContain('https://example.com/2') + }) +}) diff --git a/src/main/daemon/headless-osc-link-ranges.ts b/src/main/daemon/headless-osc-link-ranges.ts index 418a0c65166..ea017a7b928 100644 --- a/src/main/daemon/headless-osc-link-ranges.ts +++ b/src/main/daemon/headless-osc-link-ranges.ts @@ -1,10 +1,14 @@ -import type { Terminal } from '@xterm/headless' +import type { IBufferCell, IBufferLine, Terminal } from '@xterm/headless' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' type TerminalWithOscLinks = Terminal & { _core?: { _oscLinkService?: { getLinkData: (linkId: number) => { uri?: string } | undefined + // Why read it: xterm registers every OSC 8 id here, so an empty registry + // proves the buffer holds no hyperlink and the per-cell scan can be skipped. + // Optional because it is private — an xterm that renames it just scans. + _dataByLinkId?: { size?: number } } } } @@ -14,6 +18,11 @@ type CellWithOscLink = { hasExtendedAttrs?: () => boolean } +/** True when xterm holds no OSC 8 registration at all, so no cell can carry one. */ +function hasNoRegisteredOscLinks(service: { _dataByLinkId?: { size?: number } }): boolean { + return service._dataByLinkId?.size === 0 +} + export function collectHeadlessOscLinkRanges( terminal: Terminal, scrollbackRows: number | undefined, @@ -26,9 +35,19 @@ export function collectHeadlessOscLinkRanges( return [] } const buffer = terminal.buffer.active + // Why before the scan: the walk below reads every cell of every row, and a + // session that never emitted a hyperlink — the overwhelming majority — would + // pay that for a guaranteed-empty result. `restoredLinks` still needs mapping. + if (hasNoRegisteredOscLinks(service) && restoredLinks.length === 0) { + return [] + } const startRow = scrollbackRows === undefined ? 0 : Math.max(0, buffer.length - terminal.rows - scrollbackRows) const ranges: TerminalOscLinkRange[] = [] + // Why one cell for the whole walk: xterm's getCell allocates a fresh CellData + // per call unless handed a target, which is a per-cell allocation across the + // entire scrollback. See the IBufferLine.getCell docs. + const scratchCell = buffer.getNullCell() for (let row = startRow; row < buffer.length; row += 1) { const line = buffer.getLine(row) if (!line) { @@ -38,7 +57,7 @@ export function collectHeadlessOscLinkRanges( let currentUrlId = 0 let currentStart = -1 for (let col = 0; col <= lineLength; col += 1) { - const urlId = col < lineLength ? getOscLinkIdAtCell(line, col) : 0 + const urlId = col < lineLength ? getOscLinkIdAtCell(line, col, scratchCell) : 0 if (urlId === currentUrlId) { continue } @@ -83,8 +102,8 @@ function dedupeOscLinkRanges(ranges: TerminalOscLinkRange[]): TerminalOscLinkRan }) } -function getOscLinkIdAtCell(line: { getCell: (col: number) => unknown }, col: number): number { - const cell = line.getCell(col) as CellWithOscLink | undefined +function getOscLinkIdAtCell(line: IBufferLine, col: number, scratchCell: IBufferCell): number { + const cell = line.getCell(col, scratchCell) as (IBufferCell & CellWithOscLink) | undefined // Why: OSC link IDs live in extended cell attrs; missing attrs means no link. return cell?.hasExtendedAttrs?.() && cell.extended?.urlId ? cell.extended.urlId : 0 } diff --git a/src/main/ipc/runtime-environment-capability-evidence.test.ts b/src/main/ipc/runtime-environment-capability-evidence.test.ts index 4671326ef02..8c9bce5f272 100644 --- a/src/main/ipc/runtime-environment-capability-evidence.test.ts +++ b/src/main/ipc/runtime-environment-capability-evidence.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import type { PairingOffer } from '../../shared/pairing' import { advanceRuntimeEnvironmentCapabilityIncarnation, @@ -17,7 +17,6 @@ describe('runtime environment capability evidence', () => { it('accepts evidence by dispatch order instead of completion order', () => { const older = captureRuntimeEnvironmentCapabilityEvidence('env', pairing()) const newer = captureRuntimeEnvironmentCapabilityEvidence('env', pairing()) - const pause = vi.fn() expect( applyRuntimeEnvironmentCapabilityVerdict({ @@ -30,12 +29,10 @@ describe('runtime environment capability evidence', () => { applyRuntimeEnvironmentCapabilityVerdict({ evidence: older, verdict: 'absent', - runtimeId: 'runtime-old', - onAbsent: pause + runtimeId: 'runtime-old' }) ).toBe(false) - expect(pause).not.toHaveBeenCalled() expect(isRuntimeEnvironmentCapabilityPaused('env')).toBe(false) }) diff --git a/src/main/ipc/runtime-environment-capability-evidence.ts b/src/main/ipc/runtime-environment-capability-evidence.ts index d32bda584e9..197ec71f4f8 100644 --- a/src/main/ipc/runtime-environment-capability-evidence.ts +++ b/src/main/ipc/runtime-environment-capability-evidence.ts @@ -68,8 +68,6 @@ export function applyRuntimeEnvironmentCapabilityVerdict(args: { evidence: RuntimeEnvironmentCapabilityEvidence verdict: RuntimeEnvironmentCapabilityVerdict runtimeId: string - onCapable?: () => void - onAbsent?: () => void }): boolean { const state = stateFor(args.evidence.environmentId) if ( @@ -83,11 +81,6 @@ export function applyRuntimeEnvironmentCapabilityVerdict(args: { verdict: args.verdict, runtimeId: args.runtimeId } - if (args.verdict === 'capable') { - args.onCapable?.() - } else { - args.onAbsent?.() - } return true } diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 1e267d8675a..bfbc63847c4 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -20,6 +20,8 @@ import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environ import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence' import { closeRemoteRuntimeRequestConnection, + getRuntimeEnvironmentStatusOwner, + getRuntimeEnvironmentStatusSnapshots, retryRemoteRuntimeSharedControlConnectionNow } from './runtime-environment-request-connections' import { @@ -29,7 +31,6 @@ import { } from './runtime-environment-manual-disconnect' import { callRuntimeEnvironment, - clearSharedControlSupport, getRuntimeEnvironmentStatus } from './runtime-environment-transport-routing' @@ -60,6 +61,9 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ getUserDataPath, invalidateTransport }: ConnectivityHandlerOptions): void { + ipcMain.handle('runtimeEnvironments:getStatusSnapshots', () => + getRuntimeEnvironmentStatusSnapshots() + ) ipcMain.handle('runtimeEnvironments:list', () => listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment) ) @@ -80,6 +84,12 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ const result = await verifyAndAddRuntimeEnvironmentFromPairingCode(getUserDataPath(), args) if (result.ok) { clearRuntimeEnvironmentManualDisconnect(result.environment.id) + getRuntimeEnvironmentStatusOwner(getUserDataPath(), result.environment.id).acceptVerified({ + id: 'status.get', + ok: true, + result: result.runtimeStatus, + _meta: { runtimeId: result.runtimeStatus.runtimeId } + }) } return result } @@ -121,6 +131,8 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ markRuntimeEnvironmentManuallyDisconnected(environment.id) invalidateTransport(environment.id) closeLegacySelectorTransport(args.selector, environment.id) + // Retain disconnected evidence for renderers that missed the teardown event. + getRuntimeEnvironmentStatusOwner(getUserDataPath(), environment.id) return { disconnected: redactRuntimeEnvironment(environment) } } ) @@ -132,7 +144,9 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ ): Promise> => { const environment = resolveEnvironment(getUserDataPath(), args.selector) clearRuntimeEnvironmentManualDisconnect(environment.id) - return getRuntimeEnvironmentStatus(getUserDataPath(), environment.id, args.timeoutMs) + return getRuntimeEnvironmentStatus(getUserDataPath(), environment.id, args.timeoutMs, { + reconnect: true + }) } ) ipcMain.handle( @@ -156,7 +170,6 @@ function closeLegacySelectorTransport(selector: string, environmentId: string): return } closeRemoteRuntimeRequestConnection(selector) - clearSharedControlSupport(selector) } function registerPassiveStatusHandler(getUserDataPath: () => string): void { diff --git a/src/main/ipc/runtime-environment-federated-read-routing.test.ts b/src/main/ipc/runtime-environment-federated-read-routing.test.ts index c58a404fd39..51c77510a01 100644 --- a/src/main/ipc/runtime-environment-federated-read-routing.test.ts +++ b/src/main/ipc/runtime-environment-federated-read-routing.test.ts @@ -1,3 +1,5 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -20,14 +22,17 @@ vi.mock('../../shared/remote-runtime-client', () => ({ sendRemoteRuntimeRequest: sendRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: vi.fn(), - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - reconnectRemoteRuntimeSharedControlConnection: vi.fn(), - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn() -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: vi.fn(), + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + reconnectRemoteRuntimeSharedControlConnection: vi.fn(), + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn() + }) +}) import { callRuntimeEnvironment, @@ -55,6 +60,7 @@ describe('federated read RPC transport routing', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environment-handler-channels.ts b/src/main/ipc/runtime-environment-handler-channels.ts index 0b63dea943a..23b40fe5183 100644 --- a/src/main/ipc/runtime-environment-handler-channels.ts +++ b/src/main/ipc/runtime-environment-handler-channels.ts @@ -9,6 +9,7 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:retryControlConnection', 'runtimeEnvironments:prepareBrowserClientHostPlacement', 'runtimeEnvironments:getStatus', + 'runtimeEnvironments:getStatusSnapshots', 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', 'runtimeEnvironments:unsubscribe' diff --git a/src/main/ipc/runtime-environment-request-connections.test.ts b/src/main/ipc/runtime-environment-request-connections.test.ts index 750d1becc6a..b02d1b06fb5 100644 --- a/src/main/ipc/runtime-environment-request-connections.test.ts +++ b/src/main/ipc/runtime-environment-request-connections.test.ts @@ -47,9 +47,9 @@ describe('runtime environment shared-control connection cache', () => { applyRuntimeEnvironmentCapabilityVerdict({ evidence: absent, verdict: 'absent', - runtimeId: 'runtime-test', - onAbsent: () => pauseRemoteRuntimeSharedControlRetry(ENVIRONMENT_ID) + runtimeId: 'runtime-test' }) + pauseRemoteRuntimeSharedControlRetry(ENVIRONMENT_ID) expect(getRemoteRuntimeSharedControlDiagnostics(ENVIRONMENT_ID)?.state).toBe('closed') await delay(400) expect(server.connectionCount()).toBe(1) @@ -58,12 +58,10 @@ describe('runtime environment shared-control connection cache', () => { applyRuntimeEnvironmentCapabilityVerdict({ evidence: capable, verdict: 'capable', - runtimeId: 'runtime-test', - onCapable: () => { - ensureRemoteRuntimeSharedControlConnection(ENVIRONMENT_ID, server.pairing) - reconnectRemoteRuntimeSharedControlConnection(ENVIRONMENT_ID) - } + runtimeId: 'runtime-test' }) + ensureRemoteRuntimeSharedControlConnection(ENVIRONMENT_ID, server.pairing) + reconnectRemoteRuntimeSharedControlConnection(ENVIRONMENT_ID) await waitFor(() => server.connectionCount() === 2) }) @@ -119,9 +117,9 @@ describe('runtime environment shared-control connection cache', () => { applyRuntimeEnvironmentCapabilityVerdict({ evidence, verdict: 'absent', - runtimeId: 'runtime-test', - onAbsent: () => pauseRemoteRuntimeSharedControlRetry(ENVIRONMENT_ID) + runtimeId: 'runtime-test' }) + pauseRemoteRuntimeSharedControlRetry(ENVIRONMENT_ID) expect(getRemoteRuntimeSharedControlDiagnostics(ENVIRONMENT_ID)?.state).toBe('reconnecting') await waitFor(() => server.connectionCount() === 2) diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts index c1f855697e5..6ba9f273c1e 100644 --- a/src/main/ipc/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -1,4 +1,9 @@ import type { PairingOffer } from '../../shared/pairing' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { getPreferredPairingOffer } from '../../shared/runtime-environments' +import type { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner' +import type { RuntimeStatus } from '../../shared/runtime-types' +import { createRuntimeEnvironmentStatusOwner } from './runtime-environment-status-owner' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' import type { RuntimeOrchestrationEnvelope, @@ -30,6 +35,56 @@ type CachedSharedControlConnection = { const requestConnections = new Map() const sharedControlConnections = new Map() +const statusOwners = new Map() + +export function getRuntimeEnvironmentStatusOwner( + userDataPath: string, + selector: string +): RuntimeHostStatusOwner { + const environment = resolveEnvironment(userDataPath, selector) + const pairing = getPreferredPairingOffer(environment) + const key = `${userDataPath}\0${environment.pairingRevision ?? environment.createdAt}\0${getPairingKey(pairing)}` + let cached = statusOwners.get(environment.id) + if (!cached || cached.key !== key || cached.owner.read().retired) { + if (cached) { + closeRemoteRuntimeRequestConnection(environment.id) + } + const owner = createRuntimeEnvironmentStatusOwner(userDataPath, environment, { + isReady: () => getRemoteRuntimeSharedControlDiagnostics(environment.id)?.state === 'ready', + request: (signal) => + sendRemoteRuntimeSharedControlRequest( + environment.id, + pairing, + 'status.get', + undefined, + 15_000, + undefined, + signal + ), + establish: () => { + ensureRemoteRuntimeSharedControlConnection(environment.id, pairing) + reconnectRemoteRuntimeSharedControlConnection(environment.id) + }, + pause: () => pauseRemoteRuntimeSharedControlRetry(environment.id) + }) + cached = { key, owner } + statusOwners.set(environment.id, cached) + if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + owner.dispose() + } + } + return cached.owner +} + +export function resetRuntimeEnvironmentStatusOwners(): void { + for (const id of statusOwners.keys()) { + closeRemoteRuntimeRequestConnection(id) + } +} + +export function getRuntimeEnvironmentStatusSnapshots() { + return [...statusOwners.values()].map(({ owner }) => owner.read()) +} export function sendRemoteRuntimeConnectionRequest( environmentId: string, @@ -56,6 +111,9 @@ export function sendRemoteRuntimeConnectionRequest( } export function closeRemoteRuntimeRequestConnection(environmentId: string): void { + const status = statusOwners.get(environmentId) + statusOwners.delete(environmentId) + status?.owner.dispose() const cached = requestConnections.get(environmentId) requestConnections.delete(environmentId) cached?.connection.close() @@ -166,6 +224,16 @@ function getSharedControlConnection( transportGeneration, diagnostics }) + statusOwners + .get(environmentId) + ?.owner.connectionChanged( + diagnostics.state === 'ready' + ? 'ready' + : diagnostics.state === 'closed' || diagnostics.state === 'reconnecting' + ? 'disconnected' + : 'connecting', + diagnostics + ) } }) } diff --git a/src/main/ipc/runtime-environment-shared-control-support.ts b/src/main/ipc/runtime-environment-shared-control-support.ts index 29513e2970a..0de6a35e0f6 100644 --- a/src/main/ipc/runtime-environment-shared-control-support.ts +++ b/src/main/ipc/runtime-environment-shared-control-support.ts @@ -1,39 +1,23 @@ -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY -} from '../../shared/protocol-version' -import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' -import { markEnvironmentUsed } from '../../shared/runtime-environment-store' import type { getPreferredPairingOffer, KnownRuntimeEnvironment } from '../../shared/runtime-environments' -import type { RuntimeStatus } from '../../shared/runtime-types' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' import { - applyRuntimeEnvironmentCapabilityVerdict, - captureRuntimeEnvironmentCapabilityEvidence, getAcceptedRuntimeEnvironmentCapabilityOutcome, - isRuntimeEnvironmentCapabilityOutcomeCurrent, - runtimeEnvironmentCapabilityOutcome, resetRuntimeEnvironmentCapabilityEvidence, type RuntimeEnvironmentCapabilityOutcome } from './runtime-environment-capability-evidence' -import { pauseRemoteRuntimeSharedControlRetry } from './runtime-environment-request-connections' - -const sharedControlSupport = new Map< - string, - { cacheKey: string; check: Promise } ->() +import { + getRuntimeEnvironmentStatusOwner, + resetRuntimeEnvironmentStatusOwners +} from './runtime-environment-request-connections' export function resetSharedControlSupport(): void { - sharedControlSupport.clear() + resetRuntimeEnvironmentStatusOwners() resetRuntimeEnvironmentCapabilityEvidence() } -export function clearSharedControlSupport(environmentId: string): void { - sharedControlSupport.delete(environmentId) -} - export async function supportsSharedControl( userDataPath: string, environment: KnownRuntimeEnvironment, @@ -48,85 +32,17 @@ export async function supportsSharedControl( if (accepted) { return accepted } - const cacheKey = getSharedControlSupportCacheKey(environment, pairing) - const cached = sharedControlSupport.get(environment.id) - if (cached?.cacheKey === cacheKey) { - const outcome = await cached.check - if (isRuntimeEnvironmentCapabilityOutcomeCurrent(outcome)) { - return outcome - } - if (sharedControlSupport.get(environment.id)?.check === cached.check) { - sharedControlSupport.delete(environment.id) - } - return { kind: 'stale_incarnation' } + const response = await getRuntimeEnvironmentStatusOwner(userDataPath, environment.id).refresh({ + timeoutMs + }) + if (!response.ok) { + throw new RemoteRuntimeClientError(response.error.code, response.error.message) } - let resolvedCacheKey = cacheKey - const evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing) - const check = (async () => { - const response = await sendRemoteRuntimeRequest( + return ( + getAcceptedRuntimeEnvironmentCapabilityOutcome( + environment.id, pairing, - 'status.get', - undefined, - timeoutMs, - undefined, - undefined, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - ) - if (response.ok === true) { - const verdict = response.result.capabilities?.includes( - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY - ) - ? 'capable' - : 'absent' - const acceptedEvidence = applyRuntimeEnvironmentCapabilityVerdict({ - evidence, - verdict, - runtimeId: response._meta.runtimeId, - onAbsent: () => pauseRemoteRuntimeSharedControlRetry(environment.id) - }) - if (!acceptedEvidence) { - return { kind: 'stale_incarnation' } as const - } - markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId }) - resolvedCacheKey = getSharedControlSupportCacheKey( - environment, - pairing, - response._meta.runtimeId - ) - return runtimeEnvironmentCapabilityOutcome(evidence, verdict, response._meta.runtimeId) - } - return runtimeEnvironmentCapabilityOutcome( - evidence, - 'absent', - environment.runtimeId ?? 'unknown-runtime' - ) - })() - // Why: support belongs to the saved pairing/runtime identity, not its mutable display name. - sharedControlSupport.set(environment.id, { cacheKey, check }) - try { - const outcome = await check - const cachedAfterCheck = sharedControlSupport.get(environment.id) - if (cachedAfterCheck?.check === check && cachedAfterCheck.cacheKey !== resolvedCacheKey) { - sharedControlSupport.set(environment.id, { cacheKey: resolvedCacheKey, check }) - } - return outcome - } catch (error) { - if (sharedControlSupport.get(environment.id)?.check === check) { - sharedControlSupport.delete(environment.id) - } - throw error - } -} - -function getSharedControlSupportCacheKey( - environment: KnownRuntimeEnvironment, - pairing: ReturnType, - runtimeId = environment.runtimeId -): string { - return [ - runtimeId ?? 'unknown-runtime', - pairing.endpoint, - pairing.deviceToken, - pairing.publicKeyB64 - ].join('\0') + response._meta.runtimeId + ) ?? { kind: 'stale_incarnation' } + ) } diff --git a/src/main/ipc/runtime-environment-status-connection.test.ts b/src/main/ipc/runtime-environment-status-connection.test.ts new file mode 100644 index 00000000000..8d4fad6f9b9 --- /dev/null +++ b/src/main/ipc/runtime-environment-status-connection.test.ts @@ -0,0 +1,65 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { encodePairingOffer } from '../../shared/pairing' +import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { + createSharedControlTestServer, + closeSharedControlTestServers +} from '../../shared/remote-runtime-shared-control-test-server' +import { getRuntimeEnvironmentStatus } from './runtime-environment-transport-routing' +import { + getRuntimeEnvironmentStatusOwner, + resetRuntimeEnvironmentStatusOwners +} from './runtime-environment-request-connections' + +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) +const profiles: string[] = [] +afterEach(async () => { + resetRuntimeEnvironmentStatusOwners() + await closeSharedControlTestServers() + profiles.splice(0).forEach((profile) => rmSync(profile, { recursive: true, force: true })) +}) + +it('publishes real same-socket verification after every authenticated reconnect', async () => { + let runtimeId = 'host-before' + const server = await createSharedControlTestServer({ + resultForRequest: () => ({ + runtimeId, + capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] + }) + }) + const profile = mkdtempSync(join(tmpdir(), 'orca-status-socket-')) + profiles.push(profile) + const environment = addEnvironmentFromPairingCode(profile, { + name: 'host', + pairingCode: encodePairingOffer(server.pairing) + }) + await getRuntimeEnvironmentStatus(profile, environment.id) + const owner = getRuntimeEnvironmentStatusOwner(profile, environment.id) + await vi.waitFor( + () => { + expect(owner.read()).toMatchObject({ transport: 'ready', verification: 'verified' }) + expect(server.requests).toHaveLength(2) + }, + { timeout: 3_000 } + ) + expect(server.connectionCount()).toBe(2) // Bootstrap plus persistent control. + runtimeId = 'host-after' + server.closeClients() + await vi.waitFor( + () => { + expect(owner.read().status?.runtimeId).toBe('host-after') + expect(owner.read().verification).toBe('verified') + }, + { timeout: 3_000 } + ) + expect(server.connectionCount()).toBe(3) + expect(server.requests.map((request) => request.method)).toEqual([ + 'status.get', + 'status.get', + 'status.get' + ]) +}) diff --git a/src/main/ipc/runtime-environment-status-owner.ts b/src/main/ipc/runtime-environment-status-owner.ts new file mode 100644 index 00000000000..4ac3c067f74 --- /dev/null +++ b/src/main/ipc/runtime-environment-status-owner.ts @@ -0,0 +1,89 @@ +import { BrowserWindow } from 'electron' +import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY +} from '../../shared/protocol-version' +import { + getPreferredPairingOffer, + type KnownRuntimeEnvironment +} from '../../shared/runtime-environments' +import { markEnvironmentUsed } from '../../shared/runtime-environment-store' +import { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner' +import { + RUNTIME_HOST_STATUS_CHANNEL, + type RuntimeHostStatusResponse +} from '../../shared/runtime-host-status' +import { + applyRuntimeEnvironmentCapabilityVerdict, + getAcceptedRuntimeEnvironmentCapabilityOutcome, + captureRuntimeEnvironmentCapabilityEvidence +} from './runtime-environment-capability-evidence' +import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' + +export function createRuntimeEnvironmentStatusOwner( + userDataPath: string, + environment: KnownRuntimeEnvironment, + transport: { + isReady: () => boolean + request: (signal: AbortSignal) => Promise + establish: () => void + pause: () => void + } +): RuntimeHostStatusOwner { + const pairing = getPreferredPairingOffer(environment) + let evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing) + return new RuntimeHostStatusOwner({ + environmentId: environment.id, + pairingRevision: environment.pairingRevision ?? environment.createdAt, + request: (signal) => { + evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing) + return transport.isReady() && + getAcceptedRuntimeEnvironmentCapabilityOutcome(environment.id, pairing, null)?.kind === + 'supported' + ? transport.request(signal) + : sendRemoteRuntimeRequest( + pairing, + 'status.get', + undefined, + 15_000, + undefined, + signal, + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + ) + }, + verified: (response, active) => { + const capable = + response.result.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) ?? false + const accepted = applyRuntimeEnvironmentCapabilityVerdict({ + evidence, + verdict: capable ? 'capable' : 'absent', + runtimeId: response._meta.runtimeId + }) + if (accepted && active && !isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + markEnvironmentUsed(userDataPath, environment.id, { + runtimeId: response._meta.runtimeId, + pairedDeviceId: response.result.pairedDeviceId + }) + if (capable) { + transport.establish() + } else { + transport.pause() + } + } + return capable && active + }, + publish: (snapshot) => { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) { + continue + } + try { + window.webContents.send(RUNTIME_HOST_STATUS_CHANNEL, snapshot) + } catch { + /* A renderer can close during publication. */ + } + } + } + }) +} diff --git a/src/main/ipc/runtime-environment-status-recovery.test.ts b/src/main/ipc/runtime-environment-status-recovery.test.ts new file mode 100644 index 00000000000..82e94ea62e0 --- /dev/null +++ b/src/main/ipc/runtime-environment-status-recovery.test.ts @@ -0,0 +1,89 @@ +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' +import { pairingCode } from './runtime-environments-ipc-test-harness' +import { + getRuntimeEnvironmentStatus, + resetSharedControlSupport +} from './runtime-environment-transport-routing' + +const { request, publish } = vi.hoisted(() => ({ request: vi.fn(), publish: vi.fn() })) +vi.mock('../../shared/remote-runtime-client', () => ({ + sendRemoteRuntimeRequest: request, + subscribeRemoteRuntimeRequest: vi.fn() +})) +vi.mock('electron', () => ({ + BrowserWindow: { + getAllWindows: () => [ + { + isDestroyed: () => false, + webContents: { send: publish } + } + ] + } +})) + +let profile: string +beforeEach(() => { + vi.useFakeTimers() + request.mockReset() + publish.mockReset() + profile = mkdtempSync(join(tmpdir(), 'orca-status-recovery-')) +}) +afterEach(() => { + resetSharedControlSupport() + vi.useRealTimers() + rmSync(profile, { recursive: true, force: true }) +}) + +it('recovers a saved host after its first status check fails, without another UI request', async () => { + const environment = addEnvironmentFromPairingCode(profile, { + name: 'offline-at-startup', + pairingCode: pairingCode() + }) + request + .mockRejectedValueOnce( + Object.assign(new Error('host offline'), { code: 'runtime_unavailable' }) + ) + .mockResolvedValue({ + id: 'status', + ok: true, + result: { runtimeId: 'host-1', graphStatus: 'ready', capabilities: [] }, + _meta: { runtimeId: 'host-1' } + }) + expect((await getRuntimeEnvironmentStatus(profile, environment.id)).ok).toBe(false) + await vi.advanceTimersByTimeAsync(3_000) + expect(request).toHaveBeenCalledTimes(2) + expect(publish).toHaveBeenCalledWith( + 'runtimeEnvironments:statusChanged', + expect.objectContaining({ + environmentId: environment.id, + verification: 'verified', + status: expect.objectContaining({ runtimeId: 'host-1' }) + }) + ) + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledTimes(2) +}) + +it('a passive capability check does not strand later active bootstrap recovery', async () => { + const environment = addEnvironmentFromPairingCode(profile, { + name: 'passive-first', + pairingCode: pairingCode() + }) + request + .mockResolvedValueOnce({ + id: 'status', + ok: true, + result: { runtimeId: 'host-1', capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] }, + _meta: { runtimeId: 'host-1' } + }) + .mockRejectedValue(new Error('host offline')) + await getRuntimeEnvironmentStatus(profile, environment.id, undefined, { observeOnly: true }) + await getRuntimeEnvironmentStatus(profile, environment.id) + await vi.advanceTimersByTimeAsync(3_000) + expect(request).toHaveBeenCalledTimes(3) +}) diff --git a/src/main/ipc/runtime-environment-support-routing.test.ts b/src/main/ipc/runtime-environment-support-routing.test.ts index feb09ee481a..8fdde15a81c 100644 --- a/src/main/ipc/runtime-environment-support-routing.test.ts +++ b/src/main/ipc/runtime-environment-support-routing.test.ts @@ -57,7 +57,6 @@ describe('runtime environment support routing', () => { ).resolves.toMatchObject({ ok: true }) expect(supportsMock).toHaveBeenCalledTimes(2) - expect(clearSupportMock).toHaveBeenCalledOnce() expect(supported).toHaveBeenCalledOnce() expect(unsupported).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/runtime-environment-support-routing.ts b/src/main/ipc/runtime-environment-support-routing.ts index e2503ad4445..9566b2fc1b7 100644 --- a/src/main/ipc/runtime-environment-support-routing.ts +++ b/src/main/ipc/runtime-environment-support-routing.ts @@ -18,10 +18,7 @@ import { type RuntimeEnvironmentCapabilityOutcome } from './runtime-environment-capability-evidence' import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard' -import { - clearSharedControlSupport, - supportsSharedControl -} from './runtime-environment-shared-control-support' +import { supportsSharedControl } from './runtime-environment-shared-control-support' import { sendRemoteRuntimeRequestAbortable, sendRemoteRuntimeSharedControlRequestAbortable @@ -205,7 +202,6 @@ export async function routeRuntimeEnvironmentCallBySupport(args: { } return response } - clearSharedControlSupport(environment.id) environment = resolveEnvironment(args.userDataPath, environment.id) } return runtimeEnvironmentChangedFailure(environment, args.method) diff --git a/src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts b/src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts index a44f5df84c6..bb2bbdac322 100644 --- a/src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts +++ b/src/main/ipc/runtime-environment-transport-routing-tailscale-hint.test.ts @@ -1,20 +1,23 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { generateKeyPair, publicKeyToBase64 } from '../../shared/e2ee-crypto' import { encodePairingOffer, type PairingOffer } from '../../shared/pairing' import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store' import { callRuntimeEnvironment, getRuntimeEnvironmentStatus, - subscribeRuntimeEnvironment + subscribeRuntimeEnvironment, + resetSharedControlSupport } from './runtime-environment-transport-routing' // Why: prove the wiring, not just the helper — an unreachable endpoint exercises // the real WebSocket failure → reject → Tailscale-hint join points the settings // probe (returned ok:false) and in-use calls (thrown) actually use. +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) + let userDataPath: string function seedEnvironment(name: string, endpoint: string): string { @@ -39,6 +42,7 @@ beforeEach(() => { }) afterEach(() => { + resetSharedControlSupport() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index 70f49816603..b19b0d9e376 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -1,8 +1,5 @@ import { getPreferredPairingOffer } from '../../shared/runtime-environments' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY -} from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store' import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract' import type { @@ -11,33 +8,22 @@ import type { } from '../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../shared/runtime-types' import { - sendRemoteRuntimeRequest, subscribeRemoteRuntimeRequest, type RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint' import { enqueueRuntimeCall } from './runtime-environment-call-queue' -import { - ensureRemoteRuntimeSharedControlConnection, - pauseRemoteRuntimeSharedControlRetry, - reconnectRemoteRuntimeSharedControlConnection -} from './runtime-environment-request-connections' +import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections' import { sendRemoteRuntimeConnectionRequestAbortable, sendRemoteRuntimeRequestAbortable } from './runtime-environment-abortable-requests' import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics' -import { - applyRuntimeEnvironmentCapabilityVerdict, - captureRuntimeEnvironmentCapabilityEvidence -} from './runtime-environment-capability-evidence' + import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard' import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response' -import { - clearSharedControlSupport, - resetSharedControlSupport -} from './runtime-environment-shared-control-support' +import { resetSharedControlSupport } from './runtime-environment-shared-control-support' import { executeSupportRoutedCall, shouldRouteCallBySupport, @@ -47,72 +33,31 @@ import { const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000 -export { clearSharedControlSupport, resetSharedControlSupport } +export { resetSharedControlSupport } export async function getRuntimeEnvironmentStatus( userDataPath: string, selector: string, timeoutMs?: number, - options?: { observeOnly?: true } + options?: { observeOnly?: true; signal?: AbortSignal; reconnect?: true } ): Promise> { const environment = resolveEnvironment(userDataPath, selector) - const pairing = getPreferredPairingOffer(environment) - const evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing) - let response: RuntimeRpcResponse - try { - response = await sendRemoteRuntimeRequest( - pairing, - 'status.get', - undefined, - timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS, - undefined, - undefined, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - ) - } catch (error) { - // Why: the status UI needs shared-control diagnostics most when the - // fresh status probe failed and the host is reconnecting/offline. - return attachRemoteControlDiagnostics( - withTailscaleHintForResponse( - { - id: 'status.get', - ok: false, - error: { - code: 'runtime_unavailable', - message: error instanceof Error ? error.message : String(error) - }, - _meta: { runtimeId: environment.runtimeId } - }, - pairing.endpoint - ), - environment.id - ) - } - if (response.ok === true) { - const verdict = response.result.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) - ? 'capable' - : 'absent' - const accepted = applyRuntimeEnvironmentCapabilityVerdict({ - evidence, - verdict, - runtimeId: response._meta.runtimeId, - onCapable: () => { - if (!options?.observeOnly && !isRuntimeEnvironmentManuallyDisconnected(environment.id)) { - ensureRemoteRuntimeSharedControlConnection(environment.id, pairing) - reconnectRemoteRuntimeSharedControlConnection(environment.id) - } - }, - onAbsent: () => pauseRemoteRuntimeSharedControlRetry(environment.id) - }) - if (accepted && !options?.observeOnly) { - markEnvironmentUsed(userDataPath, environment.id, { - runtimeId: response._meta.runtimeId, - pairedDeviceId: response.result.pairedDeviceId - }) + if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + return { + id: 'status.get', + ok: false, + error: { + code: 'runtime_manually_disconnected', + message: 'Runtime environment is manually disconnected.' + } } } + const response = await getRuntimeEnvironmentStatusOwner(userDataPath, environment.id).refresh({ + timeoutMs, + ...options + }) return attachRemoteControlDiagnostics( - withTailscaleHintForResponse(response, pairing.endpoint), + withTailscaleHintForResponse(response, getPreferredPairingOffer(environment).endpoint), environment.id ) } @@ -127,6 +72,15 @@ export async function callRuntimeEnvironment( envelope?: RuntimeOrchestrationEnvelope, options?: { signal?: AbortSignal } ): Promise> { + if (method === 'status.get') { + const environment = resolveEnvironment(userDataPath, selector) + const failure = runtimeEnvironmentRevisionFailure( + environment, + expectedEnvironmentPairingRevision, + method + ) + return failure ?? getRuntimeEnvironmentStatus(userDataPath, selector, timeoutMs, options) + } const environment = resolveEnvironment(userDataPath, selector) // Why: connection failures reject (they don't resolve as ok:false), so the // Tailscale hint is applied to the thrown error here — wrapping the resolved diff --git a/src/main/ipc/runtime-environments-call-routing.test.ts b/src/main/ipc/runtime-environments-call-routing.test.ts index ef92dc66826..e6f8946527d 100644 --- a/src/main/ipc/runtime-environments-call-routing.test.ts +++ b/src/main/ipc/runtime-environments-call-routing.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -44,6 +45,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -58,18 +60,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness' @@ -112,6 +119,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) @@ -339,7 +347,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { undefined, 15_000, undefined, - undefined, + expect.any(AbortSignal), ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES ) expect(sendRemoteRuntimeSharedControlRequestMock).toHaveBeenCalledWith( @@ -451,7 +459,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { } ) - it('keeps uncoded call failures on the rejected IPC fallback path', async () => { + it('returns uncoded status failures through the owner response', async () => { registerRuntimeEnvironmentHandlers(store as never) sendRemoteRuntimeRequestMock.mockRejectedValue(new Error('shared down')) @@ -464,9 +472,10 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:call' ) - await expect(call(null, { selector: 'desk', method: 'status.get' })).rejects.toThrow( - 'shared down' - ) + await expect(call(null, { selector: 'desk', method: 'status.get' })).resolves.toMatchObject({ + ok: false, + error: { code: 'runtime_unavailable', message: 'shared down' } + }) }) it('does not fall back after a shared-control request fails on a supported runtime', async () => { diff --git a/src/main/ipc/runtime-environments-capability-cache.test.ts b/src/main/ipc/runtime-environments-capability-cache.test.ts index 8ac11d69dd1..32f724df981 100644 --- a/src/main/ipc/runtime-environments-capability-cache.test.ts +++ b/src/main/ipc/runtime-environments-capability-cache.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -37,6 +38,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -51,18 +53,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness' @@ -105,6 +112,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) @@ -182,9 +190,10 @@ describe('registerRuntimeEnvironmentHandlers', () => { { selector: string; method: string; params?: unknown; timeoutMs?: number }, { ok: true; result: unknown } >('runtimeEnvironments:call') - await expect(call(null, { selector: 'desk', method: 'repo.list' })).rejects.toThrow( - 'probe failed' - ) + await expect(call(null, { selector: 'desk', method: 'repo.list' })).resolves.toMatchObject({ + ok: false, + error: { code: 'runtime_unavailable', message: 'probe failed' } + }) await expect(call(null, { selector: 'desk', method: 'repo.list' })).resolves.toMatchObject({ ok: true, result: { repos: [] } diff --git a/src/main/ipc/runtime-environments-ipc-test-harness.ts b/src/main/ipc/runtime-environments-ipc-test-harness.ts index 016352793cb..e97e45ede1a 100644 --- a/src/main/ipc/runtime-environments-ipc-test-harness.ts +++ b/src/main/ipc/runtime-environments-ipc-test-harness.ts @@ -1,6 +1,62 @@ import { expect } from 'vitest' import type { Mock } from 'vitest' +import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { encodePairingOffer } from '../../shared/pairing' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { createRuntimeEnvironmentStatusOwner } from './runtime-environment-status-owner' +import type { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner' +import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' + +/** Keep IPC tests on the production owner while replacing only its transport. */ +export function withRuntimeStatusOwners>(transport: T) { + const owners = new Map() + return { + ...transport, + getRuntimeEnvironmentStatusOwner: (profile: string, selector: string) => { + const environment = resolveEnvironment(profile, selector) + let owner = owners.get(environment.id) + if (!owner || owner.read().retired) { + owner = createRuntimeEnvironmentStatusOwner(profile, environment, { + isReady: () => + transport.getRemoteRuntimeSharedControlDiagnostics?.(environment.id)?.state === 'ready', + request: (signal) => + transport.sendRemoteRuntimeSharedControlRequest( + environment.id, + undefined, + 'status.get', + undefined, + 15_000, + undefined, + signal + ), + establish: () => { + transport.ensureRemoteRuntimeSharedControlConnection?.( + environment.id, + getPreferredPairingOffer(environment) + ) + transport.reconnectRemoteRuntimeSharedControlConnection?.(environment.id) + }, + pause: () => transport.pauseRemoteRuntimeSharedControlRetry?.(environment.id) + }) + owners.set(environment.id, owner) + if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + owner.dispose() + } + } + return owner + }, + getRuntimeEnvironmentStatusSnapshots: () => [...owners.values()].map((owner) => owner.read()), + resetRuntimeEnvironmentStatusOwners: () => { + owners.forEach((owner) => owner.dispose()) + owners.clear() + }, + closeRemoteRuntimeRequestConnection: (...args: unknown[]) => { + owners.get(args[0] as string)?.dispose() + owners.delete(args[0] as string) + transport.closeRemoteRuntimeRequestConnection(...args) + } + } +} export function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string { return encodePairingOffer({ diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index 87d6c2698ab..ce492a7a740 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -1,3 +1,5 @@ +import type { RuntimeHostStatusSnapshot } from '../../shared/runtime-host-status' +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -44,6 +46,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -58,18 +61,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: retryRemoteRuntimeSharedControlConnectionNowMock, - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: retryRemoteRuntimeSharedControlConnectionNowMock, + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness' @@ -125,6 +133,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) @@ -132,6 +141,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { registerRuntimeEnvironmentHandlers(store as never) expect(handleMock.mock.calls.map((call) => call[0])).toEqual([ + 'runtimeEnvironments:getStatusSnapshots', 'runtimeEnvironments:list', 'runtimeEnvironments:addFromPairingCode', 'runtimeEnvironments:verifyAndAddFromPairingCode', @@ -166,6 +176,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:retryControlConnection', 'runtimeEnvironments:prepareBrowserClientHostPlacement', 'runtimeEnvironments:getStatus', + 'runtimeEnvironments:getStatusSnapshots', 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', 'runtimeEnvironments:unsubscribe', @@ -467,6 +478,13 @@ describe('registerRuntimeEnvironmentHandlers', () => { ok: false, error: { code: 'runtime_manually_disconnected' } }) + const getSnapshots = handler( + 'runtimeEnvironments:getStatusSnapshots' + ) + // A new renderer only has the snapshot read, not the earlier disconnect event. + expect(await getSnapshots(null, undefined)).toMatchObject([ + { environmentId: added.environment.id, retired: true, transport: 'disconnected' } + ]) const call = handler< { selector: string; method: string }, { ok: boolean; error?: { code: string } } @@ -492,6 +510,10 @@ describe('registerRuntimeEnvironmentHandlers', () => { result: { runtimeId: 'runtime-remote' } }) expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledOnce() + expect(await getSnapshots(null, undefined)).toMatchObject([ + { environmentId: added.environment.id, verification: 'verified' } + ]) + expect((await getSnapshots(null, undefined))[0].retired).not.toBe(true) }) it('marks environments owned by ephemeral VM runtimes in the public list', async () => { diff --git a/src/main/ipc/runtime-environments-status-diagnostics.test.ts b/src/main/ipc/runtime-environments-status-diagnostics.test.ts index b210e7c209b..9fe3d6baf0e 100644 --- a/src/main/ipc/runtime-environments-status-diagnostics.test.ts +++ b/src/main/ipc/runtime-environments-status-diagnostics.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -44,6 +45,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -58,18 +60,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: ensureRemoteRuntimeSharedControlConnectionMock, - pauseRemoteRuntimeSharedControlRetry: pauseRemoteRuntimeSharedControlRetryMock, - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: ensureRemoteRuntimeSharedControlConnectionMock, + pauseRemoteRuntimeSharedControlRetry: pauseRemoteRuntimeSharedControlRetryMock, + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness' @@ -114,6 +121,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) @@ -148,9 +156,9 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768', deviceToken: 'device-token' }), 'status.get', undefined, - 50, - undefined, + 15_000, undefined, + expect.any(AbortSignal), ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES ) expect(reconnectRemoteRuntimeSharedControlConnectionMock).toHaveBeenCalledWith( @@ -319,36 +327,41 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) }) - it('returns shared-control diagnostics when saved remote runtime status throws', async () => { - registerRuntimeEnvironmentHandlers(store as never) - getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({ - state: 'reconnecting', - pendingRequestCount: 0, - subscriptionCount: 1, - reconnectAttempt: 2, - lastConnectedAt: 123, - lastClose: { code: 1006, reason: '' }, - lastError: 'closed' - }) - sendRemoteRuntimeRequestMock.mockRejectedValue(new Error('socket closed')) + it.each(['runtimeEnvironments:getStatus', 'runtimeEnvironments:connect'])( + 'preserves failure diagnostics and guidance on %s', + async (channel) => { + registerRuntimeEnvironmentHandlers(store as never) + getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({ + state: 'reconnecting', + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 2, + lastConnectedAt: 123, + lastClose: { code: 1006, reason: '' }, + lastError: 'closed' + }) + sendRemoteRuntimeRequestMock.mockRejectedValue( + new Error('Could not connect to the remote Orca runtime.') + ) - const add = handler< - { name: string; pairingCode: string }, - { environment: { id: string; name: string } } - >('runtimeEnvironments:addFromPairingCode') - await add(null, { name: 'desk', pairingCode: pairingCode() }) + const add = handler< + { name: string; pairingCode: string }, + { environment: { id: string; name: string } } + >('runtimeEnvironments:addFromPairingCode') + await add(null, { name: 'desk', pairingCode: pairingCode() }) - const getStatus = handler< - { selector: string; timeoutMs?: number }, - { ok: false; error: { message: string; data?: { remoteControl?: { state: string } } } } - >('runtimeEnvironments:getStatus') + const getStatus = handler< + { selector: string; timeoutMs?: number }, + { ok: false; error: { message: string; data?: { remoteControl?: { state: string } } } } + >(channel) - await expect(getStatus(null, { selector: 'desk' })).resolves.toMatchObject({ - ok: false, - error: { - message: 'socket closed', - data: { remoteControl: { state: 'reconnecting' } } - } - }) - }) + await expect(getStatus(null, { selector: 'desk' })).resolves.toMatchObject({ + ok: false, + error: { + message: expect.stringContaining('connect both devices to Tailscale'), + data: { remoteControl: { state: 'reconnecting' } } + } + }) + } + ) }) diff --git a/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts b/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts index 53a07442c3b..ed4dcd62182 100644 --- a/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts +++ b/src/main/ipc/runtime-environments-subscription-lifecycle.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -38,6 +39,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -52,18 +54,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { invalidateRuntimeEnvironmentTransport, @@ -109,6 +116,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environments-subscription-routing.test.ts b/src/main/ipc/runtime-environments-subscription-routing.test.ts index 494f0b9ea6b..0ef70f7ac96 100644 --- a/src/main/ipc/runtime-environments-subscription-routing.test.ts +++ b/src/main/ipc/runtime-environments-subscription-routing.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -40,6 +41,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -54,18 +56,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness' @@ -108,6 +115,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environments-subscription-teardown.test.ts b/src/main/ipc/runtime-environments-subscription-teardown.test.ts index 13a98e1057a..afb1adf457a 100644 --- a/src/main/ipc/runtime-environments-subscription-teardown.test.ts +++ b/src/main/ipc/runtime-environments-subscription-teardown.test.ts @@ -1,3 +1,4 @@ +import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -38,6 +39,7 @@ const { })) vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, app: { getPath: getPathMock }, ipcMain: { handle: handleMock, @@ -52,18 +54,23 @@ vi.mock('../../shared/remote-runtime-client', () => ({ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock })) -vi.mock('./runtime-environment-request-connections', () => ({ - sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, - sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, - subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, - getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, - reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, - retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, - retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), - ensureRemoteRuntimeSharedControlConnection: vi.fn(), - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock -})) +vi.mock('./runtime-environment-request-connections', async () => { + const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') + return withRuntimeStatusOwners({ + sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock, + sendRemoteRuntimeSharedControlRequest: sendRemoteRuntimeSharedControlRequestMock, + subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, + getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, + reconnectRemoteRuntimeSharedControlConnection: + reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: + retryRemoteRuntimeSharedControlConnectionsNowMock, + retryRemoteRuntimeSharedControlConnectionNow: vi.fn(), + ensureRemoteRuntimeSharedControlConnection: vi.fn(), + pauseRemoteRuntimeSharedControlRetry: vi.fn(), + closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock + }) +}) vi.mock('../browser/paired-runtime-browser-client-host-runtime', () => ({ retirePairedRuntimeBrowserClientHostEnvironment: retirePairedRuntimeBrowserClientHostEnvironmentMock @@ -115,6 +122,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { }) afterEach(() => { + resetRuntimeEnvironmentStatusOwners() rmSync(userDataPath, { recursive: true, force: true }) }) diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 7d9a261eef9..ac7a4107bc8 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -1,6 +1,6 @@ import { app, ipcMain } from 'electron' import { randomUUID } from 'node:crypto' -import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { listEnvironments, resolveEnvironment } from '../../shared/runtime-environment-store' import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import type { Store } from '../persistence' import { @@ -8,14 +8,16 @@ import { registerRuntimeEnvironmentConnectivityHandlers, registerRuntimeEnvironmentPassiveHandlers } from './runtime-environment-connectivity-handlers' -import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections' +import { + closeRemoteRuntimeRequestConnection, + getRuntimeEnvironmentStatusOwner +} from './runtime-environment-request-connections' import { registerRuntimeEnvironmentRecoveryHandler } from './runtime-environment-recovery-handler' import { advanceRuntimeEnvironmentTransportGeneration, getRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' import { - clearSharedControlSupport, resetSharedControlSupport, subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' @@ -64,7 +66,6 @@ export function invalidateRuntimeEnvironmentTransport(environmentId: string): Pr advanceRuntimeEnvironmentCapabilityIncarnation(environmentId) advanceRuntimeEnvironmentTransportGeneration(environmentId) closeRemoteRuntimeRequestConnection(environmentId) - clearSharedControlSupport(environmentId) closeSubscriptionsForEnvironment(environmentId) return retirePairedRuntimeBrowserClientHostEnvironment( environmentId, @@ -97,6 +98,11 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { }) registerRuntimeEnvironmentRecoveryHandler() registerRuntimeEnvironmentPassiveHandlers(getUserDataPath) + for (const environment of listEnvironments(getUserDataPath())) { + if (!isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + getRuntimeEnvironmentStatusOwner(getUserDataPath(), environment.id).activate() + } + } ipcMain.handle( 'runtimeEnvironments:subscribe', async ( diff --git a/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts index ec2198bcdb0..ecc982076c5 100644 --- a/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts +++ b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts @@ -40,11 +40,17 @@ export async function stopPtysForDestructiveWorktreeRemoval( ...(allowUnverifiedStop ? { allowUnverifiedStop: true } : {}), ...(connectionId ? { includeLocalRegistry: false } : {}) }) + // Structured sessions are counted here too: closing a user's chat is now an ordinary outcome + // of this verb, and a removal that closed one but no PTY would otherwise log nothing at all. + const structuredStopped = teardownResult.structuredStopped ?? 0 const total = - teardownResult.runtimeStopped + teardownResult.providerStopped + teardownResult.registryStopped + teardownResult.runtimeStopped + + teardownResult.providerStopped + + teardownResult.registryStopped + + structuredStopped if (total > 0) { console.info( - `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped}` + `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped} structured=${structuredStopped}` ) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index ac6dc23385a..4d0e0faab30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -13,6 +13,7 @@ import type { AgentSessionMutationResult, AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' @@ -21,8 +22,10 @@ import { runSettledAgentSessionMutation } from './structured-agent-session-opera import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +// The code is shared with the client so a read that refuses this way can be told apart from a +// transcript that failed to load; the two must never drift apart. export const AGENT_SESSION_NOT_ATTACHED: AgentSessionWireRefusal = { - code: 'agent_session_ownership_unknown', + code: AGENT_SESSION_UNATTACHED_REFUSAL_CODE, message: 'This host holds no attached session by that id.' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 1cb8439c391..678b790ed4e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -14,6 +14,7 @@ import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -180,6 +181,25 @@ describe('a chat that closes', () => { expect(host.hasSession(SESSION)).toBe(true) }) + // The pane outlives the close by a few frames — a workspace delete closes the chats inside it + // while their panes are still mounted — so whatever a read raises in that window is what the user + // sees. This is the code the client narrows on to keep that window off the pane; a host that + // starts raising a different one there puts the red error back. + it('answers a read from the pane that outlived it with the code the client treats as transitional', async () => { + await attach() + await host.hold(SESSION, SURFACE) + + await host.close(SESSION) + + expect(host.hasSession(SESSION)).toBe(false) + expect(() => host.history({ sessionId: SESSION, direction: 'tail' })).toThrow( + AGENT_SESSION_UNATTACHED_REFUSAL_CODE + ) + expect(() => + host.subscribe({ id: 'sub-1', sessionId: SESSION, emit: () => undefined }) + ).toThrow(AGENT_SESSION_UNATTACHED_REFUSAL_CODE) + }) + it('does not lose the session to a release the client sent twice', async () => { await attach() await host.hold(SESSION, SURFACE) diff --git a/src/main/pi/agent-status-extension-test-harness.ts b/src/main/pi/agent-status-extension-test-harness.ts index eec2b615017..810bc3d04d5 100644 --- a/src/main/pi/agent-status-extension-test-harness.ts +++ b/src/main/pi/agent-status-extension-test-harness.ts @@ -24,6 +24,7 @@ type FakeCurlChild = { } export type AgentStatusExtensionHarness = { + killMock: ReturnType fetchMock: ReturnType spawnMock: ReturnType spawnedChildren: FakeCurlChild[] @@ -57,6 +58,7 @@ export const AGENT_STATUS_EXTENSION_SELF_PID = 4242 export function createAgentStatusExtensionHarness(args: { kind: 'pi' | 'omp' | 'prime-agent' + killImpl?: (pid: number, signal: number) => void env?: Record pid?: number title?: string @@ -115,7 +117,9 @@ export function createAgentStatusExtensionHarness(args: { throw new Error(`unexpected require(${specifier})`) }) + const killMock = vi.fn(args.killImpl ?? (() => undefined)) const processMock = { + kill: killMock, env: { ...BASE_ENV, ...(args.kind === 'prime-agent' ? { PRIME_AGENT_INTERNAL_DAEMON_WORKER: '1' } : {}), @@ -172,6 +176,7 @@ export function createAgentStatusExtensionHarness(args: { return { fetchMock, + killMock, spawnMock, spawnedChildren, fsMock, diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index 9d02abbd78d..5a778a1c81f 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -88,13 +88,31 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] '// etc.), so we forward the raw object verbatim under the same field', '// names Claude uses (tool_name / tool_input) and let the server pick the', '// preview. Keeps tool-name knowledge centralized on the receiver side.', + '// Why: a restarted agent inherits the previous owner PID through env, so a', + '// dead owner must be claimable or the pane goes silent for good. Only ESRCH', + '// proves the owner is gone -- every other probe result keeps suppression, so', + '// a live foreign owner still cannot double-report. Mirrors the tri-state in', + '// main/agent-hooks/managed-hook-owner-identity.ts, which this runtime cannot', + '// import (the extension loads inside pi/omp with no Orca deps).', + 'function isStatusOwnerAlive(pid: string): boolean {', + ' const parsed = Number(pid)', + ' if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 0x7fffffff) return false', + " if (typeof process.kill !== 'function') return true", + ' try {', + ' process.kill(parsed, 0)', + ' return true', + ' } catch (err: unknown) {', + " return (err as { code?: string } | null)?.code !== 'ESRCH'", + ' }', + '}', + '', "// Why: child agents inherit the lead's pane env; only its process may", '// register status hooks. PID identity keeps in-process reloads reporting.', 'export default function (pi): void {', ...primeDaemonWorkerGuard, ` const ownerPid = process.env.${ownerEnv}`, ' const selfPid = String(process.pid)', - ' if (ownerPid && ownerPid !== selfPid) return', + ' if (ownerPid && ownerPid !== selfPid && isStatusOwnerAlive(ownerPid)) return', ` process.env.${ownerEnv} = selfPid`, ...sessionStartHandler, ` pi.on('before_agent_start', (event${ctxParam}) => {`, diff --git a/src/main/pi/agent-status-owner-recovery.test.ts b/src/main/pi/agent-status-owner-recovery.test.ts new file mode 100644 index 00000000000..d176bcb8dae --- /dev/null +++ b/src/main/pi/agent-status-owner-recovery.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { + createAgentStatusExtensionHarness as createHarness, + AGENT_STATUS_EXTENSION_SELF_PID as SELF_PID +} from './agent-status-extension-test-harness' + +describe('Pi status owner recovery', () => { + it.each(['pi', 'omp', 'prime-agent'] as const)( + 'claims the pane for a restarted %s agent whose inherited owner PID is dead', + async (kind) => { + // Why: STA-5245 -- a restart leaves a dead owner PID in the inherited env. + // Without a liveness probe the guard suppresses every later load, so the + // pane never reports status again. + const ownerKey = + kind === 'prime-agent' ? 'ORCA_PRIME_AGENT_STATUS_OWNED' : 'ORCA_PI_STATUS_OWNED' + const harness = createHarness({ + kind, + pid: SELF_PID, + env: { [ownerKey]: String(SELF_PID - 1) }, + killImpl: () => { + throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }) + } + }) + + expect(harness.killMock).toHaveBeenCalledWith(SELF_PID - 1, 0) + expect(harness.handlers.agent_end).toBeTypeOf('function') + expect(harness.processEnv[ownerKey]).toBe(String(SELF_PID)) + + await harness.callHook('agent_end') + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + } + ) + + it.each(['EPERM', 'EACCES', 'EINVAL', undefined])( + 'keeps suppression for unverifiable probe error %s', + (code) => { + // Why: EPERM means the owner exists but belongs to another user, so + // claiming the pane there would reintroduce double-reporting. + const harness = createHarness({ + kind: 'pi', + pid: SELF_PID, + env: { ORCA_PI_STATUS_OWNED: String(SELF_PID - 1) }, + killImpl: () => { + throw Object.assign(new Error('probe failed'), { code }) + } + }) + + expect(harness.handlers).toEqual({}) + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID - 1)) + } + ) + + it('claims the pane when the inherited owner PID is not a usable pid', () => { + // Why: a truncated/garbage marker is not evidence of a live owner. + const harness = createHarness({ + kind: 'pi', + pid: SELF_PID, + env: { ORCA_PI_STATUS_OWNED: 'not-a-pid' } + }) + + expect(harness.killMock).not.toHaveBeenCalled() + expect(harness.handlers.agent_end).toBeTypeOf('function') + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + }) + + it('claims the pane when the inherited owner PID exceeds safe integer precision', () => { + const harness = createHarness({ + kind: 'pi', + pid: SELF_PID, + env: { ORCA_PI_STATUS_OWNED: '99999999999999999999999' } + }) + + expect(harness.killMock).not.toHaveBeenCalled() + expect(harness.handlers.agent_end).toBeTypeOf('function') + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + }) + + it('claims the pane when the inherited owner PID exceeds the process API range', () => { + const harness = createHarness({ + kind: 'pi', + pid: SELF_PID, + env: { ORCA_PI_STATUS_OWNED: String(2 ** 31) } + }) + + expect(harness.killMock).not.toHaveBeenCalled() + expect(harness.handlers.agent_end).toBeTypeOf('function') + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + }) +}) diff --git a/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.meta.json b/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.meta.json new file mode 100644 index 00000000000..4e875eb047a --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T06:10:52.713Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; recording stopped ~0.3s after submit, while the spinner was live; no shutdown repaint in the file", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.txt b/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.txt new file mode 100644 index 00000000000..8f3645800f7 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-busy-mid-turn.txt @@ -0,0 +1,38 @@ +[?2026$p[?2027$p[>4m[=0;1u[?1049h[?25l[?5W[?2004h[>4;2m[=1;1u[?u +▄▀▀▄ +▀▀▀▀▀▀ +▀▀▀▀▀▀▀▀ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + + Welcome to the Antigravity CLI. You are currently not signed in. + + ⣾ Signing in... No authentication methods available. + + Press ctrl+c or ctrl+d twice to exit.[>4m[=0;1u[?1049l[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[?25lI[?25h[?25ln ab + + G[?25h[?25lout 8[?25h[?25l0 wo[?25h[?25lrds,[?25h[?25lexpla[?25h[?25lin w[?25h[?25lhat a[?25h[?25l pse[?25h[?25lud[?25h[?25loter[?25h[?25lminal[?25h[?25l is.[?25h[?25l[?25h[?25l + +? for shortcuts[?25h[?25lM +> In about 80 words, explain what a pseudoterminal is. +⣷ Generating... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.7 Flash · low [?25h[?25lng + +[?25h[?25l ⣯ Generating + +[?25h[?25l ⣟ Generating. + +[?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.meta.json b/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.meta.json new file mode 100644 index 00000000000..084be8e54bc --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T06:13:00.364Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; recording stopped after the turn ended and the composer returned, with the process still alive. This account's API key cannot complete a turn, so the turn ends in a backend error", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.txt b/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.txt new file mode 100644 index 00000000000..e10de85d361 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-busy-turn-ended.txt @@ -0,0 +1,42 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[?25lIn + + G[?25h[?25labo[?25h[?25lut 80[?25h[?25l wo[?25h[?25lrds[?25h[?25l, ex[?25h[?25lpla[?25h[?25lin wh[?25h[?25lat a[?25h[?25lpseudo[?25h[?25ltermi[?25h[?25lnal is[?25h[?25l.[?25h[?25l + +? for shortcuts[?25h[?25lM +> In about 80 words, explain what a pseudoterminal is. +⣾ Generating... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.7 Flash · low [?25h[?25l ⣷ Generatin + +[?25h[?25l ⣯ Generating + +[?25h[?25l ⣟ Generating. + +[?25h[?25l ⡿ Generating... + +[?25h[?25l ⢿ Generatin + +[?25h[?25l  +⚠ Agent execution terminated due to error. +Error ID: 00000000-0000-4000-8000-000000000000-2 +⢿ Generating... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +esc to cancelGemini 3.7 Flash · low [?25h[?25l  + + + +? for shortcuts[?25h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.meta.json b/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.meta.json new file mode 100644 index 00000000000..e098a1677ab --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:34:32.974Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; slash-command palette live, unanswered", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.txt b/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.txt new file mode 100644 index 00000000000..9bf02cc0ff9 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-command-palette.txt @@ -0,0 +1,41 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[?25l/ + +> /add-dir  Add a directory to the workspace + /agents List available custom agents + /artifact View and review artifacts + /btw Ask a side question without interrupting the current task + /changelog Show release notes and changes + ↓ 50 more + + ↑/↓ Navigate · enter Select · tab Complete + Gemini 3.7 Flash · low [?25h[?25l + + + + + + + + + +esc to cancel[?25h[>4m[=0;1u + + + + + + + + + +[?2004l[0 q \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.meta.json b/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.meta.json new file mode 100644 index 00000000000..8e8d5043fdf --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:35:06.866Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; /model picker opened then dismissed with esc, settled before stop", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.txt b/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.txt new file mode 100644 index 00000000000..bb35ae33af2 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-dismissed.txt @@ -0,0 +1,54 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[?25l/mod + +> /model Set a model, or run a single prompt on another model + /permissioned-github Guidelines for interacting with GitHub and request permissions from the user when commands f... + + ↑/↓ Navigate · enter Select · tab Complete +esc to cancelGemini 3.7 Flash · low [?25h[?25l + + + + +/model + +  + + ↑/↓ Navigate · enter Select · tab Complete +esc to cancelGemini 3.7 Flash · low [?25h[?25l[0 q + +Switch Model + + Gemini 3.8 Flash +> Gemini 3.7 Flash (current) + Gemini 3.6 Flash + Gemini 3.1 Pro + + Effort ◂  ◉──────────────○──────────────○  ▸ +  low  medium high  + Faster responses, lighter reasoning — great for simpler tasks + +Keyboard: ↑/↓ Navigate ←/→ Effort enter Select esc Go Back + + Gemini 3.7 Flash · low [0 q> /model + ⎿ Exited /model command + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +Gemini 3.7 Flash · low [?25h[?25l + +? for shortcuts[?25h[>4m[=0;1u + +[?2004l[0 q +Resume with -c (or command below): +agy --conversation=00000000-0000-4000-8000-000000000000 diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.meta.json b/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.meta.json new file mode 100644 index 00000000000..9a4e5c0c8e1 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:34:10.855Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; /model picker live, unanswered, killed while it owns the screen", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.txt b/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.txt new file mode 100644 index 00000000000..6a09f6082f8 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-model-picker.txt @@ -0,0 +1,56 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[?25l/mo + +> /model Set a model, or run a single prompt on another model + /migrate-workflows Automatically migrate legacy workflows to modern skills across global and workspace configur... + /permissions Manage tool permissions + /agy-customizations Comprehensive guide and reference for the Antigravity Customization System. Use to explain h... + /permissioned-github Guidelines for interacting with GitHub and request permissions from the user when commands f... + + ↑/↓ Navigate · enter Select · tab Complete +? for shortcutsGemini 3.7 Flash · low [?25h[?25l + + + + +/model + +  + + ↑/↓ Navigate · enter Select · tab Complete +esc to cancelGemini 3.7 Flash · low [?25h[?25l[0 q + +Switch Model + +> Gemini 3.8 Flash + Gemini 3.7 Flash (current) + Gemini 3.6 Flash + Gemini 3.1 Pro + + Effort ◂  ●━━━━━━━━━━━━━━◉──────────────○  ▸ +  low  medium  high  + Balanced speed and reasoning quality for most tasks + +Keyboard: ↑/↓ Navigate ←/→ Effort enter Select esc Go Back + +? for shortcutsGemini 3.7 Flash · low  Gemini 3.8 Flash +> Gemini 3.7 Flash + + + +◂  ◉──────────────○ + low  medium  +Faster responses, lighter reasoning — great for simpler tasks + + + +  G[>4m[=0;1u [?25h[?2004l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.meta.json b/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.meta.json new file mode 100644 index 00000000000..07fb15ab6f7 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:35:20.989Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy TUI 1.2.0; workspace trust dialog live and unanswered in a throwaway untrusted directory", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.txt b/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.txt new file mode 100644 index 00000000000..b2e1b342199 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-dialog-trust-workspace.txt @@ -0,0 +1,12 @@ +[?2026$p[?2027$p[>4m[=0;1u[?1049h[?25l[?5W[?2004h[>4;2m[=1;1u[?uAccessing workspace: + +/private/tmp/agy-trust-scratch-77950 + +Do you trust the contents of this project? + +Antigravity CLI requires permission to read, edit, and execute files here. + +> Yes, I trust this folder + No, exit + + ↑/↓ Navigate · enter ConfirmGemini 3.7 Flash · low[>4m[=0;1u [?1049l[?25h[?2004l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.meta.json b/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.meta.json new file mode 100644 index 00000000000..9607841cf6e --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:33:34.954Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "same session as antigravity-ready-api-key-gemini-model but with AGY_CLI_HIDE_ACCOUNT_INFO=1", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.txt b/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.txt new file mode 100644 index 00000000000..b93514374e0 --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-ready-account-info-hidden.txt @@ -0,0 +1,13 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini 3.7 Flash (Low) +▀▀▀▀▀▀▀▀ ~ + ▄▀▀ ▀▀▄ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[>4m[=0;1u + +[?2004l[0 q \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.meta.json b/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.meta.json new file mode 100644 index 00000000000..97a54e107dc --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-11T04:33:14.819Z", + "platform": "darwin", + "command": ["agy"], + "cols": 120, + "rows": 40, + "note": "agy binary 1.1.25, TUI banner 1.2.0; Gemini API key identity (no OAuth sign-in); model Gemini 3.7 Flash (Low); workspace ~", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.txt b/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.txt new file mode 100644 index 00000000000..c9501f1caac --- /dev/null +++ b/src/main/runtime/__fixtures__/antigravity-ready-api-key-gemini-model.txt @@ -0,0 +1,13 @@ +[?2026$p[?2027$p[?5W[?2004h[>4;2m[=1;1u[?u[0 q  +▄▀▀▄ Antigravity CLI 1.2.0 +▀▀▀▀▀▀ Gemini API key +▀▀▀▀▀▀▀▀ Gemini 3.7 Flash (Low) + ▄▀▀ ▀▀▄ ~ + ▄▀▀ ▀▀▄ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +? for shortcutsGemini 3.7 Flash · low [?25h[>4m[=0;1u + +[?2004l[0 q \ No newline at end of file diff --git a/src/main/runtime/agent-transcript-pane-test-harness.ts b/src/main/runtime/agent-transcript-pane-test-harness.ts new file mode 100644 index 00000000000..f3a9a64793c --- /dev/null +++ b/src/main/runtime/agent-transcript-pane-test-harness.ts @@ -0,0 +1,79 @@ +// One pane builder for every suite that replays a captured agent transcript through the runtime. +import { vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +const TRANSCRIPT_PANE_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const TRANSCRIPT_PANE_TAB_ID = 'tab-1' +const TRANSCRIPT_PANE_WORKTREE_ID = 'wt-1' +export const TRANSCRIPT_PANE_PTY_ID = 'pty-1' + +export type TranscriptPaneOptions = { + paneTitle: string + foregroundProcess: string | null + data: string + /** Set for a pane whose PTY lives on an SSH host or WSL distro rather than locally. */ + connectionId?: string + /** Simulates a PTY controller whose foreground probe never settles. */ + foregroundProbeHangs?: boolean + onForegroundProbe?: () => void +} + +export async function createTranscriptPane( + options: TranscriptPaneOptions +): Promise<{ runtime: OrcaRuntimeService; handle: string }> { + const runtime = new OrcaRuntimeService(null) + const internals = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise + } + vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: TRANSCRIPT_PANE_WORKTREE_ID, + path: '/repo/app', + connectionId: options.connectionId ?? null, + repo: null, + folderWorkspace: null + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: TRANSCRIPT_PANE_PTY_ID, incarnationId: 'inc-1' }), + write: () => true, + kill: () => true, + getForegroundProcess: (): Promise => { + options.onForegroundProbe?.() + return options.foregroundProbeHangs === true + ? new Promise(() => {}) + : Promise.resolve(options.foregroundProcess) + } + }) + const terminal = await runtime.createTerminal(`id:${TRANSCRIPT_PANE_WORKTREE_ID}`, { + tabId: TRANSCRIPT_PANE_TAB_ID, + leafId: TRANSCRIPT_PANE_LEAF_ID, + title: 'Terminal' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TRANSCRIPT_PANE_TAB_ID, + worktreeId: TRANSCRIPT_PANE_WORKTREE_ID, + title: 'Terminal', + activeLeafId: TRANSCRIPT_PANE_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: TRANSCRIPT_PANE_TAB_ID, + worktreeId: TRANSCRIPT_PANE_WORKTREE_ID, + leafId: TRANSCRIPT_PANE_LEAF_ID, + paneRuntimeId: 1, + ptyId: TRANSCRIPT_PANE_PTY_ID, + paneTitle: options.paneTitle + } + ] + }) + // Why the guard: a restore seed is only applied to a never-written record, so the restore + // cases must not write an empty chunk first. + if (options.data.length > 0) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, options.data, Date.now()) + } + return { runtime, handle: terminal.handle } +} diff --git a/src/main/runtime/antigravity-readiness-transcripts.test.ts b/src/main/runtime/antigravity-readiness-transcripts.test.ts new file mode 100644 index 00000000000..3ac7707565f --- /dev/null +++ b/src/main/runtime/antigravity-readiness-transcripts.test.ts @@ -0,0 +1,281 @@ +/** + * Pins Antigravity readiness to captured transcripts instead of hand-written fixtures. + * + * Five detector attempts were tuned against a five-line screen someone typed from memory, and + * three of them shipped worse behaviour than the bug they replaced. Nothing here asserts what + * Antigravity prints: the transcripts do. Six are recorded from a live `agy`; the rest name + * themselves as skipped until someone can reach them. + * + * Four cases are pinned as KNOWN DEFECT: on real output the shipped detector refuses the ready + * screen and accepts the live model picker. Those assert what it does, not what it should. + * + * Capture protocol: docs/reference/agent-pty-transcript-capture.md + * What each transcript decides: docs/reference/antigravity-readiness-evidence.md + */ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { extractLastOscTitle } from '../../shared/osc-title-extraction' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const FIXTURE_DIR = join(__dirname, '__fixtures__') +const EVIDENCE_DOC = join( + __dirname, + '..', + '..', + '..', + 'docs', + 'reference', + 'antigravity-readiness-evidence.md' +) +// Why asymmetric: a ready verdict has to survive the settle window, while a refusal only has to +// hold for one poll. Keeping the refusal short keeps seven transcripts off the suite's clock. +const READY_TIMEOUT_MS = 2_000 +const REFUSAL_TIMEOUT_MS = 600 +/** Antigravity's binary, as Orca launches and probes it (`tui-agent-config.ts` detectCmd). */ +const ANTIGRAVITY_COMMAND = 'agy' +// String.fromCharCode, not a literal: the formatter rewrites an escape sequence into a raw +// control byte in source, which is unreadable and survives badly in diffs. +const ESC = String.fromCharCode(27) + +type TranscriptCase = { + /** Fixture basename; `.txt` under `__fixtures__/`. */ + name: string + /** Capture in docs/reference/antigravity-readiness-evidence.md. */ + capture: string + what: string + /** What a correct detector must answer. Not what the shipped one answers. */ + expectReady: boolean + /** + * Set where the shipped detector contradicts the transcript. The case then runs inverted, so + * CI pins the defect instead of going permanently red — and flips to failing the moment + * someone fixes it, which is exactly when these expectations need re-reading. + */ + knownDefect?: string +} + +const TRANSCRIPTS: readonly TranscriptCase[] = [ + { + name: 'antigravity-ready-api-key-gemini-model', + capture: 'B', + what: 'ready screen, API-key identity — the account row reads "Gemini API key", not an email', + expectReady: true, + knownDefect: 'refused: the model row never starts a line, the logo shares it' + }, + { + name: 'antigravity-ready-account-info-hidden', + capture: 'B', + what: 'ready screen with AGY_CLI_HIDE_ACCOUNT_INFO=1 — no account row at all', + expectReady: true, + knownDefect: 'refused: same line-start defect, and no account row exists to require' + }, + { + name: 'antigravity-dialog-trust-workspace', + capture: 'C', + what: 'workspace trust dialog owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-model-picker', + capture: 'C', + what: 'model picker owning the screen', + expectReady: false, + knownDefect: "accepted: the picker's own `Gemini 3.x Flash` rows satisfy the model rule" + }, + { + name: 'antigravity-dialog-command-palette', + capture: 'C', + what: 'slash-command palette owning the screen', + expectReady: false + }, + { + name: 'antigravity-busy-mid-turn', + capture: 'E', + what: 'mid-turn, spinner live — the pane is working, not waiting for a prompt', + expectReady: false + }, + { + // Expected ready because the turn is over and the composer is back on screen. The captured + // turn ends in a backend error, which is the only ending this account's key can produce. + name: 'antigravity-busy-turn-ended', + capture: 'E', + what: 'the turn has ended and the composer has returned, process still alive', + expectReady: true, + knownDefect: 'refused: the retained tail ends on the error block, with no composer row in it' + }, + { + name: 'antigravity-dialog-dismissed', + capture: 'D', + what: 'the screen immediately after the model picker is dismissed', + expectReady: true, + knownDefect: 'refused: the banner is not reprinted and no model row starts a line' + }, + // Not captured: this machine's agy has no OAuth session and offers only Gemini models, and + // reaching the rest would mean signing the operator out or deleting their config. See + // docs/reference/antigravity-readiness-evidence.md § What could not be captured. + { + name: 'antigravity-ready-business-non-gemini', + capture: 'A', + what: 'ready screen, Business account, non-Gemini model', + expectReady: true + }, + { + name: 'antigravity-dialog-sign-in', + capture: 'C', + what: 'sign-in dialog owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-theme-picker', + capture: 'C', + what: 'theme picker owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-privacy-notice', + capture: 'C', + what: 'privacy notice owning the screen', + expectReady: false + }, + { + name: 'antigravity-dialog-update-banner', + capture: 'C', + what: 'update banner owning the screen', + expectReady: false + } +] + +function fixturePath(name: string): string { + return join(FIXTURE_DIR, `${name}.txt`) +} + +/** + * A `tui-idle` wait ends three ways, and only one of them is readiness: it resolves satisfied, it + * resolves unsatisfied with a blocked reason, or it rejects with `timeout` because nothing ever + * looked ready. The orchestrator treats the last two identically — no prompt is delivered — so + * they are both `ready: false` here. This is the shape `worker-start` sees. + */ +async function readinessVerdict( + transcript: string, + timeoutMs: number +): Promise<{ ready: boolean; blockedReason: unknown; outcome: string }> { + const { runtime, handle } = await createTranscriptPane({ + // Why the transcript's own title: every attempt guessed at Antigravity's title. A raw + // capture carries the OSC bytes, so the pane wears whatever the CLI actually set. + paneTitle: extractLastOscTitle(transcript) ?? ANTIGRAVITY_COMMAND, + foregroundProcess: ANTIGRAVITY_COMMAND, + data: transcript + }) + try { + const result = (await runtime.waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs + })) as { satisfied?: boolean; blockedReason?: unknown } + return { + ready: result.satisfied === true, + blockedReason: result.blockedReason ?? null, + outcome: result.satisfied === true ? 'satisfied' : 'unsatisfied' + } + } catch (error) { + return { ready: false, blockedReason: null, outcome: `rejected: ${String(error)}` } + } +} + +describe('Antigravity readiness, decided by captured transcripts', () => { + for (const transcript of TRANSCRIPTS) { + const path = fixturePath(transcript.name) + const captured = existsSync(path) + const label = `capture ${transcript.capture}: ${transcript.what}` + + // A pinned defect asserts what the detector DOES, so CI is honest rather than permanently + // red; fixing the detector flips this case to failing, which is when these expectations + // need re-reading. The correct answer stays in `expectReady` and in the test's name. + const shipped = + transcript.knownDefect === undefined ? transcript.expectReady : !transcript.expectReady + const verdictName = + transcript.knownDefect === undefined + ? `${label} → ${transcript.expectReady ? 'ready' : 'not ready'}` + : `${label} → must be ${transcript.expectReady ? 'ready' : 'not ready'}; KNOWN DEFECT, ${transcript.knownDefect}` + + it.skipIf(!captured)( + verdictName, + async () => { + // A refusal only has to hold for one poll; a ready verdict has to survive the settle + // window. Keeping the refusal short keeps eleven transcripts off the suite's clock. + const verdict = await readinessVerdict( + readFileSync(path, 'utf8'), + transcript.expectReady ? READY_TIMEOUT_MS : REFUSAL_TIMEOUT_MS + ) + // A silent dialog carries no blocked-signal wording, so the assertion is only that Orca + // does not call the pane ready and type a prompt into a dialog that owns the screen. + expect({ ready: verdict.ready, outcome: verdict.outcome }).toMatchObject({ + ready: shipped + }) + }, + READY_TIMEOUT_MS + 10_000 + ) + + it.skipIf(!captured)(`${label} was captured raw, not pasted from a rendered screen`, () => { + const text = readFileSync(path, 'utf8') + // Why: a transcript with no escape bytes went through a terminal's renderer and a + // human's clipboard. It cannot answer what the caret or chrome looked like. + expect(text).toContain(ESC) + }) + } + + it('documents every transcript the detector is allowed to depend on', () => { + // Why a test: the doc is the operator's checklist. A name that drifts out of it is a + // transcript nobody will capture, and a case that silently skips forever. + const doc = readFileSync(EVIDENCE_DOC, 'utf8') + for (const transcript of TRANSCRIPTS) { + expect(doc).toContain(`${transcript.name}.txt`) + } + }) + + it('reports how much evidence exists, so a fully skipped run is visible', () => { + const missing = TRANSCRIPTS.filter( + (transcript) => !existsSync(fixturePath(transcript.name)) + ).map((transcript) => `${transcript.name}.txt`) + if (missing.length > 0) { + console.info( + `Antigravity transcripts: ${TRANSCRIPTS.length - missing.length}/${TRANSCRIPTS.length} captured. Missing: ${missing.join(', ')}` + ) + } + expect(missing.length).toBeLessThanOrEqual(TRANSCRIPTS.length) + }) +}) + +describe('scaffold self-check', () => { + // Why these two live here: when a transcript lands and fails, the failure has to mean the + // capture disagreed with the detector — not that the harness or the timeouts are broken. + // Neither case is evidence about Antigravity; both are shapes the current detector already + // decides, used only to prove the plumbing reaches a verdict. + it('reaches a ready verdict through the harness', async () => { + const verdict = await readinessVerdict( + [ + 'Antigravity CLI 1.0.3', + 'user@example.com (Antigravity Business)', + 'Gemini 3.5 Flash (High)', + '~/orca/workspaces/orca/agy-dispatch-issue', + '>' + ].join('\n'), + READY_TIMEOUT_MS + ) + expect(verdict.ready).toBe(true) + }) + + it('reaches a not-ready verdict through the harness', async () => { + const verdict = await readinessVerdict( + 'Do you trust this workspace directory?\nPress t to trust\n', + REFUSAL_TIMEOUT_MS + ) + expect(verdict.ready).toBe(false) + }) +}) diff --git a/src/main/runtime/orca-runtime-pty-foreground-process-reads.ts b/src/main/runtime/orca-runtime-pty-foreground-process-reads.ts index a7e10247fed..84161b64789 100644 --- a/src/main/runtime/orca-runtime-pty-foreground-process-reads.ts +++ b/src/main/runtime/orca-runtime-pty-foreground-process-reads.ts @@ -149,13 +149,17 @@ export class OrcaRuntimeWithPtyForegroundProcessReads extends OrcaRuntimeWithSta ...(allowUnverifiedStop ? { allowUnverifiedStop: true } : {}), ...(connectionId ? { includeLocalRegistry: false } : {}) }) + // Structured sessions are counted here too, mirroring the IPC path: closing a user's chat is + // now an ordinary outcome of this verb, and a removal that closed one but no PTY logged nothing. + const structuredStopped = teardownResult.structuredStopped ?? 0 const total = teardownResult.runtimeStopped + teardownResult.providerStopped + - teardownResult.registryStopped + teardownResult.registryStopped + + structuredStopped if (total > 0) { console.info( - `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped}` + `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped} structured=${structuredStopped}` ) } } diff --git a/src/main/runtime/structured-agent-session-close.test.ts b/src/main/runtime/structured-agent-session-close.test.ts new file mode 100644 index 00000000000..531ca0aa129 --- /dev/null +++ b/src/main/runtime/structured-agent-session-close.test.ts @@ -0,0 +1,237 @@ +/** + * The chat tab must survive a close that did not land. + * + * `closeStructuredAgentSessionChild` hides the tab BEFORE it issues the close, so every failure + * shape past that point used to leave the user's chat tab pulled out of the durable restore index + * for a session that is still running — a destructive operation that refused, and still took + * something away. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +const hostRef: { current: unknown } = { current: null } + +vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +const { closeStructuredAgentSessionChild } = await import('./structured-agent-session-close') + +const SESSION = 'session-1' + +function record(sessionId: string): AgentSessionRecord { + return { + sessionId, + provider: 'claude', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'repo_1::/tmp/wt-a', + workspaceKind: 'folder' + }, + lease: { + sessionId, + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null, + runtimeFence: 1, + deathEvidence: null + } + } as unknown as AgentSessionRecord +} + +type HostOptions = { + /** Sessions the host keeps holding through a close, so the post-close observation is `live`. */ + stuck?: boolean + /** Rejects the close, without the child going. */ + closeThrows?: Error + /** The child dies and is recorded dead, but the close then fails past that proof. */ + settledThenThrows?: boolean + /** Rejects the visibility write itself, so the hide never lands. */ + visibilityThrows?: Error + /** Sessions already in the persisted visible-tab index. */ + visible?: string[] + /** Blows up the index read, so the rollback cannot prove the tab was ever visible. */ + indexThrows?: boolean +} + +function installHost(options: HostOptions = {}) { + const entry = record(SESSION) + const held = new Set([SESSION]) + const visible = new Set(options.visible ?? [SESSION]) + const setSessionTabVisibility = vi.fn(async (sessionId: string, isVisible: boolean) => { + if (options.visibilityThrows) { + throw options.visibilityThrows + } + if (isVisible) { + visible.add(sessionId) + } else { + visible.delete(sessionId) + } + }) + const close = vi.fn(async (sessionId: string) => { + if (options.closeThrows) { + throw options.closeThrows + } + if (options.stuck) { + return + } + held.delete(sessionId) + entry.lease.claimStatus = 'released' + entry.lease.deathEvidence = { kind: 'exit-observed', detail: 'closed', observedAt: 1 } + if (options.settledThenThrows) { + throw new Error('the event sink could not be flushed') + } + }) + hostRef.current = { + deps: { store: { getRecord: (id: string) => (id === SESSION ? entry : null) } }, + hasSession: (sessionId: string) => held.has(sessionId), + getPersistedVisibleSessionTabIndex: () => { + if (options.indexThrows) { + throw new Error('visible tab index unreadable') + } + return { present: true, sessionIds: [...visible] } + }, + setSessionTabVisibility, + close + } + return { close, setSessionTabVisibility, visible } +} + +describe('closeStructuredAgentSessionChild tab-visibility rollback', () => { + beforeEach(() => { + hostRef.current = null + vi.restoreAllMocks() + }) + + it('retires the tab and reports the close on the success path', async () => { + const host = installHost() + const retire = vi.fn(() => true) + + const outcome = await closeStructuredAgentSessionChild(SESSION, { + runtime: { + retireStructuredAgentSessionTabFromSnapshot: retire + } as never + }) + + expect(outcome).toEqual({ stopped: true, closeAttempted: true }) + expect(host.visible.has(SESSION)).toBe(false) + expect(retire).toHaveBeenCalledWith(SESSION) + // The hide is the only visibility write a settled close performs. + expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + }) + + it('restores the tab when the close throws and the child is still there', async () => { + const host = installHost({ closeThrows: new Error('provider round trip failed') }) + + const outcome = await closeStructuredAgentSessionChild(SESSION) + + expect(outcome.stopped).toBe(false) + expect(outcome.closeAttempted).toBe(true) + expect(outcome.reason).toBe('provider round trip failed') + expect(host.visible.has(SESSION)).toBe(true) + expect(host.setSessionTabVisibility.mock.calls).toEqual([ + [SESSION, false], + [SESSION, true] + ]) + }) + + it('restores the tab when the post-close observation is not `exited`', async () => { + const host = installHost({ stuck: true }) + + const outcome = await closeStructuredAgentSessionChild(SESSION) + + expect(outcome.stopped).toBe(false) + expect(outcome.closeAttempted).toBe(true) + expect(host.visible.has(SESSION)).toBe(true) + expect(host.setSessionTabVisibility.mock.calls).toEqual([ + [SESSION, false], + [SESSION, true] + ]) + }) + + it('leaves the tab retired when a close throws PAST a proven exit', async () => { + // `closeStructuredSessionsForWorktree` re-observes and counts this session closed; republishing + // the tab here would resurrect it at the next launch for a workspace that is gone. + const host = installHost({ settledThenThrows: true }) + + const outcome = await closeStructuredAgentSessionChild(SESSION) + + expect(outcome.stopped).toBe(false) + expect(host.visible.has(SESSION)).toBe(false) + expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + }) + + it('does not put the tab back when the caller is discarding the workspace anyway', async () => { + // Worktree teardown passes this off for a removal that cannot refuse — force, and the + // folder-workspace paths. A tab put back there is a durable reference to a workspace that is + // about to be gone, so it republishes the chat at the next launch pointing at it. + const host = installHost({ stuck: true }) + + const outcome = await closeStructuredAgentSessionChild(SESSION, { + restoreTabOnUnprovenClose: false + }) + + expect(outcome.stopped).toBe(false) + expect(host.visible.has(SESSION)).toBe(false) + expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + }) + + it('does not publish a tab for a session that was already hidden', async () => { + const host = installHost({ closeThrows: new Error('provider round trip failed'), visible: [] }) + + await closeStructuredAgentSessionChild(SESSION) + + expect(host.visible.has(SESSION)).toBe(false) + expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + }) + + it('does not roll back a visibility write that never landed', async () => { + const host = installHost({ visibilityThrows: new Error('visibility write failed') }) + + const outcome = await closeStructuredAgentSessionChild(SESSION) + + expect(outcome).toEqual({ + stopped: false, + closeAttempted: false, + reason: 'visibility write failed' + }) + expect(host.close).not.toHaveBeenCalled() + expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + }) + + it('keeps the original failure when the restore itself throws', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const host = installHost({ stuck: true }) + host.setSessionTabVisibility.mockImplementation(async (_sessionId, isVisible) => { + if (isVisible) { + throw new Error('agent_session_identity_required') + } + }) + + const outcome = await closeStructuredAgentSessionChild(SESSION) + + expect(outcome.stopped).toBe(false) + expect(outcome.closeAttempted).toBe(true) + expect(outcome.reason).not.toContain('agent_session_identity_required') + expect(warn).toHaveBeenCalled() + }) + + it('claims nothing when the visible-tab index cannot be read', async () => { + const host = installHost({ indexThrows: true, closeThrows: new Error('boom') }) + + await closeStructuredAgentSessionChild(SESSION) + + expect(host.setSessionTabVisibility.mock.calls).toEqual([[SESSION, false]]) + }) + + it('reports no close attempt when no host is installed', async () => { + hostRef.current = null + + const outcome = await closeStructuredAgentSessionChild(SESSION) + + expect(outcome.stopped).toBe(false) + expect(outcome.closeAttempted).toBe(false) + }) +}) diff --git a/src/main/runtime/structured-agent-session-close.ts b/src/main/runtime/structured-agent-session-close.ts index 756dbdeaef4..f65d1204db9 100644 --- a/src/main/runtime/structured-agent-session-close.ts +++ b/src/main/runtime/structured-agent-session-close.ts @@ -11,6 +11,7 @@ * longer live is proven gone. Anything else is retained rather than settled. */ +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrcaRuntimeService } from './orca-runtime' import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab-retirement' @@ -35,6 +36,15 @@ export type StructuredAgentSessionCloseOptions = { * keep the child un-evictable for the life of the app. Every settlement has to reach it. */ afterClose?: () => void + /** + * Whether an unproven close may put the chat tab back in the durable restore index. + * + * On by default, which is the retryable case: a stop that refused and still took the user's tab + * away is the loss the rollback exists to undo. A caller that will discard the WORKSPACE + * whatever this close reports passes false — a tab put back there is a durable reference to a + * workspace about to be gone, and it republishes the chat at the next launch pointing at it. + */ + restoreTabOnUnprovenClose?: boolean } export async function closeStructuredAgentSessionChild( @@ -50,6 +60,11 @@ export async function closeStructuredAgentSessionChild( reason: 'The structured agent-session host is not installed; no session was closed.' } } + // Read BEFORE the hide, so a rollback puts the tab back exactly as it was. Restoring + // unconditionally would publish a tab for a session that was already hidden — a worker started + // without a chat tab, or one the user had closed — which is a new side effect, not an undo. + const restoreTabIfCloseFails = + options.restoreTabOnUnprovenClose !== false && readPersistedTabVisibility(host, sessionId) // Set only once the close is actually issued: `setSessionTabVisibility` throwing first leaves a // running child, and a receipt that still said `closed_agent_terminal` for it would be the // close-that-never-happened this flag exists to rule out. @@ -59,6 +74,11 @@ export async function closeStructuredAgentSessionChild( closeAttempted = true await host.close(sessionId) } catch (error) { + // Only `closeAttempted` proves the hide landed: the store transaction restores its own state on + // failure, so a `setSessionTabVisibility` that threw hid nothing and has nothing to undo. + if (closeAttempted) { + await restorePersistedTabVisibility(host, sessionId, restoreTabIfCloseFails) + } return { stopped: false, closeAttempted, @@ -68,6 +88,7 @@ export async function closeStructuredAgentSessionChild( options.afterClose?.() const observation = observeStructuredWorker({ sessionId }) if (observation.status !== 'exited') { + await restorePersistedTabVisibility(host, sessionId, restoreTabIfCloseFails) return { stopped: false, closeAttempted: true, @@ -79,3 +100,52 @@ export async function closeStructuredAgentSessionChild( retireSettledStructuredWorkerTab(sessionId, options.runtime) return { stopped: true, closeAttempted: true } } + +function readPersistedTabVisibility(host: StructuredAgentSessionHost, sessionId: string): boolean { + try { + return host.getPersistedVisibleSessionTabIndex?.().sessionIds.includes(sessionId) ?? false + } catch { + // Unreadable index: claim nothing. A rollback that cannot prove the tab was visible must not + // publish one, for the same reason the read exists at all. + return false + } +} + +/** + * Puts the chat tab back after a close that did not settle. + * + * The hide is the one visible side effect this function performs before the destructive step, so a + * failed close that kept it left the user's chat tab gone from the durable restore index — the + * conversation survived under `userData`, but nothing brought the tab back at the next launch. + * + * Re-observed first rather than restored outright: a close can throw PAST its own proof and still + * have taken the child with it, and `closeStructuredSessionsForWorktree` reads exactly that, + * counting such a session closed and retiring its tab. Republishing there would resurrect a tab for + * a session that is demonstrably gone, at the next launch, pointing at a deleted workspace. + * + * That observation NARROWS the window; it does not close it. This one and the sweep's are taken a + * store write apart, so a child that dies in between is unverifiable here and exited there — which + * is why the sweep re-drops the tab reference when it takes that proof. Do not delete either half + * on the strength of the other. + * + * Never throws: the caller's `reason` is what the user is asked to act on, and a rollback failure + * must not replace it. `agent_session_identity_required` is the expected one — the record can be + * gone by now, which is itself the exit this restore is declining to undo. + */ +async function restorePersistedTabVisibility( + host: StructuredAgentSessionHost, + sessionId: string, + restoreTab: boolean +): Promise { + if (!restoreTab || observeStructuredWorker({ sessionId }).status === 'exited') { + return + } + try { + await host.setSessionTabVisibility?.(sessionId, true) + } catch (error) { + console.warn( + `[structured-session-close] could not restore the chat tab for ${sessionId} after a failed close`, + error + ) + } +} diff --git a/src/main/runtime/structured-session-worktree-teardown.test.ts b/src/main/runtime/structured-session-worktree-teardown.test.ts index a9bdf6aa45c..84977aad4eb 100644 --- a/src/main/runtime/structured-session-worktree-teardown.test.ts +++ b/src/main/runtime/structured-session-worktree-teardown.test.ts @@ -8,18 +8,31 @@ vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', ( })) const { killAllProcessesForWorktree } = await import('./worktree-teardown') -const { classifyWorktreeForceDeleteReason } = await import('../../shared/worktree/removal') +const { + classifyWorktreeForceDeleteReason, + isProvenLiveStructuredSessionRemovalError, + isUnstoppedPtyRemovalError +} = await import('../../shared/worktree/removal') const { listLiveStructuredSessionsForWorktree } = await import('./structured-session-worktree-teardown') const WORKTREE = 'repo_1::/tmp/wt-a' const OTHER_WORKTREE = 'repo_1::/tmp/wt-b' -function record(sessionId: string, workspaceId: string): AgentSessionRecord { +function record( + sessionId: string, + workspaceId: string, + options: { provider?: 'claude' | 'codex'; executionHostId?: string } = {} +): AgentSessionRecord { return { sessionId, - provider: 'claude', - location: { executionHostId: 'local', wslDistro: null, workspaceId, workspaceKind: 'folder' }, + provider: options.provider ?? 'claude', + location: { + executionHostId: options.executionHostId ?? 'local', + wslDistro: null, + workspaceId, + workspaceKind: 'folder' + }, lease: { sessionId, runtimeKind: 'native', @@ -33,24 +46,66 @@ function record(sessionId: string, workspaceId: string): AgentSessionRecord { function installHost(options: { records: AgentSessionRecord[] - /** Sessions the host still holds; a close removes one unless it is listed as stuck. */ + /** Sessions the host keeps holding through a close, so the post-close observation is `live`. */ stuck?: Set -}): { closed: string[] } { + /** Sessions the host drops without death evidence, so the observation is `unverifiable`. */ + unverifiable?: Set + /** Sessions whose child dies and is recorded dead, but whose close then fails past that point. */ + settledThenThrows?: Set + /** Blocks every close, to exercise the shared sweep budget without fake timers. */ + closeGate?: Promise + /** Blocks ONE session's close, so the serial loop can be caught part-way through. */ + closeGates?: Record> + /** Sessions in the persisted visible-tab index, so a rollback has something to put back. */ + visible?: string[] + /** + * Sessions whose death evidence lands DURING the close's tab-restore write. + * + * `setSessionTabVisibility` is a store transaction — a real disk write — so the close's own + * observation and the sweep's re-read straddle it and can disagree about the same session. + */ + exitsDuringTabRestore?: Set +}): { closed: string[]; visible: Set } { const held = new Set(options.records.map((entry) => entry.sessionId)) const closed: string[] = [] + const visible = new Set(options.visible ?? []) + const recordExit = (sessionId: string): void => { + const entry = options.records.find((candidate) => candidate.sessionId === sessionId) + if (entry) { + entry.lease.claimStatus = 'released' + entry.lease.deathEvidence = { kind: 'exit-observed', detail: 'closed', observedAt: 1 } + } + } hostRef.current = { deps: { store: { listRecords: () => options.records, getRecord: () => null } }, hasSession: (sessionId: string) => held.has(sessionId), - setSessionTabVisibility: async () => {}, + getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [...visible] }), + setSessionTabVisibility: async (sessionId: string, isVisible: boolean) => { + if (!isVisible) { + visible.delete(sessionId) + return + } + if (options.exitsDuringTabRestore?.has(sessionId)) { + recordExit(sessionId) + } + visible.add(sessionId) + }, close: async (sessionId: string) => { closed.push(sessionId) - if (!options.stuck?.has(sessionId)) { - held.delete(sessionId) - const record = options.records.find((entry) => entry.sessionId === sessionId) - if (record) { - record.lease.claimStatus = 'released' - record.lease.deathEvidence = { kind: 'exit-observed', detail: 'closed', observedAt: 1 } - } + await options.closeGate + await options.closeGates?.[sessionId] + if (options.stuck?.has(sessionId)) { + return + } + held.delete(sessionId) + if (options.unverifiable?.has(sessionId)) { + return + } + if (!options.exitsDuringTabRestore?.has(sessionId)) { + recordExit(sessionId) + } + if (options.settledThenThrows?.has(sessionId)) { + throw new Error('the event sink could not be flushed') } } } @@ -59,7 +114,7 @@ function installHost(options: { hostRef.current as { deps: { store: { getRecord: (id: string) => unknown } } } ).deps.store.getRecord = (sessionId: string) => options.records.find((entry) => entry.sessionId === sessionId) ?? null - return { closed } + return { closed, visible } } const localProvider = { @@ -67,7 +122,7 @@ const localProvider = { shutdown: async () => {} } as never -function destructiveDeps(extra: { allowUnverifiedStop?: boolean } = {}) { +function destructiveDeps(extra: { allowUnverifiedStop?: boolean; timeoutMs?: number } = {}) { return { localProvider, requirePhysicalStop: true, @@ -77,6 +132,15 @@ function destructiveDeps(extra: { allowUnverifiedStop?: boolean } = {}) { } } +/** The structured sweep's own warn — a forced removal can emit a PTY-sweep one onto the same spy. */ +function structuredSessionWarning(warn: { mock: { calls: unknown[][] } }): string { + return ( + warn.mock.calls + .map((call) => String(call[0])) + .find((message) => message.includes('agent session')) ?? '' + ) +} + describe('worktree teardown and structured agent sessions', () => { beforeEach(() => { hostRef.current = null @@ -84,24 +148,98 @@ describe('worktree teardown and structured agent sessions', () => { it('finds sessions by workspace, and ignores a sibling worktree', () => { installHost({ records: [record('s1', WORKTREE), record('s2', OTHER_WORKTREE)] }) - expect(listLiveStructuredSessionsForWorktree(WORKTREE)).toEqual([ + expect(listLiveStructuredSessionsForWorktree(WORKTREE, {})).toEqual([ { sessionId: 's1', agent: 'claude' } ]) }) - it('refuses a destructive removal rather than deleting the checkout under a live child', async () => { - // The defect this pins: all three PTY sweeps enumerate leaves, provider sessions and the local - // registry, and a structured session is on NONE of them. Every sweep answered zero, nothing - // errored, and removal proceeded — leaving the provider child running with its `cwd` deleted - // and the dispatch still reporting the worker live and exact. - installHost({ records: [record('s1', WORKTREE)] }) + it('closes a live session on an ordinary removal instead of refusing it', async () => { + // The defect this pins, and the reason the guard is not simply deleted: all three PTY sweeps + // enumerate leaves, provider sessions and the local registry, and a structured session is on + // NONE of them, so removal used to proceed leaving the provider child running with its `cwd` + // deleted. The stop belongs on the ordinary path — the same one that kills a terminal running + // the same agent — so an idle chat is no harder to delete than that terminal. + const host = installHost({ records: [record('s1', WORKTREE)] }) + await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).resolves.toMatchObject({ + structuredStopped: 1 + }) + expect(host.closed).toEqual(['s1']) + }) + + it('refuses only when the close does not settle', async () => { + installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) }) await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).rejects.toThrow( - /1 running agent session/ + /still live: 1 agent session \(claude\)/ ) }) + it('puts the chat tab back when the removal refuses over the session', async () => { + // The workspace survives a refusal, so the tab has to survive it too: a destructive operation + // that refused and still took the user's chat tab away is the loss the rollback exists to undo. + const host = installHost({ + records: [record('s1', WORKTREE)], + stuck: new Set(['s1']), + visible: ['s1'] + }) + await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).rejects.toThrow( + /still live: 1 agent session \(claude\)/ + ) + expect([...host.visible]).toEqual(['s1']) + }) + + it('leaves the chat tab dropped when a forced removal deletes the workspace anyway', async () => { + // The other half of the same rollback. Force does not refuse — it warns and goes on to delete + // the checkout — so putting the tab back leaves a DURABLE reference to a workspace that is + // about to be gone, which republishes the chat at the next launch pointing at a deleted + // worktree: the exact outcome this whole sweep exists to remove. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const host = installHost({ + records: [record('s1', WORKTREE)], + stuck: new Set(['s1']), + visible: ['s1'] + }) + await killAllProcessesForWorktree(WORKTREE, destructiveDeps({ allowUnverifiedStop: true })) + expect([...host.visible]).toEqual([]) + warn.mockRestore() + }) + + it('leaves the chat tab dropped for a folder-workspace removal, which never refuses', async () => { + // Same reasoning without the force waiver: this caller cannot refuse at all, so the workspace + // is forgotten whatever the close reports. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const host = installHost({ + records: [record('s1', WORKTREE)], + stuck: new Set(['s1']), + visible: ['s1'] + }) + await killAllProcessesForWorktree(WORKTREE, { + localProvider, + includeProviderInventory: false as const, + includeLocalRegistry: false as const, + closeStructuredSessions: true + }) + expect([...host.visible]).toEqual([]) + warn.mockRestore() + }) + + it('drops the chat tab for a session the sweep proves exited after the close gave up', async () => { + // `host.close` can return BEFORE the child's exit is recorded, so the close's own observation + // reads unverifiable and puts the tab back — and the sweep's re-read, one store write later, + // proves the exit and counts the session closed. The two observations straddle that write and + // can disagree; the tab must not survive the disagreement, because this removal proceeds. + const host = installHost({ + records: [record('s1', WORKTREE)], + visible: ['s1'], + exitsDuringTabRestore: new Set(['s1']) + }) + await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).resolves.toMatchObject({ + structuredStopped: 1 + }) + expect([...host.visible]).toEqual([]) + }) + it('names the force escape hatch in the refusal, like the unstopped-PTY gate', async () => { - installHost({ records: [record('s1', WORKTREE)] }) + installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) }) await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).rejects.toThrow(/force/i) }) @@ -109,7 +247,7 @@ describe('worktree teardown and structured agent sessions', () => { // The #11960 dead end, and the shape this file's own comments warn about: the desktop // affordance comes ONLY from the classifier, and an ordinary delete already passes force:true // for the dirty-file skip — so a refusal with no matcher shows raw CLI wording with no button. - installHost({ records: [record('s1', WORKTREE)] }) + installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) }) const error = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch( (thrown: Error) => thrown.message ) @@ -123,12 +261,12 @@ describe('worktree teardown and structured agent sessions', () => { // A session id is one tab-id hop from the random pane key that gates a worker's mailbox, and // this string reaches CLI output and a desktop toast. A count and the providers are what a // user deciding whether to force actually needs. - installHost({ records: [record('s1', WORKTREE)] }) + installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) }) const error = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch( (thrown: Error) => thrown.message ) expect(error).not.toContain('s1') - expect(error).toContain('1 running agent session') + expect(error).toContain('1 agent session (claude)') }) it('closes best-effort for a folder-workspace removal, which requires no stop proof', async () => { @@ -166,10 +304,33 @@ describe('worktree teardown and structured agent sessions', () => { destructiveDeps({ allowUnverifiedStop: true }) ) expect(result.structuredStopped).toBeUndefined() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('still attached')) + // The live arm of that record, carrying the verdict the refusal would have shown. + expect(structuredSessionWarning(warn)).toContain('still live: 1 agent session (claude)') warn.mockRestore() }) + it('takes the proof when a failed close is re-observed as exited', async () => { + // `closeStructuredAgentSessionChild` reports `stopped: false` for anything that throws past its + // own observation, and for a record whose death evidence lands after it read. The re-read here + // can still PROVE the exit — refusing a delete over a child that is demonstrably gone is the + // defect this whole sweep exists to remove, so the proof has to win over the close's verdict. + const retired: string[] = [] + const runtime = { + stopTerminalsForWorktree: async () => ({ stopped: 0 }), + retireStructuredAgentSessionTabFromSnapshot: (sessionId: string) => { + retired.push(sessionId) + return true + } + } as never + installHost({ records: [record('s1', WORKTREE)], settledThenThrows: new Set(['s1']) }) + await expect( + killAllProcessesForWorktree(WORKTREE, { ...destructiveDeps(), runtime }) + ).resolves.toMatchObject({ structuredStopped: 1 }) + // Retired here because the close gave up before its own retirement step, and a chat tab left + // behind re-attaches a released session pointing at a workspace that is about to be deleted. + expect(retired).toEqual(['s1']) + }) + it('leaves the best-effort reconciliation paths alone', async () => { // Those callers repair state and delete nothing, so a refusal there would wedge a repair. installHost({ records: [record('s1', WORKTREE)] }) @@ -182,6 +343,262 @@ describe('worktree teardown and structured agent sessions', () => { ).resolves.toMatchObject({ runtimeStopped: 0 }) }) + it('leaves a same-id workspace on another execution host alone', async () => { + // A workspace id is `repoId::path` with no host component, so the local, SSH and paired-runtime + // copies of one id are DIFFERENT workspaces. Unfenced, deleting the local one closed a chat + // running on somebody else's machine — a destructive cross-host act, not a spurious refusal. + const host = installHost({ + records: [record('s1', WORKTREE, { executionHostId: 'ssh:host-a' })] + }) + await expect(killAllProcessesForWorktree(WORKTREE, destructiveDeps())).resolves.toMatchObject({ + runtimeStopped: 0 + }) + expect(host.closed).toEqual([]) + }) + + it('reads an explicit local fence the way the PTY sweeps do', () => { + // This helper reuses the PTY fence's own type, so the two cannot answer `null` differently: + // there it means this machine, and it has to mean this machine here. ABSENT is the one + // deliberate difference — no fence at all for the PTY sweeps, narrowed to local here, because + // a single-host-id comparison cannot express match-all and closing every host's chats is + // destructive. Latent today only because `WorktreeTeardownDeps` cannot yet carry the `null`. + installHost({ + records: [record('s1', WORKTREE, { executionHostId: 'ssh:host-a' }), record('s2', WORKTREE)] + }) + const local = [{ sessionId: 's2', agent: 'claude' }] + expect(listLiveStructuredSessionsForWorktree(WORKTREE, { resolvedConnectionId: null })).toEqual( + local + ) + expect(listLiveStructuredSessionsForWorktree(WORKTREE, {})).toEqual(local) + }) + + it('closes only the session on the host the removal resolved to', async () => { + const host = installHost({ + records: [record('s1', WORKTREE, { executionHostId: 'ssh:host-a' }), record('s2', WORKTREE)] + }) + await expect( + killAllProcessesForWorktree(WORKTREE, { + ...destructiveDeps(), + resolvedConnectionId: 'host-a' + }) + ).resolves.toMatchObject({ structuredStopped: 1 }) + expect(host.closed).toEqual(['s1']) + }) + + it('names only the sessions that stayed, and every provider still there', async () => { + installHost({ + records: [ + record('s1', WORKTREE), + record('s2', WORKTREE, { provider: 'codex' }), + record('s3', WORKTREE) + ], + stuck: new Set(['s2', 's3']) + }) + const error = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch( + (thrown: Error) => thrown.message + ) + expect(error).toContain('still live: 2 agent sessions (claude, codex)') + }) + + it('names the unconfirmed sessions too, instead of counting only the live ones', async () => { + // The PTY sibling may drop everything outside its live list because a fresh inventory PROVED + // those exited. Nothing proves that here: an `unverifiable` session is unclosed as well, so + // naming only the live subset told the user "1 agent session" while two were about to go. + installHost({ + records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], + stuck: new Set(['s1']), + unverifiable: new Set(['s2']) + }) + const error = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch( + (thrown: Error) => thrown.message + ) + expect(error).toContain( + 'still live: 1 agent session (claude); could not confirm these closed: 1 agent session (codex)' + ) + // The marker still leads, so the toast keeps showing the stronger of the two warnings. + expect(isProvenLiveStructuredSessionRemovalError(error as string)).toBe(true) + }) + + it('still reports what it closed when a forced removal skips the PTY verdict', async () => { + // A sweep that fails outright short-circuits the per-PTY verdict — but not the structured + // close that already ran, so the count has to survive that return or the removal log claims + // `structured=0` for chats it just ended. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const runtime = { + stopTerminalsForWorktree: async () => { + throw new Error('the terminal sweep died') + } + } as never + const host = installHost({ records: [record('s1', WORKTREE)] }) + const result = await killAllProcessesForWorktree(WORKTREE, { + ...destructiveDeps({ allowUnverifiedStop: true }), + runtime + }) + expect(host.closed).toEqual(['s1']) + expect(result.structuredStopped).toBe(1) + warn.mockRestore() + }) + + it('separates a close it could not confirm from one it watched stay attached', async () => { + // `src/shared/worktree/removal.ts` keeps these two apart on purpose: a user waiving "we could + // not confirm" is making a different decision than one discarding a conversation Orca just saw + // running. The toast branches on this marker, so flattening them makes one of the two a lie. + installHost({ records: [record('s1', WORKTREE)], unverifiable: new Set(['s1']) }) + const unconfirmed = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch( + (thrown: Error) => thrown.message + ) + expect(unconfirmed).toContain('could not confirm these closed: 1 agent session (claude)') + expect(isProvenLiveStructuredSessionRemovalError(unconfirmed as string)).toBe(false) + + installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) }) + const live = await killAllProcessesForWorktree(WORKTREE, destructiveDeps()).catch( + (thrown: Error) => thrown.message + ) + expect(isProvenLiveStructuredSessionRemovalError(live as string)).toBe(true) + }) + + it('refuses in agent-session wording when the close outlives the sweep budget', async () => { + // A structured close that runs out of time used to reject with the PTY timeout sentinel, which + // the classifier reads FIRST — so the toast blamed terminals, and the Force Delete meant to + // clear the wedge hit the same rejection again (#11960). + installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise(() => {}) }) + const error = await killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ timeoutMs: 5 }) + ).catch((thrown: Error) => thrown.message) + expect(error).toContain('could not confirm these closed: 1 agent session (claude)') + expect(isUnstoppedPtyRemovalError(error as string)).toBe(false) + expect(classifyWorktreeForceDeleteReason(error as string, true)).toBe('running-agent-session') + }) + + it('never wedges Force Delete on a close that will not settle', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + installHost({ records: [record('s1', WORKTREE)], closeGate: new Promise(() => {}) }) + await expect( + killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ allowUnverifiedStop: true, timeoutMs: 5 }) + ) + ).resolves.toMatchObject({ runtimeStopped: 0 }) + const message = structuredSessionWarning(warn) + expect(message).toContain('could not confirm these closed: 1 agent session (claude)') + // The pin: a close that ran out of time was never watched stay attached. This warn is the only + // record a forced removal leaves, and the removal.ts split exists precisely so "we could not + // confirm" is never reported as "we saw it running" — including here. + expect(message).not.toContain('still attached') + warn.mockRestore() + }) + + it('names only the sessions still open when the budget expires mid-close', async () => { + // The close loop is serial, so a deadline can land part-way through it. A fallback assembled + // at the deadline could only name the whole list — so a removal that had already closed the + // first chat still told the user both were still there, which is the exact thing this sweep + // exists to stop doing: never report state nobody observed. + installHost({ + records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], + closeGates: { s2: new Promise(() => {}) } + }) + const error = await killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ timeoutMs: 40 }) + ).catch((thrown: Error) => thrown.message) + expect(error).toContain('could not confirm these closed: 1 agent session (codex)') + expect(error).not.toContain('claude') + }) + + it('counts the closes that landed before the budget expired', async () => { + // The other half of the same fallback: it reported zero closes, so the removal log said + // `structured=0` for a chat it had just ended. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const slowClose = new Promise((resolve) => { + setTimeout(resolve, 300) + }) + installHost({ + records: [record('s1', WORKTREE), record('s2', WORKTREE, { provider: 'codex' })], + closeGates: { s2: slowClose } + }) + const result = await killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ allowUnverifiedStop: true, timeoutMs: 40 }) + ) + expect(result.structuredStopped).toBe(1) + expect(structuredSessionWarning(warn)).toContain( + 'could not confirm these closed: 1 agent session (codex)' + ) + warn.mockRestore() + }) + + it('stops issuing new closes once the budget is spent', async () => { + // One slow provider round trip used to starve every session behind it: the outer race had + // already given up on the loop, and it went on issuing closes whose outcome nobody would read. + // The in-flight one is NOT cancelled — nothing here can cancel a provider round trip — so it + // still has to be reported, which is why both sessions are named below. + let releaseFirstClose: () => void = () => {} + const firstClose = new Promise((resolve) => { + releaseFirstClose = resolve + }) + const host = installHost({ + records: [record('s1', WORKTREE), record('s2', WORKTREE)], + closeGates: { s1: firstClose } + }) + const error = await killAllProcessesForWorktree( + WORKTREE, + destructiveDeps({ timeoutMs: 5 }) + ).catch((thrown: Error) => thrown.message) + expect(error).toContain('could not confirm these closed: 2 agent sessions (claude)') + releaseFirstClose() + await new Promise((resolve) => { + setTimeout(resolve, 25) + }) + expect(host.closed).toEqual(['s1']) + }) + + it('leaves the terminals already stopped when it refuses over a stuck session', async () => { + // Pins a tradeoff that was accepted, not an outcome that is wanted. The PTY sweeps now run + // concurrently with the structured close, so a removal that refuses over a session that will + // not close has ALREADY killed that workspace's terminals — the head-first serial order spared + // them. Serialising it back is worse: it spends the whole shared budget before a single PTY is + // asked, and the alternative — refusing before the PTY sweeps — leaves force-delete removing + // files while PTY handles are open. The PTY gate itself already kills first and refuses only + // on what it could not verify stopped. A later change must not flip this back silently. + let terminalSweeps = 0 + const runtime = { + stopTerminalsForWorktree: async () => { + terminalSweeps += 1 + return { stopped: 2 } + } + } as never + installHost({ records: [record('s1', WORKTREE)], stuck: new Set(['s1']) }) + await expect( + killAllProcessesForWorktree(WORKTREE, { ...destructiveDeps(), runtime }) + ).rejects.toThrow(/still live: 1 agent session \(claude\)/) + expect(terminalSweeps).toBe(1) + }) + + it('starts the terminal sweeps while the structured close is still in flight', async () => { + // The close is serial and each one waits on a provider round trip. Awaiting it before the + // sweeps exist spends the shared budget head-first, and the sweeps then report a timeout for + // a stop they never attempted. + let releaseClose: () => void = () => {} + const closeGate = new Promise((resolve) => { + releaseClose = resolve + }) + installHost({ records: [record('s1', WORKTREE)], closeGate }) + let terminalSweepStarted = false + const runtime = { + stopTerminalsForWorktree: async () => { + terminalSweepStarted = true + return { stopped: 0 } + } + } as never + const removal = killAllProcessesForWorktree(WORKTREE, { ...destructiveDeps(), runtime }) + await vi.waitFor(() => { + expect(terminalSweepStarted).toBe(true) + }) + releaseClose() + await expect(removal).resolves.toMatchObject({ structuredStopped: 1 }) + }) + it('does not block removal when no structured host is installed', async () => { // Not being able to look is not evidence a child is there, and reading the persisted store // directly would force-install the host as a side effect of a teardown. diff --git a/src/main/runtime/structured-session-worktree-teardown.ts b/src/main/runtime/structured-session-worktree-teardown.ts index 226f785f039..8b161e4f5a8 100644 --- a/src/main/runtime/structured-session-worktree-teardown.ts +++ b/src/main/runtime/structured-session-worktree-teardown.ts @@ -8,15 +8,29 @@ * kept running with its `cwd` gone, the durable record and chat tab survived to republish at the * next launch pointing at a deleted worktree, and `worker-show` still reported the worker live. * - * Membership is `location.workspaceId`, which every structured session carries — so this covers a - * plain chat session in the worktree as well as a dispatched worker. Liveness is - * `observeStructuredWorker`, the same `live` / `unverifiable` / `exited` vocabulary the rest of the - * structured surface uses; only a PROVEN live child is worth refusing a removal over. + * Membership is `location.workspaceId` PLUS the host fence below, and every structured session + * carries both — so this covers a plain chat session in the worktree as well as a dispatched + * worker. Liveness is `observeStructuredWorker`, the same `live` / `unverifiable` / `exited` + * vocabulary the rest of the structured surface uses. + * + * `live` here is lease state — a provider child is attached — not work in flight, so it says + * nothing about whether the user would lose anything. It selects what to CLOSE, never what to + * refuse over: a removal refuses only on a close that did not settle, exactly as the PTY sweep + * refuses only on a stop it could not verify. */ +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' +import { STILL_LIVE_DETAIL_PREFIX } from '../../shared/worktree/removal' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { observeStructuredWorker } from './structured-worker-authority' import { closeStructuredAgentSessionChild } from './structured-agent-session-close' +import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab-retirement' +import type { WorktreePtyHostFence } from './worktree-pty-host-fence' import type { OrcaRuntimeService } from './orca-runtime' export type LiveStructuredSessionInWorkspace = { @@ -24,13 +38,52 @@ export type LiveStructuredSessionInWorkspace = { agent: 'claude' | 'codex' } +export type UnclosedStructuredSession = LiveStructuredSessionInWorkspace & { + /** Read AFTER the close: `live` is a child watched stay attached, not merely one left unproven. */ + status: 'live' | 'unverifiable' +} + export type StructuredWorktreeSweepRuntime = Pick< OrcaRuntimeService, 'forgetStructuredSessionMail' | 'retireStructuredAgentSessionTabFromSnapshot' > /** - * Structured sessions with a proven-live child in this worktree. + * The two fields every teardown caller already resolves to fence its PTY sweeps to one host. + * + * Deliberately the PTY fence's own type rather than a look-alike: these two helpers are written + * against each other, so a widening on one side must not become a silent disagreement on the + * other. `resolvedConnectionId: null` means this machine on both. + * + * They differ in exactly one reading, and only that one: ABSENT. The PTY fence takes it as no + * fence at all and matches every host, which a single-host-id comparison cannot express — and + * closing every host's chats is destructive, not merely noisy. So this side reads absent as local + * too, the narrower half of that pair. Pinned by test, not left to the next reader to rediscover. + */ +export type StructuredSessionHostFence = WorktreePtyHostFence + +/** + * The one execution host this teardown may touch. + * + * A workspace id is `repoId::path` with no host component, so the local machine, an SSH host and a + * paired runtime can all publish the SAME id and each names a DIFFERENT workspace (STA-4343). The + * PTY sweeps fence on exactly these two fields; a structured session records its host directly, so + * the comparison is on `location.executionHostId` instead of on a pty-id shape. + */ +export function structuredSessionTeardownHostId( + fence: StructuredSessionHostFence +): ExecutionHostId { + if (fence.resolvedRuntimeEnvironmentId !== undefined) { + return toRuntimeExecutionHostId(fence.resolvedRuntimeEnvironmentId) + } + // Both no-connection readings collapse here on purpose — see the fence type. A caller that + // resolved no host, and one that resolved this machine, each close nothing on anyone else's. + const connectionId = fence.resolvedConnectionId ?? null + return connectionId === null ? LOCAL_EXECUTION_HOST_ID : toSshExecutionHostId(connectionId) +} + +/** + * Structured sessions with a proven-live child in this worktree, on the fenced host only. * * An uninstalled host answers empty rather than throwing: no host in this generation means no * provider child was started by this process, and the three PTY sweeps fall through the same way @@ -38,7 +91,8 @@ export type StructuredWorktreeSweepRuntime = Pick< * directly — that would force-install the host, which is itself a side effect on a teardown path. */ export function listLiveStructuredSessionsForWorktree( - worktreeId: string + worktreeId: string, + fence: StructuredSessionHostFence ): LiveStructuredSessionInWorkspace[] { const host = getStructuredAgentSessionHost() if (!host) { @@ -50,58 +104,191 @@ export function listLiveStructuredSessionsForWorktree( } catch { return [] } + const hostId = structuredSessionTeardownHostId(fence) return records .filter( (record) => record.location.workspaceId === worktreeId && + record.location.executionHostId === hostId && observeStructuredWorker({ sessionId: record.sessionId }).status === 'live' ) .map((record) => ({ sessionId: record.sessionId, agent: record.provider })) } /** - * Counts and providers, never session ids. + * A count and its providers — never session ids. * * A session id is one tab-id hop from the random pane key that gates a worker's mailbox, and this * string reaches agent-readable CLI output and a desktop toast. The count and the providers are * what a user deciding whether to force actually needs; the ids identify nothing they can act on. */ -export function describeLiveStructuredSessions( - sessions: readonly LiveStructuredSessionInWorkspace[] -): string { +function countStructuredSessions(sessions: readonly UnclosedStructuredSession[]): string { const noun = sessions.length === 1 ? 'agent session' : 'agent sessions' const providers = [...new Set(sessions.map((session) => session.agent))].sort().join(', ') - return `${sessions.length} running ${noun} (${providers})` + return `${sessions.length} ${noun} (${providers})` } /** - * Closes every live structured session in the worktree, and reports what stayed. + * The two post-close verdicts, each with its own count. * - * Force is the documented escape hatch, so it closes rather than orphaning: a child left running - * against a deleted `cwd` is the exact outcome this whole sweep exists to prevent. + * The split is here for the reason `describeUnstoppedPtys` carries one: "we watched it stay + * attached" and "we could not confirm it went" are different decisions to waive, and the delete + * toast branches on the marker a proven-live session leads with. + * + * Both groups are named, though, which is where this differs from the PTY sibling: there, the + * verdict is a fresh inventory, so anything absent from the live list is PROVEN exited and + * rightly dropped. Here an `unverifiable` session is unclosed too — folding it into the live + * count would overstate what Orca watched, and dropping it said "1 agent session" while three + * were about to be discarded. + */ +export function describeUnclosedStructuredSessions( + sessions: readonly UnclosedStructuredSession[] +): string { + const stillLive = sessions.filter((session) => session.status === 'live') + const unconfirmed = sessions.filter((session) => session.status !== 'live') + if (stillLive.length === 0) { + return `could not confirm these closed: ${countStructuredSessions(unconfirmed)}` + } + const live = `${STILL_LIVE_DETAIL_PREFIX} ${countStructuredSessions(stillLive)}` + return unconfirmed.length === 0 + ? live + : `${live}; could not confirm these closed: ${countStructuredSessions(unconfirmed)}` +} + +/** + * What the close loop has done so far, readable while it is still running. + * + * The loop is serial and every close waits on a provider round trip, so the shared sweep budget can + * expire part-way through it. This is written as it goes rather than returned at the end, because + * the caller's timeout path reads THIS: a fabricated whole-list fallback reported sessions the + * sweep had already closed as unclosed, named them in the refusal the user reads, and logged + * `structured=0` for closes that landed. Saying only what was observed is the point of the sweep. + */ +export type StructuredSweepProgress = { + /** The sessions this sweep closes, in the order the loop reaches them. */ + readonly sessions: readonly LiveStructuredSessionInWorkspace[] + /** Sessions no longer attached after their close — the count this sweep reports. */ + closed: number + /** Attempted closes that did not settle, each carrying the verdict re-read after the attempt. */ + unstopped: UnclosedStructuredSession[] + /** How many of `sessions`, from the front, have an outcome recorded. */ + settled: number +} + +export function createStructuredSweepProgress( + sessions: readonly LiveStructuredSessionInWorkspace[] +): StructuredSweepProgress { + return { sessions, closed: 0, unstopped: [], settled: 0 } +} + +/** + * Everything this sweep did not prove closed. + * + * A session with no recorded outcome — never started, or still in flight — reports `unverifiable`, + * the same verdict as an attempted close that stayed unproven. Chosen, not conflated: the vocabulary is `live` / `unverifiable` / `exited` with no + * synonyms, and "we never asked" and "we asked and could not confirm" are both exactly "not + * observed exited". A fourth bucket would need its own refusal wording and its own toast + * classification for a distinction the user cannot act on any differently — and `live` is the only + * verdict either could be mistaken for, which is the one thing neither is allowed to claim. + */ +export function unclosedStructuredSessions( + progress: StructuredSweepProgress +): UnclosedStructuredSession[] { + return [ + ...progress.unstopped, + ...progress.sessions + .slice(progress.settled) + .map((session) => ({ ...session, status: 'unverifiable' as const })) + ] +} + +/** + * Closes the structured sessions in `progress`, recording what stayed as it goes. + * + * Runs on the ordinary removal too, not just force: a child left running against a deleted `cwd` is + * the outcome this whole sweep exists to prevent, and closing is how you prevent it. What stayed is + * the only thing worth refusing over. + * + * Takes the list rather than re-deriving it, so the refusal can only ever name a session out of + * the set this sweep was handed — re-enumerating would run every liveness observation twice and + * let it name one this call never touched. Not every one of them is a session a close was + * attempted on: the deadline check below can leave the tail of the list unasked, and + * `unclosedStructuredSessions` reports those as `unverifiable` precisely because nobody looked. */ export async function closeStructuredSessionsForWorktree( - worktreeId: string, - runtime?: StructuredWorktreeSweepRuntime -): Promise<{ closed: number; unstopped: LiveStructuredSessionInWorkspace[] }> { + progress: StructuredSweepProgress, + deadline: number, + options: { + runtime?: StructuredWorktreeSweepRuntime + /** + * Whether this removal can still refuse over an unclosed session. + * + * It is the only case where the workspace — and therefore its chat tabs — survives, so it is + * the only case where an unproven close may put a tab back. Force and the folder-workspace + * paths discard the workspace whatever the sweep reports. + */ + mayRefuse?: boolean + } = {} +): Promise { + const { runtime, mayRefuse } = options // No `afterClose` for a dispatched worker: `host.close` drops the holds, so nothing keeps a // provider child un-evictable, but the dispatch's redrive subscription and registry entry do // survive until it settles by another verb. That is a bounded leak, not a hazard — and passing // one here would mean resolving a dispatch id per session on a teardown path that must stay // inside the sweep deadline. - const sessions = listLiveStructuredSessionsForWorktree(worktreeId) - const unstopped: LiveStructuredSessionInWorkspace[] = [] - let closed = 0 - for (const session of sessions) { - const outcome = await closeStructuredAgentSessionChild( - session.sessionId, - runtime ? { runtime } : {} - ) - if (outcome.stopped) { - closed += 1 - } else { - unstopped.push(session) + for (const session of progress.sessions) { + // Stops ISSUING new closes once the budget is spent; an in-flight one is left to finish, since + // nothing here can cancel a provider round trip. Without this, one slow round trip starved + // every session behind it: the caller's race had already given up, and the loop went on + // closing sessions whose outcome nobody would read. + if (Date.now() >= deadline) { + return } + const outcome = await closeStructuredAgentSessionChild(session.sessionId, { + ...(runtime ? { runtime } : {}), + restoreTabOnUnprovenClose: mayRefuse === true + }) + if (outcome.stopped) { + progress.closed += 1 + } else { + // Re-observed rather than reusing the close's own reason string: what the user is asked to + // waive is the state AFTER the attempt, and a close that threw never reached an observation. + const status = observeStructuredWorker({ sessionId: session.sessionId }).status + if (status === 'exited') { + // The re-read can PROVE the exit a failed close could not — it threw past its own + // observation, or the record's death evidence landed after it read. Refusing on a child + // that is demonstrably gone is the defect this sweep exists to remove, so take the proof + // and run the retirement `closeStructuredAgentSessionChild` skipped when it gave up. + // + // Including the hide it UNDID: its rollback ran against an observation taken one store + // write before this one, so a child that died in between left the tab republished for a + // session this sweep is about to count closed. Taking the proof has to take that back. + await dropDurableChatTabReference(session.sessionId) + retireSettledStructuredWorkerTab(session.sessionId, runtime) + progress.closed += 1 + } else { + progress.unstopped.push({ ...session, status }) + } + } + // Advanced only once an outcome is recorded, so a close still in flight when the deadline + // lands stays reported as unclosed instead of falling out of both counts. + progress.settled += 1 + } +} + +/** + * Drops a settled session's durable chat-tab reference, and cannot fail the settlement. + * + * The close's own hide is the ordinary path; this is only for the session whose exit this sweep + * proved after that close had already rolled the hide back. + */ +async function dropDurableChatTabReference(sessionId: string): Promise { + try { + await getStructuredAgentSessionHost()?.setSessionTabVisibility?.(sessionId, false) + } catch (error) { + console.warn( + `[worktree-teardown] could not drop the chat tab reference for ${sessionId}`, + error + ) } - return { closed, unstopped } } diff --git a/src/main/runtime/terminal-interactive-wait-visibility.test.ts b/src/main/runtime/terminal-interactive-wait-visibility.test.ts index 173c3482b14..5e652af41f6 100644 --- a/src/main/runtime/terminal-interactive-wait-visibility.test.ts +++ b/src/main/runtime/terminal-interactive-wait-visibility.test.ts @@ -3,7 +3,10 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { OrcaRuntimeService } from './orca-runtime' +import { + createTranscriptPane as createPane, + TRANSCRIPT_PANE_PTY_ID as PTY_ID +} from './agent-transcript-pane-test-harness' import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' vi.mock('electron', () => ({ @@ -13,12 +16,11 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -const LEAF_ID = '11111111-1111-4111-8111-111111111111' -const TAB_ID = 'tab-1' -const WORKTREE_ID = 'wt-1' -const PTY_ID = 'pty-1' - -// Captured verbatim from cursor-agent 2026.08.11-e8db854 driven through Orca. +// cursor-agent 2026.08.11-e8db854's screens, but NOT raw PTY output: these files contain no +// escape bytes and no carriage returns, so they came through a terminal's renderer and a +// clipboard. They evidence wording, ordering and glyphs — which is all the rules below key on — +// and evidence nothing about the caret, cursor moves, repaints or the alternate screen buffer. +// Record new fixtures with config/scripts/capture-agent-pty-transcript.mjs, which keeps the bytes. function fixture(name: string): string { return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') } @@ -39,73 +41,6 @@ function agentStatusOsc(state: string): string { return `]9999;${JSON.stringify({ state, prompt: 'ship it', agentType: 'claude' })}` } -async function createPane(options: { - paneTitle: string - foregroundProcess: string | null - data: string - /** Set for a pane whose PTY lives on an SSH host or WSL distro rather than locally. */ - connectionId?: string - /** Simulates a PTY controller whose foreground probe never settles. */ - foregroundProbeHangs?: boolean - onForegroundProbe?: () => void -}): Promise<{ runtime: OrcaRuntimeService; handle: string }> { - const runtime = new OrcaRuntimeService(null) - const internals = runtime as unknown as { - resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise - } - vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ - id: WORKTREE_ID, - path: '/repo/app', - connectionId: options.connectionId ?? null, - repo: null, - folderWorkspace: null - }) - runtime.setPtyController({ - spawn: vi.fn().mockResolvedValue({ id: PTY_ID, incarnationId: 'inc-1' }), - write: () => true, - kill: () => true, - getForegroundProcess: (): Promise => { - options.onForegroundProbe?.() - return options.foregroundProbeHangs === true - ? new Promise(() => {}) - : Promise.resolve(options.foregroundProcess) - } - }) - const terminal = await runtime.createTerminal(`id:${WORKTREE_ID}`, { - tabId: TAB_ID, - leafId: LEAF_ID, - title: 'Terminal' - }) - runtime.attachWindow(1) - runtime.syncWindowGraph(1, { - tabs: [ - { - tabId: TAB_ID, - worktreeId: WORKTREE_ID, - title: 'Terminal', - activeLeafId: LEAF_ID, - layout: null - } - ], - leaves: [ - { - tabId: TAB_ID, - worktreeId: WORKTREE_ID, - leafId: LEAF_ID, - paneRuntimeId: 1, - ptyId: PTY_ID, - paneTitle: options.paneTitle - } - ] - }) - // Why the guard: a restore seed is only applied to a never-written record, so the restore - // cases must not write an empty chunk first. - if (options.data.length > 0) { - runtime.onPtyData(PTY_ID, options.data, Date.now()) - } - return { runtime, handle: terminal.handle } -} - // cursor-agent renders a braille spinner in its OSC title while it works, and Orca reads // that as `working`; the title is identical whether it is running a command or waiting. const CURSOR_TITLE = '⠇ Cursor Agent' diff --git a/src/main/runtime/unstopped-pty-verification.ts b/src/main/runtime/unstopped-pty-verification.ts index a2cd83b8eec..7cfd5907797 100644 --- a/src/main/runtime/unstopped-pty-verification.ts +++ b/src/main/runtime/unstopped-pty-verification.ts @@ -2,7 +2,7 @@ import type { IPtyProvider } from '../providers/types' import type { OrcaRuntimeService } from './orca-runtime' import { UNSTOPPED_PTY_DETAIL_SEPARATOR, - UNSTOPPED_PTY_LIVE_DETAIL_PREFIX, + STILL_LIVE_DETAIL_PREFIX, UNSTOPPED_PTY_REMOVAL_PREFIX } from '../../shared/worktree/removal' import { @@ -105,7 +105,7 @@ export function describeUnstoppedPtys( ): string { const detail = verdict.status === 'live' - ? `${UNSTOPPED_PTY_LIVE_DETAIL_PREFIX} ${verdict.ptyIds.join(', ')}` + ? `${STILL_LIVE_DETAIL_PREFIX} ${verdict.ptyIds.join(', ')}` : `could not verify these exited: ${failedPtyIds.join(', ')} (${verdict.reason})` return `${UNSTOPPED_PTY_REMOVAL_PREFIX} ${worktreeId}${UNSTOPPED_PTY_DETAIL_SEPARATOR}${detail}` } diff --git a/src/main/runtime/worktree-pty-host-fence.ts b/src/main/runtime/worktree-pty-host-fence.ts index 855371c4aef..a2a6099bf11 100644 --- a/src/main/runtime/worktree-pty-host-fence.ts +++ b/src/main/runtime/worktree-pty-host-fence.ts @@ -1,8 +1,14 @@ export type WorktreePtyHostFence = { + /** `null` is this machine; ABSENT is no fence at all, so every host matches. */ resolvedConnectionId?: string | null resolvedRuntimeEnvironmentId?: string } +/** + * Also fences the structured sweep, through `structuredSessionTeardownHostId`, which reuses this + * exact type so the two cannot drift. That helper narrows ABSENT to local — the one deliberate + * difference, documented where it is made. + */ export function worktreePtyBelongsToHost( ptyId: string, connectionId: string | null | undefined, diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index 82fa055cc3a..84275b40eed 100644 --- a/src/main/runtime/worktree-teardown.ts +++ b/src/main/runtime/worktree-teardown.ts @@ -15,10 +15,16 @@ import { } from './worktree-pty-surface-sweeps' import { closeStructuredSessionsForWorktree, - describeLiveStructuredSessions, - listLiveStructuredSessionsForWorktree + createStructuredSweepProgress, + describeUnclosedStructuredSessions, + listLiveStructuredSessionsForWorktree, + unclosedStructuredSessions } from './structured-session-worktree-teardown' -import { createWorktreeSweepTracker, settleSweepsForForcedRemoval } from './forced-sweep-settlement' +import { + createWorktreeSweepTracker, + settleSweepsForForcedRemoval, + type WorktreeSweepTracker +} from './forced-sweep-settlement' import { describeError, describeFailedPtySweep, @@ -57,7 +63,7 @@ export type WorktreeTeardownResult = { runtimeStopped: number providerStopped: number registryStopped: number - /** Structured agent sessions closed by the force path; absent when none were found. */ + /** Structured agent sessions this teardown closed; absent when it closed none. */ structuredStopped?: number } @@ -99,12 +105,18 @@ export async function killAllProcessesForWorktree( const deadlineError = new Error( `${WORKTREE_TEARDOWN_TIMEOUT_PREFIX} ${worktreeId}. ${WORKTREE_TEARDOWN_FORCE_HINT}` ) - // FIRST, and before a single PTY sweep starts: a structured agent session is registered on none - // of the three surfaces below, so all three answered zero and removal deleted the checkout out - // from under a running provider child. Refusing costs nothing when there are none, and the check - // is synchronous, so a destructive removal fails fast instead of after the whole sweep budget. - const structuredStopped = await sweepStructuredSessions(worktreeId, deps, deadline, deadlineError) const sweeps = createWorktreeSweepTracker() + // ISSUED first, before a single PTY is touched: a structured agent session is registered on none + // of the three surfaces below, so all three answered zero and removal deleted the checkout out + // from under a running provider child. Asking the agent plane ahead of the terminal plane also + // keeps an intentional stop from reading as a failed process exit. + // + // Not AWAITED first, though. Its close is serial and each one waits on a provider round trip, so + // awaiting here would spend the shared budget before a single PTY was asked — and the sweeps + // would then report a timeout for a stop they never attempted. It is joined below, ahead of the + // PTY verdict, so a structured refusal still outranks one. + const structuredSweep = sweepStructuredSessions(worktreeId, deps, deadline, sweeps) + void structuredSweep.catch(() => undefined) const stopAttempts = new Map>() const stopPty = ( ptyId: string, @@ -196,6 +208,7 @@ export async function killAllProcessesForWorktree( for (const sweep of [runtimeSweep, providerSweep, registrySweep]) { void sweep.catch(() => undefined) } + const structuredStopped = await structuredSweep let runtimeResult: { stopped: number } let providerStopped: number let registryStopped: number @@ -207,7 +220,10 @@ export async function killAllProcessesForWorktree( deadlineError ) if (forced.incomplete) { - return forced.stopped + // Carries the structured count out too: this early return skips the PTY verdict, not the + // sweep that already closed a user's chats, and dropping it makes the log say `structured=0` + // for a removal that closed some. + return { ...forced.stopped, ...(structuredStopped > 0 ? { structuredStopped } : {}) } } runtimeResult = { stopped: forced.stopped.runtimeStopped } providerStopped = forced.stopped.providerStopped @@ -277,59 +293,84 @@ export async function killAllProcessesForWorktree( } /** - * The fourth sweep: structured agent sessions bound to this worktree. + * The fourth sweep: structured agent sessions bound to this worktree, on this host. * - * Refuses rather than auto-closing on the ordinary destructive path. `worktree rm` is the verb - * that deletes a user's work, and a running agent session is exactly the thing they would want to - * be told about before it goes — the same bargain the unstopped-PTY gate already strikes, using - * the same `--force` escape hatch. Force closes them properly instead of orphaning a child against - * a `cwd` that is about to disappear. + * Stops first and refuses only on unproven stops, which is the bargain the unstopped-PTY gate + * actually strikes: that gate kills every PTY — a terminal running an agent included — and refuses + * only for the ones whose exit it could not then verify. Refusing merely because a session is + * attached made an idle chat, which the user is done with, harder to delete than a terminal running + * the same agent. Attachment is lease state, not work in flight, so it was never the right proxy. * - * Two callers participate, for different reasons. A proof-requiring removal (`requirePhysicalStop`) - * refuses, then closes under force. A folder-workspace removal (`closeStructuredSessions`) closes - * best-effort without refusing: it shares its root so no checkout vanishes under the child, and one - * of those paths is a never-throw forget that a refusal would wedge. Reconciliation sweeps set - * neither — they repair state, delete nothing, and must never close a session. + * Two callers participate. A proof-requiring removal (`requirePhysicalStop`) refuses when a close + * does not settle, so nothing deletes a checkout out from under a child that is still there. A + * folder-workspace removal (`closeStructuredSessions`) never refuses: it shares its root so no + * checkout vanishes under the child, and every one of those call sites discards a rejection, so a + * refusal there would be words nobody reads. Reconciliation sweeps set neither — they repair state, + * delete nothing, and must never close a session. */ async function sweepStructuredSessions( worktreeId: string, deps: WorktreeTeardownDeps, deadline: number, - deadlineError: Error + sweeps: WorktreeSweepTracker ): Promise { if (!deps.requirePhysicalStop && !deps.closeStructuredSessions) { return 0 } - const live = listLiveStructuredSessionsForWorktree(worktreeId) + // `deps` carries the same two host fields the PTY sweeps fence on, and a `repoId::path` id names + // a different workspace on every host — so an unfenced list would close a live chat belonging to + // an SSH or paired-runtime copy of the id being removed here. + const live = listLiveStructuredSessionsForWorktree(worktreeId, deps) if (live.length === 0) { return 0 } + // Raced against the same sweep budget every PTY surface is bounded by, because `host.close` + // awaits a provider round trip whose own eviction steps are each bounded well past this budget. + // + // Deliberately NOT fail-closed, unlike the PTY sweeps: their timeout sentinel carries the PTY + // timeout prefix, which the desktop classifier reads as a TERMINAL failure — so a wedged session + // close would refuse in terminal wording, and refuse identically again under the Force Delete + // that is meant to clear it (#11960). A close that ran out of time is a session this removal + // could not confirm closed, which is exactly what the branch below already words. Tracked so a + // forced removal still waits out the abandoned-sweep grace before it deletes files. + // + // The verdict is read off `progress`, which the serial loop fills as it goes, rather than off + // this call's result: the deadline can land mid-loop, and a fallback assembled here could only + // guess — it named every session, including the ones already closed, and reported zero closes. + const progress = createStructuredSweepProgress(live) + await settleBeforeDeadline( + sweeps.track(() => + closeStructuredSessionsForWorktree(progress, deadline, { + ...(deps.runtime ? { runtime: deps.runtime } : {}), + // The only shape of removal that can leave this workspace — and its chat tabs — in place. + mayRefuse: Boolean(deps.requirePhysicalStop) && !deps.allowUnverifiedStop + }) + ), + undefined, + deadline + ) + const closed = progress.closed + const unstopped = unclosedStructuredSessions(progress) + if (unstopped.length === 0) { + return closed + } // Only a proof-requiring removal may refuse. A folder-workspace removal shares its root, so no // checkout disappears under the child — the harm is a session left pointing at a workspace Orca - // has forgotten — and one of those paths is a never-throw forget, which a refusal would wedge. + // has forgotten — and every one of those callers discards a rejection anyway. if (deps.requirePhysicalStop && !deps.allowUnverifiedStop) { // The prefix is what the desktop classifier matches on; without it the toast shows raw CLI // wording and hides the Force Delete button — the #11960 dead end this file already documents. throw new Error( - `${RUNNING_AGENT_SESSION_REMOVAL_PREFIX} ${worktreeId}${UNSTOPPED_PTY_DETAIL_SEPARATOR}${describeLiveStructuredSessions(live)}. ${WORKTREE_TEARDOWN_FORCE_HINT}` + `${RUNNING_AGENT_SESSION_REMOVAL_PREFIX} ${worktreeId}${UNSTOPPED_PTY_DETAIL_SEPARATOR}${describeUnclosedStructuredSessions(unstopped)}. ${WORKTREE_TEARDOWN_FORCE_HINT}` ) } - // Raced against the same sweep budget every PTY surface is bounded by: `host.close` awaits a - // provider round trip, and a wedged one would otherwise hang `worktree rm --force` forever with - // no timeout error at all. On expiry the force path reports the timeout exactly as the PTY - // sweeps do rather than proceeding as if the sessions had closed. - const { closed, unstopped } = await settleBeforeDeadline( - () => closeStructuredSessionsForWorktree(worktreeId, deps.runtime), - { closed: 0, unstopped: live }, - deadline, - deadlineError + // Force is the documented escape hatch, so removal continues — but say so, because the child + // outliving its `cwd` is the failure this sweep exists to make visible. Carries the verdict + // verbatim, like the unstopped-PTY warn above: this line is the only record a forced removal + // leaves, and appending "still attached" asserted the live verdict over sessions the sweep had + // just said it could not confirm either way. + console.warn( + `[worktree-teardown] forcing removal of ${worktreeId}${UNSTOPPED_PTY_DETAIL_SEPARATOR}${describeUnclosedStructuredSessions(unstopped)}` ) - if (unstopped.length > 0) { - // Force is the documented escape hatch, so removal continues — but say so, because the child - // outliving its `cwd` is the failure this sweep exists to make visible. - console.warn( - `[worktree-teardown] forcing removal of ${worktreeId} with ${describeLiveStructuredSessions(unstopped)} still attached` - ) - } return closed } diff --git a/src/preload/api/runtime-api.ts b/src/preload/api/runtime-api.ts index f7f553ec2c0..7fdf229dd88 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -1,3 +1,4 @@ +import type { RuntimeHostStatusSnapshot } from '../../shared/runtime-host-status' import type { RuntimeBrowserDriverState, RuntimeRendererSyncWindowGraph, @@ -77,6 +78,8 @@ export type RuntimeApi = { ) => () => void } runtimeEnvironments: { + getStatusSnapshots: () => Promise + onStatusChanged: (callback: (snapshot: RuntimeHostStatusSnapshot) => void) => () => void list: () => Promise addFromPairingCode: (args: { name: string diff --git a/src/preload/api/runtime-environments-bridge.ts b/src/preload/api/runtime-environments-bridge.ts index ddfa498dc74..63c31df52f8 100644 --- a/src/preload/api/runtime-environments-bridge.ts +++ b/src/preload/api/runtime-environments-bridge.ts @@ -1,4 +1,8 @@ import { ipcRenderer } from 'electron' +import { + RUNTIME_HOST_STATUS_CHANNEL, + type RuntimeHostStatusSnapshot +} from '../../shared/runtime-host-status' import type { VerifyAndAddRuntimeEnvironmentResult } from '../../shared/remote-pairing-verification' import type { RuntimeStatus } from '../../shared/runtime-types' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' @@ -12,6 +16,16 @@ import { import type { PreloadApi } from '../api-types' export const runtimeEnvironmentsApi = { + getStatusSnapshots: (): Promise => + ipcRenderer.invoke('runtimeEnvironments:getStatusSnapshots'), + onStatusChanged: (callback: (snapshot: RuntimeHostStatusSnapshot) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + snapshot: RuntimeHostStatusSnapshot + ): void => callback(snapshot) + ipcRenderer.on(RUNTIME_HOST_STATUS_CHANNEL, listener) + return () => ipcRenderer.removeListener(RUNTIME_HOST_STATUS_CHANNEL, listener) + }, list: (): Promise => ipcRenderer.invoke('runtimeEnvironments:list'), addFromPairingCode: (args: { diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index d17bb8ba56d..7145bed732c 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -242,17 +242,11 @@ export default function NewWorkspaceComposerCard( selector: action.environmentId, timeoutMs: 15_000 }) - const runtimeStatus = unwrapRuntimeRpcResult(response) - useAppStore.getState().setRuntimeEnvironmentStatus(action.environmentId, { - status: runtimeStatus, - checkedAt: Date.now() - }) + unwrapRuntimeRpcResult(response) + await useAppStore.getState().readRuntimeHostStatusSnapshots() } catch (error) { if (action.kind === 'runtime') { - useAppStore.getState().setRuntimeEnvironmentStatus(action.environmentId, { - status: null, - checkedAt: Date.now() - }) + await useAppStore.getState().readRuntimeHostStatusSnapshots() } toast.error( error instanceof Error diff --git a/src/renderer/src/components/cmd-j/palette-host-badge.test.ts b/src/renderer/src/components/cmd-j/palette-host-badge.test.ts index cd6310c8580..8d15ff8d034 100644 --- a/src/renderer/src/components/cmd-j/palette-host-badge.test.ts +++ b/src/renderer/src/components/cmd-j/palette-host-badge.test.ts @@ -83,8 +83,7 @@ describe('getPaletteHostBadge', () => { repos: [{ executionHostId: 'runtime:env-1' }], sshTargetLabels: new Map(), settings: { activeRuntimeEnvironmentId: 'env-2' }, - // A live status makes the runtime 'available'; without it the host reads - // 'disconnected' and the badge is suppressed (covered below). + // Only verified availability enables unfiltered host badges. runtimeStatusByEnvironmentId: new Map([ [ 'env-1', @@ -145,3 +144,19 @@ describe('getPaletteHostBadge', () => { expect(getPaletteHostBadge(null, hosts)).toBeNull() }) }) + +it.each(['connecting', 'blocked', 'disconnected', 'error'] as const)( + 'does not infer reachability from %s health, but preserves explicit filter labels', + (health) => { + const hosts = buildSidebarHostOptions({ + repos: [{ executionHostId: 'runtime:env-1' }], + sshTargetLabels: new Map(), + settings: { activeRuntimeEnvironmentId: null } + }).map((host) => (host.kind === 'runtime' ? { ...host, health } : host)) + expect(getPaletteHostBadge({ connectionId: null }, hosts)).toBeNull() + expect(getPaletteHostBadge({ executionHostId: 'runtime:env-1' }, hosts, true)).toEqual({ + hostId: 'runtime:env-1', + label: 'env-1' + }) + } +) diff --git a/src/renderer/src/components/cmd-j/palette-host-badge.ts b/src/renderer/src/components/cmd-j/palette-host-badge.ts index 8ac94adeae4..01a9e33d3ac 100644 --- a/src/renderer/src/components/cmd-j/palette-host-badge.ts +++ b/src/renderer/src/components/cmd-j/palette-host-badge.ts @@ -17,7 +17,7 @@ export type PaletteHostBadge = { // unlike the sidebar gate, which lists disconnected hosts so users can connect. function hasActiveRemoteHost(hostOptions: readonly SidebarHostOption[]): boolean { return hostOptions.some( - (host) => host.id !== LOCAL_EXECUTION_HOST_ID && host.health !== 'disconnected' + (host) => host.id !== LOCAL_EXECUTION_HOST_ID && host.health === 'available' ) } diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx index 9a30aa67eba..d6d74a20304 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx @@ -95,221 +95,6 @@ describe('NativeChatMessageList assistant messages', () => { expect(document.querySelector('.text-destructive')).toBeNull() }) - it('keeps a reduced-motion-safe spinner activity line at the tail of a no-tool Codex turn', () => { - render( - - ) - - const activity = screen.getByText('Working…') - const row = activity.closest('[data-native-chat-turn-activity]') - const spinner = row?.querySelector('svg') - expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') - expect(spinner).toHaveClass('size-4', 'animate-spin', 'motion-reduce:animate-none') - expect(row).toHaveAttribute('aria-live', 'polite') - expect(screen.getByText('The answer is still streaming.').compareDocumentPosition(row!)).toBe( - Node.DOCUMENT_POSITION_FOLLOWING - ) - }) - - it('keeps the broad fallback distinct from the running tool row', () => { - render( - - ) - - const toolLabel = screen.getByText('Running pnpm test') - expect(toolLabel).toHaveClass('animate-pulse') - expect(screen.getAllByText('Running pnpm test')).toHaveLength(1) - const activity = screen.getByText('Working…') - expect(activity.textContent).not.toBe(toolLabel.textContent) - expect(activity).not.toHaveTextContent('shell') - expect(activity).not.toHaveTextContent('pnpm test') - const spinner = activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg') - expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') - expect(spinner).toHaveClass('animate-spin', 'motion-reduce:animate-none') - }) - - it('uses the broad fallback after a tool settles', () => { - render( - - ) - - const settledTool = screen.getByText('shell') - const activity = screen.getByText('Working…') - expect(activity.textContent).not.toBe(settledTool.textContent) - expect(activity).not.toHaveTextContent('shell') - expect(activity).not.toHaveTextContent('pnpm test') - expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') - expect(activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg')).toHaveClass( - 'animate-spin' - ) - }) - - it('keeps a completed tool row static while the turn tail spins, then removes the tail', () => { - const workingSession: NativeChatLiveSession = { - ...session, - status: 'working', - messages: [ - { - id: 'assistant-settled-tool', - role: 'assistant', - blocks: [ - { - type: 'tool-call', - name: 'shell', - input: { command: 'pnpm test' }, - state: 'completed' - }, - { type: 'tool-result', output: 'passed' } - ], - timestamp: 1, - source: 'transcript' - } - ] - } - const { container, rerender } = render( - - ) - - const settledTool = screen.getByText('shell') - expect(settledTool).toHaveTextContent('shell pnpm test') - expect(settledTool.closest('button')?.querySelector('.animate-pulse')).toBeNull() - expect(settledTool.closest('button')?.querySelector('.lucide-check')).toBeInTheDocument() - const activity = screen.getByText('Preparing the answer') - expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') - expect(activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg')).toHaveClass( - 'animate-spin' - ) - - rerender( - - ) - - expect(container.querySelector('[data-native-chat-turn-activity]')).toBeNull() - expect(container.querySelector('.animate-pulse')).toBeNull() - expect(container.querySelector('.animate-spin')).toBeNull() - }) - - it('keeps bridge chats on the legacy activity chrome', () => { - render( - - ) - - expect(screen.queryByText('Thinking')).toBeNull() - expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull() - expect(screen.queryByText('Running sleep 5')).toBeNull() - expect(document.querySelectorAll('.animate-bounce')).toHaveLength(3) - }) - it('keeps the current tool live when a stale completed lifecycle meets active hook state', () => { render( { expect(screen.getByText('Running sleep 5')).toBeInTheDocument() }) - - it('shows a stable thinking status directly below the user message', () => { - const { container } = render( - - ) - - const user = screen.getByText('Start the task') - const thinking = screen.getByText('Thinking') - expect(user.compareDocumentPosition(thinking)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - expect(thinking.parentElement).not.toHaveClass('border-b') - expect(thinking.parentElement).toHaveClass('text-sm') - expect(container.querySelector('.animate-bounce')).toBeNull() - expect(thinking).toHaveClass('animate-pulse') - expect(container.querySelectorAll('.size-1.5.animate-pulse')).toHaveLength(0) - }) - - it('places the thinking status directly after the latest user message', () => { - render( - - ) - - const user = screen.getByText('Run the checks') - const status = screen.getByText('Working for 0s') - const assistant = screen.getByText('I am checking now.') - expect(user.compareDocumentPosition(status)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - expect(status.compareDocumentPosition(assistant)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - expect(status.parentElement).toHaveClass('border-b') - }) - - it('shows elapsed working time once tool activity starts', () => { - render( - - ) - - expect(screen.getByText('Working for 3s')).toBeInTheDocument() - }) - - it('keeps the completed duration below the user message', () => { - const startedAt = Date.now() - 3000 - const turnSession: NativeChatLiveSession = { - ...session, - status: 'working', - messages: [ - { - id: 'user-complete', - role: 'user', - blocks: [{ type: 'text', text: 'Complete this task' }], - timestamp: startedAt, - source: 'transcript' - }, - { - id: 'assistant-complete', - role: 'assistant', - blocks: [{ type: 'text', text: 'Task complete.' }], - timestamp: Date.now(), - source: 'transcript' - } - ] - } - const { rerender } = render( - - ) - - rerender( - - ) - - const user = screen.getByText('Complete this task') - const status = screen.getByText('Worked for 3s') - const assistant = screen.getByText('Task complete.') - expect(user.compareDocumentPosition(status)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - expect(status.compareDocumentPosition(assistant)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - - rerender( - - ) - - expect(screen.getByText('Worked for 3s')).toBeInTheDocument() - expect(screen.getByText('Thinking')).toBeInTheDocument() - }) - - it("uses the completed caret to expand that turn's tool details", () => { - const startedAt = Date.now() - 3000 - render( - - ) - - const status = screen.getByRole('button', { name: 'Toggle turn details' }) - expect(status).toHaveAttribute('aria-expanded', 'false') - expect(screen.queryByRole('button', { name: /1× shell/ })).toBeNull() - fireEvent.click(status) - expect(status).toHaveAttribute('aria-expanded', 'true') - const tool = screen.getByRole('button', { name: /1× shell/ }) - expect(tool).toHaveAttribute('aria-expanded', 'true') - expect(screen.getAllByRole('button', { name: /shell pwd/ })[1]).toHaveAttribute( - 'aria-expanded', - 'false' - ) - }) }) // List-level, because every defect this feature has shipped so far lived in the diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 6728aa99439..3da6283057a 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -9,11 +9,10 @@ import { nativeChatTaskListPredecessors } from './native-chat-task-list-history' import { NativeChatTaskList } from './NativeChatTaskList' import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames' import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator' -import { NativeChatWorkingStatus } from './NativeChatWorkingStatus' import { useNativeChatTurnStatus } from './use-native-chat-turn-status' import { NativeChatTypingIndicatorRow } from './NativeChatTypingIndicatorRow' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' -import type { NativeChatTurnActivity } from './native-chat-turn-activity' +import type { NativeChatTurnActivity } from '../../../../shared/native-chat-turn-activity' import { NativeChatTurnActivityLine } from './NativeChatTurnActivityLine' import { NativeChatDisclosureContext, @@ -29,6 +28,7 @@ import { useNativeChatTranscriptWindow } from './use-native-chat-transcript-wind import { useNativeChatTranscriptScroll } from './use-native-chat-transcript-scroll' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { isStructuredAgentSessionThinking } from '../../../../shared/structured-agent-session-live-turn' import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status' import { nativeChatTurnDiffs, @@ -150,12 +150,19 @@ export function NativeChatMessageList({ : new Map(), [journalItems, messages, turnKeys] ) + // "Thinking" is real reasoning content at the tail of the turn, not the absence + // of output — the latter reports thinking while the request is merely in flight. + const thinking = useMemo( + () => (journalItems ? isStructuredAgentSessionThinking(journalItems) : false), + [journalItems] + ) const turnStatuses = useNativeChatTurnStatus({ messages, latestUserIndex, isWorking: showTurnStatus && isWorking, workingStartedAt: showTurnStatus ? workingStartedAt : null, - settledTurns: showTurnStatus ? settledTurns : null + settledTurns: showTurnStatus ? settledTurns : null, + thinking }) const lifecycleWorking = session.transcriptLifecycle?.state === 'working' const slots = useMemo( @@ -169,7 +176,6 @@ export function NativeChatMessageList({ turnStatuses, turnDiffs, showTurnStatus, - showTypingIndicator, isWorking, lifecycleWorking }), @@ -181,7 +187,6 @@ export function NativeChatMessageList({ messages, receipts, showTurnStatus, - showTypingIndicator, turnDiffs, turnKeys, turnStatuses @@ -280,18 +285,11 @@ export function NativeChatMessageList({ context={rowContext} window={transcriptWindow} /> - {showTurnStatus && - latestUserIndex === -1 && - turnStatuses.active && - showTypingIndicator ? ( - - ) : null} {showTurnStatus && isWorking ? ( - + ) : null} {!showTurnStatus && showTypingIndicator ? : null} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx new file mode 100644 index 00000000000..ab706436f91 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx @@ -0,0 +1,563 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' +import type { + AgentJournalItemBody, + AgentJournalRenderItem +} from '../../../../shared/agent-session-journal-types' + +// The turn record this host writes, and the legacy status row an older host sends. +const turnItem: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'running' } +const legacyTurnRow: AgentJournalItemBody = { + kind: 'status', + text: 'Codex is working…', + turnLifecycle: { turnId: 'turn-1', state: 'running' } +} +const reasoningRow: AgentJournalItemBody = { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: '' }] +} + +function journalItem(sequence: number, body: AgentJournalItemBody): AgentJournalRenderItem { + return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body } +} + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const session: NativeChatLiveSession = { + messages: [ + { + id: 'assistant-1', + role: 'assistant', + blocks: [{ type: 'text', text: 'Selectable agent response.' }], + timestamp: 1, + source: 'transcript' + } + ], + status: 'ready', + sessionId: 'session-1', + agent: 'codex', + hasMore: false, + loadingEarlier: false, + loadEarlier: vi.fn(), + readPhase: 'ready' +} + +// The live turn renders exactly one indicator row; a settled turn keeps its own. +describe('NativeChatMessageList turn indicator', () => { + it('keeps a reduced-motion-safe spinner on the live row of a no-tool Codex turn', () => { + render( + + ) + + const activity = screen.getByText('Working for 0s') + const row = activity.closest('[data-native-chat-turn-activity]') + const spinner = row?.querySelector('svg') + expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') + expect(spinner).toHaveClass('size-4', 'animate-spin', 'motion-reduce:animate-none') + expect(row).toHaveAttribute('aria-live', 'polite') + expect(screen.getByText('The answer is still streaming.').compareDocumentPosition(row!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + }) + + it('keeps the live row distinct from the running tool row', () => { + render( + + ) + + const toolLabel = screen.getByText('Running pnpm test') + expect(toolLabel).toHaveClass('animate-pulse') + expect(screen.getAllByText('Running pnpm test')).toHaveLength(1) + const activity = screen.getByText('Working for 0s') + expect(activity.textContent).not.toBe(toolLabel.textContent) + expect(activity).not.toHaveTextContent('shell') + expect(activity).not.toHaveTextContent('pnpm test') + const spinner = activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg') + expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') + expect(spinner).toHaveClass('animate-spin', 'motion-reduce:animate-none') + }) + + it('keeps the live row up after a tool settles', () => { + render( + + ) + + const settledTool = screen.getByText('shell') + const activity = screen.getByText('Working for 0s') + expect(activity.textContent).not.toBe(settledTool.textContent) + expect(activity).not.toHaveTextContent('shell') + expect(activity).not.toHaveTextContent('pnpm test') + expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') + expect(activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg')).toHaveClass( + 'animate-spin' + ) + }) + + it('keeps a completed tool row static while the turn tail spins, then removes the tail', () => { + const workingSession: NativeChatLiveSession = { + ...session, + status: 'working', + messages: [ + { + id: 'assistant-settled-tool', + role: 'assistant', + blocks: [ + { + type: 'tool-call', + name: 'shell', + input: { command: 'pnpm test' }, + state: 'completed' + }, + { type: 'tool-result', output: 'passed' } + ], + timestamp: 1, + source: 'transcript' + } + ] + } + const { container, rerender } = render( + + ) + + const settledTool = screen.getByText('shell') + expect(settledTool).toHaveTextContent('shell pnpm test') + expect(settledTool.closest('button')?.querySelector('.animate-pulse')).toBeNull() + expect(settledTool.closest('button')?.querySelector('.lucide-check')).toBeInTheDocument() + const activity = screen.getByText('Preparing the answer') + expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') + expect(activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg')).toHaveClass( + 'animate-spin' + ) + + rerender( + + ) + + expect(container.querySelector('[data-native-chat-turn-activity]')).toBeNull() + expect(container.querySelector('.animate-pulse')).toBeNull() + expect(container.querySelector('.animate-spin')).toBeNull() + }) + + it('keeps bridge chats on the legacy activity chrome', () => { + render( + + ) + + expect(screen.queryByText('Thinking')).toBeNull() + expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull() + expect(screen.queryByText('Running sleep 5')).toBeNull() + expect(document.querySelectorAll('.animate-bounce')).toHaveLength(3) + }) + + it('reads "Thinking" on the one live row while the turn is reasoning', () => { + const { container } = render( + + ) + + const user = screen.getByText('Start the task') + const thinking = screen.getByText('Thinking') + expect(user.compareDocumentPosition(thinking)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + // One indicator, not a "Thinking" row stacked above a spinning "Working…" row. + expect(container.querySelectorAll('[data-native-chat-turn-status]')).toHaveLength(1) + expect(thinking.closest('[data-native-chat-turn-activity]')?.querySelector('svg')).toHaveClass( + 'animate-spin' + ) + expect(container.querySelector('.animate-bounce')).toBeNull() + }) + + it('does not reuse completed-turn reasoning while the next dispatch is pending', () => { + render( + + ) + + expect(screen.queryByText('Thinking')).toBeNull() + expect(screen.getByText('Working for 0s')).toBeInTheDocument() + }) + + it('lets provider activity text beat the reasoning label on the same single row', () => { + const { container } = render( + + ) + + expect(screen.getByText('Exploring the repo layout')).toBeInTheDocument() + expect(screen.queryByText('Thinking')).toBeNull() + expect(container.querySelectorAll('[data-native-chat-turn-status]')).toHaveLength(1) + }) + + it('places the one live row after the newest content in the turn', () => { + render( + + ) + + const status = screen.getByText('Working for 0s') + const assistant = screen.getByText('I am checking now.') + // The live row trails the newest content instead of sitting under the prompt. + expect(assistant.compareDocumentPosition(status)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(document.querySelectorAll('[data-native-chat-turn-status]')).toHaveLength(1) + }) + + it('shows elapsed working time once tool activity starts', () => { + render( + + ) + + expect(screen.getByText('Working for 3s')).toBeInTheDocument() + }) + + it('keeps the completed duration below the user message', () => { + const startedAt = Date.now() - 3000 + const turnSession: NativeChatLiveSession = { + ...session, + status: 'working', + messages: [ + { + id: 'user-complete', + role: 'user', + blocks: [{ type: 'text', text: 'Complete this task' }], + timestamp: startedAt, + source: 'transcript' + }, + { + id: 'assistant-complete', + role: 'assistant', + blocks: [{ type: 'text', text: 'Task complete.' }], + timestamp: Date.now(), + source: 'transcript' + } + ] + } + const { rerender } = render( + + ) + + rerender( + + ) + + const user = screen.getByText('Complete this task') + const status = screen.getByText('Worked for 3s') + const assistant = screen.getByText('Task complete.') + expect(user.compareDocumentPosition(status)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(status.compareDocumentPosition(assistant)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + + rerender( + + ) + + expect(screen.getByText('Worked for 3s')).toBeInTheDocument() + expect(screen.getByText('Working for 0s')).toBeInTheDocument() + }) + + it("uses the completed caret to expand that turn's tool details", () => { + const startedAt = Date.now() - 3000 + render( + + ) + + const status = screen.getByRole('button', { name: 'Toggle turn details' }) + expect(status).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('button', { name: /1× shell/ })).toBeNull() + fireEvent.click(status) + expect(status).toHaveAttribute('aria-expanded', 'true') + const tool = screen.getByRole('button', { name: /1× shell/ }) + expect(tool).toHaveAttribute('aria-expanded', 'true') + expect(screen.getAllByRole('button', { name: /shell pwd/ })[1]).toHaveAttribute( + 'aria-expanded', + 'false' + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx index da11773105d..72e11ad25a8 100644 --- a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx @@ -1,18 +1,55 @@ import { Loader2 } from 'lucide-react' import { translate } from '@/i18n/i18n' -import type { NativeChatTurnActivity } from './native-chat-turn-activity' +import type { NativeChatTurnActivity } from '../../../../shared/native-chat-turn-activity' +import { + describeNativeChatActiveTurnLabel, + NATIVE_CHAT_TURN_STATUS_COPY, + type NativeChatTurnStatus +} from '../../../../shared/native-chat-turn-status' +import { useNativeChatElapsedSeconds } from './use-native-chat-elapsed-seconds' +/** The live turn's one indicator: a spinner plus whatever the turn can say about + * itself — the provider's activity text, else that it is reasoning, else how + * long it has been working. A settled turn keeps its own `NativeChatWorkingStatus` + * row; this one is only ever rendered while the turn is in flight. */ export function NativeChatTurnActivityLine({ - activity + activity, + status }: { activity?: NativeChatTurnActivity | null + status?: NativeChatTurnStatus | null }): React.JSX.Element { - const label = activity?.text ?? translate('components.native-chat.status.working', 'Working…') + const thinking = status?.thinking === true + // The clock only ticks when its number is the label; activity text and + // "Thinking" carry no duration. + const counting = status != null && !thinking && !activity?.text + const elapsedSeconds = useNativeChatElapsedSeconds(status?.startedAt ?? null, counting) + const resolved = describeNativeChatActiveTurnLabel({ + activityText: activity?.text, + thinking, + elapsedSeconds + }) + const label = + resolved.source === 'activity' + ? resolved.text + : status == null + ? translate('components.native-chat.status.working', 'Working…') + : resolved.key === 'thinking' + ? translate( + 'components.native-chat.status.thinking', + NATIVE_CHAT_TURN_STATUS_COPY.thinking + ) + : translate( + 'components.native-chat.status.workingFor', + NATIVE_CHAT_TURN_STATUS_COPY.workingFor, + { value0: resolved.duration } + ) return (
diff --git a/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx b/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx index e6c38de83f5..8b910daa936 100644 --- a/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx @@ -1,13 +1,11 @@ -import { useState } from 'react' import { ChevronRight } from 'lucide-react' import { translate } from '@/i18n/i18n' -import { useNow } from '@/hooks/use-now' import { describeNativeChatTurnStatus, formatNativeChatDuration, - NATIVE_CHAT_TURN_STATUS_COPY, - nativeChatElapsedSeconds + NATIVE_CHAT_TURN_STATUS_COPY } from '../../../../shared/native-chat-turn-status' +import { useNativeChatElapsedSeconds } from './use-native-chat-elapsed-seconds' export { formatNativeChatDuration } @@ -24,15 +22,8 @@ export function NativeChatWorkingStatus({ expanded?: boolean onToggleExpanded?: () => void }): React.JSX.Element { - // Why: elapsed seconds is ordinary render dataflow, not an external system. - // The shared 1s clock is visibility-gated and collapses every in-flight turn - // onto one tick, instead of one interval plus one commit per turn. const counting = !thinking && workedSeconds == null - const now = useNow(1_000, counting) - // Why: preserves the old effect's `startedAt ?? Date.now()` epoch for the - // single frame before the turn's startedAt lands. - const [mountedAt] = useState(() => Date.now()) - const elapsedSeconds = counting ? nativeChatElapsedSeconds(startedAt, mountedAt, now) : 0 + const elapsedSeconds = useNativeChatElapsedSeconds(startedAt, counting) const { key, duration } = describeNativeChatTurnStatus({ thinking, @@ -67,6 +58,7 @@ export function NativeChatWorkingStatus({ return ( | 0:-@html 17:delimiter.curly.svelte@svelte 18:-@typescript 27:delimiter.curly.svelte@svelte 28:-@html 29:delimiter.curly.svelte@svelte 30:-@typescript 35:delimiter.curly.svelte@svelte 36:-@html | embed=html", + "{@html 'raw'} | 0:keyword.control.svelte@svelte 6:-@typescript 21:delimiter.curly.svelte@svelte | embed=none", + " | | embed=html", + " | 0:tag.svelte@svelte | embed=none", ] `) }) -}) -describe('svelte tokenizer regressions', () => { - // Regression: when a Svelte file starts with `{#if}`, `{name}`, or `{@html}`, - // no html embed is active yet. Earlier drafts unconditionally emitted - // `nextEmbedded: '@pop'` from root, which Monaco rejects with - // "cannot pop embedded language if not inside one". The fix splits the - // entry-only `root` state from the html-embedded `markup` state. - it('does not pop a non-existent embed when a file starts with a Svelte block', () => { - const action = findRuleAction('root', '{#if foo}') - expect(action).toMatchObject({ switchTo: '@svelteBlockExpressionEnter' }) - expect(action?.nextEmbedded).toBeUndefined() + // Regression (the field failure): the first interpolation of a file threw + // "cannot pop embedded language if not inside one" — every Svelte file with a + // `{}` in it, which is essentially all of them. + it('highlights every interpolation of a markup line', () => { + const [line] = tokenizeSvelte('

a {first} b {second} c

') + + expect(tokenLanguages(line)).toEqual([ + 'html', + 'svelte', + 'typescript', + 'svelte', + 'html', + 'svelte', + 'typescript', + 'svelte', + 'html' + ]) }) - it('starts the html embed and switches to markup when markup begins', () => { - expect(findRuleAction('root', '

Counter

')).toMatchObject({ - switchTo: '@markup', - nextEmbedded: 'html' - }) + it('opens a file on a Svelte block without popping a missing embed', () => { + // No html embed exists yet at file start, so the block's entry rule must not + // pop one — Monarch throws outright if it does. + const [line] = tokenizeSvelte('{#if count > 0}') + + expect(tokenTypeAt(line, 0)).toBe('keyword.control') + expect(tokenLanguages(line)).toEqual(['svelte', 'typescript', 'svelte']) }) - // Regression: while the html embed is active, only parent rules whose action - // pops the embed are consulted before delegating to html. The first draft - // omitted `nextEmbedded: '@pop'` from ``)).toEqual([ + ['html'], + ['svelte'], + [embeddedLanguageId], + ['svelte'] + ]) + }) + + it.each([ + ['`)).toEqual([ + ['html'], + ['svelte'], + [embeddedLanguageId], + ['svelte'] + ]) + }) +}) + +describe('svelte root state invariant', () => { + // Structural on purpose: behaviour can only reach the root rules some fixture + // happens to exercise, and a root rule that pops an embed throws on the very + // first character of a file. Guard every root rule, exercised or not. + it('has no root rule that pops an embedded language', () => { + const rootRules = (svelteMonarchLanguage.tokenizer as Record).root + const popRules = rootRules.filter( + (rule) => + Array.isArray(rule) && (rule[1] as { nextEmbedded?: string })?.nextEmbedded === '@pop' + ) + + expect(popRules).toEqual([]) }) }) diff --git a/src/renderer/src/lib/monaco-languages/register-vue.test.ts b/src/renderer/src/lib/monaco-languages/register-vue.test.ts index 3898dea7a38..8483b1a9713 100644 --- a/src/renderer/src/lib/monaco-languages/register-vue.test.ts +++ b/src/renderer/src/lib/monaco-languages/register-vue.test.ts @@ -1,110 +1,28 @@ import { describe, expect, it, vi } from 'vitest' +import { + endEmbeddedLanguages, + formatTokenizedLines, + tokenizeMonarchDocument, + tokenLanguages, + tokenLanguagesPerLine +} from './monarch-tokenizer-test-harness' import { registerVueLanguage, vueLanguageConfiguration, vueMonarchLanguage } from './register-vue' -type MonarchAction = { - next?: string - nextEmbedded?: string - switchTo?: string -} -type MonarchRule = [RegExp, string | MonarchAction, string?] | { include: string } - -function normalizeState(nextState: string): string { - return nextState.startsWith('@') ? nextState.slice(1) : nextState +// Driven through the real `MonarchTokenizer`: a rule-table walk cannot tell a +// working grammar from one that throws on every `{{ }}`, which is how broken +// Vue highlighting shipped green. +function tokenizeVue(source: string) { + return tokenizeMonarchDocument('vue', vueMonarchLanguage, source) } -function isRuleEntry(rule: MonarchRule): rule is [RegExp, string | MonarchAction, string?] { - return Array.isArray(rule) +/** Which languages actually cover each line — a dropped embed shows up as `vue`. */ +function languagesPerLine(source: string): string[][] { + return tokenLanguagesPerLine(tokenizeVue(source)) } -function getRuleAction(rule: [RegExp, string | MonarchAction, string?]): MonarchAction | undefined { - const [, action, nextStateShortcut] = rule - return typeof action === 'object' - ? action - : nextStateShortcut - ? { next: nextStateShortcut } - : undefined -} - -function findRuleAction(state: string, source: string): MonarchAction | undefined { - const tokenizer = vueMonarchLanguage.tokenizer as Record - const stateRules = tokenizer[state] ?? tokenizer[state.split('.')[0]] - const matchedRule = stateRules.find((rule) => { - if (!isRuleEntry(rule)) { - return false - } - const [regexp] = rule - regexp.lastIndex = 0 - const match = regexp.exec(source) - return match !== null && match.index === 0 - }) - - return matchedRule && isRuleEntry(matchedRule) ? getRuleAction(matchedRule) : undefined -} - -function collectFixtureRuleActions(source: string): { - line: number - state: string - matched: string - nextState?: string - nextEmbedded?: string - switchTo?: string -}[] { - const ruleActions: { - line: number - state: string - matched: string - nextState?: string - nextEmbedded?: string - switchTo?: string - }[] = [] - const tokenizer = vueMonarchLanguage.tokenizer as Record - const lines = source.split('\n') - const checks: { line: number; state: string; pattern: string }[] = [ - { line: 1, state: 'root', pattern: '' }, - { line: 2, state: 'templateBody', pattern: '{{' }, - { line: 2, state: 'templateExpression', pattern: '}}' }, - { line: 3, state: 'templateBody', pattern: '' }, - { line: 5, state: 'root', pattern: '' }, - { line: 7, state: 'scriptBody.typescript', pattern: '' }, - { line: 9, state: 'root', pattern: '' }, - { line: 11, state: 'styleBody.css', pattern: '' } - ] - - checks.forEach((check) => { - const line = lines.at(check.line - 1) ?? '' - const stateRules = tokenizer[check.state] ?? tokenizer[check.state.split('.')[0]] - const matchedRule = stateRules.find((rule) => { - if (!isRuleEntry(rule)) { - return false - } - const [regexp] = rule - regexp.lastIndex = 0 - const match = regexp.exec(line) - return match !== null && match[0] === check.pattern - }) - if (!matchedRule || !isRuleEntry(matchedRule)) { - return - } - - const actionObject = getRuleAction(matchedRule) - - ruleActions.push({ - line: check.line, - state: check.state, - matched: check.pattern, - nextState: actionObject?.next ? normalizeState(actionObject.next) : undefined, - nextEmbedded: actionObject?.nextEmbedded, - switchTo: actionObject?.switchTo ? normalizeState(actionObject.switchTo) : undefined - }) - }) - - return ruleActions -} - -describe('registerVueLanguage', () => { +describe('registerVueLanguage registration', () => { + // Structural by necessity: covers the registration call itself (ids, + // extensions, idempotence), which tokenizing cannot observe. it('registers the vue language, Monarch tokenizer, and configuration once', () => { const languages: { id: string }[] = [{ id: 'typescript' }] const register = vi.fn((entry: { id: string }) => { @@ -136,8 +54,10 @@ describe('registerVueLanguage', () => { expect(setLanguageConfiguration).toHaveBeenCalledTimes(1) expect(setLanguageConfiguration).toHaveBeenCalledWith('vue', vueLanguageConfiguration) }) +}) - it('captures Vue tokenizer transitions for a representative SFC fixture', () => { +describe('vue tokenization', () => { + it('tokenizes a representative SFC', () => { const fixture = ` @@ -150,121 +70,110 @@ const message = 'hello' p { color: rebeccapurple; } ` - const ruleActions = collectFixtureRuleActions(fixture) - - expect(ruleActions).toMatchInlineSnapshot(` + expect(formatTokenizedLines(tokenizeVue(fixture))).toMatchInlineSnapshot(` [ - { - "line": 1, - "matched": "", - "nextEmbedded": "html", - "nextState": undefined, - "state": "templateOpen", - "switchTo": "templateBody", - }, - { - "line": 2, - "matched": "{{", - "nextEmbedded": "@pop", - "nextState": undefined, - "state": "templateBody", - "switchTo": "templateExpressionEnter", - }, - { - "line": 2, - "matched": "}}", - "nextEmbedded": "@pop", - "nextState": undefined, - "state": "templateExpression", - "switchTo": "templateBodyReenter", - }, - { - "line": 3, - "matched": "", - "nextEmbedded": "@pop", - "nextState": "pop", - "state": "templateBody", - "switchTo": undefined, - }, - { - "line": 5, - "matched": "", - "nextEmbedded": "$S2", - "nextState": undefined, - "state": "scriptOpen.typescript", - "switchTo": "scriptBody.$S2", - }, - { - "line": 7, - "matched": "", - "nextEmbedded": "@pop", - "nextState": "pop", - "state": "scriptBody.typescript", - "switchTo": undefined, - }, - { - "line": 9, - "matched": "", - "nextEmbedded": "$S2", - "nextState": undefined, - "state": "styleOpen.css", - "switchTo": "styleBody.$S2", - }, - { - "line": 11, - "matched": "", - "nextEmbedded": "@pop", - "nextState": "pop", - "state": "styleBody.css", - "switchTo": undefined, - }, + " | 0:tag.vue@vue | embed=none", + " | | embed=none", + " | 0:tag.vue@vue | embed=none", + " | | embed=none", + " | 0:tag.vue@vue | embed=none", ] `) }) - it('tracks embedded languages from Vue block attributes', () => { - expect(findRuleAction('templateExpressionEnter', 'message }}')).toMatchObject({ - nextEmbedded: 'typescript', - switchTo: '@templateExpression' - }) - expect(findRuleAction('scriptLangValue.typescript', '"js"')).toMatchObject({ - switchTo: '@scriptOpen.javascript' - }) - expect(findRuleAction('scriptLangValue.javascript', '"ts"')).toMatchObject({ - switchTo: '@scriptOpen.typescript' - }) - expect(findRuleAction('scriptLangValue.typescript', 'js')).toMatchObject({ - switchTo: '@scriptOpen.javascript' - }) - expect(findRuleAction('styleLangValue.css', '"scss"')).toMatchObject({ - switchTo: '@styleOpen.scss' - }) - expect(findRuleAction('styleLangValue.css', 'less')).toMatchObject({ - switchTo: '@styleOpen.less' - }) + // Regression: every `{{ }}` threw "cannot pop embedded language if not inside + // one" once the template body lost its html embed. + it('highlights every interpolation in a template line', () => { + const [, line] = tokenizeVue('') + + expect(tokenLanguages(line)).toEqual([ + 'html', + 'vue', + 'typescript', + 'vue', + 'html', + 'vue', + 'typescript', + 'vue', + 'html' + ]) + }) + + it('embeds the template body as html', () => { + expect(endEmbeddedLanguages(tokenizeVue(''))).toEqual([ + 'html', + 'html', + null + ]) + }) + + it('keeps the template embedded across a comment before it', () => { + expect(languagesPerLine('\n')).toEqual([ + ['vue'], + ['vue'], + ['html'], + ['vue'] + ]) + }) + + it('does not enter typescript for an empty interpolation', () => { + // `{{}}` pops html on entry but never pushes typescript; the close must + // unwind only the state, or it pops an embed that is not there. + const [, line] = tokenizeVue('') + + expect(tokenLanguages(line)).toEqual(['html', 'vue', 'html']) + }) +}) + +describe('vue embedded language attributes', () => { + it.each([ + ['`)).toEqual([ + ['vue'], + [embeddedLanguageId], + ['vue'] + ]) + }) + + it.each([ + ['`)).toEqual([ + ['vue'], + [embeddedLanguageId], + ['vue'] + ]) + }) +}) + +describe('vue root state invariant', () => { + // Structural on purpose: behaviour can only reach the root rules some fixture + // happens to exercise, and a root rule that pops an embed throws on the very + // first character of a file. Guard every root rule, exercised or not. + it('has no root rule that pops an embedded language', () => { + const rootRules = (vueMonarchLanguage.tokenizer as Record).root + const popRules = rootRules.filter( + (rule) => + Array.isArray(rule) && (rule[1] as { nextEmbedded?: string })?.nextEmbedded === '@pop' + ) + + expect(popRules).toEqual([]) }) }) diff --git a/src/renderer/src/lib/worktree-sleep-intent.ts b/src/renderer/src/lib/worktree-sleep-intent.ts index 7beac240803..6512abec692 100644 --- a/src/renderer/src/lib/worktree-sleep-intent.ts +++ b/src/renderer/src/lib/worktree-sleep-intent.ts @@ -1,13 +1,69 @@ +// Why: a slept workspace keeps its panes mounted with only dead PTYs behind them. +// Any pane connect that runs while the marker is set waits here, and the clear +// that marks the workspace awake resumes every waiting connect. const sleepingWorktreeIds = new Set() +const tearingDownWorktreeIds = new Set() +const wakeListenersByWorktreeId = new Map void>>() export function markWorktreeSleepIntent(worktreeId: string): void { sleepingWorktreeIds.add(worktreeId) } -export function clearWorktreeSleepIntent(worktreeId: string): void { +/** + * Why: a spawn that resolves while the sleep teardown is still awaiting its host + * would bind a PTY and clear the marker, waking every waiting pane mid-sleep. + * Binds during the teardown window are not wakes. + */ +export async function withWorktreeSleepTeardown( + worktreeId: string, + teardown: () => Promise +): Promise { + tearingDownWorktreeIds.add(worktreeId) + try { + return await teardown() + } finally { + tearingDownWorktreeIds.delete(worktreeId) + } +} + +export function clearWorktreeSleepIntent(worktreeId: string | null): void { + if (!worktreeId || tearingDownWorktreeIds.has(worktreeId)) { + return + } + if (!sleepingWorktreeIds.delete(worktreeId)) { + return + } + const listeners = wakeListenersByWorktreeId.get(worktreeId) + wakeListenersByWorktreeId.delete(worktreeId) + for (const listener of listeners ?? []) { + try { + listener() + } catch (error) { + // Why: one pane's connect failure must not strand its siblings or throw out of a store action. + console.error('[sleep-intent] wake listener failed', { worktreeId, error }) + } + } +} + +// Why: a purged worktree must not wake its panes; they are being unmounted. +export function forgetWorktreeSleepIntent(worktreeId: string): void { sleepingWorktreeIds.delete(worktreeId) + tearingDownWorktreeIds.delete(worktreeId) + wakeListenersByWorktreeId.delete(worktreeId) } export function hasWorktreeSleepIntent(worktreeId: string | null): boolean { return worktreeId !== null && sleepingWorktreeIds.has(worktreeId) } + +export function onWorktreeSleepIntentCleared(worktreeId: string, listener: () => void): () => void { + const listeners = wakeListenersByWorktreeId.get(worktreeId) ?? new Set<() => void>() + listeners.add(listener) + wakeListenersByWorktreeId.set(worktreeId, listeners) + return () => { + listeners.delete(listener) + if (listeners.size === 0 && wakeListenersByWorktreeId.get(worktreeId) === listeners) { + wakeListenersByWorktreeId.delete(worktreeId) + } + } +} diff --git a/src/renderer/src/runtime/runtime-host-connection-state.test.ts b/src/renderer/src/runtime/runtime-host-connection-state.test.ts index 8db0c0eb010..30ee06a5e98 100644 --- a/src/renderer/src/runtime/runtime-host-connection-state.test.ts +++ b/src/renderer/src/runtime/runtime-host-connection-state.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import type { RuntimeStatus } from '../../../shared/runtime-types' import { isConnectedRuntimeHostState, + isDisconnectedRuntimeHostState, runtimeHostConnectionState, + runtimeHostConnectionStateForEntry, runtimeStatusForOverall } from './runtime-host-connection-state' @@ -177,3 +179,72 @@ describe('runtime host connection state', () => { ).toBe('disconnected') }) }) + +describe('runtime host connection state for a recorded status entry', () => { + it('separates a host that was never probed from one a probe found unreachable', () => { + // The sidebar read raw truthiness, which collapsed these two into the same red glyph. + expect(runtimeHostConnectionStateForEntry(undefined)).toBe('checking') + expect(runtimeHostConnectionStateForEntry({ status: null })).toBe('disconnected') + }) + + it('reads the remote-control diagnostics recorded beside a failed probe', () => { + expect( + runtimeHostConnectionStateForEntry({ + status: null, + remoteControl: remoteControl('reconnecting') + }) + ).toBe('reconnecting') + }) + + it('agrees with the status bar that a closed control channel is disconnected', () => { + expect( + runtimeHostConnectionStateForEntry({ + status: makeStatus({ remoteControl: remoteControl('closed') }) + }) + ).toBe('disconnected') + }) + + it('names only the disconnected verdict as disconnected', () => { + expect(isDisconnectedRuntimeHostState('disconnected')).toBe(true) + for (const state of [ + 'connected', + 'checking', + 'reconnecting', + 'runtime-unavailable', + 'workspace-window-closed' + ] as const) { + expect(isDisconnectedRuntimeHostState(state)).toBe(false) + } + }) +}) + +function remoteControl( + state: NonNullable['state'] +): NonNullable { + return { + state, + pendingRequestCount: 0, + subscriptionCount: 0, + reconnectAttempt: 1, + lastConnectedAt: null, + lastClose: null, + lastError: null + } +} + +it('does not report reconnecting after verification is terminally blocked', () => { + expect( + runtimeHostConnectionStateForEntry({ + status: null, + snapshot: { + environmentId: 'browser', + pairingRevision: 1, + sequence: 1, + checkedAt: 1, + status: null, + verification: 'blocked', + transport: 'disconnected' + } + }) + ).toBe('disconnected') +}) diff --git a/src/renderer/src/runtime/runtime-host-connection-state.ts b/src/renderer/src/runtime/runtime-host-connection-state.ts index 6094c995804..c10477415a1 100644 --- a/src/renderer/src/runtime/runtime-host-connection-state.ts +++ b/src/renderer/src/runtime/runtime-host-connection-state.ts @@ -1,3 +1,4 @@ +import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status' import type { RuntimeStatus } from '../../../shared/runtime-types' import { isRuntimeWorkspaceWindowClosed } from '../../../shared/runtime-workspace-window-availability' @@ -97,3 +98,44 @@ export function isConnectedRuntimeHostState(state: RuntimeHostConnectionState): state === 'connected' || state === 'runtime-unavailable' || state === 'workspace-window-closed' ) } + +/** + * Only this verdict earns the destructive glyph. 'checking' and 'reconnecting' are + * unverifiable, not down, per docs/reference/ssh-execution-boundary.md. + */ +export function isDisconnectedRuntimeHostState(state: RuntimeHostConnectionState): boolean { + return state === 'disconnected' +} + +/** The same derivation, read straight off a recorded status entry. */ +export function runtimeHostConnectionStateForEntry( + entry: + | { + status: RuntimeStatus | null + remoteControl?: RuntimeStatus['remoteControl'] | null + snapshot?: RuntimeHostStatusSnapshot + } + | null + | undefined +): RuntimeHostConnectionState { + if (entry?.snapshot) { + const snapshot = entry.snapshot + if (snapshot.retired || snapshot.verification === 'blocked') { + return 'disconnected' + } + if (snapshot.transport === 'disconnected') { + return 'reconnecting' + } + if (snapshot.verification === 'checking' && !entry.status) { + return 'checking' + } + if (snapshot.transport === 'ready' && snapshot.verification !== 'verified') { + return 'runtime-unavailable' + } + } + return runtimeHostConnectionState({ + hasStatusEntry: Boolean(entry), + status: entry?.status ?? null, + remoteControl: entry?.remoteControl ?? entry?.status?.remoteControl ?? null + }) +} diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts b/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts deleted file mode 100644 index 22ba23cbbb1..00000000000 --- a/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' -import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' -import type { RuntimeEnvironmentStatus } from './runtime-status' - -const diagnosticsGenerationByEnvironment = new Map() - -export function updateRuntimeEnvironmentStatusOverlay( - state: Map, - environmentId: string, - status: RuntimeEnvironmentStatus -): Map { - const current = state.get(environmentId) - if (!current || current.status?.runtimeId !== status.status?.runtimeId) { - return state - } - return new Map(state).set(environmentId, status) -} - -export function acceptRuntimeEnvironmentDiagnosticsGeneration( - environmentId: string, - transportGeneration: number -): boolean { - const previous = diagnosticsGenerationByEnvironment.get(environmentId) - if (previous !== undefined && transportGeneration < previous) { - return false - } - diagnosticsGenerationByEnvironment.set(environmentId, transportGeneration) - return true -} - -export function clearRuntimeEnvironmentDiagnosticsGenerationsForTests(): void { - diagnosticsGenerationByEnvironment.clear() -} - -export function mergePushedRuntimeEnvironmentDiagnostics(args: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - current: RuntimeEnvironmentStatus | undefined - publish: (status: RuntimeEnvironmentStatus) => void -}): void { - if ( - !args.current?.status?.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) || - !acceptRuntimeEnvironmentDiagnosticsGeneration(args.environmentId, args.transportGeneration) - ) { - return - } - args.publish({ - ...args.current, - status: { ...args.current.status, remoteControl: args.diagnostics } - }) -} diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts b/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts deleted file mode 100644 index e51c8621bae..00000000000 --- a/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' -import type { AppState } from '../types' -import type { RuntimeEnvironmentStatus } from './runtime-status' -import * as diagnosticsGeneration from './runtime-status-diagnostics-generation' -import * as runtimeStatusRecheck from './runtime-status-recheck' - -export function updateRuntimeStatusStore( - state: AppState, - updater: (state: Map) => Map -): AppState | Pick { - const next = updater(state.runtimeStatusByEnvironmentId) - return next === state.runtimeStatusByEnvironmentId - ? state - : { runtimeStatusByEnvironmentId: next } -} - -export function publishRuntimeEnvironmentDiagnostics(args: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - getCurrent: () => RuntimeEnvironmentStatus | undefined - updateState: (status: RuntimeEnvironmentStatus) => boolean - afterPublish?: (status: RuntimeEnvironmentStatus) => void -}): void { - diagnosticsGeneration.mergePushedRuntimeEnvironmentDiagnostics({ - environmentId: args.environmentId, - transportGeneration: args.transportGeneration, - diagnostics: args.diagnostics, - current: args.getCurrent(), - publish: (status) => { - if (args.updateState(status)) { - args.afterPublish?.(status) - } - } - }) -} - -export function applyRuntimeEnvironmentStatusOverlay(args: { - environmentId: string - status: RuntimeEnvironmentStatus - setState: ( - updater: (state: Map) => Map - ) => void -}): boolean { - let updated = false - args.setState((state) => { - const next = diagnosticsGeneration.updateRuntimeEnvironmentStatusOverlay( - state, - args.environmentId, - args.status - ) - updated = next !== state - return next - }) - return updated -} - -export function createRuntimeEnvironmentDiagnosticsPublisher(args: { - getCurrent: (environmentId: string) => RuntimeEnvironmentStatus | undefined - setState: ( - updater: (state: Map) => Map - ) => void - afterPublish: (environmentId: string, status: RuntimeEnvironmentStatus) => void -}): (event: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics -}) => void { - return (event) => - publishRuntimeEnvironmentDiagnostics({ - ...event, - getCurrent: () => args.getCurrent(event.environmentId), - updateState: (status) => - applyRuntimeEnvironmentStatusOverlay({ - environmentId: event.environmentId, - status, - setState: args.setState - }), - afterPublish: (status) => args.afterPublish(event.environmentId, status) - }) -} - -export function createRuntimeEnvironmentDiagnosticsSlicePublisher(args: { - getCurrent: (environmentId: string) => RuntimeEnvironmentStatus | undefined - setState: ( - updater: (state: Map) => Map - ) => void - getStore: () => AppState - getConnectionGeneration: (environmentId: string) => number -}): (event: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics -}) => void { - return createRuntimeEnvironmentDiagnosticsPublisher({ - getCurrent: args.getCurrent, - setState: args.setState, - afterPublish: (environmentId, status) => - runtimeStatusRecheck.reconcileRuntimeStatusForSlice( - environmentId, - status.status, - args.getStore, - () => args.getConnectionGeneration(environmentId) - ) - }) -} diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts b/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts deleted file mode 100644 index 9faecdcabf1..00000000000 --- a/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { create } from 'zustand' -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' -import type { RuntimeStatus } from '../../../../shared/runtime-types' -import { createRuntimeStatusSlice, type RuntimeStatusSlice } from './runtime-status' - -function makeStatus(overrides: Partial = {}): RuntimeStatus { - return { - runtimeId: 'runtime-a', - rendererGraphEpoch: 0, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 3, - liveLeafCount: 0, - runtimeProtocolVersion: 3, - minCompatibleRuntimeClientVersion: 3, - capabilities: ['browser.screencast.v1'], - ...overrides - } as RuntimeStatus -} - -function createSliceStore() { - return create()((...a) => ({ - ...createRuntimeStatusSlice(...(a as unknown as Parameters)) - })) -} - -describe('runtime-status diagnostics', () => { - it('merges transport diagnostics into the complete status and fences stale pushes', () => { - const store = createSliceStore() - const status = makeStatus({ - capabilities: ['browser.screencast.v1', REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] - }) - store.getState().setRuntimeEnvironmentStatus('env-a', { status, checkedAt: 1 }) - const closed = { - state: 'closed' as const, - pendingRequestCount: 0, - subscriptionCount: 1, - reconnectAttempt: 2, - lastConnectedAt: 1, - lastClose: { code: 1006, reason: 'network' }, - lastError: 'connection lost' - } - store.getState().publishRuntimeEnvironmentDiagnostics({ - environmentId: 'env-a', - transportGeneration: 3, - diagnostics: closed - }) - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toMatchObject({ - runtimeId: 'runtime-a', - capabilities: expect.arrayContaining([ - 'browser.screencast.v1', - REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY - ]), - liveTabCount: 3, - remoteControl: closed - }) - store.getState().publishRuntimeEnvironmentDiagnostics({ - environmentId: 'env-a', - transportGeneration: 2, - diagnostics: { ...closed, state: 'ready' } - }) - expect( - store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.remoteControl?.state - ).toBe('closed') - }) - - it('ignores diagnostics after the latest status drops shared-control support', () => { - const store = createSliceStore() - const status = makeStatus({ capabilities: [] }) - store.getState().setRuntimeEnvironmentStatus('env-a', { status, checkedAt: 1 }) - - store.getState().publishRuntimeEnvironmentDiagnostics({ - environmentId: 'env-a', - transportGeneration: 3, - diagnostics: { - state: 'reconnecting', - pendingRequestCount: 0, - subscriptionCount: 1, - reconnectAttempt: 2, - lastConnectedAt: 1, - lastClose: { code: 1006, reason: 'network' }, - lastError: 'connection lost' - } - }) - - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe(status) - }) -}) diff --git a/src/renderer/src/store/slices/runtime-status-recheck.test.ts b/src/renderer/src/store/slices/runtime-status-recheck.test.ts deleted file mode 100644 index 9f09580c390..00000000000 --- a/src/renderer/src/store/slices/runtime-status-recheck.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { create } from 'zustand' -import { toast } from 'sonner' -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' -import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' -import type { RuntimeStatus } from '../../../../shared/runtime-types' -import { - clearRuntimeEnvironmentConnectionGenerationsForTests, - createRuntimeStatusSlice, - setRuntimeEnvironmentConnectionGenerationForTests, - type RuntimeStatusSlice -} from './runtime-status' -import { clearRuntimeStatusRechecksForTests } from './runtime-status-recheck' - -vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } })) - -beforeEach(() => { - vi.useFakeTimers() - clearRuntimeStatusRechecksForTests() - clearRuntimeEnvironmentConnectionGenerationsForTests() - vi.mocked(toast.warning).mockReset() -}) - -afterEach(() => { - clearRuntimeStatusRechecksForTests() - vi.useRealTimers() - vi.unstubAllGlobals() -}) - -describe('runtime status recheck', () => { - it('publishes an observe-only ready result through the setter', async () => { - const getStatus = vi.fn().mockResolvedValue(response(status('ready'))) - const store = createStore(getStatus) - - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - await vi.advanceTimersByTimeAsync(3_000) - - expect(getStatus).toHaveBeenCalledWith({ - selector: 'env-a', - timeoutMs: 10_000, - observeOnly: true - }) - expect( - store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.remoteControl - ).toMatchObject({ - state: 'ready' - }) - await vi.advanceTimersByTimeAsync(120_000) - expect(getStatus).toHaveBeenCalledOnce() - }) - - it('continues indefinitely on the capped ladder, including unchanged publishes', async () => { - const getStatus = vi.fn().mockResolvedValue(response(status('reconnecting'))) - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('reconnecting'), - checkedAt: 1 - }) - - await vi.advanceTimersByTimeAsync(3_000 + 6_000 + 12_000 + 30_000 + 60_000 + 60_000) - - expect(getStatus).toHaveBeenCalledTimes(6) - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.checkedAt).toBe(1) - }) - - it('cancels on removal, capability loss, and null without probing again', async () => { - const getStatus = vi.fn() - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_authenticated'), - checkedAt: 1 - }) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: { ...status('awaiting_authenticated'), capabilities: [] }, - checkedAt: 2 - }) - await vi.advanceTimersByTimeAsync(60_000) - expect(getStatus).not.toHaveBeenCalled() - - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 3 - }) - store.getState().setRuntimeEnvironments([]) - await vi.advanceTimersByTimeAsync(60_000) - expect(getStatus).not.toHaveBeenCalled() - }) - - it('cancels an armed probe when the connection generation changes', async () => { - const getStatus = vi.fn() - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - - setRuntimeEnvironmentConnectionGenerationForTests('env-a', 2) - await vi.advanceTimersByTimeAsync(3_000) - - expect(getStatus).not.toHaveBeenCalled() - }) - - it('restarts the ladder for a newly published connection generation', async () => { - const getStatus = vi.fn().mockResolvedValue(response(status('ready', 'rt-next'))) - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready', 'rt-next'), - checkedAt: 2 - }) - - await vi.advanceTimersByTimeAsync(3_000) - - expect(getStatus).toHaveBeenCalledOnce() - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.runtimeId).toBe( - 'rt-next' - ) - }) - - it('discards an in-flight result after a ready publish bumps the epoch', async () => { - const pending = deferred>() - const getStatus = vi.fn().mockReturnValue(pending.promise) - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - await vi.advanceTimersByTimeAsync(3_000) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('ready'), - checkedAt: 2 - }) - - pending.resolve(response(status('reconnecting'))) - await Promise.resolve() - await Promise.resolve() - - expect( - store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.remoteControl - ).toMatchObject({ - state: 'ready' - }) - }) - - it('keeps setter side effects when a recheck discovers disconnection', async () => { - const getStatus = vi.fn().mockResolvedValue({ - id: 'status.get', - ok: false, - error: { - code: 'runtime_unavailable', - message: 'offline', - data: { remoteControl: status('reconnecting').remoteControl } - }, - _meta: { runtimeId: 'rt' } - }) - const store = createStore(getStatus) - store.getState().setRuntimeEnvironmentStatus('env-a', { - status: status('awaiting_ready'), - checkedAt: 1 - }) - - await vi.advanceTimersByTimeAsync(3_000) - - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBeNull() - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.remoteControl).toMatchObject( - { - state: 'reconnecting' - } - ) - expect(toast.warning).toHaveBeenCalledOnce() - }) -}) - -function createStore(getStatus: ReturnType) { - vi.stubGlobal('window', { - api: { runtimeEnvironments: { getStatus, list: vi.fn() } } - }) - const store = create()((...args) => ({ - ...createRuntimeStatusSlice(...(args as unknown as Parameters)) - })) - store.getState().setRuntimeEnvironments([environment()]) - return store -} - -function status( - controlState: NonNullable['state'], - runtimeId = 'rt' -): RuntimeStatus { - return { - runtimeId, - rendererGraphEpoch: 1, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 0, - liveLeafCount: 0, - capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY], - remoteControl: { - state: controlState, - pendingRequestCount: 0, - subscriptionCount: 0, - reconnectAttempt: 1, - lastConnectedAt: null, - lastClose: null, - lastError: null - } - } as RuntimeStatus -} - -function response(result: RuntimeStatus) { - return { id: 'status.get', ok: true as const, result, _meta: { runtimeId: result.runtimeId } } -} - -function environment(): PublicKnownRuntimeEnvironment { - return { - id: 'env-a', - name: 'Dev Box', - createdAt: 1, - updatedAt: 1, - lastUsedAt: null, - runtimeId: 'rt', - endpoints: [{ id: 'ws', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://x' }], - preferredEndpointId: 'ws' - } -} - -function deferred() { - let resolve: (value: T) => void = () => {} - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} diff --git a/src/renderer/src/store/slices/runtime-status-recheck.ts b/src/renderer/src/store/slices/runtime-status-recheck.ts deleted file mode 100644 index 303e5b4318d..00000000000 --- a/src/renderer/src/store/slices/runtime-status-recheck.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' -import type { RuntimeStatus } from '../../../../shared/runtime-types' -import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' -import { extractRuntimeTransportDiagnostics } from '@/runtime/runtime-status-probe-diagnostics' -import type { RuntimeEnvironmentStatus } from './runtime-status' - -const RECHECK_DELAYS_MS = [3_000, 6_000, 12_000, 30_000, 60_000] - -type RecheckState = { - epoch: number - attempt: number - timer: ReturnType | null - inFlight: boolean - connectionGeneration: number - environmentExists: () => boolean - getConnectionGeneration: () => number - publish: (status: RuntimeEnvironmentStatus) => void -} - -type RuntimeStatusStore = { - runtimeEnvironments: readonly { id: string }[] - setRuntimeEnvironmentStatus: (environmentId: string, status: RuntimeEnvironmentStatus) => void -} - -const rechecks = new Map() - -export function reconcileRuntimeStatusRecheck(args: { - environmentId: string - status: RuntimeStatus | null - connectionGeneration: number - environmentExists: () => boolean - getConnectionGeneration: () => number - publish: (status: RuntimeEnvironmentStatus) => void -}): void { - if (!shouldRecheck(args.status)) { - cancelRuntimeStatusRecheck(args.environmentId) - return - } - let state = rechecks.get(args.environmentId) - if (state && state.connectionGeneration !== args.connectionGeneration) { - cancelRuntimeStatusRecheck(args.environmentId) - state = undefined - } - if (!state) { - state = { - epoch: 0, - attempt: 0, - timer: null, - inFlight: false, - connectionGeneration: args.connectionGeneration, - environmentExists: args.environmentExists, - getConnectionGeneration: args.getConnectionGeneration, - publish: args.publish - } - rechecks.set(args.environmentId, state) - } else { - state.connectionGeneration = args.connectionGeneration - state.environmentExists = args.environmentExists - state.getConnectionGeneration = args.getConnectionGeneration - state.publish = args.publish - } - armRuntimeStatusRecheck(args.environmentId, state) -} - -export function reconcileRuntimeStatusForSlice( - environmentId: string, - status: RuntimeStatus | null, - get: () => RuntimeStatusStore, - getConnectionGeneration: () => number -): void { - reconcileRuntimeStatusRecheck({ - environmentId, - status, - connectionGeneration: getConnectionGeneration(), - environmentExists: () => - get().runtimeEnvironments.some((environment) => environment.id === environmentId), - getConnectionGeneration, - publish: (nextStatus) => get().setRuntimeEnvironmentStatus(environmentId, nextStatus) - }) -} - -export function cancelRuntimeStatusRecheck(environmentId: string): void { - const state = rechecks.get(environmentId) - if (!state) { - return - } - state.epoch += 1 - if (state.timer) { - clearTimeout(state.timer) - } - rechecks.delete(environmentId) -} - -export function cancelRuntimeStatusRechecks(environmentIds: Iterable): void { - for (const environmentId of environmentIds) { - cancelRuntimeStatusRecheck(environmentId) - } -} - -export function clearRuntimeStatusRechecksForTests(): void { - cancelRuntimeStatusRechecks([...rechecks.keys()]) -} - -function shouldRecheck(status: RuntimeStatus | null): boolean { - return Boolean( - status?.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) && - status.remoteControl && - status.remoteControl.state !== 'ready' - ) -} - -function armRuntimeStatusRecheck(environmentId: string, state: RecheckState): void { - if (state.timer || state.inFlight) { - return - } - const delay = RECHECK_DELAYS_MS[Math.min(state.attempt, RECHECK_DELAYS_MS.length - 1)] - const generation = state.connectionGeneration - state.attempt += 1 - state.timer = setTimeout( - () => void fireRuntimeStatusRecheck(environmentId, state, generation), - delay - ) -} - -async function fireRuntimeStatusRecheck( - environmentId: string, - state: RecheckState, - generation: number -): Promise { - state.timer = null - const epoch = state.epoch - if ( - rechecks.get(environmentId) !== state || - !state.environmentExists() || - state.getConnectionGeneration() !== generation - ) { - cancelRuntimeStatusRecheck(environmentId) - return - } - state.inFlight = true - let nextEntry: RuntimeEnvironmentStatus - try { - const response = await window.api.runtimeEnvironments.getStatus({ - selector: environmentId, - timeoutMs: 10_000, - observeOnly: true - }) - nextEntry = { status: unwrapRuntimeRpcResult(response), checkedAt: Date.now() } - } catch (error: unknown) { - const remoteControl = extractRuntimeTransportDiagnostics(error) - nextEntry = { - status: null, - ...(remoteControl ? { remoteControl } : {}), - checkedAt: Date.now() - } - } - state.inFlight = false - if ( - rechecks.get(environmentId) !== state || - state.epoch !== epoch || - !state.environmentExists() || - state.getConnectionGeneration() !== generation - ) { - return - } - state.publish(nextEntry) -} diff --git a/src/renderer/src/store/slices/runtime-status-refresh.ts b/src/renderer/src/store/slices/runtime-status-refresh.ts index 638a3fae636..fb27eec6a43 100644 --- a/src/renderer/src/store/slices/runtime-status-refresh.ts +++ b/src/renderer/src/store/slices/runtime-status-refresh.ts @@ -15,6 +15,22 @@ export async function refreshRuntimeEnvironmentStatus( selector: environmentId, timeoutMs }) + if (window.api.runtimeEnvironments.getStatusSnapshots) { + try { + const snapshots = await window.api.runtimeEnvironments.getStatusSnapshots() + const snapshot = snapshots.find((entry) => entry.environmentId === environmentId) + if (snapshot) { + publish({ + snapshot, + status: snapshot.verification === 'verified' ? snapshot.status : null, + checkedAt: snapshot.checkedAt + }) + } + } catch (error) { + console.error('Failed to read runtime host status snapshot:', error) + } + return response.ok + } const status = unwrapRuntimeRpcResult(response) if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentRevision) { return false diff --git a/src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts b/src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts index 0ba4d645026..d4b788cd49a 100644 --- a/src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts +++ b/src/renderer/src/store/slices/runtime-status-restored-browser-host-attach.test.ts @@ -73,14 +73,10 @@ describe('restored client-hosted browser host attach on reachability', () => { }) }) - // The reconnect policy suppresses the *failure* publish only. A probe that answered still owes - // both recovery follow-ups, or a restored client-hosted page never comes back after the gap. - it('runs both recovery follow-ups on a success when the caller opted out of publishing failures', async () => { + it('runs both recovery follow-ups after a successful refresh', async () => { stubApi(vi.fn().mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-a'))) - await storeWithRestoredHandles(true) - .getState() - .refreshRuntimeEnvironmentStatus('env-a', undefined, { publishUnreachable: false }) + await storeWithRestoredHandles(true).getState().refreshRuntimeEnvironmentStatus('env-a') expect(prepareBrowserClientHostPlacement).toHaveBeenCalledWith({ selector: 'env-a', @@ -89,24 +85,12 @@ describe('restored client-hosted browser host attach on reachability', () => { expect(replayClientHostedBrowserCloseIntents).toHaveBeenCalledWith('env-a', expect.anything()) }) - // Under either policy a failed probe owes *no* follow-ups: it verified nothing, so there is no - // recovered host to reattach restored pages to and no one to replay closes at. - it.each([ - { name: 'the default policy', options: undefined }, - { name: 'a caller that opted out of publishing', options: { publishUnreachable: false } } - ])( - 'starts no browser client host when the environment is unreachable: $name', - async (scenario) => { - stubApi(vi.fn().mockRejectedValue(new Error('unreachable'))) - - await storeWithRestoredHandles(true) - .getState() - .refreshRuntimeEnvironmentStatus('env-a', undefined, scenario.options) - - expect(prepareBrowserClientHostPlacement).not.toHaveBeenCalled() - expect(replayClientHostedBrowserCloseIntents).not.toHaveBeenCalled() - } - ) + it('runs no recovery follow-ups when the environment is unreachable', async () => { + stubApi(vi.fn().mockRejectedValue(new Error('unreachable'))) + await storeWithRestoredHandles(true).getState().refreshRuntimeEnvironmentStatus('env-a') + expect(prepareBrowserClientHostPlacement).not.toHaveBeenCalled() + expect(replayClientHostedBrowserCloseIntents).not.toHaveBeenCalled() + }) it('starts no browser client host for restored pages the server hosts', async () => { stubApi(vi.fn().mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-a'))) diff --git a/src/renderer/src/store/slices/runtime-status-snapshot.test.ts b/src/renderer/src/store/slices/runtime-status-snapshot.test.ts new file mode 100644 index 00000000000..fa437065b28 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-snapshot.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { toast } from 'sonner' +import { + createRuntimeStatusSlice, + clearRuntimeEnvironmentConnectionGenerationsForTests, + type RuntimeStatusSlice +} from './runtime-status' +import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import { runtimeHostConnectionStateForEntry } from '@/runtime/runtime-host-connection-state' + +vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } })) +vi.mock('@/runtime/restored-client-hosted-browser-host-attach', () => ({ + ensureBrowserClientHostsForRestoredPages: vi.fn(), + ensureBrowserClientHostForRestartedRuntime: vi.fn() +})) +vi.mock('@/runtime/client-hosted-browser-close-intent-replay', () => ({ + replayClientHostedBrowserCloseIntents: vi.fn() +})) + +beforeEach(() => { + clearRuntimeEnvironmentConnectionGenerationsForTests() + vi.clearAllMocks() +}) +const environment = { + id: 'env-a', + name: 'Host', + createdAt: 1, + pairingRevision: 1, + endpoints: [], + preferredEndpointId: '' +} as unknown as PublicKnownRuntimeEnvironment +function store() { + const value = create()((...args) => + createRuntimeStatusSlice(...(args as unknown as Parameters)) + ) + value.getState().setRuntimeEnvironments([environment]) + return value +} +function snapshot( + sequence: number, + patch: Partial = {} +): RuntimeHostStatusSnapshot { + return { + environmentId: 'env-a', + pairingRevision: 1, + sequence, + checkedAt: sequence, + transport: 'ready', + verification: 'verified', + status: { runtimeId: 'rt-1' } as RuntimeStatus, + ...patch + } +} + +it('hydrates both viewers and rejects an older read after a newer publication', () => { + for (const viewer of [store(), store()]) { + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(2)) + viewer + .getState() + .applyRuntimeHostStatusSnapshot(snapshot(1, { status: null, verification: 'unavailable' })) + expect(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.runtimeId).toBe( + 'rt-1' + ) + } +}) + +it('represents failed verification honestly without manufacturing a session restart or toast', () => { + const viewer = store() + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(1)) + const generation = viewer + .getState() + .runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(2, { verification: 'unavailable' })) + expect( + runtimeHostConnectionStateForEntry(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')) + ).toBe('runtime-unavailable') + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(3)) + expect(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration).toBe( + generation + ) + expect(toast.warning).not.toHaveBeenCalled() + viewer + .getState() + .applyRuntimeHostStatusSnapshot(snapshot(4, { status: { runtimeId: 'rt-2' } as RuntimeStatus })) + expect( + viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration + ).toBeGreaterThan(generation ?? 0) +}) + +it('retains disconnect ordering and rejects publications for removed or replaced pairings', () => { + const viewer = store() + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(1)) + viewer + .getState() + .applyRuntimeHostStatusSnapshot( + snapshot(3, { retired: true, verification: 'blocked', transport: 'disconnected' }) + ) + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(2)) + expect( + runtimeHostConnectionStateForEntry(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')) + ).toBe('disconnected') + viewer.getState().setRuntimeEnvironments([{ ...environment, pairingRevision: 2 }]) + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(4)) + expect(viewer.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) + viewer.getState().setRuntimeEnvironments([]) + viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(5, { pairingRevision: 2 })) + expect(viewer.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) +}) diff --git a/src/renderer/src/store/slices/runtime-status-snapshot.ts b/src/renderer/src/store/slices/runtime-status-snapshot.ts new file mode 100644 index 00000000000..b3543471d29 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-snapshot.ts @@ -0,0 +1,43 @@ +import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' +import type { AppState } from '../types' +import type { RuntimeEnvironmentStatus } from './runtime-status-types' +import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' +import { replayClientHostedBrowserCloseIntents } from '@/runtime/client-hosted-browser-close-intent-replay' + +export function applyRuntimeHostStatusSnapshot( + snapshot: RuntimeHostStatusSnapshot, + state: AppState, + publishEvidence: (entry: RuntimeEnvironmentStatus) => void +): void { + const environment = state.runtimeEnvironments.find((entry) => entry.id === snapshot.environmentId) + if ( + !environment || + (environment.pairingRevision ?? environment.createdAt) !== snapshot.pairingRevision + ) { + return + } + const previous = state.runtimeStatusByEnvironmentId.get(snapshot.environmentId) + if (previous?.snapshot && previous.snapshot.sequence >= snapshot.sequence) { + return + } + const entry: RuntimeEnvironmentStatus = { + snapshot, + checkedAt: snapshot.checkedAt, + connectionGeneration: previous?.connectionGeneration, + status: snapshot.verification === 'verified' && !snapshot.retired ? snapshot.status : null, + remoteControl: snapshot.remoteControl + } + if (entry.status) { + if (snapshot.remoteControl) { + entry.status = { ...entry.status, remoteControl: snapshot.remoteControl } + } + state.setRuntimeEnvironmentStatus(snapshot.environmentId, entry) + if (previous?.status == null) { + void ensureBrowserClientHostsForRestoredPages(state) + void replayClientHostedBrowserCloseIntents(snapshot.environmentId, state) + } + } else { + // Lost contact or a failed method observes no runtime session ending. + publishEvidence(entry) + } +} diff --git a/src/renderer/src/store/slices/runtime-status-types.ts b/src/renderer/src/store/slices/runtime-status-types.ts index c34feaf07bf..78e47123cf5 100644 --- a/src/renderer/src/store/slices/runtime-status-types.ts +++ b/src/renderer/src/store/slices/runtime-status-types.ts @@ -1,8 +1,9 @@ +import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import type { RuntimeStatus } from '../../../../shared/runtime-types' -import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' export type RuntimeEnvironmentStatus = { + snapshot?: RuntimeHostStatusSnapshot status: RuntimeStatus | null remoteControl?: RuntimeStatus['remoteControl'] | null appVersion?: string | null @@ -10,11 +11,9 @@ export type RuntimeEnvironmentStatus = { connectionGeneration?: number } -export type RuntimeStatusRefreshOptions = { - publishUnreachable?: boolean -} - export type RuntimeStatusSlice = { + readRuntimeHostStatusSnapshots: () => Promise + applyRuntimeHostStatusSnapshot: (snapshot: RuntimeHostStatusSnapshot) => void runtimeEnvironments: readonly PublicKnownRuntimeEnvironment[] runtimeEnvironmentCatalogHydrated: boolean runtimeEnvironmentCatalogSettled: boolean @@ -26,17 +25,8 @@ export type RuntimeStatusSlice = { status: RuntimeEnvironmentStatus, options?: { suppressDisconnectToast?: boolean } ) => void - publishRuntimeEnvironmentDiagnostics: (args: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - }) => void clearRuntimeEnvironmentStatus: (environmentId: string) => void retainRuntimeEnvironmentStatuses: (environmentIds: Iterable) => void - refreshRuntimeEnvironmentStatus: ( - environmentId: string, - timeoutMs?: number, - options?: RuntimeStatusRefreshOptions - ) => Promise + refreshRuntimeEnvironmentStatus: (environmentId: string, timeoutMs?: number) => Promise hydrateRuntimeEnvironmentStatuses: () => Promise } diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts index 8e3260957bf..fdc843c748a 100644 --- a/src/renderer/src/store/slices/runtime-status.test.ts +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -710,33 +710,37 @@ describe('runtime-status slice', () => { clearRuntimeCompatibilityCacheForTests() }) - // Both directions of the failure-publication policy, from one failing probe. A user-initiated - // check publishes the outage it just observed; a caller holding live transport evidence must - // not, because status.get dials its own socket and its failure is unverifiable, not exited. - it.each([ - { name: 'a user-initiated check', options: undefined, publishes: true }, - { name: 'publishUnreachable defaulted', options: {}, publishes: true }, - { - name: 'a caller that opted out of publishing', - options: { publishUnreachable: false }, - publishes: false - } - ])('records null and returns false when a runtime refresh fails: $name', async (scenario) => { + it('records null and returns false when a runtime refresh fails', async () => { const getStatus = vi.fn().mockRejectedValue(new Error('closed')) stubRuntimeEnvironmentApi({ getStatus }) const store = createSliceStore() const cached = makeStatus() store.getState().setRuntimeEnvironmentStatus('env-a', { status: cached, checkedAt: 1 }) - const reachable = await store - .getState() - .refreshRuntimeEnvironmentStatus('env-a', undefined, scenario.options) + const reachable = await store.getState().refreshRuntimeEnvironmentStatus('env-a') - // The dial-answered contract the bridge's bounded retry chain reads is policy-independent. expect(reachable).toBe(false) - expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe( - scenario.publishes ? null : cached - ) + expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe(null) + }) + + it('preserves successful reachability when reading its snapshot fails', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + getStatus: vi.fn().mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-a')), + getStatusSnapshots: vi.fn().mockRejectedValue(new Error('IPC read failed')) + } + } + }) + try { + const store = createSliceStore() + expect(await store.getState().refreshRuntimeEnvironmentStatus('env-a')).toBe(true) + expect(store.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) + expect(log).toHaveBeenCalled() + } finally { + log.mockRestore() + } }) it('hydrates saved environments through the single-environment refresh path', async () => { diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 10985f389d8..c11fba3308a 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -1,11 +1,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { RuntimeStatusSlice } from './runtime-status-types' -export type { - RuntimeEnvironmentStatus, - RuntimeStatusRefreshOptions, - RuntimeStatusSlice -} from './runtime-status-types' +export type { RuntimeEnvironmentStatus, RuntimeStatusSlice } from './runtime-status-types' import { runtimeEnvironmentStatusesEqual } from './runtime-environment-status-equality' import { clearRecentRuntimeCompatibilityFailure, @@ -20,21 +16,16 @@ import { import { reconcileCatalogRows } from './repo-identity-reconcile' import { createRuntimeStatusHydration } from './runtime-status-hydration' import { refreshRuntimeEnvironmentStatus } from './runtime-status-refresh' -import * as runtimeStatusDiagnostics from './runtime-status-diagnostics-generation' import * as runtimeStatusConnectionGeneration from './runtime-status-connection-generation' import { replayClientHostedBrowserCloseIntents } from '@/runtime/client-hosted-browser-close-intent-replay' import { ensureBrowserClientHostForRestartedRuntime, ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' -import * as runtimeStatusRecheck from './runtime-status-recheck' -import * as runtimeStatusDiagnosticsPublish from './runtime-status-diagnostics-publish' +import { applyRuntimeHostStatusSnapshot } from './runtime-status-snapshot' export const clearRuntimeEnvironmentConnectionGenerationsForTests = (): void => { - runtimeStatusRecheck.cancelRuntimeStatusRechecks( - runtimeStatusConnectionGeneration.clearRuntimeEnvironmentConnectionGenerations() - ) - runtimeStatusDiagnostics.clearRuntimeEnvironmentDiagnosticsGenerationsForTests() + runtimeStatusConnectionGeneration.clearRuntimeEnvironmentConnectionGenerations() } export { @@ -52,6 +43,15 @@ export const createRuntimeStatusSlice: StateCreator { + try { + const snapshots = await window.api.runtimeEnvironments.getStatusSnapshots() + snapshots.forEach((snapshot) => get().applyRuntimeHostStatusSnapshot(snapshot)) + } catch (error) { + console.error('Failed to read runtime host status:', error) + } + }, + setRuntimeEnvironments: (environments) => { const previousRevisionById = new Map( get().runtimeEnvironments.map((environment) => [ @@ -76,7 +76,6 @@ export const createRuntimeStatusSlice: StateCreator environment.id) .filter((id) => !nextIds.has(id)) - runtimeStatusRecheck.cancelRuntimeStatusRechecks([...removedIds, ...replacedEnvironmentIds]) set((s) => { const keep = new Set(environments.map((environment) => environment.id)) const nextStatuses = new Map(s.runtimeStatusByEnvironmentId) @@ -155,15 +154,29 @@ export const createRuntimeStatusSlice: StateCreator + applyRuntimeHostStatusSnapshot(snapshot, get(), (entry) => { + set((s) => ({ + runtimeStatusByEnvironmentId: new Map(s.runtimeStatusByEnvironmentId).set( + snapshot.environmentId, + entry + ) + })) + }), + setRuntimeEnvironmentStatus: (environmentId, status, options) => { const previous = get().runtimeStatusByEnvironmentId.get(environmentId) + if (previous?.snapshot && !status.snapshot) { + return + } + const previousVerifiedStatus = previous?.snapshot?.status ?? previous?.status const pairedDeviceId = status.status?.pairedDeviceId?.trim() // A new runtime id under a known previous one is a restart, not a first connect: the guests are // still ours to host, but only a fresh attach hands them back to the replacement runtime. const runtimeRestarted = Boolean( status.status !== null && - previous?.status != null && - previous.status.runtimeId !== status.status.runtimeId + previousVerifiedStatus != null && + previousVerifiedStatus.runtimeId !== status.status.runtimeId ) // Why: a non-null status proves the runtime just answered, so drop any stale // "offline" compat failure before this online transition fires the @@ -177,7 +190,8 @@ export const createRuntimeStatusSlice: StateCreator - get().runtimeEnvironments.some((environment) => environment.id === environmentId), - getConnectionGeneration: () => - runtimeStatusConnectionGeneration.getRuntimeEnvironmentConnectionGeneration(environmentId), - publish: (entry) => get().setRuntimeEnvironmentStatus(environmentId, entry) - }) if (runtimeRestarted) { void ensureBrowserClientHostForRestartedRuntime(get(), environmentId) } @@ -250,18 +253,7 @@ export const createRuntimeStatusSlice: StateCreator get().runtimeStatusByEnvironmentId.get(environmentId), - setState: (updater) => - set((s) => runtimeStatusDiagnosticsPublish.updateRuntimeStatusStore(s, updater)), - getStore: get, - getConnectionGeneration: - runtimeStatusConnectionGeneration.getRuntimeEnvironmentConnectionGeneration - }), - clearRuntimeEnvironmentStatus: (environmentId) => { - runtimeStatusRecheck.cancelRuntimeStatusRecheck(environmentId) dismissRuntimeDisconnectedToast(environmentId) set((s) => { runtimeStatusConnectionGeneration.advanceRuntimeEnvironmentConnectionGeneration(environmentId) @@ -278,7 +270,6 @@ export const createRuntimeStatusSlice: StateCreator + refreshRuntimeEnvironmentStatus: (environmentId, timeoutMs = 10_000) => refreshRuntimeEnvironmentStatus(environmentId, timeoutMs, (entry) => { - if (entry.status === null && options?.publishUnreachable === false) { - // Unverifiable, not exited: leave the cached verdict for the caller's retry to settle. + if (entry.snapshot) { + get().applyRuntimeHostStatusSnapshot(entry.snapshot) return } // Why: setRuntimeEnvironmentStatus drops any stale compat failure on a non-null diff --git a/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts b/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts new file mode 100644 index 00000000000..8b3c73e2ff1 --- /dev/null +++ b/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as intent from '@/lib/worktree-sleep-intent' +import { buildWorktreePurgeState } from './worktrees/teardown/worktree-purge-state' +import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' +import { createStoreCascadesMockApi } from './store-cascades-test-harness' + +const { clearWorktreeSleepIntent, hasWorktreeSleepIntent, markWorktreeSleepIntent } = intent +const WORKTREE_ID = 'repo1::/path/wt1' +const FOLDER_KEY = 'folder:folder-1' + +createStoreCascadesMockApi() + +function seedWorktree(store: ReturnType): void { + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] + }, + refreshGitHubForWorktree: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn() + }) +} + +// Why this suite exists: the sleep marker outlives teardown so mounted panes stay cold +// (#10205). Every route that makes a workspace awake again must release it, or the +// workspace is stuck cold and its PTY exits stop counting as activity. +describe('worktree sleep intent lifecycle', () => { + beforeEach(() => { + clearWorktreeSleepIntent(WORKTREE_ID) + clearWorktreeSleepIntent(FOLDER_KEY) + }) + + it('is released by activating the worktree', () => { + const store = createTestStore() + seedWorktree(store) + markWorktreeSleepIntent(WORKTREE_ID) + + store.getState().setActiveWorktree(WORKTREE_ID) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + }) + + it('survives the sleep flow clearing the active selection', () => { + const store = createTestStore() + seedWorktree(store) + markWorktreeSleepIntent(WORKTREE_ID) + + store.getState().setActiveWorktree(null) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(true) + }) + + it('is released by activating a folder workspace', () => { + const store = createTestStore() + store.setState({ + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'Folder', + folderPath: '/folder', + executionHostId: 'local', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + ] + }) + markWorktreeSleepIntent(FOLDER_KEY) + + store.getState().setActiveFolderWorkspace('folder-1') + + expect(hasWorktreeSleepIntent(FOLDER_KEY)).toBe(false) + }) + + it('is released when any PTY binds to a tab in the worktree', () => { + const store = createTestStore() + seedWorktree(store) + const tab = store.getState().createTab(WORKTREE_ID, undefined, undefined, { activate: false }) + markWorktreeSleepIntent(WORKTREE_ID) + + store.getState().updateTabPtyId(tab.id, 'pty-cli-created') + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + }) + + it('is released when a tab is created with a live PTY', () => { + const store = createTestStore() + seedWorktree(store) + markWorktreeSleepIntent(WORKTREE_ID) + + store.getState().createTab(WORKTREE_ID, undefined, undefined, { + activate: false, + initialPtyId: 'pty-cli-created' + }) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + }) + + it('notifies wake listeners once and only on a real clear', () => { + const { onWorktreeSleepIntentCleared } = intent + const woke = vi.fn() + markWorktreeSleepIntent(WORKTREE_ID) + const unsubscribe = onWorktreeSleepIntentCleared(WORKTREE_ID, woke) + + clearWorktreeSleepIntent('repo1::/path/other') + expect(woke).not.toHaveBeenCalled() + clearWorktreeSleepIntent(WORKTREE_ID) + clearWorktreeSleepIntent(WORKTREE_ID) + expect(woke).toHaveBeenCalledTimes(1) + + markWorktreeSleepIntent(WORKTREE_ID) + clearWorktreeSleepIntent(WORKTREE_ID) + expect(woke).toHaveBeenCalledTimes(1) + unsubscribe() + }) + + it('ignores a PTY bind that lands while the sleep teardown is in flight', async () => { + const store = createTestStore() + seedWorktree(store) + const tab = store.getState().createTab(WORKTREE_ID, undefined, undefined, { activate: false }) + markWorktreeSleepIntent(WORKTREE_ID) + const woke = vi.fn() + intent.onWorktreeSleepIntentCleared(WORKTREE_ID, woke) + + await intent.withWorktreeSleepTeardown(WORKTREE_ID, async () => { + store.getState().updateTabPtyId(tab.id, 'pty-late-spawn') + }) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(true) + expect(woke).not.toHaveBeenCalled() + store.getState().updateTabPtyId(tab.id, 'pty-after-teardown') + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + }) + + it('keeps notifying siblings when one wake listener throws', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const woke = vi.fn() + markWorktreeSleepIntent(WORKTREE_ID) + intent.onWorktreeSleepIntentCleared(WORKTREE_ID, () => { + throw new Error('boom') + }) + intent.onWorktreeSleepIntentCleared(WORKTREE_ID, woke) + + expect(() => clearWorktreeSleepIntent(WORKTREE_ID)).not.toThrow() + expect(woke).toHaveBeenCalledTimes(1) + errorSpy.mockRestore() + }) + + it('is forgotten without waking panes when the worktree is purged', () => { + const store = createTestStore() + seedWorktree(store) + markWorktreeSleepIntent(WORKTREE_ID) + const woke = vi.fn() + intent.onWorktreeSleepIntentCleared(WORKTREE_ID, woke) + + store.setState(buildWorktreePurgeState(store.getState(), [WORKTREE_ID])) + + expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) + expect(woke).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts b/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts index d2b5af79114..b89da1ace44 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts @@ -9,6 +9,7 @@ import { } from '../listing/detected-worktree-meta' import { shouldDeferActivationTerminalPrep } from './activation-terminal-prep' import { deriveActiveSurfaceForWorktree } from '../../tabs/tabs-surface' +import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' export function createSetActiveFolderWorkspace( set: WorktreeSliceSet, @@ -62,6 +63,8 @@ export function createSetActiveFolderWorkspace( : s.folderWorkspaces } }) + // Why: cleared after the set() so a waiting pane connects against the activated state. + clearWorktreeSleepIntent(workspaceKey) if (workspace.isUnread) { void get().updateFolderWorkspace( folderWorkspaceId, diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts index b4ec0b0f99a..31c7e8bbb37 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts @@ -24,6 +24,7 @@ import { } from '../listing/detected-worktree-meta' import { persistPassiveWorktreeMetaForOwner } from '../listing/worktree-owner-settings' import { resolveActivatedWorktreeSurface } from './active-worktree-surface' +import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { pendingActivationTerminalPrepCancels, shouldDeferActivationTerminalPrep @@ -206,6 +207,11 @@ export function createSetActiveWorktree( } }) + // Why: any activation is an explicit wake (null is the sleep flow clearing selection). + // Cleared after the set() above so a pane still waiting on the marker connects once, + // in the remounted generation, instead of connecting and then being remounted. + clearWorktreeSleepIntent(worktreeId) + if (worktreeId && shouldPrepareTerminalTabs) { const prepareTerminalTabs = (): void => { pendingActivationTerminalPrepCancels.delete(worktreeId) diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts index 075172ddce1..a6a9c87d838 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts @@ -6,6 +6,7 @@ import { parseExecutionHostId } from '../../../../../../shared/execution-host' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals' +import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' import { resolveWorktreeOperationRouteResult, @@ -222,6 +223,7 @@ export function createRemoveWorktree( // Why: invalidate stale probes once deletion is authoritative, so an old toast can't mutate a same-path replacement. forgetHugeRepoWarningDismissalsForWorktrees([worktreeId]) + forgetWorktreeSleepIntent(worktreeId) // Why: forget-local is legal while the host is unreachable, so record the removal here too — otherwise an // in-flight metadata read that snapshotted this row re-appends it, and disconnected polls never drop it. if (hostId && parseExecutionHostId(hostId)?.kind === 'ssh') { diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 78a57da7955..e2f6e5945fe 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -8,6 +8,7 @@ import { createWorktreePurgeOmitters } from './worktree-purge-omitters' import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state' import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency' import { forgetAmbiguousOwnerWarnings } from '../listing/worktree-owner-settings' +import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' export function buildWorktreePurgeState( s: AppState, @@ -18,6 +19,10 @@ export function buildWorktreePurgeState( ) const worktreeIdSet = new Set(normalizedTargets.map((target) => target.id)) pruneHostedReviewLinkMutationGenerations(worktreeIdSet) + // Why: ids are repo::path, so a worktree recreated at the same path must not inherit a stale sleep. + for (const id of worktreeIdSet) { + forgetWorktreeSleepIntent(id) + } // Why: every authoritative and explicit purge converges here, so a deleted path can't inherit stale UI state. forgetHugeRepoWarningDismissalsForWorktrees(worktreeIdSet) forgetAmbiguousOwnerWarnings(worktreeIdSet) diff --git a/src/renderer/src/store/terminals/terminal-pty-bindings.ts b/src/renderer/src/store/terminals/terminal-pty-bindings.ts index 2e0397aff45..499ea63e8de 100644 --- a/src/renderer/src/store/terminals/terminal-pty-bindings.ts +++ b/src/renderer/src/store/terminals/terminal-pty-bindings.ts @@ -9,6 +9,7 @@ import { isRemoteRuntimePtyId } from './terminal-pty-identities' import { omitUnverifiedPtyLossTabIds } from './terminal-unverified-pty-loss' +import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { omitDisownedPtyIds } from './terminal-disowned-pty-sources' export function createTerminalPtyBindingActions( @@ -275,6 +276,8 @@ export function createTerminalPtyBindingActions( ...(shouldBumpSortEpoch ? { sortEpoch: s.sortEpoch + 1 } : {}) } }) + // Why: a bound PTY means the workspace is awake by any route (CLI, automation, client wake), not only activation. + clearWorktreeSleepIntent(worktreeId) // Why: activation spawns come from clicking a worktree, not work in it — skip the lastActivityAt stamp and sortEpoch bump; other spawn reasons still bump. if (worktreeId && !wasActivationSpawn && !isRemoteRuntimeMirror) { get().bumpWorktreeActivity(worktreeId) diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts index 11f9d1d2a59..83310850475 100644 --- a/src/renderer/src/store/terminals/terminal-tab-creation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -1,3 +1,4 @@ +import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { isValidHostTerminalTabId } from '../../../../shared/terminal-tab-id' import { emptyLayoutSnapshot, singlePaneLayoutSnapshot } from '../slices/terminal-helpers' @@ -271,6 +272,10 @@ export function createTerminalTabCreationActions( } } }) + if (options?.initialPtyId) { + // Why: a tab born with a live PTY (CLI/runtime create) wakes the workspace like any other bind. + clearWorktreeSleepIntent(worktreeId) + } const shouldRecordInteraction = options?.recordInteraction ?? (!options?.pendingActivationSpawn && !options?.initialPtyId) if (shouldRecordInteraction) { diff --git a/src/renderer/src/web/preload-api/web-runtime-environments-api.ts b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts index e6ce2fd5a5d..8e9b0ecc245 100644 --- a/src/renderer/src/web/preload-api/web-runtime-environments-api.ts +++ b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts @@ -16,6 +16,9 @@ import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy' import { callEnvironmentEnvelope } from './web-runtime-calls' import { closeActiveRuntimeClients, + subscribeWebRuntimeStatus, + readWebRuntimeStatusSnapshots, + observeWebRuntimeStatus, disconnectActiveRuntimeEnvironment, getClientForEnvironment, manuallyDisconnectedEnvironmentIds, @@ -29,6 +32,8 @@ export function createRuntimeEnvironmentsApi(): NonNullable< Partial['runtimeEnvironments'] > { return { + onStatusChanged: subscribeWebRuntimeStatus, + getStatusSnapshots: async () => readWebRuntimeStatusSnapshots(), list: async () => { const environment = requireActiveEnvironmentOrNull() return environment ? [redactStoredWebRuntimeEnvironment(environment)] : [] @@ -146,6 +151,12 @@ export function createRuntimeEnvironmentsApi(): NonNullable< manuallyDisconnectedEnvironmentIds.clear() closeActiveRuntimeClients() webRuntimeState.activeEnvironment = nextEnvironment + getClientForEnvironment(nextEnvironment).statusOwner?.acceptVerified({ + id: 'status.get', + ok: true, + result: runtimeStatus, + _meta: { runtimeId: runtimeStatus.runtimeId } + }) return { ok: true, environment: redactStoredWebRuntimeEnvironment(nextEnvironment), @@ -173,6 +184,7 @@ export function createRuntimeEnvironmentsApi(): NonNullable< connect: ({ selector, timeoutMs }) => { const environment = resolveEnvironment(selector) manuallyDisconnectedEnvironmentIds.delete(environment.id) + closeActiveRuntimeClients() return callEnvironmentEnvelope( environment.id, 'status.get', @@ -180,8 +192,10 @@ export function createRuntimeEnvironmentsApi(): NonNullable< timeoutMs ) }, - getStatus: ({ selector, timeoutMs }) => - callEnvironmentEnvelope(selector, 'status.get', undefined, timeoutMs), + getStatus: ({ selector, timeoutMs, observeOnly }) => + observeOnly + ? observeWebRuntimeStatus(selector, timeoutMs) + : callEnvironmentEnvelope(selector, 'status.get', undefined, timeoutMs), retryControlConnection: () => Promise.resolve(), prepareBrowserClientHostPlacement: async () => ({ kind: 'server' }), call: ({ selector, method, params, timeoutMs }) => diff --git a/src/renderer/src/web/preload-api/web-runtime-session.ts b/src/renderer/src/web/preload-api/web-runtime-session.ts index 17a19136002..15b6cb63ee7 100644 --- a/src/renderer/src/web/preload-api/web-runtime-session.ts +++ b/src/renderer/src/web/preload-api/web-runtime-session.ts @@ -1,3 +1,7 @@ +import type { + RuntimeHostStatusSnapshot, + RuntimeHostStatusResponse +} from '../../../../shared/runtime-host-status' import type { WorktreeVisibilityDefaults } from '../../../../shared/global-settings-types' import { RuntimeRpcCallQueuePool } from '../../../../shared/runtime-rpc-call-queue' import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' @@ -30,6 +34,43 @@ export const webRuntimeState: { cachedDetectedWorktrees: null } +const statusListeners = new Set<(snapshot: RuntimeHostStatusSnapshot) => void>() +export function subscribeWebRuntimeStatus( + callback: (snapshot: RuntimeHostStatusSnapshot) => void +): () => void { + statusListeners.add(callback) + return () => { + statusListeners.delete(callback) + } +} +export function readWebRuntimeStatusSnapshots(): RuntimeHostStatusSnapshot[] { + const snapshot = webRuntimeState.activeClient?.statusOwner?.read() + return snapshot ? [snapshot] : [] +} +export async function observeWebRuntimeStatus( + selector: string, + timeoutMs?: number +): Promise { + const environment = resolveEnvironment(selector) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return manuallyDisconnectedResponse(environment) + } + const existing = webRuntimeState.activeClient?.statusOwner + if (existing) { + return existing.refresh({ timeoutMs, observeOnly: true }) + } + const transient = new WebRuntimeClient(getPreferredWebPairingOffer(environment), { + reconnect: false + }) + try { + return (await transient.call('status.get', undefined, { + timeoutMs + })) as RuntimeHostStatusResponse + } finally { + transient.close() + } +} + export const manuallyDisconnectedEnvironmentIds = new Set() export const runtimeCallQueuePool = new RuntimeRpcCallQueuePool() @@ -50,7 +91,18 @@ export function getClientForEnvironment( webRuntimeState.activeClientEnvironmentId !== environment.id ) { webRuntimeState.activeClient?.close() - webRuntimeState.activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment)) + webRuntimeState.activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment), { + status: { + environmentId: environment.id, + pairingRevision: environment.pairingRevision ?? environment.createdAt, + publish: (snapshot) => { + for (const listener of statusListeners) { + listener(snapshot) + } + }, + verified: (response) => updateEnvironmentFromResponse(environment, response) + } + }) webRuntimeState.activeClientEnvironmentId = environment.id } return webRuntimeState.activeClient diff --git a/src/renderer/src/web/web-runtime-client-export-parity.test.ts b/src/renderer/src/web/web-runtime-client-export-parity.test.ts index 27dce452549..6ba87eaad36 100644 --- a/src/renderer/src/web/web-runtime-client-export-parity.test.ts +++ b/src/renderer/src/web/web-runtime-client-export-parity.test.ts @@ -6,8 +6,13 @@ it('keeps the paired-web client public export surface exact', () => { expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf>().toEqualTypeOf< - [pairing: WebPairingOffer] + [ + pairing: WebPairingOffer, + options?: ConstructorParameters[1] + ] + >() + expectTypeOf().toEqualTypeOf< + 'call' | 'close' | 'subscribe' | 'statusOwner' >() - expectTypeOf().toEqualTypeOf<'call' | 'close' | 'subscribe'>() expect(Object.keys(WebClient)).toEqual(['WebRuntimeClient']) }) diff --git a/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts b/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts index 71568adc4da..95417748271 100644 --- a/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts +++ b/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts @@ -70,7 +70,7 @@ describe('WebRuntimeClient timeout budget', () => { await vi.advanceTimersByTimeAsync(60_000) expect(settled).toBe(false) - expect(waitForConnected).toHaveBeenCalledWith(25) + expect(waitForConnected).toHaveBeenCalledWith(25, undefined) resolveConnection() await Promise.resolve() diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 7bc756ca0c0..8a3b944f701 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -1,3 +1,8 @@ +import { RuntimeHostStatusOwner } from '../../../shared/runtime-host-status-owner' +import type { + RuntimeHostStatusSnapshot, + RuntimeHostStatusResponse +} from '../../../shared/runtime-host-status' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { WebRuntimeConnectionTransport } from './web-runtime-connection-transport' import { subscribeWebRuntimeFileWatch } from './web-runtime-file-watch-subscription' @@ -24,11 +29,62 @@ export class WebRuntimeClient { private readonly fileWatchTeardownRetries = new Map Promise>>() private readonly childClients = new Set() - constructor(private readonly pairing: WebPairingOffer) { - this.transport = new WebRuntimeConnectionTransport(pairing, { - now: () => this.now(), - isDocumentVisible: () => this.isDocumentVisible() - }) + readonly statusOwner?: RuntimeHostStatusOwner + + constructor( + private readonly pairing: WebPairingOffer, + options: { + reconnect?: boolean + status?: { + environmentId: string + pairingRevision: number + publish: (snapshot: RuntimeHostStatusSnapshot) => void + verified: (response: RuntimeHostStatusResponse) => void + } + } = {} + ) { + this.transport = new WebRuntimeConnectionTransport( + pairing, + { + now: () => this.now(), + isDocumentVisible: () => this.isDocumentVisible() + }, + { + reconnect: options.reconnect, + onStateChanged: (state) => { + if (state === 'auth-failed') { + this.statusOwner?.authenticationRejected() + } + this.statusOwner?.connectionChanged( + state === 'connected' + ? 'ready' + : state === 'disconnected' || state === 'auth-failed' + ? 'disconnected' + : 'connecting' + ) + } + } + ) + if (options.status) { + const status = options.status + this.statusOwner = new RuntimeHostStatusOwner({ + ...status, + persistent: true, + request: (signal) => + this.transport.call('status.get', undefined, { + timeoutMs: 15_000, + signal + }) as Promise, + verified: (response) => { + status.verified(response) + return true + } + }) + this.statusOwner.connectionChanged( + this.transport.state === 'connected' ? 'ready' : 'connecting' + ) + this.statusOwner.activate() + } } call( @@ -36,7 +92,9 @@ export class WebRuntimeClient { params?: unknown, options?: { timeoutMs?: number } ): Promise> { - return this.transport.call(method, params, options) + return method === 'status.get' && this.statusOwner + ? this.statusOwner.refresh(options) + : this.transport.call(method, params, options) } async subscribe( @@ -94,6 +152,7 @@ export class WebRuntimeClient { } close(options: { notifySubscriptions?: boolean } = {}): void { + this.statusOwner?.dispose() const shouldNotifySubscriptions = options.notifySubscriptions ?? true for (const child of Array.from(this.childClients)) { child.close({ notifySubscriptions: shouldNotifySubscriptions }) diff --git a/src/renderer/src/web/web-runtime-connection-transport.ts b/src/renderer/src/web/web-runtime-connection-transport.ts index d62cf3b48ba..1ead257bbc5 100644 --- a/src/renderer/src/web/web-runtime-connection-transport.ts +++ b/src/renderer/src/web/web-runtime-connection-transport.ts @@ -43,7 +43,11 @@ export class WebRuntimeConnectionTransport { constructor( private readonly pairing: WebPairingOffer, - clock: { now: () => number; isDocumentVisible: () => boolean } + clock: { now: () => number; isDocumentVisible: () => boolean }, + private readonly lifecycle: { + onStateChanged?: (state: WebRuntimeConnectionState) => void + reconnect?: boolean + } = {} ) { this.serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64) this.connectionWaiters = new WebRuntimeConnectionWaiters({ @@ -60,7 +64,7 @@ export class WebRuntimeConnectionTransport { this.requestRegistry = new WebRuntimeRequestRegistry({ deviceToken: pairing.deviceToken, nextId: () => this.nextId(), - waitForConnected: (timeoutMs) => this.connectionWaiters.wait(timeoutMs), + waitForConnected: (timeoutMs, signal) => this.connectionWaiters.wait(timeoutMs, signal), sendEncrypted: (message) => this.sendEncrypted(message) }) this.heartbeat = new WebRuntimeConnectionHeartbeat({ @@ -82,7 +86,7 @@ export class WebRuntimeConnectionTransport { async call( method: string, params?: unknown, - options?: { timeoutMs?: number } + options?: { timeoutMs?: number; signal?: AbortSignal } ): Promise> { return this.requestRegistry.call(method, params, options) } @@ -153,6 +157,7 @@ export class WebRuntimeConnectionTransport { } else if (next === 'auth-failed') { this.connectionWaiters.rejectAll(createWebRuntimeUnauthorizedError()) } + this.lifecycle.onStateChanged?.(next) } private openConnection(): void { @@ -232,7 +237,7 @@ export class WebRuntimeConnectionTransport { } private scheduleReconnect(): void { - if (this.reconnectTimer || this.intentionallyClosed) { + if (this.reconnectTimer || this.intentionallyClosed || this.lifecycle.reconnect === false) { return } const delay = withReconnectJitter( diff --git a/src/renderer/src/web/web-runtime-connection-waiters.ts b/src/renderer/src/web/web-runtime-connection-waiters.ts index c16e30cd779..a8f3d081e86 100644 --- a/src/renderer/src/web/web-runtime-connection-waiters.ts +++ b/src/renderer/src/web/web-runtime-connection-waiters.ts @@ -13,7 +13,10 @@ export class WebRuntimeConnectionWaiters { constructor(private readonly options: WebRuntimeConnectionWaiterOptions) {} - wait(timeoutMs = 30_000): Promise { + wait(timeoutMs = 30_000, signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.reject(signal.reason) + } if (this.options.getState() === 'connected') { return Promise.resolve() } @@ -24,11 +27,20 @@ export class WebRuntimeConnectionWaiters { return Promise.reject(new Error('Remote Orca runtime connection closed.')) } return new Promise((resolve, reject) => { - const timeout = window.setTimeout(() => { - const index = this.waiters.findIndex((waiter) => waiter.resolve === resolve) + const cleanup = (): void => { + window.clearTimeout(timeout) + signal?.removeEventListener('abort', abort) + const index = this.waiters.indexOf(waiter) if (index !== -1) { this.waiters.splice(index, 1) } + } + const abort = (): void => { + cleanup() + reject(signal?.reason) + } + const timeout = window.setTimeout(() => { + cleanup() reject( new Error( withRemoteRuntimeTailscaleHint( @@ -38,16 +50,18 @@ export class WebRuntimeConnectionWaiters { ) ) }, timeoutMs) - this.waiters.push({ + const waiter = { resolve: () => { - window.clearTimeout(timeout) + cleanup() resolve() }, - reject: (error) => { - window.clearTimeout(timeout) + reject: (error: Error) => { + cleanup() reject(error) } - }) + } + this.waiters.push(waiter) + signal?.addEventListener('abort', abort, { once: true }) }) } diff --git a/src/renderer/src/web/web-runtime-request-registry.ts b/src/renderer/src/web/web-runtime-request-registry.ts index 1347d580f00..0329e208ba3 100644 --- a/src/renderer/src/web/web-runtime-request-registry.ts +++ b/src/renderer/src/web/web-runtime-request-registry.ts @@ -6,7 +6,7 @@ const REQUEST_TIMEOUT_MS = 30_000 type WebRuntimeRequestRegistryOptions = { deviceToken: string nextId: () => string - waitForConnected: (timeoutMs?: number) => Promise + waitForConnected: (timeoutMs?: number, signal?: AbortSignal) => Promise sendEncrypted: (message: unknown) => boolean } @@ -18,17 +18,41 @@ export class WebRuntimeRequestRegistry { async call( method: string, params?: unknown, - callOptions?: { timeoutMs?: number } + callOptions?: { timeoutMs?: number; signal?: AbortSignal } ): Promise> { - await this.options.waitForConnected(callOptions?.timeoutMs) + const signal = callOptions?.signal + await this.options.waitForConnected(callOptions?.timeoutMs, signal) + signal?.throwIfAborted() return new Promise((resolve, reject) => { const id = this.options.nextId() const timeoutMs = callOptions?.timeoutMs ?? REQUEST_TIMEOUT_MS const timeout = window.setTimeout(() => { this.pending.delete(id) + cleanup() reject(new Error(`Request timed out: ${method}`)) }, timeoutMs) - this.pending.set(id, { method, resolve, reject, timeout }) + const cleanup = (): void => { + signal?.removeEventListener('abort', abort) + } + const abort = (): void => { + this.pending.delete(id) + window.clearTimeout(timeout) + cleanup() + reject(signal?.reason) + } + signal?.addEventListener('abort', abort, { once: true }) + this.pending.set(id, { + method, + resolve: (value) => { + cleanup() + resolve(value) + }, + reject: (error) => { + cleanup() + reject(error) + }, + timeout + }) if ( !this.options.sendEncrypted({ id, @@ -39,6 +63,7 @@ export class WebRuntimeRequestRegistry { ) { this.pending.delete(id) window.clearTimeout(timeout) + cleanup() reject(new Error('Remote Orca runtime is not connected.')) } }) diff --git a/src/renderer/src/web/web-runtime-status-owner.test.ts b/src/renderer/src/web/web-runtime-status-owner.test.ts new file mode 100644 index 00000000000..7abfe6f19f4 --- /dev/null +++ b/src/renderer/src/web/web-runtime-status-owner.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import { + createSharedControlTestServer, + closeSharedControlTestServers +} from '../../../shared/remote-runtime-shared-control-test-server' +import { WebRuntimeClient } from './web-runtime-client' + +const clients: WebRuntimeClient[] = [] +beforeEach(() => { + vi.stubGlobal('WebSocket', WebSocket) + vi.stubGlobal('window', { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + atob: (value: string) => Buffer.from(value, 'base64').toString('binary'), + btoa: (value: string) => Buffer.from(value, 'binary').toString('base64') + }) +}) +afterEach(async () => { + clients.splice(0).forEach((client) => client.close()) + await closeSharedControlTestServers() + vi.unstubAllGlobals() +}) + +it('primary browser status follows the authenticated socket and closing it retires the owner', async () => { + let runtimeId = 'before' + const server = await createSharedControlTestServer({ + resultForRequest: () => ({ runtimeId, capabilities: [] }) + }) + const publish = vi.fn() + const client = new WebRuntimeClient(server.pairing, { + status: { environmentId: 'browser', pairingRevision: 1, publish, verified: vi.fn() } + }) + clients.push(client) + await expect + .poll(() => client.statusOwner?.read().verification, { timeout: 3_000 }) + .toBe('verified') + expect(client.statusOwner?.read().status?.runtimeId).toBe('before') + runtimeId = 'after' + server.closeClients() + await expect + .poll(() => client.statusOwner?.read().status?.runtimeId, { timeout: 3_000 }) + .toBe('after') + expect(client.statusOwner?.read().transport).toBe('ready') + client.close() + expect(publish.mock.lastCall?.[0]).toMatchObject({ retired: true, verification: 'blocked' }) +}) diff --git a/src/shared/commit-message-agent-specs-primary.ts b/src/shared/commit-message-agent-specs-primary.ts index e6ba42f775b..3e42a4c5865 100644 --- a/src/shared/commit-message-agent-specs-primary.ts +++ b/src/shared/commit-message-agent-specs-primary.ts @@ -197,7 +197,6 @@ export function buildPrimaryCommitMessageAgentSpecs({ '--print', '--no-session', '--no-tools', - '--no-extensions', '--no-skills', '--no-context-files', '--mode', diff --git a/src/shared/commit-message-plan.test.ts b/src/shared/commit-message-plan.test.ts index 0b728307bf7..3c2fa62aea5 100644 --- a/src/shared/commit-message-plan.test.ts +++ b/src/shared/commit-message-plan.test.ts @@ -2,6 +2,29 @@ import { describe, expect, it } from 'vitest' import { planCommitMessageGeneration, planAgentBinary } from './commit-message-plan' describe('planCommitMessageGeneration', () => { + it('keeps extension-provided Pi models available in generated Git text plans', () => { + const result = planCommitMessageGeneration( + { agentId: 'pi', model: 'local-extension/model' }, + 'Write a commit message' + ) + expect(result.ok).toBe(true) + if (!result.ok) { + throw new Error(result.error) + } + expect(result.plan.args).not.toContain('--no-extensions') + expect(result.plan.args).toEqual( + expect.arrayContaining([ + '--no-session', + '--no-tools', + '--no-skills', + '--no-context-files', + '--model', + 'local-extension/model' + ]) + ) + expect(result.plan.stdinPayload).toBe('Write a commit message') + }) + it('plans Claude non-interactive generation with the prompt on stdin only', () => { const result = planCommitMessageGeneration( { diff --git a/src/shared/execution-host-registry.test.ts b/src/shared/execution-host-registry.test.ts index e509fe05bfc..e397e8ed5bf 100644 --- a/src/shared/execution-host-registry.test.ts +++ b/src/shared/execution-host-registry.test.ts @@ -320,17 +320,15 @@ describe('execution host registry', () => { ]) }) - it('includes runtime hosts from repo ownership but marks them disconnected without live status', () => { + it('keeps runtime hosts checking before their first status result', () => { const hosts = buildExecutionHostRegistry({ repos: [{ connectionId: null, executionHostId: 'runtime:env-2' }], settings: { activeRuntimeEnvironmentId: null } }) - // No live status means no evidence the Orca server is reachable, so it must - // read 'disconnected' rather than defaulting to 'available'/"Connected". expect(hosts).toMatchObject([ { id: 'local', health: 'local' }, - { id: 'runtime:env-2', kind: 'runtime', label: 'env-2', health: 'disconnected' } + { id: 'runtime:env-2', kind: 'runtime', label: 'env-2', health: 'connecting' } ]) }) @@ -373,3 +371,29 @@ describe('execution host registry', () => { ]) }) }) + +it('keeps an initial unknown-transport verification connecting', () => { + const hosts = buildExecutionHostRegistry({ + repos: [], + settings: null, + runtimeEnvironments: [{ id: 'host', name: 'Host' }], + runtimeStatusByEnvironmentId: new Map([ + [ + 'host', + { + status: null, + snapshot: { + environmentId: 'host', + pairingRevision: 1, + sequence: 1, + checkedAt: 0, + status: null, + verification: 'checking', + transport: 'unknown' + } + } + ] + ]) + }) + expect(hosts.find((host) => host.id === 'runtime:host')?.health).toBe('connecting') +}) diff --git a/src/shared/execution-host-registry.ts b/src/shared/execution-host-registry.ts index a970f9da45c..bad6b40f257 100644 --- a/src/shared/execution-host-registry.ts +++ b/src/shared/execution-host-registry.ts @@ -1,3 +1,4 @@ +import type { RuntimeHostStatusSnapshot } from './runtime-host-status' import { LOCAL_EXECUTION_HOST_ID, getLocalExecutionHostLabel, @@ -49,6 +50,7 @@ type RuntimeEnvironmentSummary = { } type RuntimeHostStatus = { + snapshot?: RuntimeHostStatusSnapshot status?: RuntimeStatus | null remoteControl?: RuntimeStatus['remoteControl'] | null appVersion?: string | null @@ -158,9 +160,24 @@ function addRuntimeHost( const hostId = toRuntimeExecutionHostId(environmentId) const runtimeStatus = statusByEnvironmentId?.get(environmentId) const status = runtimeStatus?.status - const compatibility = runtimeCompatibility(status) + const snapshot = runtimeStatus?.snapshot + const metadata = status ?? snapshot?.status + const compatibility = runtimeCompatibility(metadata) const remoteControl = runtimeStatus?.remoteControl ?? status?.remoteControl - const controlHealth = runtimeControlHealth(remoteControl) + const controlHealth = snapshot?.retired + ? 'disconnected' + : snapshot?.verification === 'blocked' + ? 'blocked' + : !runtimeStatus || + snapshot?.verification === 'checking' || + snapshot?.transport === 'disconnected' || + snapshot?.transport === 'connecting' + ? 'connecting' + : snapshot?.transport === 'ready' + ? compatibility?.kind === 'blocked' + ? 'blocked' + : 'available' + : runtimeControlHealth(remoteControl) setHost(hosts, { id: hostId, kind: 'runtime', @@ -168,12 +185,12 @@ function addRuntimeHost( detail: 'Orca server', health: controlHealth ?? runtimeHealth(status, compatibility, remoteControl), compatibility: compatibility ?? undefined, - capabilities: status?.capabilities, - appVersion: runtimeStatus?.appVersion ?? status?.appVersion ?? null, - protocolVersion: status?.runtimeProtocolVersion ?? status?.protocolVersion ?? null, + capabilities: metadata?.capabilities, + appVersion: runtimeStatus?.appVersion ?? metadata?.appVersion ?? null, + protocolVersion: metadata?.runtimeProtocolVersion ?? metadata?.protocolVersion ?? null, minCompatibleClientVersion: - status?.minCompatibleRuntimeClientVersion ?? status?.minCompatibleMobileVersion ?? null, - platform: status?.hostPlatform ?? null, + metadata?.minCompatibleRuntimeClientVersion ?? metadata?.minCompatibleMobileVersion ?? null, + platform: metadata?.hostPlatform ?? null, remoteControlState: remoteControl ?? null, ...(source ? { source } : {}) }) diff --git a/src/renderer/src/components/native-chat/native-chat-turn-activity.test.ts b/src/shared/native-chat-turn-activity.test.ts similarity index 72% rename from src/renderer/src/components/native-chat/native-chat-turn-activity.test.ts rename to src/shared/native-chat-turn-activity.test.ts index a819e3054a2..3cc64f4e106 100644 --- a/src/renderer/src/components/native-chat/native-chat-turn-activity.test.ts +++ b/src/shared/native-chat-turn-activity.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { - AgentJournalItemBody, - AgentJournalRenderItem -} from '../../../../shared/agent-session-journal-types' +import type { AgentJournalItemBody, AgentJournalRenderItem } from './agent-session-journal-types' import { selectStructuredAgentTurnActivity } from './native-chat-turn-activity' function item(sequence: number, body: AgentJournalItemBody): AgentJournalRenderItem { @@ -34,6 +31,49 @@ describe('selectStructuredAgentTurnActivity', () => { expect(activity).toEqual({ kind: 'description', text: 'Preparing the answer' }) }) + it("never puts the model's reasoning on the indicator line", () => { + const reasoning = item(2, { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Let me check whether the journal already records this' }] + }) + + // Reasoning is the turn's content; the row says the turn is thinking instead. + expect(selectStructuredAgentTurnActivity([turnStart, reasoning], 'turn-1')).toBeNull() + // An ordinary status row is still a description of what the turn is doing. + expect( + selectStructuredAgentTurnActivity( + [turnStart, reasoning, item(3, { kind: 'status', text: 'Updating the plan' })], + 'turn-1' + ) + ).toEqual({ kind: 'description', text: 'Updating the plan' }) + // Provider-authored copy is unaffected, so Codex keeps its line. + expect( + selectStructuredAgentTurnActivity([turnStart, reasoning], 'turn-1', { + turnId: 'turn-1', + text: 'Running a command' + }) + ).toEqual({ kind: 'description', text: 'Running a command' }) + }) + + it('skips reasoning behind a typed turn item too', () => { + const typedTurnStart = item(1, { kind: 'turn', turnId: 'turn-1', state: 'running' }) + + expect( + selectStructuredAgentTurnActivity( + [ + typedTurnStart, + item(2, { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }] + }) + ], + 'turn-1' + ) + ).toBeNull() + }) + it('prefers matching ephemeral provider activity over journal-derived status', () => { const activity = selectStructuredAgentTurnActivity( [turnStart, item(2, { kind: 'status', text: 'Older journal status' })], diff --git a/src/renderer/src/components/native-chat/native-chat-turn-activity.ts b/src/shared/native-chat-turn-activity.ts similarity index 86% rename from src/renderer/src/components/native-chat/native-chat-turn-activity.ts rename to src/shared/native-chat-turn-activity.ts index e42abea2bf6..fbc1756529b 100644 --- a/src/renderer/src/components/native-chat/native-chat-turn-activity.ts +++ b/src/shared/native-chat-turn-activity.ts @@ -1,11 +1,8 @@ -import { readAgentJournalTurn } from '../../../../shared/agent-session-turn-record' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' -import type { AgentSessionTurnActivity } from '../../../../shared/agent-session-wire' -import { normalizePromptField } from '../../../../shared/agent-status-field-normalization' -import { - describeActiveToolCall, - formatActiveToolLabel -} from '../../../../shared/native-chat-tool-activity' +import { readAgentJournalTurn } from './agent-session-turn-record' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import type { AgentSessionTurnActivity } from './agent-session-wire' +import { normalizePromptField } from './agent-status-field-normalization' +import { describeActiveToolCall, formatActiveToolLabel } from './native-chat-tool-activity' export type NativeChatTurnActivity = { kind: 'description'; text: string } diff --git a/src/shared/native-chat-turn-status.test.ts b/src/shared/native-chat-turn-status.test.ts index 3fdcccb7bf3..c98d4f79a73 100644 --- a/src/shared/native-chat-turn-status.test.ts +++ b/src/shared/native-chat-turn-status.test.ts @@ -1,24 +1,16 @@ import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from './native-chat-types' import { + describeNativeChatActiveTurnLabel, describeNativeChatTurnStatus, + formatNativeChatActiveTurnLabel, formatNativeChatDuration, formatNativeChatTurnStatusLabel, nativeChatElapsedSeconds, - nativeChatTurnHasResponse, reduceNativeChatTurnTiming, selectNativeChatTurnStatuses, type NativeChatTurnTimingByTurn } from './native-chat-turn-status' -function message( - id: string, - role: NativeChatMessage['role'], - blocks: NativeChatMessage['blocks'] -): NativeChatMessage { - return { id, role, blocks, timestamp: null, source: 'transcript' } -} - describe('formatNativeChatDuration', () => { it.each([ [0, '0s'], @@ -59,6 +51,51 @@ describe('describeNativeChatTurnStatus', () => { }) }) +describe('describeNativeChatActiveTurnLabel', () => { + it('lets provider activity beat both fallbacks', () => { + expect( + describeNativeChatActiveTurnLabel({ + activityText: 'Reading src/main.ts', + thinking: true, + elapsedSeconds: 12 + }) + ).toEqual({ source: 'activity', text: 'Reading src/main.ts' }) + }) + + it('falls back to reasoning when the provider says nothing usable', () => { + expect( + describeNativeChatActiveTurnLabel({ activityText: ' ', thinking: true, elapsedSeconds: 12 }) + ).toEqual({ source: 'status', key: 'thinking', duration: null }) + expect( + describeNativeChatActiveTurnLabel({ activityText: null, thinking: true, elapsedSeconds: 12 }) + ).toEqual({ source: 'status', key: 'thinking', duration: null }) + }) + + it('falls back to the running clock when the turn is neither talking nor reasoning', () => { + expect(describeNativeChatActiveTurnLabel({ thinking: false, elapsedSeconds: 184 })).toEqual({ + source: 'status', + key: 'workingFor', + duration: '3m 4s' + }) + }) +}) + +describe('formatNativeChatActiveTurnLabel', () => { + it('renders the one live row in English for platforms without i18n', () => { + expect( + formatNativeChatActiveTurnLabel({ + activityText: 'Running pnpm test', + thinking: false, + elapsedSeconds: 4 + }) + ).toBe('Running pnpm test') + expect(formatNativeChatActiveTurnLabel({ thinking: true, elapsedSeconds: 4 })).toBe('Thinking') + expect(formatNativeChatActiveTurnLabel({ thinking: false, elapsedSeconds: 12 })).toBe( + 'Working for 12s' + ) + }) +}) + describe('formatNativeChatTurnStatusLabel', () => { it('renders each state in English for platforms without i18n', () => { expect( @@ -73,45 +110,6 @@ describe('formatNativeChatTurnStatusLabel', () => { }) }) -describe('nativeChatTurnHasResponse', () => { - const user = message('u1', 'user', [{ type: 'text', text: 'go' }]) - - it('is false while the turn has produced nothing', () => { - expect(nativeChatTurnHasResponse([user], 0)).toBe(false) - }) - - it('ignores a whitespace-only assistant block', () => { - const blank = message('a1', 'assistant', [{ type: 'text', text: ' \n ' }]) - expect(nativeChatTurnHasResponse([user, blank], 0)).toBe(false) - }) - - it('is true on the first real text, tool call, or tool result', () => { - expect( - nativeChatTurnHasResponse( - [user, message('a1', 'assistant', [{ type: 'text', text: 'hi' }])], - 0 - ) - ).toBe(true) - expect( - nativeChatTurnHasResponse( - [user, message('t1', 'tool', [{ type: 'tool-call', name: 'Read', input: {} }])], - 0 - ) - ).toBe(true) - expect( - nativeChatTurnHasResponse( - [user, message('t1', 'tool', [{ type: 'tool-result', output: 'ok' }])], - 0 - ) - ).toBe(true) - }) - - it('does not count output that preceded the latest user turn', () => { - const earlier = message('a0', 'assistant', [{ type: 'text', text: 'old' }]) - expect(nativeChatTurnHasResponse([earlier, user], 1)).toBe(false) - }) -}) - describe('reduceNativeChatTurnTiming', () => { const validTurnKeys = new Set(['u1']) @@ -290,18 +288,18 @@ describe('reduceNativeChatTurnTiming', () => { }) describe('selectNativeChatTurnStatuses', () => { - it('reports the working turn as thinking until it produces output', () => { + it('carries the reasoning verdict it is given onto the working turn', () => { const { active } = selectNativeChatTurnStatuses( { u1: { startedAt: 1_000, workedSeconds: null } }, - { activeTurnKey: 'u1', isWorking: true, hasCurrentTurnResponse: false } + { activeTurnKey: 'u1', isWorking: true, thinking: true } ) expect(active).toEqual({ startedAt: 1_000, thinking: true, workedSeconds: null }) }) - it('stops thinking once the turn has output', () => { + it('reports a working turn that is not reasoning as counting', () => { const { active } = selectNativeChatTurnStatuses( { u1: { startedAt: 1_000, workedSeconds: null } }, - { activeTurnKey: 'u1', isWorking: true, hasCurrentTurnResponse: true } + { activeTurnKey: 'u1', isWorking: true, thinking: false } ) expect(active?.thinking).toBe(false) }) @@ -309,7 +307,7 @@ describe('selectNativeChatTurnStatuses', () => { it('exposes settled turns and resolves the active one from them when idle', () => { const { active, completedByTurn } = selectNativeChatTurnStatuses( { u1: { startedAt: 1_000, workedSeconds: 12 } }, - { activeTurnKey: 'u1', isWorking: false, hasCurrentTurnResponse: true } + { activeTurnKey: 'u1', isWorking: false, thinking: false } ) expect(completedByTurn.u1).toEqual({ startedAt: 1_000, thinking: false, workedSeconds: 12 }) expect(active).toEqual(completedByTurn.u1) @@ -318,7 +316,7 @@ describe('selectNativeChatTurnStatuses', () => { it('omits an in-flight turn from the completed map', () => { const { completedByTurn } = selectNativeChatTurnStatuses( { u1: { startedAt: 1_000, workedSeconds: null } }, - { activeTurnKey: 'u1', isWorking: true, hasCurrentTurnResponse: true } + { activeTurnKey: 'u1', isWorking: true, thinking: false } ) expect(completedByTurn).toEqual({}) }) diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index aaecbc05ce5..b34b199e9d0 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -3,8 +3,6 @@ // and the mobile app (used directly — mobile ships English only) so the two // surfaces never drift. Everything here is pure; each platform owns its own clock. -import type { NativeChatMessage } from './native-chat-types' - export const NATIVE_CHAT_TURN_STATUS_COPY = { thinking: 'Thinking', workingFor: 'Working for {{value0}}', @@ -48,6 +46,52 @@ export function describeNativeChatTurnStatus({ return { key: 'workingFor', duration: formatNativeChatDuration(elapsedSeconds) } } +/** The two readings that label a live turn's one indicator row, carried together + * so a surface cannot pick up one without the other. */ +export type NativeChatLiveTurnIndicator = { + thinking: boolean + activityText: string | null +} + +export type NativeChatActiveTurnLabel = + | { source: 'activity'; text: string } + | { source: 'status'; key: 'thinking' | 'workingFor'; duration: string | null } + +/** The live turn's single indicator label. Provider activity wins because it is the + * only text that says what the turn is actually doing; reasoning is next; the + * running clock is the floor. Shared so desktop and mobile cannot disagree. */ +export function describeNativeChatActiveTurnLabel({ + activityText, + thinking, + elapsedSeconds +}: { + activityText?: string | null + thinking: boolean + elapsedSeconds: number +}): NativeChatActiveTurnLabel { + const text = activityText?.trim() + if (text) { + return { source: 'activity', text } + } + return thinking + ? { source: 'status', key: 'thinking', duration: null } + : { source: 'status', key: 'workingFor', duration: formatNativeChatDuration(elapsedSeconds) } +} + +/** The live turn's label in English. For platforms without i18n (mobile). */ +export function formatNativeChatActiveTurnLabel(input: { + activityText?: string | null + thinking: boolean + elapsedSeconds: number +}): string { + const label = describeNativeChatActiveTurnLabel(input) + if (label.source === 'activity') { + return label.text + } + const copy = NATIVE_CHAT_TURN_STATUS_COPY[label.key] + return label.duration == null ? copy : copy.replaceAll('{{value0}}', label.duration) +} + /** Resolve the turn-status label in English. For platforms without i18n (mobile). */ export function formatNativeChatTurnStatusLabel(input: { thinking: boolean @@ -59,26 +103,6 @@ export function formatNativeChatTurnStatusLabel(input: { return duration == null ? copy : copy.replaceAll('{{value0}}', duration) } -/** True once the current turn has produced anything renderable — the boundary - * between the "Thinking" label and the counting "Working for N" label. */ -export function nativeChatTurnHasResponse( - messages: readonly NativeChatMessage[], - latestUserIndex: number -): boolean { - return messages - .slice(latestUserIndex + 1) - .some( - (message) => - (message.role === 'assistant' || message.role === 'tool') && - message.blocks.some( - (block) => - block.type === 'tool-call' || - block.type === 'tool-result' || - (block.type === 'text' && block.text.trim().length > 0) - ) - ) -} - export type NativeChatTurnTiming = { startedAt: number workedSeconds: number | null @@ -183,13 +207,14 @@ export function selectNativeChatTurnStatuses( activeTurnKey, isWorking, workingStartedAt, - hasCurrentTurnResponse, + thinking, settledByTurn }: { activeTurnKey: string isWorking: boolean workingStartedAt?: number | null - hasCurrentTurnResponse: boolean + /** Whether the active turn is reasoning right now, from its journal content. */ + thinking: boolean settledByTurn?: NativeChatSettledTurns } ): { active: NativeChatTurnStatus | null; completedByTurn: Record } { @@ -216,7 +241,7 @@ export function selectNativeChatTurnStatuses( active: isWorking ? { startedAt: workingStartedAt ?? timingByTurn[activeTurnKey]?.startedAt ?? null, - thinking: !hasCurrentTurnResponse, + thinking, workedSeconds: null } : (completedByTurn[activeTurnKey] ?? null), diff --git a/src/shared/native-chat-unverifiable-turn-status.test.ts b/src/shared/native-chat-unverifiable-turn-status.test.ts index 7e93f9cecc4..592ca699183 100644 --- a/src/shared/native-chat-unverifiable-turn-status.test.ts +++ b/src/shared/native-chat-unverifiable-turn-status.test.ts @@ -57,7 +57,7 @@ describe('authoritative unknown turn duration at the shared status consumer', () const options = { activeTurnKey: user.itemId, isWorking: false, - hasCurrentTurnResponse: true, + thinking: false, settledByTurn: selectStructuredAgentSettledTurns([user, recoveredTurn]) } @@ -87,7 +87,7 @@ describe('authoritative unknown turn duration at the shared status consumer', () { activeTurnKey: 'next', isWorking: true, - hasCurrentTurnResponse: true, + thinking: false, settledByTurn: selectStructuredAgentSettledTurns([userItem('turn-1'), item]) } ) @@ -113,7 +113,7 @@ describe('authoritative unknown turn duration at the shared status consumer', () { activeTurnKey: 'turn-1', isWorking: false, - hasCurrentTurnResponse: true, + thinking: false, settledByTurn: selectStructuredAgentSettledTurns([ userItem('turn-1'), userItem('old'), diff --git a/src/shared/remote-runtime-shared-control-test-server.ts b/src/shared/remote-runtime-shared-control-test-server.ts index 0e4adb1a69c..701ea0e5893 100644 --- a/src/shared/remote-runtime-shared-control-test-server.ts +++ b/src/shared/remote-runtime-shared-control-test-server.ts @@ -20,6 +20,7 @@ export type SharedControlTestServer = { } type ServerOptions = { + resultForRequest?: (method: string) => unknown delaySubscriptionReady?: boolean sendKeepaliveBeforeResponse?: boolean keepaliveDelayMs?: number @@ -174,7 +175,7 @@ function handleRequest( const streaming = isStreamingMethod(request.method) const result = streaming ? { type: 'ready', subscriptionId: `${request.method}:subscription` } - : { method: request.method } + : (options.resultForRequest?.(request.method) ?? { method: request.method }) const sendResponse = (): void => { if (options.sendUnknownResponseBeforeResponse) { sendEncrypted(ws, sharedKey, { diff --git a/src/shared/runtime-host-status-owner.test.ts b/src/shared/runtime-host-status-owner.test.ts new file mode 100644 index 00000000000..32331991988 --- /dev/null +++ b/src/shared/runtime-host-status-owner.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { RuntimeHostStatusOwner } from './runtime-host-status-owner' +import { runtimeHostStatusFailure, type RuntimeHostStatusResponse } from './runtime-host-status' +import type { RuntimeStatus } from './runtime-types' + +const owners: RuntimeHostStatusOwner[] = [] +beforeEach(() => vi.useFakeTimers()) +afterEach(() => { + owners.splice(0).forEach((owner) => owner.dispose()) + vi.useRealTimers() +}) +function success(runtimeId = 'host-1'): RuntimeHostStatusResponse & { ok: true } { + return { + id: 'status', + ok: true, + result: { runtimeId, capabilities: [] } as unknown as RuntimeStatus, + _meta: { runtimeId } + } +} +function deferred() { + let resolve!: (response: RuntimeHostStatusResponse) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +function createOwner(persistent = false) { + const request = vi + .fn<(signal: AbortSignal) => Promise>() + .mockResolvedValue(success()) + const publish = vi.fn() + const verified = vi.fn((_response: RuntimeHostStatusResponse, _active: boolean) => persistent) + const owner = new RuntimeHostStatusOwner({ + environmentId: 'env-a', + pairingRevision: 1, + persistent, + request, + publish, + verified + }) + owners.push(owner) + return { owner, request, publish, verified } +} + +it('shares one verification between viewers with independent deadlines', async () => { + const { owner, request } = createOwner() + const pending = deferred() + request.mockReturnValue(pending.promise) + const impatient = owner.refresh({ timeoutMs: 100 }) + const patient = owner.refresh({ timeoutMs: 1_000 }) + await vi.advanceTimersByTimeAsync(100) + expect((await impatient).ok).toBe(false) + expect(request).toHaveBeenCalledOnce() + expect(request.mock.calls[0][0].aborted).toBe(false) + pending.resolve(success()) + expect((await patient).ok).toBe(true) +}) + +it('uses ready transitions, not diagnostic updates or a healthy polling timer', async () => { + const { owner, request } = createOwner(true) + await owner.refresh() + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(0) + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledTimes(2) + owner.connectionChanged('disconnected') + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledTimes(2) + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(3) +}) + +it('retries a failed status operation while retaining healthy transport and last good metadata', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + await owner.refresh() + request.mockResolvedValueOnce(runtimeHostStatusFailure('runtime_unavailable', 'status timed out')) + await owner.refresh() + expect(owner.read()).toMatchObject({ + transport: 'ready', + verification: 'unavailable', + status: { runtimeId: 'host-1' } + }) + await vi.advanceTimersByTimeAsync(3_000) + expect(owner.read().verification).toBe('verified') + expect(request).toHaveBeenCalledTimes(3) +}) + +it('retires a lost-socket request before explicit fallback and rejects its late result', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + const old = deferred() + request.mockReturnValueOnce(old.promise) + const waiting = owner.refresh() + owner.connectionChanged('disconnected') + expect(request.mock.calls[0][0].aborted).toBe(true) + request.mockResolvedValueOnce(success('fallback-host')) + expect((await owner.refresh()).ok).toBe(true) + expect((await waiting).ok).toBe(true) + old.resolve(success('obsolete-host')) + await vi.advanceTimersByTimeAsync(0) + expect(owner.read().status?.runtimeId).toBe('fallback-host') + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(3) +}) + +it('a reconnect transfers waiting readers to a fresh verification', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + const old = deferred() + request.mockReturnValueOnce(old.promise) + const waiting = owner.refresh() + owner.connectionChanged('disconnected') + owner.connectionChanged('ready') + expect((await waiting).ok).toBe(true) + old.resolve(success('old')) + await vi.advanceTimersByTimeAsync(0) + expect(owner.read().status?.runtimeId).toBe('host-1') +}) + +it('disconnect settles readers and prevents late results and retry resurrection', async () => { + const { owner, request, publish } = createOwner() + const old = deferred() + request.mockReturnValue(old.promise) + const waiting = owner.refresh() + owner.dispose() + expect((await waiting).ok).toBe(false) + const sequence = owner.read().sequence + old.resolve(success()) + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(300_000) + expect(owner.read()).toMatchObject({ retired: true, sequence }) + expect(publish.mock.lastCall?.[0].retired).toBe(true) + expect(request).toHaveBeenCalledOnce() +}) + +it('passive reads create neither standing retries nor connection intent', async () => { + const { owner, request, verified } = createOwner() + request.mockResolvedValueOnce(runtimeHostStatusFailure('runtime_unavailable', 'offline')) + await owner.refresh({ observeOnly: true }) + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledOnce() + await owner.refresh({ observeOnly: true }) + expect(verified.mock.lastCall?.[1]).toBe(false) +}) + +it('authentication rejection blocks automatic verification until explicit reconnect', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + request.mockResolvedValueOnce(runtimeHostStatusFailure('unauthorized', 're-pair')) + await owner.refresh() + owner.connectionChanged('disconnected') + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledOnce() + expect((await owner.refresh({ reconnect: true })).ok).toBe(true) +}) + +it('blocks a rejected reconnect even without an outstanding status request', async () => { + const { owner, request } = createOwner(true) + owner.connectionChanged('ready') + await owner.refresh() + owner.connectionChanged('disconnected') + owner.authenticationRejected() + expect(owner.read()).toMatchObject({ verification: 'blocked', status: { runtimeId: 'host-1' } }) + owner.connectionChanged('ready') + await vi.advanceTimersByTimeAsync(300_000) + expect(request).toHaveBeenCalledOnce() +}) + +it('cancelling one reader leaves the shared request available to other readers', async () => { + const { owner, request } = createOwner() + const pending = deferred() + request.mockReturnValue(pending.promise) + const controller = new AbortController() + const cancelled = owner.refresh({ signal: controller.signal }) + const remaining = owner.refresh() + const rejection = expect(cancelled).rejects.toThrow('cancelled') + controller.abort(new Error('cancelled')) + await rejection + expect(request.mock.calls[0][0].aborted).toBe(false) + pending.resolve(success()) + expect((await remaining).ok).toBe(true) +}) + +it.each(['unknown', 'ready'] as const)( + 'distinguishes the caller deadline with %s transport', + async (transport) => { + const { owner, request } = createOwner() + owner.connectionChanged(transport) + request.mockReturnValue(deferred().promise) + const response = owner.refresh({ timeoutMs: 100 }) + await vi.advanceTimersByTimeAsync(100) + expect(await response).toMatchObject({ + ok: false, + error: { + message: + transport === 'ready' + ? 'Status request timed out.' + : 'Timed out waiting for the remote Orca runtime.' + } + }) + } +) diff --git a/src/shared/runtime-host-status-owner.ts b/src/shared/runtime-host-status-owner.ts new file mode 100644 index 00000000000..d9c52395c89 --- /dev/null +++ b/src/shared/runtime-host-status-owner.ts @@ -0,0 +1,274 @@ +import { + isRuntimeHostStatusBlocked, + runtimeHostStatusError, + runtimeHostStatusFailure, + type RuntimeHostStatusResponse, + type RuntimeHostStatusSnapshot +} from './runtime-host-status' + +const RETRY_DELAYS_MS = [3_000, 6_000, 12_000, 30_000, 60_000] +const REQUEST_TIMEOUT_MS = 15_000 +let publicationSequence = 0 + +type Waiter = { + resolve: (response: RuntimeHostStatusResponse) => void + cleanup: () => void +} + +type StatusOwnerOptions = { + environmentId: string + pairingRevision: number + persistent?: boolean + request: (signal: AbortSignal) => Promise + verified: (response: Extract, active: boolean) => boolean + publish: (snapshot: RuntimeHostStatusSnapshot) => void +} + +/** One verification and one retry slot, shared by all readers of this connection. */ +export class RuntimeHostStatusOwner { + private active = false + private disposed = false + private persistent: boolean + private attempt = 0 + private retry: ReturnType | null = null + private request: AbortController | null = null + private readonly waiters = new Set() + private response: RuntimeHostStatusResponse = runtimeHostStatusFailure( + 'runtime_unavailable', + 'Status has not been checked.' + ) + private snapshot: RuntimeHostStatusSnapshot + + constructor(private readonly options: StatusOwnerOptions) { + this.persistent = options.persistent ?? false + this.snapshot = { + environmentId: options.environmentId, + pairingRevision: options.pairingRevision, + sequence: ++publicationSequence, + checkedAt: 0, + status: null, + verification: 'checking', + transport: 'unknown' + } + } + + read(): RuntimeHostStatusSnapshot { + return this.snapshot + } + + activate(): void { + if (this.active || this.disposed) { + return + } + this.active = true + this.startRequest() + } + + acceptVerified(response: Extract): void { + if (this.disposed) { + return + } + this.active = true + this.retireRequest() + this.clearRetry() + this.complete(response) + } + + refresh( + options: { timeoutMs?: number; observeOnly?: true; reconnect?: true; signal?: AbortSignal } = {} + ): Promise { + if (options.signal?.aborted) { + return Promise.reject(options.signal.reason) + } + if (this.disposed) { + return Promise.resolve(this.response) + } + if (!options.observeOnly) { + this.active = true + } + if (options.reconnect) { + this.attempt = 0 + this.update({ verification: 'checking' }) + } + if (this.snapshot.verification === 'blocked') { + return Promise.resolve(this.response) + } + const result = new Promise((resolve, reject) => { + const release = (): void => { + waiter.cleanup() + this.waiters.delete(waiter) + if (!this.active && this.waiters.size === 0) { + this.retireRequest() + } + } + const abort = (): void => { + release() + reject(options.signal?.reason) + } + const timer = setTimeout(() => { + release() + resolve( + runtimeHostStatusFailure( + 'runtime_unavailable', + this.snapshot.transport === 'ready' + ? 'Status request timed out.' + : 'Timed out waiting for the remote Orca runtime.' + ) + ) + }, options.timeoutMs ?? REQUEST_TIMEOUT_MS) + const waiter: Waiter = { + resolve, + cleanup: () => { + clearTimeout(timer) + options.signal?.removeEventListener('abort', abort) + } + } + this.waiters.add(waiter) + options.signal?.addEventListener('abort', abort, { once: true }) + }) + this.startRequest() + return result + } + + connectionChanged( + transport: RuntimeHostStatusSnapshot['transport'], + remoteControl?: RuntimeHostStatusSnapshot['remoteControl'] + ): void { + if (this.disposed) { + return + } + const previous = this.snapshot.transport + this.update({ transport, ...(remoteControl !== undefined ? { remoteControl } : {}) }) + if (transport === previous) { + return + } + if (previous === 'ready') { + this.retireRequest() + this.clearRetry() + if (this.snapshot.verification !== 'blocked') { + this.update({ verification: 'unavailable' }) + } + } + if (transport === 'ready' && this.snapshot.verification !== 'blocked') { + // A pre-reconnect answer cannot verify the new socket's runtime. + this.retireRequest() + if (this.active || this.waiters.size > 0) { + this.startRequest() + } + } + } + + authenticationRejected(): void { + if (this.disposed) { + return + } + this.retireRequest() + this.clearRetry() + this.complete(runtimeHostStatusFailure('unauthorized', 'Pair this client again.')) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.active = false + this.retireRequest() + this.clearRetry() + this.response = runtimeHostStatusFailure( + 'runtime_manually_disconnected', + 'Runtime environment was disconnected or replaced.' + ) + this.update({ retired: true, transport: 'disconnected', verification: 'blocked' }) + this.settleWaiters() + } + + private startRequest(): void { + if (this.disposed || this.request || this.snapshot.verification === 'blocked') { + return + } + this.clearRetry() + const controller = new AbortController() + this.request = controller + if (this.snapshot.verification !== 'verified') { + this.update({ verification: 'checking' }) + } + void this.verify(controller) + } + + private async verify(controller: AbortController): Promise { + let response: RuntimeHostStatusResponse + try { + response = await this.options.request(controller.signal) + } catch (error) { + response = runtimeHostStatusError(error) + if (error instanceof TypeError || error instanceof SyntaxError) { + console.error('Runtime status verification failed:', error) + response = runtimeHostStatusFailure('invalid_runtime_response', error.message) + } + } + if (this.request !== controller || this.disposed) { + return + } + this.request = null + this.complete(response) + } + + private complete(response: RuntimeHostStatusResponse): void { + this.response = response + if (response.ok) { + this.attempt = 0 + this.update({ status: response.result, checkedAt: Date.now(), verification: 'verified' }) + this.persistent = this.options.verified(response, this.active) + } else { + this.update({ + checkedAt: Date.now(), + verification: isRuntimeHostStatusBlocked(response) ? 'blocked' : 'unavailable' + }) + this.scheduleRetry() + } + this.settleWaiters() + } + + private scheduleRetry(): void { + if ( + !this.active || + this.disposed || + this.snapshot.verification === 'blocked' || + (this.persistent && this.snapshot.transport !== 'ready') + ) { + return + } + const delay = RETRY_DELAYS_MS[Math.min(this.attempt++, RETRY_DELAYS_MS.length - 1)] + this.retry = setTimeout(() => { + this.retry = null + this.startRequest() + }, delay) + } + + private settleWaiters(): void { + for (const waiter of this.waiters) { + waiter.cleanup() + waiter.resolve(this.response) + } + this.waiters.clear() + } + + private retireRequest(): void { + const request = this.request + this.request = null + request?.abort() + } + + private clearRetry(): void { + if (this.retry) { + clearTimeout(this.retry) + } + this.retry = null + } + + private update(patch: Partial): void { + this.snapshot = { ...this.snapshot, ...patch, sequence: ++publicationSequence } + this.options.publish(this.snapshot) + } +} diff --git a/src/shared/runtime-host-status.ts b/src/shared/runtime-host-status.ts new file mode 100644 index 00000000000..4dd7ff7cc22 --- /dev/null +++ b/src/shared/runtime-host-status.ts @@ -0,0 +1,44 @@ +import type { RemoteRuntimeSharedConnectionDiagnostics } from './remote-runtime-shared-control-types' +import type { RuntimeRpcFailure, RuntimeRpcResponse } from './runtime-rpc-envelope' +import type { RuntimeStatus } from './runtime-types' + +export const RUNTIME_HOST_STATUS_CHANNEL = 'runtimeEnvironments:statusChanged' + +/** Local client state; never exchanged with the paired host. */ +export type RuntimeHostStatusSnapshot = { + environmentId: string + pairingRevision: number + sequence: number + checkedAt: number + status: RuntimeStatus | null + verification: 'checking' | 'verified' | 'unavailable' | 'blocked' + transport: 'unknown' | 'connecting' | 'ready' | 'disconnected' + remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null + retired?: true +} + +export type RuntimeHostStatusResponse = RuntimeRpcResponse + +export function runtimeHostStatusFailure(code: string, message: string): RuntimeRpcFailure { + return { id: 'status.get', ok: false, error: { code, message } } +} + +export function runtimeHostStatusError(error: unknown): RuntimeRpcFailure { + const code = + error instanceof Error && 'code' in error && typeof error.code === 'string' + ? error.code + : 'runtime_unavailable' + return runtimeHostStatusFailure(code, error instanceof Error ? error.message : String(error)) +} + +export function isRuntimeHostStatusBlocked(response: RuntimeRpcFailure): boolean { + return [ + 'unauthorized', + 'forbidden', + 'invalid_argument', + 'invalid_runtime_response', + 'protocol_version_mismatch', + 'method_not_found', + 'unsupported_method' + ].includes(response.error.code) +} diff --git a/src/shared/structured-agent-session-live-turn.test.ts b/src/shared/structured-agent-session-live-turn.test.ts new file mode 100644 index 00000000000..b9ce5f2c022 --- /dev/null +++ b/src/shared/structured-agent-session-live-turn.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { isStructuredAgentSessionThinking } from './structured-agent-session-live-turn' + +function item( + itemId: string, + sequence: number, + body: AgentJournalRenderItem['body'] +): AgentJournalRenderItem { + return { itemId, sequence, revision: 1, observedAt: sequence, body } +} + +describe('isStructuredAgentSessionThinking', () => { + const turnStart = item('turn-start', 1, { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + }) + const reasoning = (sequence: number): AgentJournalRenderItem => + item(`reasoning-${sequence}`, sequence, { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }] + }) + + it('is true while reasoning is the newest thing the turn produced', () => { + expect(isStructuredAgentSessionThinking([turnStart, reasoning(2)])).toBe(true) + }) + + it('is false once a tool call, a message or a diff lands after the reasoning', () => { + const after = (body: AgentJournalRenderItem['body']): boolean => + isStructuredAgentSessionThinking([turnStart, reasoning(2), item('after', 3, body)]) + expect(after({ kind: 'tool-call', name: 'shell', input: null, state: 'running' })).toBe(false) + expect( + after({ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Here you go' }] }) + ).toBe(false) + expect( + after({ + kind: 'diff', + path: 'src/a.ts', + patch: { head: '@@', byteLength: 2, digest: 'd', truncated: false } + }) + ).toBe(false) + }) + + it('is false when a turn produced no reasoning at all', () => { + expect(isStructuredAgentSessionThinking([turnStart])).toBe(false) + expect(isStructuredAgentSessionThinking([])).toBe(false) + }) + + it('does not read an earlier turn as this one reasoning', () => { + // The scan stops at this turn's own record, so the previous turn's reasoning + // cannot leak forward into a turn that has produced nothing yet. + const newTurn = item('turn-2-start', 2, { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-2', state: 'running' } + }) + expect(isStructuredAgentSessionThinking([reasoning(1), newTurn])).toBe(false) + }) + + it('does not read a completed turn as reasoning during the next pending dispatch', () => { + const completedTurn = item('turn-1', 1, { + kind: 'turn', + turnId: 'turn-1', + state: 'completed' + }) + expect(isStructuredAgentSessionThinking([completedTurn, reasoning(2)])).toBe(false) + }) + + it('stops at a typed turn item, the carrier this host writes', () => { + const typedTurn = (sequence: number, turnId: string): AgentJournalRenderItem => + item(`turn-${turnId}`, sequence, { kind: 'turn', turnId, state: 'running' }) + expect(isStructuredAgentSessionThinking([typedTurn(1, 'turn-1'), reasoning(2)])).toBe(true) + expect(isStructuredAgentSessionThinking([reasoning(1), typedTurn(2, 'turn-2')])).toBe(false) + }) + + it('lets an unmarked status stay transparent to the latest reasoning state', () => { + const plan = item('plan', 3, { kind: 'status', text: 'Step 1. Read the file' }) + expect(isStructuredAgentSessionThinking([turnStart, plan])).toBe(false) + expect(isStructuredAgentSessionThinking([turnStart, reasoning(2), plan])).toBe(true) + }) + + it.each([ + { + kind: 'approval' as const, + title: 'Run the command?', + detail: null, + options: [], + resolution: { + state: 'pending' as const, + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + }, + { + kind: 'question' as const, + question: 'Which path?', + options: [], + resolution: { + state: 'pending' as const, + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + ])('stops thinking when the turn is waiting on a $kind', (prompt) => { + expect( + isStructuredAgentSessionThinking([turnStart, reasoning(2), item('prompt', 3, prompt)]) + ).toBe(false) + }) +}) diff --git a/src/shared/structured-agent-session-live-turn.ts b/src/shared/structured-agent-session-live-turn.ts new file mode 100644 index 00000000000..319a6d7d579 --- /dev/null +++ b/src/shared/structured-agent-session-live-turn.ts @@ -0,0 +1,76 @@ +// What the newest turn in a structured journal is doing right now, read off the +// tail of the item list. Every scan here stops at the turn's own record — the +// typed `turn` item, or the legacy status row that carries one — because state +// from an earlier turn is never this turn's state. + +import type { + AgentJournalRenderItem, + AgentJournalToolCallItem +} from './agent-session-journal-types' +import { readAgentJournalTurn } from './agent-session-turn-record' + +export function activeStructuredAgentSessionTurnId( + items: readonly AgentJournalRenderItem[] +): string | null { + for (let index = items.length - 1; index >= 0; index -= 1) { + const turn = readAgentJournalTurn(items[index]?.body) + if (turn) { + return turn.state === 'running' ? turn.turnId : null + } + } + return null +} + +/** + * Whether the newest thing the active turn produced is the model's own reasoning. + * + * This is what "thinking" has to mean for the indicator to be honest: the turn is reasoning + * *right now*. The older rule — "the turn has produced no renderable output yet" — reports + * thinking while the request is merely in flight, and stops reporting it the moment a tool call + * lands, which is usually when reasoning actually starts. + */ +export function isStructuredAgentSessionThinking( + items: readonly AgentJournalRenderItem[] +): boolean { + let newestContentIsReasoning: boolean | null = null + for (let index = items.length - 1; index >= 0; index -= 1) { + const body = items[index]?.body + const turn = readAgentJournalTurn(body) + if (turn) { + return turn.state === 'running' && newestContentIsReasoning === true + } + if (newestContentIsReasoning !== null) { + continue + } + if (body?.kind === 'message') { + newestContentIsReasoning = body.role === 'reasoning' + } else if ( + body?.kind === 'tool-call' || + body?.kind === 'diff' || + body?.kind === 'approval' || + body?.kind === 'question' + ) { + newestContentIsReasoning = false + } + // Plain status copy is activity chrome, not newer transcript content. + } + return false +} + +/** The tool call the newest turn is still inside, or null when nothing is running. + * An abandoned `running` call from an earlier crashed turn can never be reported + * as live work. */ +export function activeStructuredAgentSessionToolCall( + items: readonly AgentJournalRenderItem[] +): AgentJournalToolCallItem | null { + for (let index = items.length - 1; index >= 0; index -= 1) { + const body = items[index]?.body + if (readAgentJournalTurn(body)) { + return null + } + if (body?.kind === 'tool-call' && body.state === 'running') { + return body + } + } + return null +} diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index bd13d600a34..e0189ed7124 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -3,20 +3,26 @@ import { normalizeOptionalField, normalizePromptField } from './agent-status-field-normalization' -import type { - AgentJournalRenderItem, - AgentJournalSubmission, - AgentJournalToolCallItem -} from './agent-session-journal-types' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_STATUS_TOOL_NAME_MAX_LENGTH } from './agent-status-types' import { describeToolInput } from './native-chat-tool-summary' -import { readAgentJournalTurn } from './agent-session-turn-record' +import { + activeStructuredAgentSessionToolCall, + activeStructuredAgentSessionTurnId +} from './structured-agent-session-live-turn' + import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' import { sha256 } from './sha256' +// Re-exported so the live-turn readers' existing consumers keep one import site. +export { + activeStructuredAgentSessionToolCall, + activeStructuredAgentSessionTurnId +} from './structured-agent-session-live-turn' + function boundedText(payload: { head: string; truncated: boolean; byteLength: number }): string { return payload.truncated ? `${payload.head}\n… (${payload.byteLength} bytes)` : payload.head } @@ -159,18 +165,6 @@ export function projectStructuredItemToNativeChat( return message } -export function activeStructuredAgentSessionTurnId( - items: readonly AgentJournalRenderItem[] -): string | null { - for (let index = items.length - 1; index >= 0; index -= 1) { - const turn = readAgentJournalTurn(items[index]?.body) - if (turn) { - return turn.state === 'running' ? turn.turnId : null - } - } - return null -} - export function hasPersistedStructuredAgentSessionTurn( items: readonly AgentJournalRenderItem[] ): boolean { @@ -270,24 +264,6 @@ export function latestStructuredAgentSessionAssistantMessage( return '' } -/** The tool call the newest turn is still inside, or null when nothing is running. - * Scanning stops at the turn's own lifecycle row so an abandoned `running` call - * from an earlier crashed turn can never be reported as live work. */ -export function activeStructuredAgentSessionToolCall( - items: readonly AgentJournalRenderItem[] -): AgentJournalToolCallItem | null { - for (let index = items.length - 1; index >= 0; index -= 1) { - const body = items[index]?.body - if (readAgentJournalTurn(body)) { - return null - } - if (body?.kind === 'tool-call' && body.state === 'running') { - return body - } - } - return null -} - /** The activity fields a sidebar row shows beside the prompt, named as the agent-status * entry names them so the client can hand them straight to a row. */ export type StructuredAgentSessionStatusProjection = { diff --git a/src/shared/structured-agent-session-read-refusal.ts b/src/shared/structured-agent-session-read-refusal.ts new file mode 100644 index 00000000000..18487f12be3 --- /dev/null +++ b/src/shared/structured-agent-session-read-refusal.ts @@ -0,0 +1,45 @@ +/** + * The one refusal a structured-session READ can raise that is not a failure to read. + * + * `agentSession.history` and `agentSession.subscribe` both resolve the session through the host's + * `requireSession`, which raises this code when the host holds no session object by that id. That + * is never a transcript Orca could not read — it is a session this host has not attached YET (the + * surface's hold is what attaches one) or one it has just closed. Both windows end on their own: + * the first when the hold lands, the second when the chat tab retires. + * + * The genuinely latched lease — "Orca cannot prove the previous owner exited" — reaches the client + * through the ACQUISITION path instead, so narrowing on the code costs a read no real diagnosis. + * See `agent-session-lease-adjudication`. + */ + +/** Raised by a host that holds no attached session by that id. */ +export const AGENT_SESSION_UNATTACHED_REFUSAL_CODE = 'agent_session_ownership_unknown' + +/** + * How long a read may keep refusing this way before the pane is allowed to call it a failure. + * + * Both windows this code covers are sub-second in practice, so an unattached read that outlives + * this one is no longer transitional and the user is owed the error rather than a spinner that + * never resolves. + */ +export const AGENT_SESSION_UNATTACHED_READ_GRACE_MS = 5_000 + +/** + * Whether a read failure is that refusal. + * + * Takes both shapes the client sees: the thrown RPC error, whose `code` and `message` are each the + * bare refusal code, and the raw failure payload a stream delivers to its error callback. + */ +export function isUnattachedAgentSessionReadRefusal(error: unknown): boolean { + if (typeof error === 'string') { + return error === AGENT_SESSION_UNATTACHED_REFUSAL_CODE + } + if (typeof error !== 'object' || error === null) { + return false + } + const { code, message } = error as { code?: unknown; message?: unknown } + return ( + code === AGENT_SESSION_UNATTACHED_REFUSAL_CODE || + message === AGENT_SESSION_UNATTACHED_REFUSAL_CODE + ) +} diff --git a/src/shared/structured-agent-session-turn-timing.test.ts b/src/shared/structured-agent-session-turn-timing.test.ts index f1487f0ef42..769d30fab10 100644 --- a/src/shared/structured-agent-session-turn-timing.test.ts +++ b/src/shared/structured-agent-session-turn-timing.test.ts @@ -158,7 +158,7 @@ describe('host-settled turns override local observation', () => { { activeTurnKey: 'u1', isWorking: false, - hasCurrentTurnResponse: true, + thinking: false, settledByTurn: settled } ) diff --git a/src/shared/worktree/removal.ts b/src/shared/worktree/removal.ts index 59e5803eddb..237d73630df 100644 --- a/src/shared/worktree/removal.ts +++ b/src/shared/worktree/removal.ts @@ -22,11 +22,12 @@ export type WorktreeForceDeleteReason = // rather than scanning the whole message and letting a path spell out a verdict. export const UNSTOPPED_PTY_DETAIL_SEPARATOR = ' — ' -// Why: verification distinguishes a PTY it watched stay alive from one it could not reach, +// Why: verification distinguishes a process it watched stay alive from one it could not reach, // and the delete toast must not flatten the two — a user waiving "we could not confirm" is -// making a different decision than one killing a terminal Orca just saw running. The marker -// and its matcher stay together for the same reason the force hint does. -export const UNSTOPPED_PTY_LIVE_DETAIL_PREFIX = 'still live:' +// making a different decision than one killing something Orca just saw running. The marker +// and its matcher stay together for the same reason the force hint does. Shared by the PTY +// sweep and the structured-session sweep, which both re-observe after their stop. +export const STILL_LIVE_DETAIL_PREFIX = 'still live:' // Why (#11960): a sweep that never answers wedges removal exactly like a stop that could not // be proven, and the waiver clears both — but this error carries different words, so without @@ -54,7 +55,15 @@ export function isUnstoppedPtyRemovalError(error: string): boolean { export function isProvenLivePtyRemovalError(error: string): boolean { return ( isUnstoppedPtyRemovalError(error) && - error.includes(`${UNSTOPPED_PTY_DETAIL_SEPARATOR}${UNSTOPPED_PTY_LIVE_DETAIL_PREFIX}`) + error.includes(`${UNSTOPPED_PTY_DETAIL_SEPARATOR}${STILL_LIVE_DETAIL_PREFIX}`) + ) +} + +/** True only when the observation AFTER the close found the session still attached. */ +export function isProvenLiveStructuredSessionRemovalError(error: string): boolean { + return ( + isRunningAgentSessionRemovalError(error) && + error.includes(`${UNSTOPPED_PTY_DETAIL_SEPARATOR}${STILL_LIVE_DETAIL_PREFIX}`) ) } diff --git a/tests/e2e/helpers/slept-workspace-probe.ts b/tests/e2e/helpers/slept-workspace-probe.ts new file mode 100644 index 00000000000..b2b5635b526 --- /dev/null +++ b/tests/e2e/helpers/slept-workspace-probe.ts @@ -0,0 +1,133 @@ +/** + * Shared probes for GH #10205: a deliberately slept workspace must stay cold. + * Drives the shipping sleep path (sidebar context menu) and reads both the + * renderer's live PTY model and host truth. + */ +import type { Locator, Page } from '@stablyai/playwright-test' +import { expect } from '@stablyai/playwright-test' +import { ensureTerminalVisible } from './store' +import { waitForActivePanePtyId, waitForActiveTerminalManager } from './terminal' + +export type WorkspaceSample = { + livePtyCount: number + tabCount: number + tabIds: string[] + mountedTabIds: string[] + tabPtyHints: (string | null)[] +} + +export function rowLocator(page: Page, worktreeId: string): Locator { + return page + .locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(worktreeId)}]` + ) + .first() +} + +export async function readWorkspaceSample( + page: Page, + worktreeId: string +): Promise { + return page.evaluate((id) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('window.__store is not available') + } + const tabs = state.tabsByWorktree[id] ?? [] + const tabIds = new Set(tabs.map((tab) => tab.id)) + const managers = window.__paneManagers + return { + livePtyCount: tabs.reduce( + (count, tab) => count + (state.ptyIdsByTabId[tab.id]?.length ?? 0), + 0 + ), + tabCount: tabs.length, + tabIds: tabs.map((tab) => tab.id), + mountedTabIds: managers + ? Array.from(managers.keys()).filter((tabId) => tabIds.has(tabId)) + : [], + tabPtyHints: tabs.map((tab) => tab.ptyId ?? null) + } + }, worktreeId) +} + +/** Host-side truth: a revived workspace shows a freshly created live session here. */ +export async function readHostLiveTerminalCount(page: Page, worktreeId: string): Promise { + return (await page.evaluate(async (id) => { + const result = await window.api.runtime.call({ + method: 'terminal.list', + params: { worktree: `id:${id}`, requireFreshPtyLiveness: true } + }) + if (!result.ok) { + throw new Error(result.error.message) + } + return (result.result as { totalCount: number }).totalCount + }, worktreeId)) as number +} + +/** Connect-verdict lines (REATTACH / ATTACH / FRESH SPAWN / SKIP SPAWN) for one workspace. */ +export async function readConnectDiagnostics(page: Page, worktreeId: string): Promise { + return page.evaluate((id) => { + const state = window.__store?.getState() + const target = globalThis as unknown as Record + const diag = (target.__ptyConnectDiag as string[] | undefined) ?? [] + const tabIds = new Set((state?.tabsByWorktree[id] ?? []).map((tab) => tab.id)) + // Pane ids restart at 1 per worktree, so a verdict line is attributed to the + // tab named by the most recent connect line for that same pane id. + const tabByPaneId = new Map() + const owned: string[] = [] + for (const line of diag) { + const connect = /^pane=(\d+) tab=(\S+) /.exec(line) + if (connect) { + tabByPaneId.set(connect[1], connect[2]) + if (tabIds.has(connect[2])) { + owned.push(line) + } + continue + } + const verdict = /^pane=(\d+) ->/.exec(line) + if (verdict) { + const tabId = tabByPaneId.get(verdict[1]) + if (tabId && tabIds.has(tabId)) { + owned.push(line) + } + } + } + return owned + }, worktreeId) +} + +export async function giveWorkspaceALivePty(page: Page, worktreeId: string): Promise { + await page.evaluate((id) => { + window.__store?.getState().setActiveWorktree(id) + }, worktreeId) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + return waitForActivePanePtyId(page, 30_000) +} + +/** The shipping sleep path: right-click the sidebar row, click "Sleep". */ +export async function sleepWorkspaceViaSidebar(page: Page, worktreeId: string): Promise { + const row = rowLocator(page, worktreeId) + await expect(row).toBeVisible() + await row.scrollIntoViewIfNeeded() + const scope = row.locator('[data-worktree-context-menu-scope="worktree"]').first() + const target = (await scope.count()) > 0 ? scope : row + await target.click({ button: 'right' }) + const sleepItem = page.getByRole('menuitem', { name: 'Sleep', exact: true }).first() + await expect(sleepItem).toBeVisible() + await sleepItem.click() +} + +export async function activateWorkspaceByClick(page: Page, worktreeId: string): Promise { + const row = rowLocator(page, worktreeId) + await expect(row).toBeVisible() + await row.scrollIntoViewIfNeeded() + await row.click() + await expect + .poll(() => page.evaluate(() => window.__store?.getState().activeWorktreeId ?? null), { + timeout: 10_000, + message: `sidebar click did not activate ${worktreeId}` + }) + .toBe(worktreeId) +} diff --git a/tests/e2e/runtime-host-status-recovery.spec.ts b/tests/e2e/runtime-host-status-recovery.spec.ts new file mode 100644 index 00000000000..09707606da1 --- /dev/null +++ b/tests/e2e/runtime-host-status-recovery.spec.ts @@ -0,0 +1,221 @@ +import { createConnection, createServer, type Socket, type AddressInfo } from 'node:net' +import type { Page } from '@stablyai/playwright-test' +import { decodePairingOffer, encodePairingOffer } from '../../src/shared/pairing' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + launchPairedWebClient, + type RuntimeDesktopPairingOffer +} from './helpers/paired-electron-client' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' + +async function interruptibleHost(offer: RuntimeDesktopPairingOffer) { + const pairing = decodePairingOffer(offer.pairingUrl) + const endpoint = new URL(pairing.endpoint) + const sockets = new Set() + let online = true + const server = createServer((client) => { + if (!online) { + client.destroy() + return + } + const host = createConnection({ host: endpoint.hostname, port: Number(endpoint.port) }) + for (const socket of [client, host]) { + sockets.add(socket) + socket.on('error', () => { + client.destroy() + host.destroy() + }) + socket.on('close', () => { + sockets.delete(socket) + client.destroy() + host.destroy() + }) + } + client.pipe(host).pipe(client) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() as AddressInfo + const pairingUrl = encodePairingOffer({ ...pairing, endpoint: `ws://127.0.0.1:${address.port}` }) + let webClientUrl: string | undefined + if (offer.webClientUrl) { + const url = new URL(offer.webClientUrl) + url.search = '' + url.hash = new URLSearchParams({ pairing: pairingUrl }).toString() + webClientUrl = url.href + } + return { + offer: { pairingUrl, webClientUrl }, + setOnline(value: boolean) { + online = value + if (!online) { + sockets.forEach((socket) => socket.destroy()) + } + }, + async close() { + sockets.forEach((socket) => socket.destroy()) + await new Promise((resolve) => server.close(() => resolve())) + } + } +} + +async function statusEvidence(page: Page, environmentId?: string) { + return page.evaluate((id) => { + const entries = window.__store?.getState().runtimeStatusByEnvironmentId + const entry = id ? entries?.get(id) : entries?.values().next().value + return entry?.snapshot + ? { + verification: entry.snapshot.verification, + transport: entry.snapshot.transport, + runtimeId: entry.status?.runtimeId, + sequence: entry.snapshot.sequence + } + : null + }, environmentId) +} + +async function expectWorkspaceHostAppearance( + page: Page, + disconnected: boolean, + hostLabel?: string +) { + const cards = page.locator('[data-worktree-card-surface="true"]') + const card = ( + hostLabel ? cards.filter({ has: page.getByText(hostLabel, { exact: true }) }) : cards + ).first() + await expect(card).toBeVisible() + await expect(card).toHaveCSS('opacity', disconnected ? '0.6' : '1') + const icon = card.locator(disconnected ? 'svg.lucide-server-off' : 'svg.lucide-server').first() + await expect(icon).toBeVisible() + await expect( + card.locator(disconnected ? 'svg.lucide-server' : 'svg.lucide-server-off') + ).toHaveCount(0) + await expect(icon).toHaveClass(disconnected ? /text-destructive/ : /text-muted-foreground/) + await icon.hover() + await expect( + page.getByRole('tooltip', { name: disconnected ? /disconnected/i : /Project on/ }) + ).toBeVisible() + await page.mouse.move(900, 600) +} + +for (const topology of ['desktop', 'headless'] as const) { + test(`connection-owned status recovers with a ${topology} host and independent viewers`, async ({ + electronApp, + orcaPage: page, + testRepoPath + }, testInfo) => { + test.setTimeout(180_000) + let headless: Awaited> | null = null + let proxy: Awaited> | undefined + let client: Awaited> | undefined + let browser: Awaited> | undefined + try { + headless = + topology === 'headless' + ? await launchHeadlessPairedRuntimeHost({ pinnedServePort: true }) + : null + const offer = headless?.offer ?? (await createRuntimeDesktopPairingOffer(page)) + await (headless + ? headless.client.call('repo.add', { path: testRepoPath }) + : page.evaluate(async (path) => { + await window.api.repos.add({ path }) + await window.__store?.getState().fetchRepos() + }, testRepoPath)) + proxy = await interruptibleHost(offer) + client = await launchPairedElectronClient(offer, testInfo, 'Direct host') + proxy.setOnline(false) + const offlineId = await client.page.evaluate(async (pairingCode) => { + const { environment } = await window.api.runtimeEnvironments.addFromPairingCode({ + name: 'Recovering host', + pairingCode + }) + const store = window.__store!.getState() + store.setRuntimeEnvironments(await window.api.runtimeEnvironments.list()) + await store.refreshRuntimeEnvironmentStatus(environment.id, 1_000) + return environment.id + }, proxy.offer.pairingUrl) + await expect + .poll(() => statusEvidence(client!.page, offlineId)) + .toMatchObject({ verification: 'unavailable' }) + expect(await statusEvidence(client!.page, client.environmentId)).toMatchObject({ + verification: 'verified' + }) + proxy.setOnline(true) + await expect + .poll(() => statusEvidence(client!.page, offlineId), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + const initial = await statusEvidence(client!.page, offlineId) + await expectWorkspaceHostAppearance(client.page, false, 'Recovering host') + await expect(client.page.getByText('Recovering host', { exact: true }).first()).toBeVisible() + await client.page.screenshot({ path: testInfo.outputPath(`${topology}-recovered.png`) }) + browser = await launchPairedWebClient(electronApp, proxy.offer) + await expect + .poll(() => statusEvidence(browser!.page), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + await expectWorkspaceHostAppearance(browser.page, false) + proxy.setOnline(false) + await expect + .poll(() => statusEvidence(client!.page, offlineId)) + .toMatchObject({ transport: 'disconnected' }) + await expect + .poll(() => statusEvidence(browser!.page), { timeout: 30_000 }) + .toMatchObject({ transport: 'disconnected' }) + expect(await statusEvidence(client!.page, client.environmentId)).toMatchObject({ + verification: 'verified', + transport: 'ready' + }) + await expectWorkspaceHostAppearance(client.page, false, 'Recovering host') + await expectWorkspaceHostAppearance(client.page, false, 'Direct host') + await expectWorkspaceHostAppearance(browser.page, false) + await client.page.screenshot({ + path: testInfo.outputPath(`${topology}-sidebar-reconnecting.png`) + }) + await browser.page.screenshot({ + path: testInfo.outputPath(`${topology}-browser-reconnecting.png`) + }) + proxy.setOnline(true) + await expect + .poll(() => statusEvidence(client!.page, offlineId), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + await expect + .poll(() => statusEvidence(browser!.page), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + expect((await statusEvidence(client!.page, offlineId))!.sequence).toBeGreaterThan( + initial!.sequence + ) + await expectWorkspaceHostAppearance(client.page, false, 'Recovering host') + await expectWorkspaceHostAppearance(browser.page, false) + await browser.page.screenshot({ + path: testInfo.outputPath(`${topology}-browser-recovered.png`) + }) + await client.page.evaluate(async (selector) => { + await window.api.runtimeEnvironments.disconnect({ selector }) + }, offlineId) + await expect + .poll(() => statusEvidence(client!.page, offlineId)) + .toMatchObject({ verification: 'blocked', transport: 'disconnected' }) + await expectWorkspaceHostAppearance(client.page, true, 'Recovering host') + await expectWorkspaceHostAppearance(client.page, false, 'Direct host') + await client.page.screenshot({ + path: testInfo.outputPath(`${topology}-sidebar-disconnected.png`) + }) + await client.page.evaluate(async (selector) => { + await window.api.runtimeEnvironments.connect({ selector }) + }, offlineId) + await expect + .poll(() => statusEvidence(client!.page, offlineId), { timeout: 30_000 }) + .toMatchObject({ verification: 'verified', transport: 'ready' }) + await expectWorkspaceHostAppearance(client.page, false, 'Recovering host') + await expectWorkspaceHostAppearance(browser.page, false) + await client.page.screenshot({ + path: testInfo.outputPath(`${topology}-sidebar-restored.png`) + }) + } finally { + await browser?.dispose() + await client?.dispose() + await proxy?.close() + await headless?.dispose() + } + }) +} diff --git a/tests/e2e/slept-workspace-remount-wake.spec.ts b/tests/e2e/slept-workspace-remount-wake.spec.ts new file mode 100644 index 00000000000..f820a7c7f69 --- /dev/null +++ b/tests/e2e/slept-workspace-remount-wake.spec.ts @@ -0,0 +1,87 @@ +/** + * GH #10205: a manual sleep keeps the tab's session id as a wake hint, so a later + * remount of its still-mounted pane reattaches that dead id and the daemon spawns + * a fresh shell. Production parking timings are deliberate: a shrunk park delay + * unmounts the slept panes and hides the behavior. + */ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { getAllWorktreeIds, waitForSessionReady } from './helpers/store' +import { + activateWorkspaceByClick, + giveWorkspaceALivePty, + readConnectDiagnostics, + readHostLiveTerminalCount, + readWorkspaceSample, + sleepWorkspaceViaSidebar +} from './helpers/slept-workspace-probe' + +const OBSERVATION_MS = 8_000 +const SAMPLE_INTERVAL_MS = 200 + +async function assertStaysCold(page: Page, worktreeId: string): Promise { + let peakLivePty = 0 + let peakTabs = 0 + const deadline = Date.now() + OBSERVATION_MS + while (Date.now() < deadline) { + const sample = await readWorkspaceSample(page, worktreeId) + peakLivePty = Math.max(peakLivePty, sample.livePtyCount) + peakTabs = Math.max(peakTabs, sample.tabCount) + await page.waitForTimeout(SAMPLE_INTERVAL_MS) + } + const hostLive = await readHostLiveTerminalCount(page, worktreeId) + const diag = await readConnectDiagnostics(page, worktreeId) + console.error(`[#10205] ${JSON.stringify({ peakLivePty, peakTabs, hostLive, diag })}`) + expect(peakLivePty, 'slept workspace grew a live PTY').toBe(0) + expect(peakTabs, 'slept workspace grew a tab').toBe(1) + expect(hostLive, 'host created a session for the slept workspace').toBe(0) + // Why: proves the gate held rather than the pane having quietly unmounted. + expect(diag.at(-1), 'remounted pane did not wait for the wake').toContain('WAIT FOR WAKE') +} + +test('remounting a slept hidden pane does not respawn its PTY', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const [slept, other] = await getAllWorktreeIds(orcaPage) + expect(other, 'seeded repo must expose two worktrees').toBeTruthy() + await giveWorkspaceALivePty(orcaPage, slept) + await giveWorkspaceALivePty(orcaPage, other) + await activateWorkspaceByClick(orcaPage, slept) + expect((await readWorkspaceSample(orcaPage, slept)).livePtyCount).toBeGreaterThan(0) + + await sleepWorkspaceViaSidebar(orcaPage, slept) + await expect + .poll(async () => (await readWorkspaceSample(orcaPage, slept)).livePtyCount, { + timeout: 20_000, + message: 'sleep did not release the workspace PTYs' + }) + .toBe(0) + await activateWorkspaceByClick(orcaPage, other) + + const sample = await readWorkspaceSample(orcaPage, slept) + const sleptTabId = sample.tabIds[0] + expect(sleptTabId, 'slept workspace must retain a tab').toBeTruthy() + // Presence preconditions: the pane is still mounted and still carries its wake hint, + // otherwise a remount has nothing to reattach and the oracle passes vacuously. + expect(sample.mountedTabIds, 'slept pane was parked before the remount').toContain(sleptTabId) + expect(sample.tabPtyHints[0], 'sleep must keep the session id as a wake hint').toBeTruthy() + + const remounted = await orcaPage.evaluate( + (tabId) => window.__store?.getState().remountTerminalTabForRecovery(tabId) ?? false, + sleptTabId + ) + expect(remounted, 'remountTerminalTabForRecovery did not find the slept tab').toBe(true) + await assertStaysCold(orcaPage, slept) + + // Non-vacuity: a deliberate click must still wake it, and exactly once — the + // waiting pane and its remounted successor must not both reattach. + await activateWorkspaceByClick(orcaPage, slept) + await expect + .poll(async () => (await readWorkspaceSample(orcaPage, slept)).livePtyCount, { + timeout: 40_000, + message: 'the slept workspace never wakes even on deliberate activation' + }) + .toBeGreaterThan(0) + await orcaPage.waitForTimeout(3_000) + expect((await readWorkspaceSample(orcaPage, slept)).livePtyCount).toBe(1) + expect(await readHostLiveTerminalCount(orcaPage, slept)).toBe(1) +}) diff --git a/tests/e2e/worktree-switch-first-paint.spec.ts b/tests/e2e/worktree-switch-first-paint.spec.ts new file mode 100644 index 00000000000..aaaea9aaac8 --- /dev/null +++ b/tests/e2e/worktree-switch-first-paint.spec.ts @@ -0,0 +1,479 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { loadWorktreesUntilPathsPresent } from './helpers/worktree-registration' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' + +/** + * Worktree-switch first-paint budget. + * + * Why this exists: worktree-switch-responsiveness.spec.ts proves the click task + * stays short, and the reveal-convergence spec proves the buffer eventually + * matches. Neither covers the symptom users report — the revealed terminal is + * BLANK for a beat after the switch. This measures the phase that owns that + * beat: switch click -> revealed pane has painted its restored content. + * + * The scenario is the one that dominates at many-worktree scale: a switch to a + * worktree whose tabs are in the persisted session but have never been mounted + * in this renderer. Hot-retain only keeps 4 worktrees warm, so with hundreds of + * worktrees essentially every switch is this one. Reloading the renderer between + * rounds reproduces it exactly, at production parking timings. + */ + +// Why 3: the field profile that motivated this budget has 449 worktrees whose +// median tab count is 2-3, so a 3-tab worktree is the switch users actually pay for. +const TABS_PER_WORKTREE = Number(process.env.ORCA_SWITCH_TABS ?? '3') +const SCROLLBACK_LINES = 1_500 +// Budget: a switch has to look instant. Anything over this reads as a stall. +const FIRST_PAINT_BUDGET_MS = Number(process.env.ORCA_SWITCH_BUDGET_MS ?? '250') +// Why repeat: a single cold reveal on a loaded dev machine swings by tens of ms, +// which is the same order as the effect under test. +const SWITCH_SAMPLE_COUNT = Number(process.env.ORCA_SWITCH_ROUNDS ?? '5') + +type SwitchSample = { + activationMs: number | null + paneMountedMs: number | null + contentRestoredMs: number | null + maxFrameGapMs: number + longTaskTotalMs: number + worstLongTaskMs: number + mountedAtActivation: number + settledPaneManagers: number + settledPanes: number + settledWebglContexts: number +} + +type SwitchPaintProbe = { + t0: number + activationMs: number | null + paneMountedMs: number | null + contentRestoredMs: number | null + frames: number[] + longTasks: number[] + mountedAtActivation: number + stop: () => void +} + +declare global { + var __switchPaintProbe: SwitchPaintProbe | undefined +} + +async function ensureTabs(page: Page, worktreeId: string, marker: string): Promise { + await switchToWorktree(page, worktreeId) + await ensureTerminalVisible(page) + const tabIds: string[] = [] + for (let index = 0; index < TABS_PER_WORKTREE; index += 1) { + const tabId = await page.evaluate( + ({ id, wanted }) => { + const state = window.__store!.getState() + const existing = state.tabsByWorktree[id] ?? [] + const reuse = existing[wanted] + const tab = reuse ?? state.createTab(id, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, + { id: worktreeId, wanted: index } + ) + await waitForActiveTerminalManager(page, 30_000) + const ptyId = await waitForActivePanePtyId(page, 30_000) + const label = `${marker}_T${index}` + await execInTerminal( + page, + ptyId, + `for i in $(seq 1 ${SCROLLBACK_LINES}); do echo "${label}_$i ${'y'.repeat(48)}"; done; echo ${label}_READY` + ) + await waitForTerminalOutput(page, `${label}_READY`, 60_000) + tabIds.push(tabId) + } + return tabIds +} + +async function waitForUnmountedTabs(page: Page, tabIds: readonly string[]): Promise { + return expect + .poll( + () => + page.evaluate((ids) => ids.every((id) => window.__paneManagers?.has(id) !== true), tabIds), + { timeout: 20_000, message: 'switch target still had mounted panes' } + ) + .toBe(true) + .then( + () => true, + () => false + ) +} + +/** Tabs with a mounted pane, once the post-reveal warm-up has settled. */ +async function waitForMountedTabs(page: Page, tabIds: readonly string[]): Promise { + const read = () => + page.evaluate( + (ids) => ids.filter((id) => window.__paneManagers?.has(id) === true).sort(), + [...tabIds] + ) + await expect + .poll(async () => (await read()).length, { + timeout: 20_000, + message: 'activation-deferred tabs never mounted after the reveal' + }) + .toBe(tabIds.length) + .catch(() => undefined) + return read() +} + +async function measureSwitch( + page: Page, + targetWorktreeId: string, + targetTabIds: readonly string[] +): Promise { + await page.evaluate( + ({ worktreeId, tabIds }) => { + const probe = { + t0: performance.now(), + activationMs: null as number | null, + paneMountedMs: null as number | null, + contentRestoredMs: null as number | null, + frames: [] as number[], + longTasks: [] as number[], + mountedAtActivation: 0, + stop: () => {} + } + globalThis.__switchPaintProbe = probe + let observer: PerformanceObserver | null = null + try { + observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + probe.longTasks.push(entry.duration) + } + }) + observer.observe({ entryTypes: ['longtask'] }) + } catch { + /* longtask unsupported */ + } + let running = true + const visibleTabId = () => { + const state = window.__store!.getState() + return state.activeWorktreeId === worktreeId && state.activeTabType === 'terminal' + ? state.activeTabId + : (state.activeTabIdByWorktree?.[worktreeId] ?? null) + } + const tick = () => { + if (!running) { + return + } + const now = performance.now() - probe.t0 + probe.frames.push(now) + const state = window.__store!.getState() + if (probe.activationMs === null && state.activeWorktreeId === worktreeId) { + probe.activationMs = now + // Why here and not at paint: this is the switch's own frame, before any + // idle admission can run, so it measures what the SWITCH mounted. + probe.mountedAtActivation = tabIds.filter( + (id) => window.__paneManagers?.has(id) === true + ).length + } + const tabId = visibleTabId() + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (probe.paneMountedMs === null && pane?.container?.isConnected) { + probe.paneMountedMs = now + } + if (probe.contentRestoredMs === null && pane) { + // Restored = the revealed viewport carries real text rather than an + // empty grid. Read on a frame callback, so this is the frame the + // content became renderable — one frame ahead of the pixels, and not + // a pixel assertion. Both arms are measured identically. + const buffer = pane.terminal.buffer.active + let filledRows = 0 + for (let row = 0; row < pane.terminal.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row) + if (line && line.translateToString(true).trim().length > 0) { + filledRows += 1 + } + } + if (filledRows >= Math.min(5, pane.terminal.rows)) { + probe.contentRestoredMs = now + } + } + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + probe.stop = () => { + running = false + try { + observer?.disconnect() + } catch { + /* ignore */ + } + } + window.__store!.getState().setActiveWorktree(worktreeId) + }, + { worktreeId: targetWorktreeId, tabIds: [...targetTabIds] } + ) + + // Why poll rather than sample a fixed window: the measurement is "how long did + // the restore take", so the harness must outlast the slowest runner rather than + // give up at a deadline and report the reveal as never restoring. + await expect + .poll(() => page.evaluate(() => globalThis.__switchPaintProbe?.contentRestoredMs ?? null), { + timeout: 30_000, + message: 'revealed terminal never restored its content' + }) + .not.toBeNull() + // Let the idle admission drain so the settled-resource readings are steady. + await page.waitForTimeout(2_000) + + return page.evaluate(() => { + const probe = globalThis.__switchPaintProbe! + probe.stop() + let maxGap = 0 + let previous = 0 + for (const frame of probe.frames) { + maxGap = Math.max(maxGap, frame - previous) + previous = frame + } + let settledPanes = 0 + let settledWebglContexts = 0 + const managers = window.__paneManagers + for (const manager of managers?.values() ?? []) { + settledPanes += (manager.getPanes?.() ?? []).length + // Why diagnostics and not `pane.webglAddon`: getPanes() hands back a public + // projection that has no webglAddon field, so reading it is always falsy. + const diagnostics = + ( + manager as { getRenderingDiagnostics?: () => { hasWebgl?: boolean }[] } + ).getRenderingDiagnostics?.() ?? [] + settledWebglContexts += diagnostics.filter((entry) => entry.hasWebgl === true).length + } + return { + settledPaneManagers: managers?.size ?? 0, + settledPanes, + settledWebglContexts, + activationMs: probe.activationMs, + paneMountedMs: probe.paneMountedMs, + contentRestoredMs: probe.contentRestoredMs, + maxFrameGapMs: +maxGap.toFixed(1), + longTaskTotalMs: +probe.longTasks.reduce((total, value) => total + value, 0).toFixed(1), + worstLongTaskMs: +probe.longTasks + .reduce((worst, value) => Math.max(worst, value), 0) + .toFixed(1), + mountedAtActivation: probe.mountedAtActivation + } + }) +} + +function report(label: string, sample: SwitchSample): string { + return [ + `${label}:`, + ` activation ${sample.activationMs?.toFixed(1) ?? 'n/a'}ms`, + ` pane mounted ${sample.paneMountedMs?.toFixed(1) ?? 'n/a'}ms`, + ` content restored ${sample.contentRestoredMs?.toFixed(1) ?? 'never'}ms`, + ` max frame gap ${sample.maxFrameGapMs}ms`, + ` long tasks total=${sample.longTaskTotalMs}ms worst=${sample.worstLongTaskMs}ms`, + ` panes at switch ${sample.mountedAtActivation}/${TABS_PER_WORKTREE}`, + ` settled resources managers=${sample.settledPaneManagers} panes=${sample.settledPanes} webgl=${sample.settledWebglContexts}` + ].join('\n') +} + +async function publish(testInfo: TestInfo, name: string, body: string): Promise { + console.log(body) + await testInfo.attach(name, { body, contentType: 'text/plain' }) +} + +// Why 8 extra: hot-retain keeps the 4 most recently hidden worktrees mounted and +// exempts the last-active one, so a target only cold-parks once enough other +// worktrees have been visited after it. That is the steady state at field scale. +const FILLER_WORKTREE_COUNT = Number(process.env.ORCA_SWITCH_FILLER_WORKTREES ?? '8') + +async function addFillerWorktrees( + page: Page, + testRepoPath: string +): Promise<{ ids: string[]; cleanup: () => void }> { + const parent = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-switch-paint-'))) + const paths = Array.from({ length: FILLER_WORKTREE_COUNT }, (_, index) => + path.join(parent, `filler-${index}`) + ) + const removeAll = (): void => { + for (const worktreePath of paths) { + try { + execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { + cwd: testRepoPath, + stdio: 'ignore' + }) + } catch { + /* best effort */ + } + } + rmSync(parent, { recursive: true, force: true }) + } + // Why clean up before rethrowing: testRepoPath is worker-scoped and reused by + // later specs, so a half-built fixture would leak worktrees into them. + try { + for (const worktreePath of paths) { + execFileSync('git', ['worktree', 'add', '--detach', worktreePath, 'HEAD'], { + cwd: testRepoPath, + stdio: 'ignore' + }) + } + } catch (error) { + removeAll() + throw error + } + try { + return await registerFillerWorktrees(page, testRepoPath, paths, removeAll) + } catch (error) { + removeAll() + throw error + } +} + +async function registerFillerWorktrees( + page: Page, + testRepoPath: string, + paths: readonly string[], + cleanup: () => void +): Promise<{ ids: string[]; cleanup: () => void }> { + const repoId = await page.evaluate( + (repoPath) => + window.__store!.getState().repos.find((repo) => repo.path === repoPath)?.id ?? null, + testRepoPath + ) + if (!repoId) { + throw new Error(`seeded repo not registered: ${testRepoPath}`) + } + await loadWorktreesUntilPathsPresent(page, repoId, [...paths]) + const ids = await page.evaluate( + ({ id, wanted }) => + (window.__store!.getState().worktreesByRepo[id] ?? []) + .filter((worktree) => wanted.includes(worktree.path)) + .map((worktree) => worktree.id), + { id: repoId, wanted: paths } + ) + return { ids, cleanup } +} + +function median(values: readonly number[]): number { + const sorted = [...values].sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +test.describe('Worktree switch first paint', () => { + test('repaints an unmounted worktree within the switch budget', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + test.setTimeout(900_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + + const worktreeIds = await getAllWorktreeIds(orcaPage) + expect(worktreeIds.length).toBeGreaterThanOrEqual(2) + const [primaryId, targetId] = worktreeIds + const filler = await addFillerWorktrees(orcaPage, testRepoPath) + + const samples: SwitchSample[] = [] + const lines: string[] = [] + try { + const targetTabIds = await ensureTabs(orcaPage, targetId, 'WTB') + + // Give the filler worktrees persisted tabs without mounting them, so the + // store carries a field-scale tab population (the profile that motivated + // this budget has 846 tabs across 449 worktrees). + await orcaPage.evaluate( + ({ ids, perWorktree }) => { + const state = window.__store!.getState() + for (const id of ids) { + const existing = state.tabsByWorktree[id] ?? [] + for (let index = existing.length; index < perWorktree; index += 1) { + state.createTab(id) + } + } + }, + { ids: filler.ids, perWorktree: 2 } + ) + + for (let round = 0; round < SWITCH_SAMPLE_COUNT; round += 1) { + // Leave the primary active and let the session persist before reloading: + // startup restores the persisted active worktree, so this is what makes + // the target come back with tabs in the session and no pane ever mounted + // — the state every switch lands in once the worktree count exceeds the + // hot-retain working set. + await switchToWorktree(orcaPage, primaryId) + await ensureTerminalVisible(orcaPage) + await orcaPage.waitForTimeout(2_500) + await orcaPage.reload() + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await orcaPage.waitForTimeout(2_500) + const unmounted = await waitForUnmountedTabs(orcaPage, targetTabIds) + expect(unmounted, 'target worktree was already mounted before the switch').toBe(true) + + const sample = await measureSwitch(orcaPage, targetId, targetTabIds) + samples.push(sample) + lines.push(report(`round ${round + 1} (target unmounted=${unmounted})`, sample)) + + // The half of the contract that keeps the speed-up free: the hidden tabs + // the switch skipped still end up mounted, so the next tab switch is as + // warm as it was before the reveal stopped mounting them up front. + const warmedTabIds = await waitForMountedTabs(orcaPage, targetTabIds) + expect(warmedTabIds, 'deferred tabs never joined the warm working set').toEqual( + [...targetTabIds].sort() + ) + } + } finally { + filler.cleanup() + } + + const restored = samples + .map((sample) => sample.contentRestoredMs) + .filter((value): value is number => value !== null) + expect(restored.length, 'revealed terminal never restored its content').toBe(samples.length) + const summary = [ + `first activation -> ${TABS_PER_WORKTREE}-tab worktree, ${samples.length} rounds`, + ` content restored: median=${median(restored).toFixed(1)}ms samples=${restored + .map((value) => value.toFixed(0)) + .join(', ')}ms`, + ` activation: median=${median( + samples.map((sample) => sample.activationMs ?? 0) + ).toFixed(1)}ms`, + ` panes at switch: ${samples.map((sample) => sample.mountedAtActivation).join(', ')}`, + ` settled panes: ${samples.map((sample) => sample.settledPanes).join(', ')}`, + ` settled webgl: ${samples.map((sample) => sample.settledWebglContexts).join(', ')}`, + '', + ...lines + ].join('\n') + await publish(testInfo, 'first-activation-switch.txt', summary) + + for (const sample of samples) { + expect( + sample.mountedAtActivation, + 'the switch mounted more than the pane the user is looking at' + ).toBe(1) + } + // Why CI is exempt from the budget and not from the invariants: shared + // runners cannot hold a latency threshold, but "the switch mounted one pane" + // and "the warm set came back" are exact and are the real regression guards. + if (process.env.CI) { + console.log(`[switch-budget] CI run, latency budget not enforced (median ${median(restored).toFixed(1)}ms)`) + return + } + expect(median(restored)).toBeLessThanOrEqual(FIRST_PAINT_BUDGET_MS) + }) +}) diff --git a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs index 63b7222a93d..ffac29bddea 100644 --- a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs @@ -27,19 +27,19 @@ import { pickFreePort, stopDevApp, waitForStoreReady -} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' +} from '../../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' import { createCompletedOnboardingProfile, safeRemoveLocalDirectory -} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +} from '../../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs -} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' +} from '../../../config/scripts/windows-apphang-repro/repro-timing.mjs' -const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) const PARK_DELAY_MS = 1_500 const SETTLE_AFTER_PARK_MS = 4_000 // Short root so the daemon Unix socket fits under the macOS 104-char limit; diff --git a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs index a01cbf2cc3a..70bf1f66944 100644 --- a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs @@ -33,19 +33,19 @@ import { pickFreePort, stopDevApp, waitForStoreReady -} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' +} from '../../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' import { createCompletedOnboardingProfile, safeRemoveLocalDirectory -} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +} from '../../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs -} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' +} from '../../../config/scripts/windows-apphang-repro/repro-timing.mjs' -const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) const scenarioTimeoutMs = 300_000 // Short enough that a tab parks within a few seconds of being hidden, long diff --git a/tests/tools/benchmarks/terminal-perf-bench.mjs b/tests/tools/benchmarks/terminal-perf-bench.mjs index a73ffae6bc9..551f33d0433 100644 --- a/tests/tools/benchmarks/terminal-perf-bench.mjs +++ b/tests/tools/benchmarks/terminal-perf-bench.mjs @@ -19,16 +19,16 @@ import { pickFreePort, stopDevApp, waitForStoreReady -} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' +} from '../../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs -} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' -import { safeRemoveLocalDirectory } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +} from '../../../config/scripts/windows-apphang-repro/repro-timing.mjs' +import { safeRemoveLocalDirectory } from '../../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' -const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) const scenarioTimeoutMs = 300_000 const defaultIterations = 8 const defaultSwitches = 24 diff --git a/tests/tools/pi-owner-runtime-smoke.mjs b/tests/tools/pi-owner-runtime-smoke.mjs new file mode 100644 index 00000000000..204627340ac --- /dev/null +++ b/tests/tools/pi-owner-runtime-smoke.mjs @@ -0,0 +1,128 @@ +// Run: node tests/tools/pi-owner-runtime-smoke.mjs /path/to/pi-coding-agent +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { build } from 'esbuild' + +const piRoot = resolve(process.argv[2] || '') +assert.ok(process.argv[2], 'Pass an installed pi-coding-agent package directory') +const scratch = await mkdtemp(join(tmpdir(), 'orca-pi-owner-')) +const received = [] +const server = createServer(async (request, response) => { + let body = '' + for await (const chunk of request) { + body += chunk + } + received.push(JSON.parse(body)) + response.end('{}') +}) +try { + const bundle = join(scratch, 'orca.cjs') + await build({ + stdin: { + contents: [ + "export { getPiAgentStatusExtensionSource } from './src/main/pi/agent-status-extension-source';", + "export { runProcess } from './src/shared/child-process/run-process';" + ].join('\n'), + resolveDir: process.cwd() + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile: bundle, + packages: 'external' + }) + const { getPiAgentStatusExtensionSource, runProcess } = createRequire(import.meta.url)(bundle) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const dead = await runProcess({ + program: process.execPath, + args: ['-e', 'console.log(process.pid)'] + }) + assert.equal(dead.code, 0) + const deadPid = Number(dead.stdout.trim()) + assert.throws(() => process.kill(deadPid, 0), { code: 'ESRCH' }) + const worker = join(scratch, 'worker.mjs') + const moduleUrl = (file) => JSON.stringify(pathToFileURL(join(piRoot, file)).href) + await writeFile( + worker, + ` + import assert from 'node:assert/strict' + import { loadExtensions } from ${moduleUrl('dist/core/extensions/loader.js')} + import { ExtensionRunner } from ${moduleUrl('dist/core/extensions/runner.js')} + import { SessionManager } from ${moduleUrl('dist/core/session-manager.js')} + const loaded = await loadExtensions([process.argv[2]], process.cwd()) + assert.deepEqual(loaded.errors, []) + const runner = new ExtensionRunner(loaded.extensions, loaded.runtime, process.cwd(), SessionManager.inMemory(process.cwd()), undefined) + const errors = [] + runner.onError(error => errors.push(error)) + await runner.emit({ type: 'agent_start' }) + await new Promise(resolve => setTimeout(resolve, 250)) + assert.deepEqual(errors, []) + console.log(JSON.stringify({pid: process.pid, owner: process.env[process.argv[3]], handlers: loaded.extensions[0].handlers.size})) + ` + ) + const results = [] + for (const kind of ['pi', 'omp', 'prime-agent']) { + const ownerKey = + kind === 'prime-agent' ? 'ORCA_PRIME_AGENT_STATUS_OWNED' : 'ORCA_PI_STATUS_OWNED' + for (const scenario of ['baseline-dead', 'fixed-dead', 'fixed-live']) { + let source = getPiAgentStatusExtensionSource(kind) + if (scenario === 'baseline-dead') { + const guard = 'if (ownerPid && ownerPid !== selfPid && isStatusOwnerAlive(ownerPid)) return' + assert.ok( + source.includes(guard), + 'Baseline mutation must replace the actual ownership guard' + ) + source = source.replace(guard, 'if (ownerPid && ownerPid !== selfPid) return') + } + const extension = join(scratch, `${kind}-${scenario}.ts`) + await writeFile(extension, source) + const before = received.length + const owner = scenario === 'fixed-live' ? process.pid : deadPid + const child = await runProcess({ + program: process.execPath, + args: [worker, extension, ownerKey], + cwd: scratch, + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_PANE_KEY: 'owner-proof', + ORCA_AGENT_HOOK_PORT: String(server.address().port), + ORCA_AGENT_HOOK_TOKEN: 'isolated-proof-token', + ORCA_AGENT_HOOK_ENV: 'proof', + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_PI_STATUS_OWNED: '', + ORCA_PRIME_AGENT_STATUS_OWNED: '', + PRIME_AGENT_INTERNAL_DAEMON_WORKER: kind === 'prime-agent' ? '1' : '', + [ownerKey]: String(owner) + }, + timeoutMs: 15000 + }) + assert.equal(child.code, 0, child.stderr) + const observation = JSON.parse(child.stdout.trim().split('\n').at(-1)) + const shouldReport = scenario === 'fixed-dead' + assert.equal( + received.length - before, + shouldReport ? 1 : 0, + `${kind}/${scenario}: HTTP delivery` + ) + assert.equal(observation.owner, String(shouldReport ? observation.pid : owner)) + assert.equal(observation.handlers > 0, shouldReport) + if (shouldReport) { + assert.equal(received.at(-1).payload.hook_event_name, 'agent_start') + } + results.push({ kind, scenario, posts: received.length - before, ...observation }) + } + } + console.log(JSON.stringify({ platform: process.platform, results }, null, 2)) +} finally { + server.closeAllConnections() + server.close() + await rm(scratch, { recursive: true, force: true }) +} diff --git a/tests/tools/pi-provider-runtime-smoke.mjs b/tests/tools/pi-provider-runtime-smoke.mjs new file mode 100644 index 00000000000..bda4f6a0f89 --- /dev/null +++ b/tests/tools/pi-provider-runtime-smoke.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { createServer } from 'node:http' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { build } from 'esbuild' +const piCli = process.argv[2] && resolve(process.argv[2]) +assert.ok(piCli, 'Pass the installed Pi CLI entrypoint') +const scratch = await mkdtemp(join(tmpdir(), 'orca-pi-provider-')) +const requests = [] +const server = createServer(async (req, res) => { + let body = '' + for await (const part of req) { + body += part + } + requests.push(JSON.parse(body)) + res.writeHead(200, { 'content-type': 'text/event-stream' }) + for (const chunk of [ + { + id: 'proof', + object: 'chat.completion.chunk', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: 'fixture-generated-commit' }, + finish_reason: null + } + ] + }, + { + id: 'proof', + object: 'chat.completion.chunk', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } + } + ]) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`) + } + res.end('data: [DONE]\n\n') +}) +try { + const bundle = join(scratch, 'orca.cjs') + await build({ + stdin: { + contents: + "export {planCommitMessageGeneration} from './src/shared/commit-message-plan'; export {runProcess} from './src/shared/child-process/run-process';", + resolveDir: process.cwd() + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile: bundle, + packages: 'external' + }) + const { planCommitMessageGeneration, runProcess } = createRequire(import.meta.url)(bundle) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const dir = join(scratch, 'agent') + await mkdir(join(dir, 'extensions'), { recursive: true }) + await writeFile( + join(dir, 'extensions', 'provider.ts'), + `export default function(pi){pi.registerProvider('orca-proof',{name:'Proof',baseUrl:'http://127.0.0.1:${server.address().port}/v1',apiKey:'fixture-only',api:'openai-completions',models:[{id:'local',name:'Proof',reasoning:false,input:['text'],cost:{input:0,output:0,cacheRead:0,cacheWrite:0},contextWindow:8192,maxTokens:256}]})}` + ) + await writeFile( + join(dir, 'settings.json'), + JSON.stringify({ defaultProvider: 'orca-proof', defaultModel: 'local' }) + ) + const planned = planCommitMessageGeneration( + { agentId: 'pi', model: 'orca-proof/local' }, + 'Generate one short commit message.' + ) + assert.equal(planned.ok, true) + const fixedArgs = planned.plan.args + assert.ok(!fixedArgs.includes('--no-extensions')) + const variants = [ + ['baseline', [...fixedArgs, '--no-extensions']], + ['extensions-enabled', fixedArgs] + ] + const results = [] + for (const [variant, args] of variants) { + const n = requests.length + const result = await runProcess({ + program: process.execPath, + args: [piCli, ...args], + cwd: scratch, + env: { + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + HOME: scratch, + USERPROFILE: scratch, + ORCA_BACKGROUND_LAUNCH: '1', + PI_CODING_AGENT_DIR: dir + }, + input: planned.plan.stdinPayload, + timeoutMs: 20000 + }) + results.push({ + variant, + args, + code: result.code, + stdout: result.stdout, + stderr: result.stderr, + requests: requests.length - n + }) + } + assert.equal(results[0].requests, 0) + assert.notEqual(results[0].code, 0) + assert.equal(results[1].code, 0, results[1].stderr) + assert.match(results[1].stdout, /fixture-generated-commit/) + assert.equal(results[1].requests, 1) + console.log( + JSON.stringify( + { + scope: + 'Actual Pi CLI and production command planner; isolated extension provider with local OpenAI-compatible fixture.', + platform: process.platform, + results + }, + null, + 2 + ) + ) +} finally { + server.closeAllConnections() + server.close() + await rm(scratch, { recursive: true, force: true }) +}